├── .editorconfig ├── .github └── workflows │ ├── build.yml │ ├── release.yml │ └── version.yml ├── .gitignore ├── .npmrc ├── .sandbox └── .obsidian │ ├── app.json │ ├── community-plugins.json │ └── core-plugins.json ├── .vscode └── tasks.json ├── CONTRIBUTING.md ├── README.md ├── jest.config.js ├── manifest.json ├── package.json ├── rollup.config.js ├── src ├── collect.ts ├── format.ts ├── main.ts ├── metrics.ts ├── settings.ts ├── text.spec.ts └── text.ts ├── styles.css ├── tsconfig.json └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{ts,js,json}] 8 | indent_style = space 9 | indent_size = 2 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - '*' 6 | tags: 7 | - '*' 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - name: Build 16 | run: | 17 | npm install 18 | npm run test 19 | npm run build 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | with: 12 | fetch-depth: 0 13 | - name: Version 14 | id: version 15 | run: | 16 | echo "::set-output name=tag::$(git describe --exact-match --tags HEAD | head -n1)" 17 | - name: Build 18 | run: | 19 | npm install 20 | npm run test 21 | npm run build 22 | - name: Package 23 | run: | 24 | mkdir "${{ github.event.repository.name }}" 25 | cp main.js styles.css README.md manifest.json "${{ github.event.repository.name }}" 26 | zip -r "${{ github.event.repository.name }}.zip" "${{ github.event.repository.name }}" 27 | - name: Release 28 | id: create_release 29 | uses: actions/create-release@v1 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.PAT }} 32 | VERSION: ${{ github.ref }} 33 | with: 34 | tag_name: ${{ github.ref }} 35 | release_name: ${{ github.ref }} 36 | draft: false 37 | prerelease: false 38 | - name: Upload .zip 39 | uses: actions/upload-release-asset@v1 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.PAT }} 42 | with: 43 | upload_url: ${{ steps.create_release.outputs.upload_url }} 44 | asset_path: ${{ github.event.repository.name }}.zip 45 | asset_name: ${{ github.event.repository.name }}-${{steps.version.outputs.tag}}.zip 46 | asset_content_type: application/zip 47 | - name: Upload main.js 48 | uses: actions/upload-release-asset@v1 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.PAT }} 51 | with: 52 | upload_url: ${{ steps.create_release.outputs.upload_url }} 53 | asset_path: ./main.js 54 | asset_name: main.js 55 | asset_content_type: text/javascript 56 | - name: Upload manifest.json 57 | uses: actions/upload-release-asset@v1 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.PAT }} 60 | with: 61 | upload_url: ${{ steps.create_release.outputs.upload_url }} 62 | asset_path: manifest.json 63 | asset_name: manifest.json 64 | asset_content_type: application/json 65 | - name: Upload styles.css 66 | uses: actions/upload-release-asset@v1 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.PAT }} 69 | with: 70 | upload_url: ${{ steps.create_release.outputs.upload_url }} 71 | asset_path: styles.css 72 | asset_name: styles.css 73 | asset_content_type: text/css 74 | -------------------------------------------------------------------------------- /.github/workflows/version.yml: -------------------------------------------------------------------------------- 1 | name: Version 2 | on: 3 | # Triggers the workflow on push or pull request events but only for the master branch 4 | workflow_dispatch: 5 | inputs: 6 | type: 7 | description: 'Type of version bump (major, minor, patch)' 8 | required: true 9 | default: 'patch' 10 | jobs: 11 | bump: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | with: 17 | persist-credentials: false 18 | fetch-depth: 0 19 | token: ${{ secrets.PAT }} 20 | - name: Update version 21 | id: version 22 | run: | 23 | git config --local user.email "action@github.com" 24 | git config --local user.name "GitHub Action" 25 | npm version ${{ github.event.inputs.type }} 26 | echo "::set-output name=tag::$(git describe --abbrev=0)" 27 | - name: Update manifest 28 | uses: jossef/action-set-json-field@v1 29 | with: 30 | file: manifest.json 31 | field: version 32 | value: ${{ steps.version.outputs.tag }} 33 | - name: Commit 34 | run: | 35 | git branch --show-current 36 | git add -u 37 | git commit --amend --no-edit 38 | git tag -fa ${{ steps.version.outputs.tag }} -m "${{ steps.version.outputs.tag }}" 39 | - name: Push 40 | uses: ad-m/github-push-action@v0.6.0 41 | with: 42 | github_token: ${{secrets.PAT}} 43 | tags: true 44 | branch: ${{github.ref}} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | 9 | # build 10 | /main.js 11 | *.js.map 12 | 13 | # lsp-mode 14 | .log 15 | 16 | # sandbox vault 17 | .sandbox -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix = "" 2 | 3 | -------------------------------------------------------------------------------- /.sandbox/.obsidian/app.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.sandbox/.obsidian/community-plugins.json: -------------------------------------------------------------------------------- 1 | [ 2 | "obsidian-vault-statistics-plugin" 3 | ] -------------------------------------------------------------------------------- /.sandbox/.obsidian/core-plugins.json: -------------------------------------------------------------------------------- 1 | [ 2 | "file-explorer", 3 | "global-search", 4 | "switcher", 5 | "graph", 6 | "backlink", 7 | "outgoing-link", 8 | "tag-pane", 9 | "page-preview", 10 | "templates", 11 | "note-composer", 12 | "command-palette", 13 | "editor-status", 14 | "starred", 15 | "outline" 16 | ] -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "build", 7 | "group": "build", 8 | "problemMatcher": [], 9 | "label": "npm: build", 10 | "detail": "Build" 11 | }, 12 | { 13 | "type": "npm", 14 | "script": "watch", 15 | "group": "build", 16 | "problemMatcher": [], 17 | "label": "npm: watch", 18 | "detail": "Watch for file changes and rebuild" 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor's Guide 2 | 3 | ## Quick Start 4 | 5 | ```sh 6 | npm install 7 | npm run test 8 | npm run build 9 | npm run deploy 10 | ``` 11 | 12 | ## Requirements 13 | 14 | - nodejs (>= v18.10.0) 15 | - npm (>= 8.19.2) 16 | 17 | ## Getting Started 18 | 19 | Install the required modules: 20 | 21 | ```sh 22 | npm install 23 | ``` 24 | 25 | Run tests using the `test` script: 26 | 27 | ```sh 28 | npm run test 29 | ``` 30 | 31 | Build `main.js` by running the `build` script: 32 | 33 | ```sh 34 | npm run build 35 | ``` 36 | 37 | Install the plugin into a vault by running the `deploy` script. This script will copy the plugin into the directory specified in the `PLUGIN_DIR` environment variable. `PLUGIN_DIR` is expected to be set to the absolute path of the directory to copy the plugin into. This directory will be created if it doesn't already exist. `PLUGIN_DIR` defaults to `.sandbox/.obsidian/plugins/vault-statistics-plugin`. See the [[Sandbox Vault]] section below for more information. 38 | 39 | To use the default sandbox vault: 40 | 41 | ```sh 42 | npm run deploy 43 | ``` 44 | 45 | Alternatively you can specify a `PLUGIN_DIR` to use for the `deploy` script: 46 | 47 | ```sh 48 | PLUGIN_DIR=... npm run deploy 49 | ``` 50 | 51 | or 52 | 53 | ```sh 54 | export PLUGIN_DIR=... 55 | npm run deploy 56 | ``` 57 | 58 | After installing the plugin, toggle the plugin on and off in settings or reload your vault to test changes. 59 | 60 | ## Automating Build and Deploy 61 | 62 | The `watch` script will watch for changes to source files. When changes are detected the `test`, `build`, and `deploy` scripts are run. 63 | 64 | ```sh 65 | PLUGIN_DIR=... npm run watch 66 | ``` 67 | 68 | ## Sandbox Vault 69 | 70 | The `.sandbox` directory in this repository is a bare-bones vault intended to be used as a sandbox for interactive testing. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Vault Statistics Plugin 2 | 3 | Status bar item with vault statistics including the number of notes, files, attachments, and links. 4 | 5 | ## Usage 6 | 7 | After the plugin is installed and enabled you will see a new item appear in the status bar showing you the number of notes in your vault. 8 | 9 | - Click on the status bar item to cycle through the available statistics. 10 | - Hover over the status bar item to see all of the available statistics. 11 | 12 | ## Advanced Usage 13 | 14 | ### Showing All Statistics 15 | 16 | All statistics can be shown by creating and enabling a CSS snippet with the following content. 17 | 18 | ```css 19 | /* Show all vault statistics. */ 20 | .obsidian-vault-statistics--item { 21 | display: initial !important; 22 | } 23 | ``` 24 | 25 | ### Showing Selected Statistics 26 | 27 | Similarly to the above, one can show certain statistics using a similar method to the above. Below is a snippet that hides all by the notes and attachments statistics. The snippet can be modified to include more or different statistics. 28 | 29 | ``` css 30 | /* Hide all statistics. */ 31 | .obsidian-vault-statistics--item { 32 | display: none !important; 33 | } 34 | 35 | /* Always show the notes and attachments statistics. */ 36 | .obsidian-vault-statistics--item-notes, 37 | .obsidian-vault-statistics--item-attachments { 38 | display: initial !important; 39 | } 40 | ``` 41 | 42 | ## Version History 43 | 44 | All notable changes to this project will be documented in this file. 45 | 46 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 47 | 48 | ### [Unreleased] 49 | 50 | - Added 51 | - Changed 52 | - Deprecated 53 | - Removed 54 | - Fixed 55 | - Comment sections are explicitly processed and do not count toward statistics (#22) 56 | 57 | ### [0.1.3] - 2022-10-25 58 | 59 | - Fixed 60 | - Fixed issue with deleted and renamed files not correctly updating file statistics (#17) 61 | - Removed errant `debugger` statement (#14) 62 | 63 | ### [0.1.2] - 2022-08-05 64 | 65 | - Added 66 | - Added Settings pane 67 | - Changed 68 | - Users can now optionally show all or a subset of metrics instead of the default click-to-cycle behaviour (#6) 69 | 70 | ### [0.1.1] - 2022-08-05 71 | 72 | - Fixed 73 | - Fixed issue when processing files with admonitions (#12) 74 | 75 | ### [0.1.0] - 2021-12-30 76 | 77 | - Added 78 | - Added word count metric (#8) 79 | 80 | ### [0.0.8] - 2021-12-18 81 | 82 | - Added 83 | - Initial support for displaying multiple statistics at the same time. (#6) 84 | 85 | ### [0.0.6] - 2021-12-14 86 | 87 | - Fixed 88 | - FIXED: Reported values only contain 2 significant digits (#7) 89 | 90 | ### [0.0.5] - 2021-12-12 91 | 92 | - Changed 93 | - Displayed statistics are formatted with grouping for increase readability. 94 | - Added Vault Size statistic which calculates the total size of all files in the vault that are understood by Obsidian The display value is scaled to the appropriate unit. (#5) 95 | 96 | ### [0.0.4] - 2021-02-25 97 | 98 | - Fixed 99 | - Statistics will be calculated automatically as soon as the plugin loads. 100 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-vault-statistics-plugin", 3 | "name": "Vault Statistics", 4 | "version": "0.1.3", 5 | "minAppVersion": "0.11.0", 6 | "description": "Status bar item with vault statistics such as number of notes, files, attachments, and links.", 7 | "author": "Bryan Kyle", 8 | "isDesktopOnly": false 9 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-vault-statistics-plugin", 3 | "version": "0.1.3", 4 | "description": "Status bar item with vault statistics such as number of notes, files, attachments, and links.", 5 | "main": "main.js", 6 | "scripts": { 7 | "watch": "rollup --config rollup.config.js -w --watch.onStart \"npm run test\" --watch.onEnd \"npm run deploy\"", 8 | "test": "npx jest", 9 | "build": "rollup --config rollup.config.js", 10 | "deploy": "mkdir -p \"${PLUGIN_DIR:=.sandbox/.obsidian/plugins/vault-statistics-plugin}\"; cp -v manifest.json main.js styles.css \"${PLUGIN_DIR}\"" 11 | }, 12 | "keywords": [], 13 | "author": "Bryan Kyle", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@rollup/plugin-commonjs": "^15.1.0", 17 | "@rollup/plugin-node-resolve": "^9.0.0", 18 | "@rollup/plugin-typescript": "^6.0.0", 19 | "@types/jest": "^27.4.0", 20 | "@types/node": "^14.14.2", 21 | "jest": "^27.4.5", 22 | "obsidian": "latest", 23 | "rollup": "^2.32.1", 24 | "ts-jest": "^27.1.2", 25 | "tslib": "^2.0.3", 26 | "typescript": "^4.0.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | export default { 6 | input: 'src/main.ts', 7 | output: { 8 | dir: '.', 9 | sourcemap: 'inline', 10 | format: 'cjs', 11 | exports: 'default' 12 | }, 13 | external: ['obsidian', 'electron'], 14 | plugins: [ 15 | typescript(), 16 | nodeResolve({ browser: true }), 17 | commonjs(), 18 | ] 19 | }; 20 | -------------------------------------------------------------------------------- /src/collect.ts: -------------------------------------------------------------------------------- 1 | import { Component, Vault, MetadataCache, TFile, TFolder, CachedMetadata } from 'obsidian'; 2 | import { VaultMetrics } from './metrics'; 3 | import { MARKDOWN_TOKENIZER, UNIT_TOKENIZER } from './text'; 4 | 5 | 6 | enum FileType { 7 | Unknown = 0, 8 | Note, 9 | Attachment, 10 | } 11 | 12 | export class VaultMetricsCollector { 13 | 14 | private owner: Component; 15 | private vault: Vault; 16 | private metadataCache: MetadataCache; 17 | private data: Map = new Map(); 18 | private backlog: Array = new Array(); 19 | private vaultMetrics: VaultMetrics = new VaultMetrics(); 20 | 21 | constructor(owner: Component) { 22 | this.owner = owner; 23 | } 24 | 25 | public setVault(vault: Vault) { 26 | this.vault = vault; 27 | return this; 28 | } 29 | 30 | public setMetadataCache(metadataCache: MetadataCache) { 31 | this.metadataCache = metadataCache; 32 | return this; 33 | } 34 | 35 | public setVaultMetrics(vaultMetrics: VaultMetrics) { 36 | this.vaultMetrics = vaultMetrics; 37 | return this; 38 | } 39 | 40 | public start() { 41 | this.owner.registerEvent(this.vault.on("create", (file: TFile) => { this.onfilecreated(file) })); 42 | this.owner.registerEvent(this.vault.on("modify", (file: TFile) => { this.onfilemodified(file) })); 43 | this.owner.registerEvent(this.vault.on("delete", (file: TFile) => { this.onfiledeleted(file) })); 44 | this.owner.registerEvent(this.vault.on("rename", (file: TFile, oldPath: string) => { this.onfilerenamed(file, oldPath) })); 45 | this.owner.registerEvent(this.metadataCache.on("resolve", (file: TFile) => { this.onfilemodified(file) })); 46 | this.owner.registerEvent(this.metadataCache.on("changed", (file: TFile) => { this.onfilemodified(file) })); 47 | 48 | this.data.clear(); 49 | this.backlog = new Array(); 50 | this.vaultMetrics?.reset(); 51 | this.vault.getFiles().forEach((file: TFile) => { 52 | if (!(file instanceof TFolder)) { 53 | this.push(file); 54 | } 55 | }); 56 | this.owner.registerInterval(+setInterval(() => { this.processBacklog() }, 2000)); 57 | 58 | return this; 59 | } 60 | 61 | private push(fileOrPath: TFile | string) { 62 | if (fileOrPath instanceof TFolder) { 63 | return; 64 | } 65 | 66 | let path = (fileOrPath instanceof TFile) ? fileOrPath.path : fileOrPath; 67 | if (!this.backlog.contains(path)) { 68 | this.backlog.push(path); 69 | } 70 | } 71 | 72 | private async processBacklog() { 73 | while (this.backlog.length > 0) { 74 | let path = this.backlog.shift(); 75 | // console.log(`processing ${path}`); 76 | let file = this.vault.getAbstractFileByPath(path) as TFile; 77 | // console.log(`path = ${path}; file = ${file}`); 78 | let metrics = await this.collect(file); 79 | this.update(path, metrics); 80 | } 81 | // console.log("done"); 82 | } 83 | 84 | private async onfilecreated(file: TFile) { 85 | // console.log(`onfilecreated(${file?.path})`); 86 | this.push(file); 87 | } 88 | 89 | private async onfilemodified(file: TFile) { 90 | // console.log(`onfilemodified(${file?.path})`) 91 | this.push(file); 92 | } 93 | 94 | private async onfiledeleted(file: TFile) { 95 | // console.log(`onfiledeleted(${file?.path})`) 96 | this.push(file); 97 | } 98 | 99 | private async onfilerenamed(file: TFile, oldPath: string) { 100 | // console.log(`onfilerenamed(${file?.path})`) 101 | this.push(file); 102 | this.push(oldPath); 103 | } 104 | 105 | private getFileType(file: TFile): FileType { 106 | if (file.extension?.toLowerCase() === "md") { 107 | return FileType.Note; 108 | } else { 109 | return FileType.Attachment; 110 | } 111 | } 112 | 113 | public async collect(file: TFile): Promise { 114 | let metadata: CachedMetadata; 115 | try { 116 | metadata = this.metadataCache.getFileCache(file); 117 | } catch (e) { 118 | // getFileCache indicates that it should return either an instance 119 | // of CachedMetadata or null. The conditions under which a null 120 | // is returned are unspecified. Empirically, if the file does not 121 | // exist, e.g. it's been deleted or renamed then getFileCache will 122 | // throw an exception instead of returning null. 123 | metadata = null; 124 | } 125 | 126 | if (metadata == null) { 127 | return Promise.resolve(null); 128 | } 129 | 130 | switch (this.getFileType(file)) { 131 | case FileType.Note: 132 | return new NoteMetricsCollector(this.vault).collect(file, metadata); 133 | case FileType.Attachment: 134 | return new FileMetricsCollector().collect(file, metadata); 135 | } 136 | } 137 | 138 | public update(fileOrPath: TFile | string, metrics: VaultMetrics) { 139 | let key = (fileOrPath instanceof TFile) ? fileOrPath.path : fileOrPath; 140 | 141 | // Remove the existing values for the passed file if present, update the 142 | // raw values, then add the values for the passed file to the totals. 143 | this.vaultMetrics?.dec(this.data.get(key)); 144 | 145 | if (metrics == null) { 146 | this.data.delete(key); 147 | } else { 148 | this.data.set(key, metrics); 149 | } 150 | 151 | this.vaultMetrics?.inc(metrics); 152 | } 153 | 154 | } 155 | 156 | class NoteMetricsCollector { 157 | 158 | static TOKENIZERS = new Map([ 159 | ["paragraph", MARKDOWN_TOKENIZER], 160 | ["heading", MARKDOWN_TOKENIZER], 161 | ["list", MARKDOWN_TOKENIZER], 162 | ["table", UNIT_TOKENIZER], 163 | ["yaml", UNIT_TOKENIZER], 164 | ["code", UNIT_TOKENIZER], 165 | ["blockquote", MARKDOWN_TOKENIZER], 166 | ["math", UNIT_TOKENIZER], 167 | ["thematicBreak", UNIT_TOKENIZER], 168 | ["html", UNIT_TOKENIZER], 169 | ["text", UNIT_TOKENIZER], 170 | ["element", UNIT_TOKENIZER], 171 | ["footnoteDefinition", UNIT_TOKENIZER], 172 | ["definition", UNIT_TOKENIZER], 173 | ["callout", MARKDOWN_TOKENIZER], 174 | ["comment", UNIT_TOKENIZER], 175 | ]); 176 | 177 | private vault: Vault; 178 | 179 | constructor(vault: Vault) { 180 | this.vault = vault; 181 | } 182 | 183 | public async collect(file: TFile, metadata: CachedMetadata): Promise { 184 | let metrics = new VaultMetrics(); 185 | 186 | metrics.files = 1; 187 | metrics.notes = 1; 188 | metrics.attachments = 0; 189 | metrics.size = file.stat?.size; 190 | metrics.links = metadata?.links?.length || 0; 191 | metrics.words = 0; 192 | metrics.words = await this.vault.cachedRead(file).then((content: string) => { 193 | return metadata.sections?.map(section => { 194 | const sectionType = section.type; 195 | const startOffset = section.position?.start?.offset; 196 | const endOffset = section.position?.end?.offset; 197 | const tokenizer = NoteMetricsCollector.TOKENIZERS.get(sectionType); 198 | if (!tokenizer) { 199 | console.log(`${file.path}: no tokenizer, section.type=${section.type}`); 200 | return 0; 201 | } else { 202 | const tokens = tokenizer.tokenize(content.substring(startOffset, endOffset)); 203 | return tokens.length; 204 | } 205 | }).reduce((a, b) => a + b, 0); 206 | }).catch((e) => { 207 | console.log(`${file.path} ${e}`); 208 | return 0; 209 | }); 210 | 211 | return metrics; 212 | } 213 | } 214 | 215 | class FileMetricsCollector { 216 | 217 | public async collect(file: TFile, metadata: CachedMetadata): Promise { 218 | let metrics = new VaultMetrics(); 219 | metrics.files = 1; 220 | metrics.notes = 0; 221 | metrics.attachments = 1; 222 | metrics.size = file.stat?.size; 223 | metrics.links = 0; 224 | metrics.words = 0; 225 | return metrics; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/format.ts: -------------------------------------------------------------------------------- 1 | export abstract class Formatter { 2 | public abstract format(value: number): string; 3 | } 4 | 5 | /** 6 | * {@link DecimalUnitFormatter} provides an implementation of {@link Formatter} 7 | * that outputs a integers in a standard decimal format with grouped thousands. 8 | */ 9 | export class DecimalUnitFormatter extends Formatter { 10 | private unit: string; 11 | private numberFormat: Intl.NumberFormat; 12 | 13 | /** 14 | * @param unit the unit of the value being formatted. 15 | * @constructor 16 | */ 17 | constructor(unit: string) { 18 | super() 19 | this.unit = unit; 20 | this.numberFormat = Intl.NumberFormat('en-US', { style: 'decimal' }); 21 | } 22 | 23 | public format(value: number): string { 24 | return `${this.numberFormat.format(value)} ${this.unit}` 25 | } 26 | } 27 | 28 | /** 29 | * {@link ScalingUnitFormatter} 30 | */ 31 | export abstract class ScalingUnitFormatter extends Formatter { 32 | 33 | private numberFormat: Intl.NumberFormat; 34 | 35 | /** 36 | * @param numberFormat An instance of {@link Intl.NumberFormat} to use to 37 | * format the scaled value. 38 | */ 39 | constructor(numberFormat: Intl.NumberFormat) { 40 | super(); 41 | this.numberFormat = numberFormat; 42 | } 43 | 44 | /** 45 | * Scales the passed raw value (in a base unit) to an appropriate value for 46 | * presentation and returns the scaled value as well as the name of the unit 47 | * that the returned value is in. 48 | * 49 | * @param value the value to be scaled. 50 | * 51 | * @returns {number,string} an array-like containing the numerical value and 52 | * the name of the unit that the value represents. 53 | */ 54 | protected abstract scale(value: number): [number, string]; 55 | 56 | public format(value: number): string { 57 | let [scaledValue, scaledUnit] = this.scale(value); 58 | return `${this.numberFormat.format(scaledValue)} ${scaledUnit}` 59 | } 60 | 61 | } 62 | 63 | /** 64 | * {@link BytesFormatter} formats values that represent a size in bytes as a 65 | * value in bytes, kilobytes, megabytes, gigabytes, etc. 66 | */ 67 | export class BytesFormatter extends ScalingUnitFormatter { 68 | 69 | constructor() { 70 | super(Intl.NumberFormat('en-US', { 71 | style: 'decimal', 72 | minimumFractionDigits: 2, 73 | maximumFractionDigits: 2 74 | })); 75 | } 76 | 77 | protected scale(value: number): [number, string] { 78 | let units = ["bytes", "KB", "MB", "GB", "TB", "PB"] 79 | while (value > 1024 && units.length > 0) { 80 | value = value / 1024 81 | units.shift(); 82 | } 83 | return [value, units[0]]; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Component, Vault, TFile, Plugin, debounce, MetadataCache, CachedMetadata, TFolder } from 'obsidian'; 2 | import { BytesFormatter, DecimalUnitFormatter } from './format'; 3 | import { VaultMetrics } from './metrics'; 4 | import { VaultMetricsCollector } from './collect'; 5 | import { StatisticsPluginSettings, StatisticsPluginSettingTab } from './settings'; 6 | 7 | const DEFAULT_SETTINGS: Partial = { 8 | displayIndividualItems: false, 9 | showNotes: false, 10 | showAttachments: false, 11 | showFiles: false, 12 | showLinks: false, 13 | showWords: false, 14 | showSize: false, 15 | }; 16 | 17 | export default class StatisticsPlugin extends Plugin { 18 | 19 | private statusBarItem: StatisticsStatusBarItem = null; 20 | 21 | public vaultMetricsCollector: VaultMetricsCollector; 22 | public vaultMetrics: VaultMetrics; 23 | 24 | settings: StatisticsPluginSettings; 25 | 26 | async onload() { 27 | console.log('Loading vault-statistics Plugin'); 28 | 29 | await this.loadSettings(); 30 | 31 | this.vaultMetrics = new VaultMetrics(); 32 | 33 | this.vaultMetricsCollector = new VaultMetricsCollector(this). 34 | setVault(this.app.vault). 35 | setMetadataCache(this.app.metadataCache). 36 | setVaultMetrics(this.vaultMetrics). 37 | start(); 38 | 39 | this.statusBarItem = new StatisticsStatusBarItem(this, this.addStatusBarItem()). 40 | setVaultMetrics(this.vaultMetrics); 41 | 42 | this.addSettingTab(new StatisticsPluginSettingTab(this.app, this)); 43 | } 44 | 45 | async loadSettings() { 46 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 47 | } 48 | 49 | async saveSettings() { 50 | await this.saveData(this.settings); 51 | this.statusBarItem.refresh(); 52 | } 53 | } 54 | 55 | /** 56 | * {@link StatisticView} is responsible for maintaining the DOM representation 57 | * of a given statistic. 58 | */ 59 | class StatisticView { 60 | 61 | /** Root node for the {@link StatisticView}. */ 62 | private containerEl: HTMLElement; 63 | 64 | /** Formatter that extracts and formats a value from a {@link Statistics} instance. */ 65 | private formatter: (s: VaultMetrics) => string; 66 | 67 | /** 68 | * Constructor. 69 | * 70 | * @param containerEl The parent element for the view. 71 | */ 72 | constructor(containerEl: HTMLElement) { 73 | this.containerEl = containerEl.createSpan({ cls: ["obsidian-vault-statistics--item"] }); 74 | this.setActive(false); 75 | } 76 | 77 | /** 78 | * Sets the name of the statistic. 79 | */ 80 | setStatisticName(name: string): StatisticView { 81 | this.containerEl.addClass(`obsidian-vault-statistics--item-${name}`); 82 | return this; 83 | } 84 | 85 | /** 86 | * Sets the formatter to use to produce the content of the view. 87 | */ 88 | setFormatter(formatter: (s: VaultMetrics) => string): StatisticView { 89 | this.formatter = formatter; 90 | return this; 91 | } 92 | 93 | /** 94 | * Updates the view with the desired active status. 95 | * 96 | * Active views have the CSS class `obsidian-vault-statistics--item-active` 97 | * applied, inactive views have the CSS class 98 | * `obsidian-vault-statistics--item-inactive` applied. These classes are 99 | * mutually exclusive. 100 | */ 101 | setActive(isActive: boolean): StatisticView { 102 | this.containerEl.removeClass("obsidian-vault-statistics--item--active"); 103 | this.containerEl.removeClass("obsidian-vault-statistics--item--inactive"); 104 | 105 | if (isActive) { 106 | this.containerEl.addClass("obsidian-vault-statistics--item--active"); 107 | } else { 108 | this.containerEl.addClass("obsidian-vault-statistics--item--inactive"); 109 | } 110 | 111 | return this; 112 | } 113 | 114 | /** 115 | * Refreshes the content of the view with content from the passed {@link 116 | * Statistics}. 117 | */ 118 | refresh(s: VaultMetrics) { 119 | this.containerEl.setText(this.formatter(s)); 120 | } 121 | 122 | /** 123 | * Returns the text content of the view. 124 | */ 125 | getText(): string { 126 | return this.containerEl.getText(); 127 | } 128 | } 129 | 130 | class StatisticsStatusBarItem { 131 | 132 | private owner: StatisticsPlugin; 133 | 134 | // handle of the status bar item to draw into. 135 | private statusBarItem: HTMLElement; 136 | 137 | // raw stats 138 | private vaultMetrics: VaultMetrics; 139 | 140 | // index of the currently displayed stat. 141 | private displayedStatisticIndex = 0; 142 | 143 | private statisticViews: Array = []; 144 | 145 | constructor(owner: StatisticsPlugin, statusBarItem: HTMLElement) { 146 | this.owner = owner; 147 | this.statusBarItem = statusBarItem; 148 | 149 | this.statisticViews.push(new StatisticView(this.statusBarItem). 150 | setStatisticName("notes"). 151 | setFormatter((s: VaultMetrics) => { return new DecimalUnitFormatter("notes").format(s.notes) })); 152 | this.statisticViews.push(new StatisticView(this.statusBarItem). 153 | setStatisticName("attachments"). 154 | setFormatter((s: VaultMetrics) => { return new DecimalUnitFormatter("attachments").format(s.attachments) })); 155 | this.statisticViews.push(new StatisticView(this.statusBarItem). 156 | setStatisticName("files"). 157 | setFormatter((s: VaultMetrics) => { return new DecimalUnitFormatter("files").format(s.files) })); 158 | this.statisticViews.push(new StatisticView(this.statusBarItem). 159 | setStatisticName("links"). 160 | setFormatter((s: VaultMetrics) => { return new DecimalUnitFormatter("links").format(s.links) })); 161 | this.statisticViews.push(new StatisticView(this.statusBarItem). 162 | setStatisticName("words"). 163 | setFormatter((s: VaultMetrics) => { return new DecimalUnitFormatter("words").format(s.words) })); 164 | this.statisticViews.push(new StatisticView(this.statusBarItem). 165 | setStatisticName("size"). 166 | setFormatter((s: VaultMetrics) => { return new BytesFormatter().format(s.size) })); 167 | 168 | this.statusBarItem.onClickEvent(() => { this.onclick() }); 169 | } 170 | 171 | public setVaultMetrics(vaultMetrics: VaultMetrics) { 172 | this.vaultMetrics = vaultMetrics; 173 | this.owner.registerEvent(this.vaultMetrics?.on("updated", this.refreshSoon)); 174 | this.refreshSoon(); 175 | return this; 176 | } 177 | 178 | private refreshSoon = debounce(() => { this.refresh(); }, 2000, false); 179 | 180 | public refresh() { 181 | if (this.owner.settings.displayIndividualItems) { 182 | this.statisticViews[0].setActive(this.owner.settings.showNotes).refresh(this.vaultMetrics); 183 | this.statisticViews[1].setActive(this.owner.settings.showAttachments).refresh(this.vaultMetrics); 184 | this.statisticViews[2].setActive(this.owner.settings.showFiles).refresh(this.vaultMetrics); 185 | this.statisticViews[3].setActive(this.owner.settings.showLinks).refresh(this.vaultMetrics); 186 | this.statisticViews[4].setActive(this.owner.settings.showWords).refresh(this.vaultMetrics); 187 | this.statisticViews[5].setActive(this.owner.settings.showSize).refresh(this.vaultMetrics); 188 | } else { 189 | this.statisticViews.forEach((view, i) => { 190 | view.setActive(this.displayedStatisticIndex == i).refresh(this.vaultMetrics); 191 | }); 192 | } 193 | 194 | this.statusBarItem.title = this.statisticViews.map(view => view.getText()).join("\n"); 195 | } 196 | 197 | private onclick() { 198 | if (!this.owner.settings.displayIndividualItems) { 199 | this.displayedStatisticIndex = (this.displayedStatisticIndex + 1) % this.statisticViews.length; 200 | } 201 | this.refresh(); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/metrics.ts: -------------------------------------------------------------------------------- 1 | import { Events, EventRef } from 'obsidian'; 2 | 3 | export interface VaultMetrics { 4 | files: number; 5 | notes: number; 6 | attachments: number; 7 | size: number; 8 | links: number; 9 | words: number; 10 | } 11 | 12 | export class VaultMetrics extends Events implements VaultMetrics { 13 | 14 | files: number = 0; 15 | notes: number = 0; 16 | attachments: number = 0; 17 | size: number = 0; 18 | links: number = 0; 19 | words: number = 0; 20 | 21 | public reset() { 22 | this.files = 0; 23 | this.notes = 0; 24 | this.attachments = 0; 25 | this.size = 0; 26 | this.links = 0; 27 | this.words = 0; 28 | } 29 | 30 | public dec(metrics: VaultMetrics) { 31 | this.files -= metrics?.files || 0; 32 | this.notes -= metrics?.notes || 0; 33 | this.attachments -= metrics?.attachments || 0; 34 | this.size -= metrics?.size || 0; 35 | this.links -= metrics?.links || 0; 36 | this.words -= metrics?.words || 0; 37 | this.trigger("updated"); 38 | } 39 | 40 | public inc(metrics: VaultMetrics) { 41 | this.files += metrics?.files || 0; 42 | this.notes += metrics?.notes || 0; 43 | this.attachments += metrics?.attachments || 0; 44 | this.size += metrics?.size || 0; 45 | this.links += metrics?.links || 0; 46 | this.words += metrics?.words || 0; 47 | this.trigger("updated"); 48 | } 49 | 50 | public on(name: "updated", callback: (vaultMetrics: VaultMetrics) => any, ctx?: any): EventRef { 51 | return super.on("updated", callback, ctx); 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting } from "obsidian"; 2 | 3 | import StatisticsPlugin from "./main"; 4 | 5 | export interface StatisticsPluginSettings { 6 | displayIndividualItems: boolean, 7 | showNotes: boolean, 8 | showAttachments: boolean, 9 | showFiles: boolean, 10 | showLinks: boolean, 11 | showWords: boolean, 12 | showSize: boolean, 13 | } 14 | 15 | export class StatisticsPluginSettingTab extends PluginSettingTab { 16 | plugin: StatisticsPlugin; 17 | 18 | constructor(app: App, plugin: StatisticsPlugin) { 19 | super(app, plugin); 20 | this.plugin = plugin; 21 | } 22 | 23 | display(): void { 24 | let { containerEl } = this; 25 | 26 | containerEl.empty(); 27 | 28 | new Setting(containerEl) 29 | .setName("Show individual items") 30 | .setDesc("Whether to show multiple items at once or cycle them with a click") 31 | .addToggle((value) => { 32 | value 33 | .setValue(this.plugin.settings.displayIndividualItems) 34 | .onChange(async (value) => { 35 | this.plugin.settings.displayIndividualItems = value; 36 | this.display(); 37 | await this.plugin.saveSettings(); 38 | }); 39 | }); 40 | 41 | if (!this.plugin.settings.displayIndividualItems) { 42 | return; 43 | } 44 | 45 | new Setting(containerEl) 46 | .setName("Show notes") 47 | .addToggle((value) => { 48 | value 49 | .setValue(this.plugin.settings.showNotes) 50 | .onChange(async (value) => { 51 | this.plugin.settings.showNotes = value; 52 | await this.plugin.saveSettings(); 53 | }); 54 | }); 55 | 56 | new Setting(containerEl) 57 | .setName("Show attachments") 58 | .addToggle((value) => { 59 | value 60 | .setValue(this.plugin.settings.showAttachments) 61 | .onChange(async (value) => { 62 | this.plugin.settings.showAttachments = value; 63 | await this.plugin.saveSettings(); 64 | }); 65 | }); 66 | 67 | new Setting(containerEl) 68 | .setName("Show files") 69 | .addToggle((value) => { 70 | value 71 | .setValue(this.plugin.settings.showFiles) 72 | .onChange(async (value) => { 73 | this.plugin.settings.showFiles = value; 74 | await this.plugin.saveSettings(); 75 | }); 76 | }); 77 | 78 | new Setting(containerEl) 79 | .setName("Show links") 80 | .addToggle((value) => { 81 | value 82 | .setValue(this.plugin.settings.showLinks) 83 | .onChange(async (value) => { 84 | this.plugin.settings.showLinks = value; 85 | await this.plugin.saveSettings(); 86 | }); 87 | }); 88 | 89 | new Setting(containerEl) 90 | .setName("Show words") 91 | .addToggle((value) => { 92 | value 93 | .setValue(this.plugin.settings.showWords) 94 | .onChange(async (value) => { 95 | this.plugin.settings.showWords = value; 96 | await this.plugin.saveSettings(); 97 | }); 98 | }); 99 | 100 | new Setting(containerEl) 101 | .setName("Show size") 102 | .addToggle((value) => { 103 | value 104 | .setValue(this.plugin.settings.showSize) 105 | .onChange(async (value) => { 106 | this.plugin.settings.showSize = value; 107 | await this.plugin.saveSettings(); 108 | }); 109 | }); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/text.spec.ts: -------------------------------------------------------------------------------- 1 | import { markdown_tokenize } from './text'; 2 | 3 | 4 | describe("base cases", () => { 5 | test("empty string yields empty set", () => { 6 | expect(markdown_tokenize("")).toStrictEqual([]); 7 | }); 8 | 9 | test("single word content yields single element", () => { 10 | expect(markdown_tokenize("foo")).toStrictEqual(["foo"]); 11 | }); 12 | }); 13 | 14 | describe("word boundaries", () => { 15 | test("\\s", () => { 16 | expect(markdown_tokenize("foo bar baz")).toStrictEqual(["foo", "bar", "baz"]); 17 | }); 18 | 19 | test("\\n", () => { 20 | expect(markdown_tokenize("foo\nbar\nbaz")).toStrictEqual(["foo", "bar", "baz"]); 21 | }); 22 | 23 | test("\\r", () => { 24 | expect(markdown_tokenize("foo\rbar\rbaz")).toStrictEqual(["foo", "bar", "baz"]); 25 | }); 26 | 27 | test("\\t", () => { 28 | expect(markdown_tokenize("foo\tbar\tbaz")).toStrictEqual(["foo", "bar", "baz"]); 29 | }); 30 | 31 | test("\"", () => { 32 | expect(markdown_tokenize("foo \"bar\" baz")).toStrictEqual(["foo", "bar", "baz"]); 33 | }); 34 | 35 | test("|", () => { 36 | expect(markdown_tokenize("foo|bar|baz")).toStrictEqual(["foo", "bar", "baz"]); 37 | }); 38 | 39 | test(",", () => { 40 | expect(markdown_tokenize("foo,bar,baz")).toStrictEqual(["foo", "bar", "baz"]); 41 | }); 42 | 43 | test("( and )", () => { 44 | expect(markdown_tokenize("foo(bar)baz")).toStrictEqual(["foo", "bar", "baz"]); 45 | }); 46 | 47 | test("[ and ]", () => { 48 | expect(markdown_tokenize("foo[bar]baz")).toStrictEqual(["foo", "bar", "baz"]); 49 | }); 50 | 51 | test("/", () => { 52 | expect(markdown_tokenize("foo/bar")).toStrictEqual(["foo", "bar"]); 53 | }); 54 | }); 55 | 56 | describe("punctuation handling", () => { 57 | test("strips punctuation characters", () => { 58 | expect(markdown_tokenize("foo\nbar\nbaz")).toStrictEqual(["foo", "bar", "baz"]); 59 | }); 60 | }); 61 | 62 | describe("filtering", () => { 63 | test("non-words are removed", () => { 64 | expect(markdown_tokenize("!")).toStrictEqual([]); 65 | expect(markdown_tokenize("@")).toStrictEqual([]); 66 | expect(markdown_tokenize("#")).toStrictEqual([]); 67 | expect(markdown_tokenize("$")).toStrictEqual([]); 68 | expect(markdown_tokenize("%")).toStrictEqual([]); 69 | expect(markdown_tokenize("^")).toStrictEqual([]); 70 | expect(markdown_tokenize("&")).toStrictEqual([]); 71 | expect(markdown_tokenize("*")).toStrictEqual([]); 72 | expect(markdown_tokenize("(")).toStrictEqual([]); 73 | expect(markdown_tokenize(")")).toStrictEqual([]); 74 | expect(markdown_tokenize("`")).toStrictEqual([]); 75 | }); 76 | 77 | test("numbers are not words", () => { 78 | expect(markdown_tokenize("1")).toStrictEqual([]); 79 | expect(markdown_tokenize("123")).toStrictEqual([]); 80 | expect(markdown_tokenize("1231231")).toStrictEqual([]); 81 | }); 82 | 83 | test("code block headers", () => { 84 | expect(markdown_tokenize("```")).toStrictEqual([]); 85 | expect(markdown_tokenize("```java")).toStrictEqual([]); 86 | expect(markdown_tokenize("```perl")).toStrictEqual([]); 87 | expect(markdown_tokenize("```python")).toStrictEqual([]); 88 | }) 89 | }); 90 | 91 | describe("strip punctuation", () => { 92 | test("highlights", () => { 93 | expect(markdown_tokenize("==foo")).toStrictEqual(["foo"]); 94 | expect(markdown_tokenize("foo==")).toStrictEqual(["foo"]); 95 | expect(markdown_tokenize("==foo==")).toStrictEqual(["foo"]); 96 | }); 97 | 98 | test("formatting", () => { 99 | expect(markdown_tokenize("*foo")).toStrictEqual(["foo"]); 100 | expect(markdown_tokenize("foo*")).toStrictEqual(["foo"]); 101 | expect(markdown_tokenize("*foo*")).toStrictEqual(["foo"]); 102 | 103 | expect(markdown_tokenize("**foo")).toStrictEqual(["foo"]); 104 | expect(markdown_tokenize("foo**")).toStrictEqual(["foo"]); 105 | expect(markdown_tokenize("**foo**")).toStrictEqual(["foo"]); 106 | 107 | expect(markdown_tokenize("__foo")).toStrictEqual(["foo"]); 108 | expect(markdown_tokenize("foo__")).toStrictEqual(["foo"]); 109 | expect(markdown_tokenize("__foo__")).toStrictEqual(["foo"]); 110 | }); 111 | 112 | test("punctuation", () => { 113 | expect(markdown_tokenize("\"foo")).toStrictEqual(["foo"]); 114 | expect(markdown_tokenize("foo\"")).toStrictEqual(["foo"]); 115 | expect(markdown_tokenize("\"foo\"")).toStrictEqual(["foo"]); 116 | 117 | expect(markdown_tokenize("`foo")).toStrictEqual(["foo"]); 118 | expect(markdown_tokenize("foo`")).toStrictEqual(["foo"]); 119 | expect(markdown_tokenize("`foo`")).toStrictEqual(["foo"]); 120 | 121 | expect(markdown_tokenize("foo:")).toStrictEqual(["foo"]); 122 | expect(markdown_tokenize("foo.")).toStrictEqual(["foo"]); 123 | expect(markdown_tokenize("foo,")).toStrictEqual(["foo"]); 124 | expect(markdown_tokenize("foo?")).toStrictEqual(["foo"]); 125 | expect(markdown_tokenize("foo!")).toStrictEqual(["foo"]); 126 | }); 127 | 128 | test("callouts", () => { 129 | expect(markdown_tokenize("[!foo]")).toStrictEqual(["foo"]); 130 | expect(markdown_tokenize("[!foo bar]")).toStrictEqual(["foo", "bar"]); 131 | }); 132 | 133 | test("wiki links", () => { 134 | expect(markdown_tokenize("[[foo")).toStrictEqual(["foo"]); 135 | expect(markdown_tokenize("foo]]")).toStrictEqual(["foo"]); 136 | expect(markdown_tokenize("[[foo]]")).toStrictEqual(["foo"]); 137 | }); 138 | 139 | test("combinations", () => { 140 | expect(markdown_tokenize("_**foo**_:]],:`.`")).toStrictEqual(["foo"]); 141 | }); 142 | }); 143 | 144 | describe("integration tests", () => { 145 | test("sentences", () => { 146 | expect(markdown_tokenize("Lorem ipsum dolor sit amet, consectetur adipiscing elit.")). 147 | toStrictEqual([ 148 | "Lorem", 149 | "ipsum", 150 | "dolor", 151 | "sit", 152 | "amet", 153 | "consectetur", 154 | "adipiscing", 155 | "elit", 156 | ]); 157 | }); 158 | 159 | test("paragraphs", () => { 160 | expect(markdown_tokenize("Lorem ipsum dolor sit amet, consectetur adipiscing elit. \ 161 | Curabitur facilisis iaculis turpis eu viverra. Donec rhoncus sit amet velit vel euismod. \ 162 | Aenean eros orci, tincidunt a odio sed, pellentesque mattis magna. Praesent id turpis \ 163 | placerat, scelerisque sapien pharetra, suscipit erat. Quisque sed consectetur diam, \ 164 | fermentum volutpat dolor. Suspendisse et dictum tellus, in laoreet nisi. Sed nec porta \ 165 | felis. Morbi ultrices metus non metus facilisis mollis. Proin id finibus velit, in \ 166 | blandit nulla. Vivamus id posuere dui.")). 167 | toStrictEqual([ 168 | "Lorem", 169 | "ipsum", 170 | "dolor", 171 | "sit", 172 | "amet", 173 | "consectetur", 174 | "adipiscing", 175 | "elit", 176 | "Curabitur", 177 | "facilisis", 178 | "iaculis", 179 | "turpis", 180 | "eu", 181 | "viverra", 182 | "Donec", 183 | "rhoncus", 184 | "sit", 185 | "amet", 186 | "velit", 187 | "vel", 188 | "euismod", 189 | "Aenean", 190 | "eros", 191 | "orci", 192 | "tincidunt", 193 | "a", 194 | "odio", 195 | "sed", 196 | "pellentesque", 197 | "mattis", 198 | "magna", 199 | "Praesent", 200 | "id", 201 | "turpis", 202 | "placerat", 203 | "scelerisque", 204 | "sapien", 205 | "pharetra", 206 | "suscipit", 207 | "erat", 208 | "Quisque", 209 | "sed", 210 | "consectetur", 211 | "diam", 212 | "fermentum", 213 | "volutpat", 214 | "dolor", 215 | "Suspendisse", 216 | "et", 217 | "dictum", 218 | "tellus", 219 | "in", 220 | "laoreet", 221 | "nisi", 222 | "Sed", 223 | "nec", 224 | "porta", 225 | "felis", 226 | "Morbi", 227 | "ultrices", 228 | "metus", 229 | "non", 230 | "metus", 231 | "facilisis", 232 | "mollis", 233 | "Proin", 234 | "id", 235 | "finibus", 236 | "velit", 237 | "in", 238 | "blandit", 239 | "nulla", 240 | "Vivamus", 241 | "id", 242 | "posuere", 243 | "dui", 244 | ]); 245 | }); 246 | 247 | test("callouts", () => { 248 | expect(markdown_tokenize("> [!Lorem]\ 249 | > Ipsum, dolor sit amet.")). 250 | toStrictEqual([ 251 | "Lorem", 252 | "Ipsum", 253 | "dolor", 254 | "sit", 255 | "amet", 256 | ]); 257 | }); 258 | }); 259 | -------------------------------------------------------------------------------- /src/text.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface Tokenizer { 3 | tokenize(content: string): Array; 4 | } 5 | 6 | /** 7 | * The {@link UnitTokenizer} is a constant tokenizer that always returns an 8 | * empty list. 9 | */ 10 | class UnitTokenizer implements Tokenizer { 11 | public tokenize(_: string): Array { 12 | return []; 13 | } 14 | } 15 | 16 | /** 17 | * {@link MarkdownTokenizer} understands how to tokenize markdown text into word 18 | * tokens. 19 | */ 20 | class MarkdownTokenizer implements Tokenizer { 21 | 22 | private isNonWord(token: string): boolean { 23 | const NON_WORDS = /^\W+$/; 24 | return !!NON_WORDS.exec(token); 25 | } 26 | 27 | private isNumber(token: string): boolean { 28 | const NUMBER = /^\d+(\.\d+)?$/; 29 | return !!NUMBER.exec(token); 30 | } 31 | 32 | private isCodeBlockHeader(token: string): boolean { 33 | const CODE_BLOCK_HEADER = /^```\w+$/; 34 | return !!CODE_BLOCK_HEADER.exec(token); 35 | } 36 | 37 | private stripHighlights(token: string): string { 38 | const STRIP_HIGHLIGHTS = /^(==)?(.*?)(==)?$/; 39 | return STRIP_HIGHLIGHTS.exec(token)[2]; 40 | } 41 | 42 | private stripFormatting(token: string): string { 43 | const STRIP_FORMATTING = /^(_+|\*+)?(.*?)(_+|\*+)?$/; 44 | return STRIP_FORMATTING.exec(token)[2]; 45 | } 46 | 47 | private stripPunctuation(token: string): string { 48 | const STRIP_PUNCTUATION = /^(`|\.|:|"|,|!|\?)?(.*?)(`|\.|:|"|,|!|\?)?$/; 49 | return STRIP_PUNCTUATION.exec(token)[2]; 50 | } 51 | 52 | private stripWikiLinks(token: string): string { 53 | const STRIP_WIKI_LINKS = /^(\[\[)?(.*?)(\]\])?$/; 54 | return STRIP_WIKI_LINKS.exec(token)[2]; 55 | } 56 | 57 | private stripAll(token: string): string { 58 | if (token === "") { 59 | return token; 60 | } 61 | 62 | let isFixedPoint = false; 63 | while (!isFixedPoint) { 64 | let prev = token; 65 | token = [token]. 66 | map(this.stripHighlights). 67 | map(this.stripFormatting). 68 | map(this.stripPunctuation). 69 | map(this.stripWikiLinks)[0]; 70 | isFixedPoint = isFixedPoint || prev === token; 71 | } 72 | return token; 73 | } 74 | 75 | public tokenize(content: string): Array { 76 | if (content.trim() === "") { 77 | return []; 78 | } else { 79 | const WORD_BOUNDARY = /[ \n\r\t\"\|,\(\)\[\]/]+/; 80 | let words = content. 81 | split(WORD_BOUNDARY). 82 | filter(token => !this.isNonWord(token)). 83 | filter(token => !this.isNumber(token)). 84 | filter(token => !this.isCodeBlockHeader(token)). 85 | map(token => this.stripAll(token)). 86 | filter(token => token.length > 0); 87 | return words; 88 | } 89 | } 90 | } 91 | 92 | export const UNIT_TOKENIZER = new UnitTokenizer(); 93 | export const MARKDOWN_TOKENIZER = new MarkdownTokenizer(); 94 | 95 | export function unit_tokenize(_: string): Array { 96 | return UNIT_TOKENIZER.tokenize(_); 97 | } 98 | 99 | export function markdown_tokenize(content: string): Array { 100 | return MARKDOWN_TOKENIZER.tokenize(content); 101 | } 102 | 103 | export { }; 104 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .obsidian-vault-statistics--item { 2 | margin-left: 0.75em; 3 | } 4 | 5 | .obsidian-vault-statistics--item--inactive { 6 | display: none; 7 | } 8 | 9 | .obsidian-vault-statistics--item--active {} 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es5", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "lib": [ 13 | "dom", 14 | "es5", 15 | "scripthost", 16 | "es2015" 17 | ] 18 | }, 19 | "include": [ 20 | "**/*.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.1": "0.9.12", 3 | "1.0.0": "0.9.7" 4 | } 5 | --------------------------------------------------------------------------------