Skip to content

Instantly share code, notes, and snippets.

@Heziode
Created April 8, 2023 14:47
Show Gist options
  • Select an option

  • Save Heziode/f37d9ddbef0c29a40bf138fc0fc4b058 to your computer and use it in GitHub Desktop.

Select an option

Save Heziode/f37d9ddbef0c29a40bf138fc0fc4b058 to your computer and use it in GitHub Desktop.
Word and Character Count of multiple notes in Obsidian, using dataviewjs.
// Word Count Dashboard (Obsidian dataviewjs snippet)
// by @pseudometa, https://gist.github.com/chrisgrieser/ac16a80cdd9e8e0e84606cc24e35ad99
// version 1.4
// Import configuration
const source = dv.current();
const sourceFolder = source.sourceFolder;
let sourceTag = source.sourceTag;
const charTarget = source.charTarget;
const wordTarget = source.wordTarget;
const includeFootnotes = source.includeFootnotes;
const charactersIncludeSpaces = source.charactersIncludeSpaces;
const excludeComments = source.excludeComments;
let excludeTag = source.excludeTag;
const includeBibliographyEstimate = source.includeBibliographyEstimate;
const wordsPerCitation = source.wordsPerCitation;
const charsPerCitation = source.charsPerCitation;
const thousandSeperator = source.thousandSeperator;
const useThousandSeperator = source.useThousandSeperator;
const naChar = source.naChar;
const wordsPerPage = source.wordsPerPage;
// prepend hashtags for tags
if (sourceTag) if (!sourceTag.startsWith("#")) sourceTag = "#" + sourceTag;
if (excludeTag) if (!excludeTag.startsWith("#")) excludeTag = "#" + excludeTag;
// const nameOfIndexFile = source.nameOfIndexFile; // used for Longform plugin, not implemented yet
// < Functions
function getWordCount(text) {
// Regex from BetterWordCount Plugin
const spaceDelimitedChars = /A-Za-z\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0-\u08B4\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D5F-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16F1-\u16F8\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA7AD\uA7B0-\uA7B7\uA7F7-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA8FD\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uA9E0-\uA9E4\uA9E6-\uA9EF\uA9FA-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB65\uAB70-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC/
.source;
const nonSpaceDelimitedWords = /[\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u4E00-\u9FD5]{1}/
.source;
const pattern = new RegExp([
"(?:[0-9]+(?:(?:,|\\.)[0-9]+)*|[\\-" + spaceDelimitedChars + "])+",
nonSpaceDelimitedWords
].join("|"), "g");
return (text.match(pattern) || []).length;
}
function getCharacterCount(text) {
if (charactersIncludeSpaces) return text.length;
return text.replaceAll(" ", "").length;
}
function insert1000sep (num) {
let numText = String(num);
if (!useThousandSeperator) return numText;
if (num >= 10000) numText = numText.slice(0, -3) + thousandSeperator + numText.slice (-3); // eslint-disable-line no-magic-numbers
return numText;
}
String.prototype.strong = function () {
if (this === " ") return " ";
return "**" + this + "**";
};
function removeMarkdown (text) {
let plaintext = text
.replace(/`\$?=[^`]+`/g, "") // inline dataview
.replace(/^---\n.*?\n---\n/s, "") // YAML Header
.replace(/!?\[(.+)\]\(.+\)/g, "$1") // URLs & Image Captions
.replace(/\*|_|\[\[|\]\]|\||==|~~|---|#|> |`/g, ""); // Markdown Syntax
if (excludeComments) plaintext = plaintext.replace(/%%|<!--|-->/g, ""); // remove only comment syntax
else plaintext = plaintext.replace(/<!--.*?-->/sg, "").replace(/%%.*?%%/sg, "");
return plaintext;
}
function removeFootnotes (text) {
return text
.replace(/^\[\^\w+\]:.*$/gm, "") // footnote at the end
.replace(/\[\^\w+\]/g, ""); // footnote reference inline
}
function countPandocCitations (text) {
const citations = text.match(/@\w+(?=[,;\] ])/gi);
if (citations) return 0;
const uniqCitations = [...new Set(citations)]; // only unique citations
return uniqCitations.length;
}
function calculateShare (charCount, wordCount) {
if (!wordTarget && !charTarget) return naChar;
let countToUse = 0;
let targetToUse = 0;
if (charTarget) {
countToUse = charCount;
targetToUse = charTarget;
}
if (wordTarget) {
countToUse = wordCount;
targetToUse = wordTarget;
}
return (countToUse / targetToUse);
}
function toPercentStr (share) {
return (share * 100).toFixed(1).toString() + "%";
}
// < table construction
const getTableContents = () => {
let totalWords = 0;
let totalChars = 0;
const output = [];
let completeText = "";
let cumulativeShare = 0;
// get sections via folder or via tag
let sections;
if (sourceFolder) sections = dv.pages("\"" + sourceFolder + "\"");
else sections = dv.pages(sourceTag);
// < exclude sections with status "exclude"
sections = sections.filter(t => t.status !== "exclude");
numExcludeStatus = sections.filter(t => t.status === "exclude").length;
// or exclude tag
if (excludeTag !== "") {
numExcludedNotes = sections.filter(t => t.file.tags.includes(excludeTag)).length;
sections = sections.filter(t => !t.file.tags.includes(excludeTag));
}
// < SORT sections
sections = sections.sort(s => s.file.name);
// not yet implemented for Longform plugin
// let indexPath = sourceFolder.split("/").slice(0, -2).join("/") + nameOfIndexFile;
// if (indexPath.slice (-3) != ".md") indexPath += ".md";
// < SECTIONS LOOP
const sectionPaths = sections.file.path;
for (let i = 0; i < sectionPaths.length; i++) {
// < read page content via Obsidian API
let content = "";
const page = this.app.vault.getAbstractFileByPath(sectionPaths.values[i]);
if (page.unsafeCachedData) content = page.unsafeCachedData;
// < clean up
content = removeMarkdown (content);
if (!includeFootnotes) content = removeFootnotes (content);
content = content
.replace(/(^\s*)|(\s*$)/g, "") // remove the start and end spaces of the given string
.replace(/ {2,}/g, " "); // reduce multiple spaces to a single space
// < Table Values: Count & Share
const characterCount = getCharacterCount(content);
const wordCount = getWordCount(content);
cumulativeShare += calculateShare(characterCount, wordCount);
// < Table Values: name & status
const name = sections[i].file.link;
let status = sections[i].status;
if (!status) status = " ";
// < push table values
output.push([
name,
insert1000sep(characterCount),
insert1000sep(wordCount),
toPercentStr(cumulativeShare),
status
]);
// add to totals & bibliography calculation
totalChars += characterCount;
totalWords += wordCount;
if (includeBibliographyEstimate) completeText += content;
}
// < OVERALL
// < Bibliography Estimate
if (includeBibliographyEstimate) {
const citationCount = countPandocCitations(completeText);
const wordCount = citationCount * wordsPerCitation;
let characterCount = citationCount * charsPerCitation;
if (!charactersIncludeSpaces) characterCount = citationCount * (charsPerCitation - 20); // eslint-disable-line no-magic-numbers
cumulativeShare += calculateShare(characterCount, wordCount);
output.push([
"Bibliography (Estimate)",
insert1000sep(characterCount),
insert1000sep(wordCount),
toPercentStr(cumulativeShare),
naChar
]);
// add for Totals calculation
totalChars += characterCount;
totalWords += wordCount;
}
// < Totals calculation
const totalProgress = calculateShare(totalChars, totalWords);
output.push([
"Total".strong(),
insert1000sep(totalChars).strong(),
insert1000sep(totalWords).strong(),
toPercentStr(totalProgress).strong(),
naChar.strong()
]);
if (wordsPerPage) {
const totalPages = (totalWords / wordsPerPage).toFixed(1);
output.push([
(totalPages.toString() + " Pages").strong(),
"",
"",
"",
""
]);
}
// < Progress Bar
const progressBar =
"&nbsp;&nbsp;&nbsp;&nbsp;"
+ " <progress max=\"100\" value=\""
+ (totalProgress * 100).toFixed().toString()
+ "\"> </progress>";
// < Target
let charTargetText = naChar;
let wordTargetText = naChar;
if (!charTarget) charTargetText = insert1000sep(charTarget);
if (!wordTarget) wordTargetText = insert1000sep(wordTarget);
output.push([
"Target".strong() + progressBar,
charTargetText.strong(),
wordTargetText.strong(),
naChar.strong(),
naChar.strong()
]);
// < return array
return output;
};
// < Print Table
let numExcludedNotes = 0;
let numExcludeStatus = 0;
dv.table(["Section", "Chars", "Words", "Target", "Status"], getTableContents());
// < Append Settings Info
let settingFt = "Footnotes excluded. ";
let settingExcludeTag = "";
let settingExcludeStatus = "";
let settingBibliography = "";
let settingCharSpaces = "Character Count includes Spaces. ";
let settingComments = "Comments included. ";
let settingPages = "";
if (includeFootnotes) settingFt = "Footnotes included. ";
if (!includeBibliographyEstimate) settingBibliography = "Bibliography excluded. ";
if (!charactersIncludeSpaces) settingCharSpaces = "Character Count without Spaces. ";
if (excludeComments) settingComments = "Comments excluded. ";
if (wordsPerPage) settingPages = "Assuming " + wordsPerPage.toString() + " words per page. ";
if (numExcludeStatus) {
let plural = "s";
if (numExcludeStatus === 1) plural = "";
const excludedQuery = "\"status: exclude\" path:(" + sourceFolder + ")";
settingExcludeStatus =
"[" + numExcludeStatus.toString() + " Section" + plural + "]"
+ "(obsidian://search?query=" + encodeURIComponent(excludedQuery) + ")"
+ " with the status \"exclude\" omitted. ";
}
if (numExcludedNotes) {
let plural = "s";
if (numExcludedNotes === 1) plural = "";
const excludedQuery = "tag:" + excludeTag + " path:(" + sourceFolder + ")";
settingExcludeTag =
"[" + numExcludedNotes.toString() + " Section" + plural + "]"
+ "(obsidian://search?query=" + encodeURIComponent(excludedQuery) + ")"
+ " tagged with " + excludeTag
+ " omitted. ";
}
dv.span("---");
dv.span(
"<small>"
+ "Settings: ".strong()
+ settingExcludeStatus
+ settingExcludeTag
+ settingFt
+ settingBibliography
+ settingComments
+ settingCharSpaces
+ settingPages
+ "</small>"
);

