├── .editorconfig
├── .eslintrc.js
├── .github
├── FUNDING.yml
└── workflows
│ ├── main.yml
│ └── releases.yml
├── .gitignore
├── .husky
├── .gitignore
└── pre-commit
├── .npmrc
├── .prettierrc
├── README.md
├── jest.config.js
├── manifest.json
├── package.json
├── src
├── cmExtension
│ ├── suggestionsExtension.css
│ └── suggestionsExtension.ts
├── components
│ ├── suggestionsPopup.css
│ └── suggestionsPopup.ts
├── index.ts
├── indexing
│ └── indexer.ts
├── plugin-helper.ts
├── search
│ ├── index.ts
│ ├── mapStemToOriginalText.ts
│ ├── redactText.spec.ts
│ ├── redactText.ts
│ └── search.spec.ts
├── stemmers
│ └── index.ts
├── tokenizers
│ ├── index.ts
│ └── tokenizer.spec.ts
└── utils
│ ├── getAliases.spec.ts
│ └── getAliases.ts
├── tsconfig.json
├── versions.json
└── webpack.config.ts
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | trim_trailing_whitespace = true
7 | insert_final_newline = true
8 |
9 | [*.md]
10 | insert_final_newline = false
11 | trim_trailing_whitespace = false
12 |
13 | [*.{js,jsx,json,ts,tsx,yml,svelte,svg}]
14 | indent_size = 2
15 | indent_style = space
16 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | parser: '@typescript-eslint/parser',
4 | plugins: ['@typescript-eslint'],
5 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
6 | rules: {
7 | '@typescript-eslint/no-unused-vars': [2, { args: 'all', argsIgnorePattern: '^_' }],
8 | },
9 | env: {
10 | node: true,
11 | },
12 | overrides: [
13 | {
14 | files: ['*.js'],
15 | rules: {
16 | '@typescript-eslint/no-var-requires': 'off',
17 | },
18 | },
19 | ],
20 | };
21 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [hadynz]
2 | custom: ["https://buymeacoffee.com/hadynz"]
3 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | branches: [master]
6 |
7 | jobs:
8 | lint-and-test:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v2
12 | - name: Use Node.js
13 | uses: actions/setup-node@v1
14 | with:
15 | node-version: '15.x'
16 |
17 | - name: Install modules
18 | run: npm install
19 |
20 | - name: Lint
21 | run: npm run lint
22 |
23 | - name: Run tests
24 | run: npm run test
25 |
--------------------------------------------------------------------------------
/.github/workflows/releases.yml:
--------------------------------------------------------------------------------
1 | name: Build obsidian plugin
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | env:
8 | PLUGIN_NAME: obsidian-kindle-plugin
9 |
10 | jobs:
11 | build:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v2
15 | - name: Use Node.js
16 | uses: actions/setup-node@v1
17 | with:
18 | node-version: '15.x'
19 |
20 | - name: Build
21 | id: build
22 | env:
23 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
24 | run: |
25 | npm install
26 | npm run lint
27 | npm run test
28 | npm run build
29 | zip -r -j ${{ env.PLUGIN_NAME }}.zip dist -x "*.map"
30 | ls
31 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)"
32 |
33 | - name: Upload main.js
34 | id: upload-main
35 | uses: actions/upload-release-asset@v1
36 | env:
37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38 | with:
39 | upload_url: ${{ github.event.release.upload_url }}
40 | asset_path: ./dist/main.js
41 | asset_name: main.js
42 | asset_content_type: text/javascript
43 |
44 | - name: Upload manifest.json
45 | id: upload-manifest
46 | uses: actions/upload-release-asset@v1
47 | env:
48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49 | with:
50 | upload_url: ${{ github.event.release.upload_url }}
51 | asset_path: ./dist/manifest.json
52 | asset_name: manifest.json
53 | asset_content_type: application/json
54 |
55 | - name: Upload plugin zip package
56 | id: upload-zip-package
57 | uses: actions/upload-release-asset@v1
58 | env:
59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
60 | with:
61 | upload_url: ${{ github.event.release.upload_url }}
62 | asset_path: ./${{ env.PLUGIN_NAME }}.zip
63 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip
64 | asset_content_type: application/zip
65 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Intellij
2 | *.iml
3 | .idea
4 |
5 | # npm
6 | node_modules
7 |
8 | # nunjucks file
9 | main.js.LICENSE.txt
10 |
11 | # eslint
12 | .eslintcache
13 |
14 | # obsidian plugin files
15 | main.js
16 | main.js.map
17 | data.json
18 |
19 | coverage
20 | dist
21 | tsconfig.tsbuildinfo
22 | .env
23 |
--------------------------------------------------------------------------------
/.husky/.gitignore:
--------------------------------------------------------------------------------
1 | _
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 95,
3 | "svelteSortOrder": "scripts-markup-styles",
4 | "svelteBracketNewLine": true,
5 | "svelteAllowShorthand": true,
6 | "svelteIndentScriptAndStyle": true,
7 | "singleQuote": true
8 | }
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Sidekick
2 |
3 | 
4 | 
5 |
6 | An [Obsidian][3] plugin that auto-suggests new connections to make by underlining text that matches
7 | existing tags or links in your vault.
8 |
9 | Support development: @hadynz on [Twitter][2] or [Buy me a coffee][1]
10 |
11 |
12 |
13 | ## About Sidekick
14 |
15 | ### Goals
16 |
17 | * Increase the number of connections between existing ideas
18 | * Unobtrusive and near real-time automatic suggestion of connections during editing
19 |
20 | ### Preview
21 |
22 | 
23 |
24 | ## My other plugins
25 |
26 | * [Kindle Highlights][4] - sync your Kindle notes and highlights directly into your Obsidian vault
27 |
28 | ## License
29 |
30 | [MIT](LICENSE)
31 |
32 | [1]: https://www.buymeacoffee.com/hadynz
33 | [2]: https://twitter.com/hadynz
34 | [3]: https://obsidian.md
35 | [4]: https://github.com/hadynz/obsidian-kindle-plugin
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | transform: {
3 | '^.+\\.tsx?$': 'ts-jest',
4 | },
5 | moduleNameMapper: {
6 | '^~/(.*)': '/src/$1',
7 | },
8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
9 | testRegex: '(/(tests|src)/.*.(test|spec))\\.(ts|js)x?$',
10 | coverageDirectory: 'coverage',
11 | collectCoverageFrom: ['src/**/*.test.{ts,tsx,js,jsx}', '!src/**/*.d.ts'],
12 | };
13 |
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "obsidian-sidekick",
3 | "name": "Sidekick",
4 | "description": "A companion to identify hidden connections that match your tags and pages",
5 | "version": "1.5.1",
6 | "minAppVersion": "0.13.8",
7 | "author": "Hady Osman",
8 | "authorUrl": "https://hady.geek.nz",
9 | "isDesktopOnly": false
10 | }
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "obsidian-sidekick",
3 | "version": "1.5.1",
4 | "description": "A companion to identify hidden connections that match your tags and pages",
5 | "main": "src/index.ts",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/hadynz/obsidian-sidekick.git"
9 | },
10 | "keywords": [
11 | "obsidian",
12 | "autocomplete",
13 | "autosuggestion",
14 | "connections"
15 | ],
16 | "author": {
17 | "name": "Hady Osman",
18 | "email": "hadyos@gmail.com",
19 | "url": "https://hady.geek.nz"
20 | },
21 | "license": "MIT",
22 | "scripts": {
23 | "prettier": "prettier --write '**/*.{ts,js,css,html}'",
24 | "lint": "tsc --noemit && eslint . --ext .ts",
25 | "clean": "rimraf dist main.js*",
26 | "test": "jest --passWithNoTests --verbose",
27 | "test-watch": "jest --watch",
28 | "dev": "NODE_ENV=development webpack && cp ./dist/main.js* .",
29 | "build": "NODE_ENV=production webpack",
30 | "prepare": "husky install"
31 | },
32 | "devDependencies": {
33 | "@codemirror/rangeset": "^0.19.7",
34 | "@codemirror/state": "^0.19.9",
35 | "@codemirror/view": "^0.19.42",
36 | "@types/faker": "^5.5.8",
37 | "@types/jest": "^26.0.22",
38 | "@types/lodash": "^4.14.178",
39 | "@types/lokijs": "^1.5.7",
40 | "@types/webpack": "^5.28.0",
41 | "@typescript-eslint/eslint-plugin": "^4.22.0",
42 | "@typescript-eslint/parser": "^4.22.0",
43 | "babel-loader": "^8.2.2",
44 | "copy-webpack-plugin": "^10.0.0",
45 | "css-loader": "^6.6.0",
46 | "eslint": "^7.24.0",
47 | "faker": "^5.5.3",
48 | "file-loader": "^6.2.0",
49 | "husky": "^7.0.4",
50 | "jest": "^26.6.3",
51 | "lint-staged": "^11.2.4",
52 | "obsidian": "^0.13.11",
53 | "prettier": "^2.2.1",
54 | "rimraf": "^3.0.2",
55 | "style-loader": "^3.3.1",
56 | "terser-webpack-plugin": "^5.2.5",
57 | "ts-jest": "^26.5.4",
58 | "ts-loader": "^8.1.0",
59 | "ts-node": "^10.5.0",
60 | "typescript": "^4.2.3",
61 | "uglify-js": "^3.15.1",
62 | "url-loader": "^4.1.1",
63 | "webpack": "^5.30.0",
64 | "webpack-cli": "^4.6.0",
65 | "webpack-node-externals": "^2.5.2"
66 | },
67 | "lint-staged": {
68 | "*.ts": "eslint --cache --fix",
69 | "*.{js,css,md}": "prettier --write"
70 | },
71 | "dependencies": {
72 | "@tanishiking/aho-corasick": "^0.0.1",
73 | "@types/natural": "^5.1.0",
74 | "lodash": "^4.17.21",
75 | "lokijs": "^1.5.12",
76 | "natural": "^5.1.13",
77 | "tiny-typed-emitter": "^2.1.0"
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/cmExtension/suggestionsExtension.css:
--------------------------------------------------------------------------------
1 | .cm-suggestion-candidate {
2 | position: relative;
3 | border-bottom: 2px solid var(--interactive-accent);
4 | display: inline-block;
5 | }
6 |
7 | .cm-suggestion-candidate::before {
8 | content: '';
9 | position: absolute;
10 | top: 0;
11 | left: 0;
12 | bottom: 0;
13 | right: 0;
14 | background-color: var(--interactive-accent);
15 | opacity: 0.25;
16 | z-index: -1;
17 | }
18 |
--------------------------------------------------------------------------------
/src/cmExtension/suggestionsExtension.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Decoration,
3 | EditorView,
4 | ViewPlugin,
5 | ViewUpdate,
6 | type DecorationSet,
7 | type PluginValue,
8 | } from '@codemirror/view';
9 | import { RangeSetBuilder } from '@codemirror/rangeset';
10 | import { App, debounce, type Debouncer } from 'obsidian';
11 |
12 | import { showSuggestionsModal } from '../components/suggestionsPopup';
13 | import type Search from '../search';
14 |
15 | import './suggestionsExtension.css';
16 |
17 | const SuggestionCandidateClass = 'cm-suggestion-candidate';
18 |
19 | const underlineDecoration = (start: number, end: number, indexKeyword: string) =>
20 | Decoration.mark({
21 | class: SuggestionCandidateClass,
22 | attributes: {
23 | 'data-index-keyword': indexKeyword,
24 | 'data-position-start': `${start}`,
25 | 'data-position-end': `${end}`,
26 | },
27 | });
28 |
29 | export const suggestionsExtension = (search: Search, app: App): ViewPlugin => {
30 | return ViewPlugin.fromClass(
31 | class {
32 | decorations: DecorationSet;
33 | delayedDecorateView: Debouncer<[view: EditorView]>;
34 |
35 | constructor(view: EditorView) {
36 | this.updateDebouncer(view);
37 | this.decorations = this.decorateView(view);
38 | }
39 |
40 | public update(update: ViewUpdate): void {
41 | if (update.docChanged || update.viewportChanged) {
42 | this.delayedDecorateView(update.view);
43 | }
44 | }
45 |
46 | private updateDebouncer(_view: EditorView) {
47 | this.delayedDecorateView = debounce(
48 | (view: EditorView) => {
49 | this.decorations = this.decorateView(view);
50 | view.update([]); // force a view update so that the decorations we just set get applied
51 | },
52 | 1000,
53 | true
54 | );
55 | }
56 |
57 | private decorateView(view: EditorView): DecorationSet {
58 | const builder = new RangeSetBuilder();
59 |
60 | // Decorate visible ranges only for performance reasons
61 | for (const { from, to } of view.visibleRanges) {
62 | const textToHighlight = view.state.sliceDoc(from, to);
63 | const results = textToHighlight ? search.find(textToHighlight) : [];
64 |
65 | for (const result of results) {
66 | // Offset result by the start of the visible range
67 | const start = from + result.start;
68 | const end = from + result.end;
69 |
70 | // Add the decoration
71 | builder.add(start, end, underlineDecoration(start, end, result.indexKeyword));
72 | }
73 | }
74 |
75 | return builder.finish();
76 | }
77 | },
78 | {
79 | decorations: (view) => view.decorations,
80 |
81 | eventHandlers: {
82 | mousedown: (e: MouseEvent, view: EditorView) => {
83 | const target = e.target as HTMLElement;
84 | const isCandidate = target.classList.contains(SuggestionCandidateClass);
85 |
86 | // Do nothing if user right-clicked or unrelated DOM element was clicked
87 | if (!isCandidate || e.button !== 0) {
88 | return;
89 | }
90 |
91 | // Extract position and replacement text from target element data attributes state
92 | const { positionStart, positionEnd, indexKeyword } = target.dataset;
93 |
94 | // Show suggestions modal
95 | showSuggestionsModal({
96 | app,
97 | mouseEvent: e,
98 | suggestions: search.getReplacementSuggestions(indexKeyword),
99 | onClick: (replaceText) => {
100 | view.dispatch({
101 | changes: {
102 | from: +positionStart,
103 | to: +positionEnd,
104 | insert: replaceText,
105 | },
106 | });
107 | },
108 | });
109 | },
110 | },
111 | }
112 | );
113 | };
114 |
--------------------------------------------------------------------------------
/src/components/suggestionsPopup.css:
--------------------------------------------------------------------------------
1 | .tippy-box[data-theme~='obsidian'] {
2 | background-color: var(--background-secondary-alt);
3 | color: var(--text-normal);
4 | }
5 |
6 | .tippy-box[data-theme~='obsidian'] .tippy-content {
7 | padding: 5px 7px 7px;
8 | }
9 |
10 | .tippy-box[data-theme~='obsidian'] .tippy-arrow {
11 | color: var(--background-secondary-alt);
12 | }
13 |
14 | .tippy-box[data-theme~='obsidian'] button {
15 | padding: 4px 14px;
16 | margin: 0;
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/suggestionsPopup.ts:
--------------------------------------------------------------------------------
1 | import { App, Menu, MenuItem } from 'obsidian';
2 |
3 | type SuggestionsModalProps = {
4 | app: App;
5 | mouseEvent: MouseEvent;
6 | suggestions: string[];
7 | onClick: (replaceText: string) => void;
8 | };
9 |
10 | const item = (icon, title, click) => {
11 | return (item: MenuItem) => item.setIcon(icon).setTitle(title).onClick(click);
12 | };
13 |
14 | export const showSuggestionsModal = (props: SuggestionsModalProps): void => {
15 | const { app, mouseEvent, suggestions, onClick } = props;
16 |
17 | setTimeout(() => {
18 | const menu = new Menu(app);
19 |
20 | suggestions.forEach((replaceText) => {
21 | menu.addItem(
22 | item('pencil', `Replace with ${replaceText}`, () => {
23 | onClick(replaceText);
24 | })
25 | );
26 | });
27 |
28 | menu.addSeparator();
29 | menu.showAtMouseEvent(mouseEvent);
30 | }, 100);
31 | };
32 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Plugin } from 'obsidian';
2 | import type { Extension } from '@codemirror/state';
3 |
4 | import Search from './search';
5 | import { PluginHelper } from './plugin-helper';
6 | import { Indexer } from './indexing/indexer';
7 | import { suggestionsExtension } from './cmExtension/suggestionsExtension';
8 |
9 | export default class TagsAutosuggestPlugin extends Plugin {
10 | private editorExtension: Extension[] = [];
11 |
12 | public async onload(): Promise {
13 | console.log('Autosuggest plugin: loading plugin', new Date().toLocaleString());
14 |
15 | const pluginHelper = new PluginHelper(this);
16 | const indexer = new Indexer(pluginHelper);
17 |
18 | this.registerEditorExtension(this.editorExtension);
19 |
20 | // Update index for any file that was modified in the vault
21 | pluginHelper.onFileRename((file) => indexer.replaceFileIndices(file));
22 | pluginHelper.onFileMetadataChanged((file) => indexer.replaceFileIndices(file));
23 |
24 | // Re/load highlighting extension after any changes to index
25 | indexer.on('indexRebuilt', () => {
26 | const search = new Search(indexer);
27 | this.updateEditorExtension(suggestionsExtension(search, this.app));
28 | });
29 |
30 | indexer.on('indexUpdated', () => {
31 | const search = new Search(indexer);
32 | this.updateEditorExtension(suggestionsExtension(search, this.app));
33 | });
34 |
35 | // Build search index on startup (very expensive process)
36 | pluginHelper.onLayoutReady(() => indexer.buildIndex());
37 | }
38 |
39 | /**
40 | * Ref: https://github.com/obsidianmd/obsidian-releases/blob/master/plugin-review.md#how-to-changereconfigure-your-cm6-extensions
41 | */
42 | private updateEditorExtension(extension: Extension) {
43 | this.editorExtension.length = 0;
44 | this.editorExtension.push(extension);
45 | this.app.workspace.updateOptions();
46 | }
47 |
48 | public async onunload(): Promise {
49 | console.log('Autosuggest plugin: unloading plugin', new Date().toLocaleString());
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/indexing/indexer.ts:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import lokijs from 'lokijs';
3 | import { TypedEmitter } from 'tiny-typed-emitter';
4 | import type { TFile } from 'obsidian';
5 |
6 | import { stemPhrase } from '../stemmers';
7 | import { WordPermutationsTokenizer } from '../tokenizers';
8 | import type { PluginHelper } from '../plugin-helper';
9 |
10 | type Document = {
11 | fileCreationTime: number;
12 | type: 'tag' | 'alias' | 'page' | 'page-token';
13 | keyword: string;
14 | originalText: string;
15 | replaceText: string;
16 | };
17 |
18 | interface IndexerEvents {
19 | indexRebuilt: () => void;
20 | indexUpdated: () => void;
21 | }
22 |
23 | export class Indexer extends TypedEmitter {
24 | private documents: Collection;
25 | private permutationTokenizer: WordPermutationsTokenizer;
26 |
27 | constructor(private pluginHelper: PluginHelper) {
28 | super();
29 |
30 | const db = new lokijs('sidekick');
31 |
32 | this.documents = db.addCollection('documents', {
33 | indices: ['fileCreationTime', 'keyword'],
34 | });
35 |
36 | this.permutationTokenizer = new WordPermutationsTokenizer();
37 | }
38 |
39 | public getKeywords(): string[] {
40 | const keywords = this.documents
41 | .find({
42 | fileCreationTime: { $ne: this.pluginHelper.activeFile.stat.ctime }, // Always exclude indices related to active file
43 | })
44 | .map((doc) => doc.keyword);
45 |
46 | return _.uniq(keywords);
47 | }
48 |
49 | public getDocumentsByKeyword(keyword: string): Document[] {
50 | return this.documents.find({
51 | keyword,
52 | fileCreationTime: { $ne: this.pluginHelper.activeFile.stat.ctime }, // Always exclude indices related to active file
53 | });
54 | }
55 |
56 | public buildIndex(): void {
57 | this.pluginHelper.getAllFiles().forEach((file) => this.indexFile(file));
58 | this.emit('indexRebuilt');
59 | }
60 |
61 | public replaceFileIndices(file: TFile): void {
62 | // Remove all indices related to modified file
63 | this.documents.findAndRemove({ fileCreationTime: file.stat.ctime });
64 |
65 | // Re-index modified file
66 | this.indexFile(file);
67 |
68 | this.emit('indexUpdated');
69 | }
70 |
71 | private indexFile(file: TFile): void {
72 | this.documents.insert({
73 | fileCreationTime: file.stat.ctime,
74 | type: 'page',
75 | keyword: stemPhrase(file.basename),
76 | originalText: file.basename,
77 | replaceText: `[[${file.basename}]]`,
78 | });
79 |
80 | this.permutationTokenizer.tokenize(file.basename).forEach((token) => {
81 | this.documents.insert({
82 | fileCreationTime: file.stat.ctime,
83 | type: 'page-token',
84 | keyword: token,
85 | originalText: file.basename,
86 | replaceText: `[[${file.basename}]]`,
87 | });
88 | });
89 |
90 | this.pluginHelper.getAliases(file).forEach((alias) => {
91 | this.documents.insert({
92 | fileCreationTime: file.stat.ctime,
93 | type: 'alias',
94 | keyword: alias.toLowerCase(),
95 | originalText: file.basename,
96 | replaceText: `[[${file.basename}|${alias}]]`,
97 | });
98 | });
99 |
100 | this.pluginHelper.getTags(file).forEach((tag) => {
101 | this.documents.insert({
102 | fileCreationTime: file.stat.ctime,
103 | type: 'tag',
104 | keyword: tag.replace(/#/, '').toLowerCase(),
105 | originalText: tag,
106 | replaceText: tag,
107 | });
108 | });
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/plugin-helper.ts:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import { parseFrontMatterTags, TFile, Plugin } from 'obsidian';
3 |
4 | import { getAliases } from './utils/getAliases';
5 |
6 | export class PluginHelper {
7 | constructor(private plugin: Plugin) {}
8 |
9 | public get activeFile(): TFile | undefined {
10 | return this.plugin.app.workspace.getActiveFile();
11 | }
12 |
13 | public getAliases(file: TFile): string[] {
14 | return getAliases(this.plugin.app.metadataCache.getFileCache(file));
15 | }
16 |
17 | public getTags(file: TFile): string[] {
18 | const tags = this.getFrontMatterTags(file).concat(
19 | this.plugin.app.metadataCache.getFileCache(file)?.tags?.map((x) => x.tag) ?? []
20 | );
21 | return _.uniq(tags);
22 | }
23 |
24 | public getAllFiles(): TFile[] {
25 | return this.plugin.app.vault.getMarkdownFiles();
26 | }
27 |
28 | public onLayoutReady(callback: () => void): void {
29 | this.plugin.app.workspace.onLayoutReady(() => callback());
30 | }
31 |
32 | public onFileRename(callback: (file: TFile) => void): void {
33 | this.plugin.app.workspace.onLayoutReady(() => {
34 | this.plugin.registerEvent(
35 | this.plugin.app.vault.on('rename', (fileOrFolder) => {
36 | if (fileOrFolder instanceof TFile) {
37 | callback(fileOrFolder);
38 | }
39 | })
40 | );
41 | });
42 | }
43 |
44 | public onFileMetadataChanged(callback: (file: TFile) => void): void {
45 | this.plugin.app.workspace.onLayoutReady(() => {
46 | this.plugin.registerEvent(this.plugin.app.metadataCache.on('changed', callback));
47 | });
48 | }
49 |
50 | private getFrontMatterTags(file: TFile): string[] {
51 | return (
52 | parseFrontMatterTags(this.plugin.app.metadataCache.getFileCache(file)?.frontmatter) ?? []
53 | );
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/search/index.ts:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import { Trie } from '@tanishiking/aho-corasick';
3 |
4 | import type { Indexer } from '../indexing/indexer';
5 | import { redactText } from './redactText';
6 | import { mapStemToOriginalText } from './mapStemToOriginalText';
7 | import { WordPunctStemTokenizer } from '../tokenizers';
8 |
9 | const tokenizer = new WordPunctStemTokenizer();
10 |
11 | export type SearchResult = {
12 | start: number;
13 | end: number;
14 | indexKeyword: string;
15 | originalKeyword: string;
16 | };
17 |
18 | const isEqual = (a: SearchResult, b: SearchResult) => {
19 | return a.start === b.start && a.indexKeyword === b.indexKeyword;
20 | };
21 |
22 | export default class Search {
23 | private trie: Trie;
24 |
25 | constructor(private indexer: Indexer) {
26 | const keywords = this.indexer.getKeywords();
27 |
28 | // Generating the Trie is expensive, so we only do it once
29 | this.trie = new Trie(keywords, {
30 | allowOverlaps: false,
31 | onlyWholeWords: true,
32 | caseInsensitive: true,
33 | });
34 | }
35 |
36 | public getReplacementSuggestions(keyword: string): string[] {
37 | const keywords = this.indexer.getDocumentsByKeyword(keyword).map((doc) => doc.replaceText);
38 | return _.uniq(keywords);
39 | }
40 |
41 | public find(text: string): SearchResult[] {
42 | const redactedText = redactText(text); // Redact text that we don't want to be searched
43 |
44 | // Stem the text
45 | const tokens = tokenizer.tokenize(redactedText);
46 | const stemmedText = tokens.map((t) => t.stem).join('');
47 |
48 | // Search stemmed text
49 | const emits = this.trie.parseText(stemmedText);
50 |
51 | // Map stemmed results to original text
52 | return _.chain(emits)
53 | .map((emit) => mapStemToOriginalText(emit, tokens))
54 | .uniqWith(isEqual)
55 | .filter((result) => this.keywordExistsInIndex(result.indexKeyword))
56 | .sort((a, b) => a.start - b.start) // Must sort by start position to prepare for highlighting
57 | .value();
58 | }
59 |
60 | private keywordExistsInIndex(index: string): boolean {
61 | const exists = this.indexer.getDocumentsByKeyword(index).length > 0;
62 |
63 | if (!exists) {
64 | console.warn(
65 | `Search hit "${index}" was not found in Obsidian index. This could be a bug. Report on https://github.com/hadynz/obsidian-sidekick/issues`,
66 | this.indexer
67 | );
68 | }
69 |
70 | return exists;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/search/mapStemToOriginalText.ts:
--------------------------------------------------------------------------------
1 | import { Emit } from '@tanishiking/aho-corasick';
2 |
3 | import { SearchResult } from '../search/index';
4 | import { Token } from '../tokenizers';
5 |
6 | /**
7 | * Takes a given search result (which has the start/end position and a "stemmed" keyword)
8 | * that was matched, and maps them to a new start/end position for the original keyword
9 | * which was stem was created from
10 | * @param searchResult
11 | * @param tokens
12 | * @returns
13 | */
14 | export const mapStemToOriginalText = (searchResult: Emit, tokens: Token[]): SearchResult => {
15 | const matchingTokens = tokens.filter(
16 | (token) => token.stemStart >= searchResult.start && token.stemEnd <= searchResult.end + 1
17 | );
18 |
19 | return {
20 | start: matchingTokens[0].originalStart,
21 | end: matchingTokens[matchingTokens.length - 1].originalEnd,
22 | indexKeyword: matchingTokens
23 | .map((token) => token.stem)
24 | .join('')
25 | .toLowerCase(),
26 | originalKeyword: matchingTokens.map((token) => token.originalText).join(''),
27 | };
28 | };
29 |
--------------------------------------------------------------------------------
/src/search/redactText.spec.ts:
--------------------------------------------------------------------------------
1 | import { redactText } from './redactText';
2 |
3 | describe('redactText', () => {
4 | it('Hashtags are redacted', () => {
5 | const sentence = 'I love playing #football';
6 | const expected = 'I love playing ';
7 |
8 | const actual = redactText(sentence);
9 |
10 | expect(actual).toEqual(expected);
11 | });
12 |
13 | it('Hierarchial hashtags are redacted', () => {
14 | const sentence = 'I love playing #sport/football';
15 | const expected = 'I love playing ';
16 |
17 | const actual = redactText(sentence);
18 |
19 | expect(actual).toEqual(expected);
20 | });
21 |
22 | it('Links are redacted', () => {
23 | const sentence = 'I love [[sleeping]] and [[https://aoe.com|gaming]]';
24 | const expected = 'I love and ';
25 |
26 | const actual = redactText(sentence);
27 |
28 | expect(actual).toEqual(expected);
29 | });
30 |
31 | it('Code blocks are redacted', () => {
32 | const sentence = '```cs\
33 | code block\
34 | ```';
35 | const expected = ' \
36 | \
37 | ';
38 |
39 | const actual = redactText(sentence);
40 |
41 | expect(actual).toEqual(expected);
42 | });
43 |
44 | it('Frontmatter is redacted', () => {
45 | const sentence = '---\
46 | tags: [aoe, aoe2]\
47 | ---\
48 | # Heading 1\
49 | ```';
50 | const expected = ' \
51 | \
52 | \
53 | # Heading 1\
54 | ```';
55 |
56 | const actual = redactText(sentence);
57 |
58 | expect(actual).toEqual(expected);
59 | });
60 |
61 | it('Frontmatter with preceding empty lines is redacted', () => {
62 | const sentence = '\
63 | \
64 | ---\
65 | tags: [aoe, aoe2]\
66 | ---\
67 | # Heading 1\
68 | ```';
69 | const expected = '\
70 | \
71 | \
72 | \
73 | \
74 | # Heading 1\
75 | ```';
76 |
77 | const actual = redactText(sentence);
78 |
79 | expect(actual).toEqual(expected);
80 | });
81 | });
82 |
--------------------------------------------------------------------------------
/src/search/redactText.ts:
--------------------------------------------------------------------------------
1 | export const redactText = (text: string): string => {
2 | return text
3 | .replace(/```[\s\S]+?```/g, (m) => ' '.repeat(m.length)) // remove code blocks
4 | .replace(/^\n*?---[\s\S]+?---/g, (m) => ' '.repeat(m.length)) // remove yaml front matter
5 | .replace(/#+([a-zA-Z0-9_/]+)/g, (m) => ' '.repeat(m.length)) // remove hashtags
6 | .replace(/\[(.*?)\]+/g, (m) => ' '.repeat(m.length)); // remove links
7 | };
8 |
--------------------------------------------------------------------------------
/src/search/search.spec.ts:
--------------------------------------------------------------------------------
1 | import { Indexer } from '../indexing/indexer';
2 | import Search from './index';
3 |
4 | const getKeywordsMockFn = jest.fn();
5 |
6 | jest.mock('../indexing/indexer', () => {
7 | return {
8 | Indexer: jest.fn().mockImplementation(() => {
9 | return {
10 | getKeywords: getKeywordsMockFn,
11 | getDocumentsByKeyword: () => [{}],
12 | };
13 | }),
14 | };
15 | });
16 |
17 | beforeEach(() => {
18 | jest.clearAllMocks();
19 | });
20 |
21 | describe('Search class', () => {
22 | it('Highlights single keywords that can be stemmed', () => {
23 | getKeywordsMockFn.mockReturnValue(['search', 'note']);
24 | const text = 'This is a note that I will be use for searching';
25 |
26 | const indexer = new Indexer(null);
27 | const search = new Search(indexer);
28 | const results = search.find(text);
29 |
30 | expect(results).toEqual([
31 | {
32 | start: 10,
33 | end: 14,
34 | indexKeyword: 'note',
35 | originalKeyword: 'note',
36 | },
37 | {
38 | start: 38,
39 | end: 47,
40 | indexKeyword: 'search',
41 | originalKeyword: 'searching',
42 | },
43 | ]);
44 | });
45 |
46 | it('Longer keyword matches are always prioritised for highlight', () => {
47 | getKeywordsMockFn.mockReturnValue(['github', 'github fork']);
48 | const text = 'I use GitHub Forks as part of my development flow';
49 |
50 | const indexer = new Indexer(null);
51 | const search = new Search(indexer);
52 | const results = search.find(text);
53 |
54 | expect(results).toEqual([
55 | {
56 | start: 6,
57 | end: 18,
58 | indexKeyword: 'github fork',
59 | originalKeyword: 'GitHub Forks',
60 | },
61 | ]);
62 | });
63 |
64 | it('Three word keyword is highlighted', () => {
65 | getKeywordsMockFn.mockReturnValue(['shared', 'client', 'record', 'share client record']);
66 | const text = 'Designing a shared client record is a great idea but challenging';
67 |
68 | const indexer = new Indexer(null);
69 | const search = new Search(indexer);
70 | const results = search.find(text);
71 |
72 | expect(results).toEqual([
73 | {
74 | start: 12,
75 | end: 32,
76 | indexKeyword: 'share client record',
77 | originalKeyword: 'shared client record',
78 | },
79 | ]);
80 | });
81 | });
82 |
--------------------------------------------------------------------------------
/src/stemmers/index.ts:
--------------------------------------------------------------------------------
1 | import { PorterStemmer } from 'natural';
2 |
3 | import { WordPunctStemTokenizer } from '../tokenizers';
4 |
5 | /**
6 | * Stem a given phrase. If the phrase is made up of multiple words,
7 | * the last word in the phrase is the only one that will be stemmed
8 | * @param text input text
9 | * @returns stemmed text
10 | */
11 | export const stemLastWord = (text: string): string => {
12 | return PorterStemmer.stem(text);
13 | };
14 |
15 | export const stemPhrase = (text: string): string => {
16 | const tokenizer = new WordPunctStemTokenizer();
17 | return tokenizer
18 | .tokenize(text)
19 | .map((t) => t.stem)
20 | .join('');
21 | };
22 |
--------------------------------------------------------------------------------
/src/tokenizers/index.ts:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import { PorterStemmer, NGrams } from 'natural';
3 | import { Trie } from '@tanishiking/aho-corasick';
4 | import * as natural from 'natural';
5 |
6 | import { stemLastWord } from '../stemmers';
7 |
8 | export type Token = {
9 | index: number;
10 | originalText: string;
11 | originalStart: number;
12 | originalEnd: number;
13 | stem: string;
14 | stemStart: number;
15 | stemEnd: number;
16 | };
17 |
18 | export class WordPermutationsTokenizer {
19 | private trie: Trie;
20 |
21 | constructor() {
22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
23 | const stopWords: string[] = (natural as any).stopwords;
24 |
25 | this.trie = new Trie(stopWords, {
26 | allowOverlaps: false,
27 | onlyWholeWords: true,
28 | caseInsensitive: true,
29 | });
30 | }
31 |
32 | public tokenize(text: string): string[] {
33 | const tokens = PorterStemmer.tokenizeAndStem(text); // Strip punctuation and stop words, stem remaining words
34 |
35 | if (tokens.length >= 5) {
36 | return [...tokens, ...NGrams.bigrams(tokens).map((tokens) => tokens.join(' '))];
37 | }
38 |
39 | return this.combinations(tokens, 2, 2);
40 | }
41 |
42 | private combinations(arr: string[], min: number, max: number) {
43 | return [...Array(max).keys()]
44 | .reduce((result) => {
45 | return arr.concat(
46 | result.flatMap((val) =>
47 | arr.filter((char) => char !== val).map((char) => `${val} ${char}`)
48 | )
49 | );
50 | }, [])
51 | .filter((val) => val.length >= min);
52 | }
53 | }
54 |
55 | export class WordPunctStemTokenizer {
56 | private pattern = /([\s]+|[A-zÀ-ÿ-]+|[0-9._]+|.|!|\?|'|"|:|;|,|-)/i;
57 |
58 | public tokenize(text: string): Token[] {
59 | const tokens = text.split(this.pattern);
60 | return _.chain(tokens).without('').transform(this.stringToTokenAccumulator()).value();
61 | }
62 |
63 | private stringToTokenAccumulator() {
64 | let originalCharIndex = 0;
65 | let stemCharIndex = 0;
66 |
67 | return (acc: Token[], token: string, index: number) => {
68 | const stemmedToken = stemLastWord(token);
69 |
70 | acc.push({
71 | index,
72 | originalText: token,
73 | originalStart: originalCharIndex,
74 | originalEnd: originalCharIndex + token.length,
75 | stem: stemmedToken,
76 | stemStart: stemCharIndex,
77 | stemEnd: stemCharIndex + stemmedToken.length,
78 | });
79 |
80 | originalCharIndex += token.length;
81 | stemCharIndex += stemmedToken.length;
82 |
83 | return acc;
84 | };
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/tokenizers/tokenizer.spec.ts:
--------------------------------------------------------------------------------
1 | import { WordPermutationsTokenizer, WordPunctStemTokenizer } from '.';
2 |
3 | describe('WordPermutationsTokenizer', () => {
4 | const dataSet = [
5 | {
6 | description: 'Single word',
7 | sentence: 'John',
8 | expected: ['john'],
9 | },
10 | {
11 | description: 'Two words with no stop words',
12 | sentence: 'John Doe',
13 | expected: ['john', 'doe', 'john doe', 'doe john'],
14 | },
15 | {
16 | description: 'Two words (with one stop word at the start)',
17 | sentence: 'The brothers Karamazov',
18 | expected: ['brother', 'karamazov', 'brother karamazov', 'karamazov brother'],
19 | },
20 | {
21 | description: 'Two words (with stop words throughout the sentence)',
22 | sentence: 'An Officer and a Spy',
23 | expected: ['offic', 'spy', 'offic spy', 'spy offic'],
24 | },
25 | {
26 | description: 'Three words with no stop words',
27 | sentence: 'GitHub Forking tutorial',
28 | expected: [
29 | 'github',
30 | 'fork',
31 | 'tutori',
32 | 'github fork',
33 | 'github tutori',
34 | 'fork github',
35 | 'fork tutori',
36 | 'tutori github',
37 | 'tutori fork',
38 | ],
39 | },
40 |
41 | {
42 | description: 'Five words or more does not generate permutations',
43 | sentence: 'Ten Arguments For Deleting Your Social Media Accounts Right Now',
44 | expected: [
45 | 'ten',
46 | 'argument',
47 | 'delet',
48 | 'social',
49 | 'media',
50 | 'account',
51 | 'right',
52 | 'ten argument',
53 | 'argument delet',
54 | 'delet social',
55 | 'social media',
56 | 'media account',
57 | 'account right',
58 | ],
59 | },
60 | ];
61 |
62 | dataSet.forEach(({ description, sentence, expected }) => {
63 | it(`Tokenize phase permutations (${description})`, () => {
64 | const tokenizer = new WordPermutationsTokenizer();
65 | const tokens = tokenizer.tokenize(sentence);
66 |
67 | expect(tokens).toEqual(expected);
68 | });
69 | });
70 | });
71 |
72 | describe('WordPunctStemTokenizer', () => {
73 | it('Tokenize and stem a simple phrase', () => {
74 | const sentence = 'The lazy dog jumped over the fence.';
75 |
76 | const tokenizer = new WordPunctStemTokenizer();
77 | const tokens = tokenizer.tokenize(sentence);
78 |
79 | expect(tokens.length).toEqual(14);
80 |
81 | expect(tokens[2]).toEqual({
82 | index: 2,
83 | originalText: 'lazy',
84 | originalStart: 4,
85 | originalEnd: 8,
86 | stem: 'lazi',
87 | stemStart: 4,
88 | stemEnd: 8,
89 | });
90 |
91 | expect(tokens[6].stem).toEqual('jump');
92 | });
93 | });
94 |
--------------------------------------------------------------------------------
/src/utils/getAliases.spec.ts:
--------------------------------------------------------------------------------
1 | import type { CachedMetadata, FrontMatterCache } from 'obsidian';
2 |
3 | import { getAliases } from './getAliases';
4 |
5 | describe('getAliases', () => {
6 | it('Returns an empty array if no frontmatter is defined', () => {
7 | const metadata: CachedMetadata = {};
8 | const aliases = getAliases(metadata);
9 | expect(aliases).toEqual([]);
10 | });
11 |
12 | it('Returns an empty array if no aliases are defined', () => {
13 | const metadata: CachedMetadata = {
14 | frontmatter: {} as FrontMatterCache,
15 | };
16 |
17 | const aliases = getAliases(metadata);
18 | expect(aliases).toEqual([]);
19 | });
20 |
21 | it('Parses aliases defined as a string split by comma', () => {
22 | const metadata: CachedMetadata = {
23 | frontmatter: {
24 | aliases: 'foo, bar ',
25 | } as unknown as FrontMatterCache,
26 | };
27 |
28 | const aliases = getAliases(metadata);
29 | expect(aliases).toEqual(['foo', 'bar']);
30 | });
31 |
32 | it('Parses aliases defined as an array of values', () => {
33 | const metadata: CachedMetadata = {
34 | frontmatter: {
35 | aliases: ['foo', 'bar'],
36 | } as unknown as FrontMatterCache,
37 | };
38 |
39 | const aliases = getAliases(metadata);
40 | expect(aliases).toEqual(['foo', 'bar']);
41 | });
42 |
43 | it('Array of aliases is trimmed and processed as strings', () => {
44 | const metadata: CachedMetadata = {
45 | frontmatter: {
46 | aliases: ['foo', 'bar', null, undefined, '', ' ', 200],
47 | } as unknown as FrontMatterCache,
48 | };
49 |
50 | const aliases = getAliases(metadata);
51 | expect(aliases).toEqual(['foo', 'bar', '200']);
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/src/utils/getAliases.ts:
--------------------------------------------------------------------------------
1 | import type { CachedMetadata } from 'obsidian';
2 |
3 | export const getAliases = (metadata: CachedMetadata): string[] => {
4 | const frontmatterAliases = metadata?.frontmatter?.['aliases'];
5 |
6 | if (typeof frontmatterAliases === 'string') {
7 | return frontmatterAliases.split(',').map((alias: string) => alias.trim());
8 | } else if (Array.isArray(frontmatterAliases)) {
9 | return frontmatterAliases
10 | .map((alias) => alias?.toString().trim())
11 | .filter((alias: string) => alias);
12 | }
13 |
14 | return [];
15 | };
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["src/**/*", "tests/**/*", "webpack.config.ts"],
3 | "exclude": ["node_modules/*"],
4 | "compilerOptions": {
5 | "target": "es5",
6 | "types": ["node", "jest"],
7 | "baseUrl": ".",
8 | "paths": {
9 | "src": ["src/*", "tests/*"],
10 | "~/*": ["src/*"]
11 | },
12 | "resolveJsonModule": true,
13 | "esModuleInterop": true,
14 | "downlevelIteration": true
15 | },
16 | // Fixes errors when changing `module` to ES in the above compiler options
17 | // See: https://github.com/webpack/webpack-cli/issues/2458#issuecomment-846635277
18 | "ts-node": {
19 | "compilerOptions": {
20 | "module": "commonjs"
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/versions.json:
--------------------------------------------------------------------------------
1 | {
2 | "1.0.0": "0.13.8"
3 | }
4 |
--------------------------------------------------------------------------------
/webpack.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import pack from './package.json';
3 | import CopyPlugin from 'copy-webpack-plugin';
4 | import TerserPlugin from 'terser-webpack-plugin';
5 | import { type Configuration, DefinePlugin } from 'webpack';
6 |
7 | const isProduction = process.env.NODE_ENV === 'production';
8 |
9 | const config: Configuration = {
10 | entry: './src/index.ts',
11 | output: {
12 | path: path.resolve(__dirname, 'dist'),
13 | filename: '[name].js',
14 | libraryTarget: 'commonjs',
15 | clean: true,
16 | },
17 | target: 'node',
18 | mode: isProduction ? 'production' : 'development',
19 | devtool: isProduction ? 'source-map' : 'inline-source-map',
20 | module: {
21 | rules: [
22 | {
23 | test: /\.tsx?$/,
24 | loader: 'ts-loader',
25 | options: {
26 | transpileOnly: true,
27 | },
28 | },
29 | {
30 | test: /\.(svg|njk|html)$/,
31 | type: 'asset/source',
32 | },
33 | {
34 | test: /\.css$/i,
35 | use: ['style-loader', 'css-loader'],
36 | },
37 | {
38 | test: /\.(png|jpg|gif)$/i,
39 | type: 'asset/inline',
40 | },
41 | ],
42 | },
43 | optimization: {
44 | minimize: isProduction,
45 | minimizer: [
46 | new TerserPlugin({
47 | extractComments: false,
48 | minify: TerserPlugin.uglifyJsMinify,
49 | terserOptions: {},
50 | }),
51 | ],
52 | },
53 | plugins: [
54 | new CopyPlugin({
55 | patterns: [{ from: './manifest.json', to: '.' }],
56 | }),
57 | new DefinePlugin({
58 | PACKAGE_NAME: JSON.stringify(pack.name),
59 | VERSION: JSON.stringify(pack.version),
60 | PRODUCTION: JSON.stringify(isProduction),
61 | }),
62 | ],
63 | resolve: {
64 | alias: {
65 | '~': path.resolve(__dirname, 'src'),
66 | },
67 | extensions: ['.ts', '.tsx', '.js'],
68 | mainFields: ['browser', 'module', 'main'],
69 | },
70 | externals: {
71 | obsidian: 'commonjs2 obsidian',
72 | '@codemirror/view': 'commonjs2 @codemirror/view',
73 | '@codemirror/state': 'commonjs2 @codemirror/state',
74 | '@codemirror/rangeset': 'commonjs2 @codemirror/rangeset',
75 | 'webworker-threads': 'require(webworker-threads)',
76 | },
77 | };
78 |
79 | export default config;
80 |
--------------------------------------------------------------------------------