Wordcount Dashboard for Obsidian

image

Setup

  1. Install dataview
  2. Install the CSS file as CSS snippet.
  3. Create a note with the markdown note template.
  4. Insert the dataviewjs-script into the dataviewjs-codeblock
  5. Enter the configuration values in the note template. (The surrounding %% %% ensure that they are treated as comments, so the configuration will not be displayed in Preview Mode.)
  6. The status column will be populated with the value of the YAML-key status of every document, i.e., you have to use the add the following YAML-Header to every note.
---
status: 
---

Known Issues

It seems that this dahsboard fails when amount of words / notes becomes too high. As far as I can tell, this is due to limitations by dataview/Obsidian, and one could only be tackled by a dedicated plugin for Word Counts. If you really want multi-note word counts, please request a plugin like that in the forum!

/* used to properly align the numbers of the dataviewjs wordcount snippet
https://gist.github.com/chrisgrieser/ac16a80cdd9e8e0e84606cc24e35ad99 */
.wordcountTable table.dataview.table-view-table td,
.wordcountTable table.dataview.table-view-table th {
border-left: 2px solid;
}
.wordcountTable table.dataview.table-view-table td:first-child,
.wordcountTable table.dataview.table-view-table th:first-child {
border-left: none;
}
.wordcountTable table.dataview.table-view-table td:nth-child(2),
.wordcountTable table.dataview.table-view-table td:nth-child(3),
.wordcountTable table.dataview.table-view-table td:nth-child(4),
.wordcountTable table.dataview.table-view-table td:nth-child(5){
text-align: end;
}
.wordcountTable table.dataview.table-view-table td:last-child{
text-align: center;
}
/* full-width in Preview Mode*/
.wordcountTable .markdown-preview-section {
max-width: 100% !important;
}
.wordcountTable table.dataview.table-view-table th{
text-align: center;
}
---
cssclass: wordcountTable
---
%%
## Configuration
**Gets either notes in a folder or notes with a certain tag. Leave one of them empty.**
sourceFolder:: Writing/Interdependence & Innovation/Drafts/Entwurf
sourceTag:: #stories
**set to 0 to ignore**
charTarget:: 60000
wordTarget:: 0
wordsPerPage:: 0
includeFootnotes:: true
charactersIncludeSpaces:: true
excludeComments:: true
**Notes to exclude**
Leave empty to disable. (Notes with the yaml-key "status" and value "exclude" for that key are also excluded)
excludeTag:: #exclude
**Bibliography estimate for Pandoc Citations**
includeBibliographyEstimate:: true
wordsPerCitation:: 22
charsPerCitation:: 155
**purely visual**
thousandSeperator:: .
useThousandSeperator:: true
naChar:: —
%%
```dataviewjs
<!-- put the above code in a codeblock like this-->
```
@jhilker98
Copy link
Copy Markdown

I've been unable to set the path to the index file - I've set the sourceFolder above to /03 Conworlds/031 Broken Thrones/0311 The Ashes Chronicles/01 Ashes (in my specific instance), and then the pathToIndexFile I had set to /ashes.md - however, I get a cannot read properties of undefined (reading drafts). I've only got the initial draft for nanowrimo - how do I fix this?

@Heziode
Copy link
Copy Markdown
Author

Heziode commented Nov 4, 2023

Hum that strange.

I am not the original author of this gist. The original author is @chrisgrieser. I think I have cloned this gist (without editing it) and since it as deleted his gist, I am considered as the new "owner" of this gist.

I do not use this script anymore. Unfortunately, I've gone back to something more archaic for posting statistics: Excel.

I cannot really assist you with your issue…
In my case I only put the note that contains this script at the root of the folders I want to process, without editing variables.

@jhilker98
Copy link
Copy Markdown

jhilker98 commented Nov 4, 2023

No worries, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment