├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── LICENSE.md ├── README.md ├── docs ├── command.png └── preview.gif ├── esbuild.config.mjs ├── jest.config.json ├── main.ts ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── core │ └── Notes.ts └── views │ ├── MainView.tsx │ └── PendingNotesView.tsx ├── styles.css ├── test ├── core │ └── Notes.test.ts ├── fixtures │ └── code-blocks.md └── scripts │ └── test-deploy.mjs ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-pending-notes # Change this to match the id of your plugin. 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "16.x" 21 | 22 | - name: Install 23 | run: npm install 24 | 25 | - name: Test 26 | run: npm run test:all 27 | 28 | - name: Build 29 | id: build 30 | run: | 31 | npm run deploy 32 | mkdir ${{ env.PLUGIN_NAME }} 33 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 34 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 35 | ls 36 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 37 | 38 | - name: Create Release 39 | id: create_release 40 | uses: actions/create-release@v1 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | VERSION: ${{ github.ref }} 44 | with: 45 | tag_name: ${{ github.ref }} 46 | release_name: ${{ github.ref }} 47 | draft: false 48 | prerelease: false 49 | 50 | - name: Upload zip file 51 | id: upload-zip 52 | uses: actions/upload-release-asset@v1 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | with: 56 | upload_url: ${{ steps.create_release.outputs.upload_url }} 57 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 58 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 59 | asset_content_type: application/zip 60 | 61 | - name: Upload main.js 62 | id: upload-main 63 | uses: actions/upload-release-asset@v1 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | with: 67 | upload_url: ${{ steps.create_release.outputs.upload_url }} 68 | asset_path: ./main.js 69 | asset_name: main.js 70 | asset_content_type: text/javascript 71 | 72 | - name: Upload manifest.json 73 | id: upload-manifest 74 | uses: actions/upload-release-asset@v1 75 | env: 76 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 77 | with: 78 | upload_url: ${{ steps.create_release.outputs.upload_url }} 79 | asset_path: ./manifest.json 80 | asset_name: manifest.json 81 | asset_content_type: application/json 82 | 83 | - name: Upload styles.css 84 | id: upload-css 85 | uses: actions/upload-release-asset@v1 86 | env: 87 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 88 | with: 89 | upload_url: ${{ steps.create_release.outputs.upload_url }} 90 | asset_path: ./styles.css 91 | asset_name: styles.css 92 | asset_content_type: text/css -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - name: Use Node.js 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: "16.x" 16 | 17 | - name: Install dependencies 18 | run: npm install 19 | 20 | - name: Test code 21 | run: npm test 22 | 23 | - name: Test deploy 24 | run: npm run test:deploy 25 | 26 | - name: Codecov 27 | uses: codecov/codecov-action@v3.1.1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | # Misc 25 | coverage 26 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.17.1 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ulises Santana 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Pending Notes Plugin 2 | [![GitHub release](https://img.shields.io/github/release/ulisesantana/obsidian-pending-notes.svg)](https://GitHub.com/ulisesantana/obsidian-pending-notes/releases/) 3 | ![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=downloads&query=%24%5B%22obsidian-pending-notes%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json) 4 | [![codecov](https://codecov.io/github/ulisesantana/obsidian-pending-notes/branch/master/graph/badge.svg?token=XXwfgoPhoY)](https://codecov.io/github/ulisesantana/obsidian-pending-notes) 5 | 6 | Buy Me A Coffee 7 | 8 | This is a plugin for [Obsidian](https://obsidian.md). 9 | 10 | Pending Notes aims to collect in one place all the links you have created on the fly while writing, but links to nowhere yet. With this plugin, you can create those notes easily. 11 | 12 | ![Show who the plugins can be used](docs/preview.gif) 13 | 14 | You can use it from left sidebar or through the command palette: 15 | 16 | ![Command preview](docs/command.png) 17 | 18 | ## How to install 19 | 20 | This plugin is an Obsidian Community Plugin, so you can **install it right from your Obsidian settings**. However, if you want you can install it manually in two ways: 21 | 22 | ### Manual install: download release 23 | Go to [the latest release](https://github.com/ulisesantana/obsidian-pending-notes/releases/latest) and download the zip file that looks like `obsidian-pending-notes-X.X.X.zip`. 24 | 25 | Once download is finished you can unzip it inside your vault on `.obsidian/plugins/`. If you are not doing this through your terminal or console, you may need to enable your file browser to show *hidden files*. 26 | 27 | After that you can activate it on your Obsidian settings. If the plugin is not showed try to restart Obsidian. 28 | 29 | ### Manual install: build plugin 30 | Go to `.obsidian/plugins/` and run: 31 | 32 | ```shell 33 | $ git clone https://github.com/ulisesantana/obsidian-pending-notes.git 34 | $ cd obsidian-pending-notes 35 | $ npm run deploy 36 | ``` 37 | 38 | After that you can activate it on your Obsidian settings. If the plugin is not showed try to restart Obsidian. 39 | 40 | **Note**: For build the plugin you will need installed git and [Node.js](https://nodejs.org/en/) at LTS version. 41 | 42 | ## Support 43 | 44 | If you find this plugin useful, you can support me [buying me a coffee](https://www.buymeacoffee.com/ulisesantana). 45 | -------------------------------------------------------------------------------- /docs/command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulisesantana/obsidian-pending-notes/fa8754900385e826a8fa73c656cc6080e0ce2338/docs/command.png -------------------------------------------------------------------------------- /docs/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulisesantana/obsidian-pending-notes/fa8754900385e826a8fa73c656cc6080e0ce2338/docs/preview.gif -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from 'builtin-modules' 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === 'production'); 13 | 14 | esbuild.build({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ['main.ts'], 19 | bundle: true, 20 | external: [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/collab', 25 | '@codemirror/commands', 26 | '@codemirror/language', 27 | '@codemirror/lint', 28 | '@codemirror/search', 29 | '@codemirror/state', 30 | '@codemirror/view', 31 | '@lezer/common', 32 | '@lezer/highlight', 33 | '@lezer/lr', 34 | ...builtins], 35 | format: 'cjs', 36 | watch: !prod, 37 | target: 'es2018', 38 | logLevel: "info", 39 | sourcemap: prod ? false : 'inline', 40 | treeShaking: true, 41 | outfile: 'main.js', 42 | }).catch(() => process.exit(1)); 43 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "roots": [ 3 | "/test" 4 | ], 5 | "testMatch": [ 6 | "**/__tests__/**/*.+(ts|js)", 7 | "**/?(*.)+(spec|test).+(ts|js)" 8 | ], 9 | "transform": { 10 | "^.+\\.(ts)?$": "ts-jest" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import {Plugin} from 'obsidian'; 2 | import {MainView, VIEW_TYPE_MAIN} from 'src/views/MainView'; 3 | 4 | export default class PendingNotesPlugin extends Plugin { 5 | 6 | async onload() { 7 | this.registerView( 8 | VIEW_TYPE_MAIN, 9 | (leaf) => new MainView(leaf) 10 | ); 11 | 12 | // This creates an icon in the left ribbon. 13 | this.addRibbonIcon('file-clock', 'Pending Notes', () => { 14 | this.activateView() 15 | }); 16 | 17 | // This adds a simple command that can be triggered anywhere 18 | this.addCommand({ 19 | id: 'pending-notes-show-view', 20 | name: 'Open pending notes list', 21 | callback: () => { 22 | this.activateView() 23 | } 24 | }); 25 | } 26 | 27 | async onunload() { 28 | this.app.workspace.detachLeavesOfType(VIEW_TYPE_MAIN); 29 | } 30 | 31 | async activateView() { 32 | this.app.workspace.detachLeavesOfType(VIEW_TYPE_MAIN); 33 | 34 | await this.app.workspace.getRightLeaf(false).setViewState({ 35 | type: VIEW_TYPE_MAIN, 36 | active: true, 37 | }); 38 | 39 | this.app.workspace.revealLeaf( 40 | this.app.workspace.getLeavesOfType(VIEW_TYPE_MAIN)[0] 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-pending-notes", 3 | "name": "Pending notes", 4 | "version": "0.8.1", 5 | "minAppVersion": "0.15.0", 6 | "description": "Obsidian plugin for searching links without notes in your vault.", 7 | "author": "Ulises Santana", 8 | "authorUrl": "https://ulisesantana.dev", 9 | "fundingUrl": "https://www.buymeacoffee.com/ulisesantana", 10 | "isDesktopOnly": false 11 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-pending-notes", 3 | "version": "0.8.1", 4 | "description": "Obsidian plugin for searching links without notes in your vault.", 5 | "main": "main.js", 6 | "scripts": { 7 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 8 | "deploy": "npm clean-install --omit=dev && npm run build", 9 | "dev": "node esbuild.config.mjs", 10 | "lint": "eslint . --ext .ts", 11 | "lint:fix": "npm run lint -- --fix", 12 | "tag": "node version-bump.mjs && TAG=$(node -pe \"require('./package.json').version\"); git commit -am \"🔖 $TAG\" && git tag $TAG && git push --tags", 13 | "test": "jest --verbose --coverage", 14 | "test:all": "npm t && npm run test:deploy", 15 | "test:deploy": "node ./test/scripts/test-deploy.mjs", 16 | "test:watch": "npm t -- --watchAll" 17 | }, 18 | "keywords": [ 19 | "obsidian", 20 | "obsidian plugins", 21 | "pending notes" 22 | ], 23 | "author": "Ulises Santana ", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "@types/jest": "~29.2.4", 27 | "@typescript-eslint/eslint-plugin": "~5.29.0", 28 | "@typescript-eslint/parser": "~5.29.0", 29 | "eslint": "~8.30.0", 30 | "jest": "~29.3.1", 31 | "ts-jest": "~29.0.3" 32 | }, 33 | "dependencies": { 34 | "@types/node": "~16.11.6", 35 | "@types/react": "~18.0.26", 36 | "@types/react-dom": "~18.0.9", 37 | "builtin-modules": "~3.3.0", 38 | "esbuild": "~0.16.15", 39 | "obsidian": "latest", 40 | "react": "~18.2.0", 41 | "react-dom": "~18.2.0", 42 | "tslib": "~2.4.0", 43 | "typescript": "~4.7.4" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/core/Notes.ts: -------------------------------------------------------------------------------- 1 | export interface Note { 2 | content: Promise 3 | extension: string 4 | name: string 5 | path: string 6 | } 7 | 8 | type ProcessedNote = Omit & { 9 | content: string 10 | } 11 | 12 | export interface NotePendingToBeCreated { 13 | title: string, 14 | timesLinked: number 15 | } 16 | 17 | export class Notes { 18 | private static filePathExpression = /^[\p{L}\p{N}_\p{Pd}\p{Emoji}\p{P} ]+(\/[\p{L}\p{N}_\p{Pd}\p{Emoji}\p{P} ]+)*(\.[\p{L}\p{N}_\p{Pd}\p{Emoji}\p{P}]+)?$/u; 19 | private static templaterExpression = /<%.*%>/ 20 | private static wikiLinksExpression = /(?:[^!]|^)\[\[(.+?)]]/g 21 | 22 | static async getPendingToCreate(notes: Note[]): Promise { 23 | const links = await Notes.getOutlinks(notes); 24 | const [allNotes, allPaths, allExtensions] = Notes.getUniqueData(notes) 25 | const missingNotes = {} as Record 26 | for (const link of links) { 27 | const isMissingNote = Notes.isFilePathLike(link) 28 | ? !Notes.filePathExists(allPaths, allExtensions, link) 29 | : !allNotes.has(link.toLowerCase()) 30 | if (isMissingNote) { 31 | missingNotes[link] = missingNotes[link] !== undefined 32 | ? missingNotes[link] + 1 33 | : 1 34 | } 35 | } 36 | return Object.entries(missingNotes) 37 | .reduce((notes, [title, timesLinked]) => { 38 | if (Boolean(title) && Notes.hasFileExtension(title)) { 39 | return notes.concat({title, timesLinked}) 40 | } 41 | return notes 42 | }, []) 43 | .sort(Notes.sortByTitle) 44 | .sort(Notes.sortByTimesLinked) 45 | } 46 | 47 | private static getUniqueData(notes: Note[]): [Set, Set, Set] { 48 | const allNotes = new Set(); 49 | const allPaths = new Set(); 50 | const allExtensions = new Set(); 51 | 52 | for (const note of notes) { 53 | allNotes.add(note.name.toLowerCase()); 54 | allPaths.add(note.path.toLowerCase()); 55 | allExtensions.add(note.extension); 56 | } 57 | 58 | return [ 59 | allNotes, 60 | allPaths, 61 | allExtensions 62 | ] 63 | } 64 | 65 | private static filePathExists(allPaths: Set, allExtensions: Set, link: string) { 66 | return allPaths.has(link) || Array.from(allExtensions).some(extension => { 67 | const path = `${link}.${extension}`.toLowerCase(); 68 | return allPaths.has(path) 69 | }); 70 | } 71 | 72 | private static isFilePathLike(title: string) { 73 | return title.includes('/') && Notes.filePathExpression.test(title) 74 | } 75 | 76 | private static sortByTimesLinked(a: NotePendingToBeCreated, b: NotePendingToBeCreated) { 77 | return a.timesLinked < b.timesLinked 78 | ? 1 79 | : a.timesLinked > b.timesLinked 80 | ? -1 81 | : 0; 82 | } 83 | 84 | private static sortByTitle(a: NotePendingToBeCreated, b: NotePendingToBeCreated) { 85 | return a.title.toLowerCase() > b.title.toLowerCase() 86 | ? 1 87 | : a.title.toLowerCase() < b.title.toLowerCase() 88 | ? -1 89 | : 0; 90 | } 91 | 92 | private static async getOutlinks(notes: Note[]): Promise { 93 | const links = [] 94 | for await (const note of Notes.cleanNotes(notes)) { 95 | links.push(...Notes.extractNoteOutlinks(note)) 96 | } 97 | return links; 98 | } 99 | 100 | private static extractNoteOutlinks(note: ProcessedNote): string[] { 101 | return Array.from(note.content.matchAll(Notes.wikiLinksExpression)) 102 | .flatMap(([_, x]) => x) 103 | .reduce(function reduceNoteTitles(outlinks, outlink) { 104 | const title = outlink.split(/[#|]/g)[0]?.trim() 105 | if (Notes.templaterExpression.test(title)) { 106 | return outlinks 107 | } 108 | return outlinks.concat(title) 109 | }, []) 110 | } 111 | 112 | private static async* cleanNotes(notes: Note[]): AsyncGenerator { 113 | for (const note of notes) { 114 | const content = await note.content 115 | yield ({ 116 | ...note, 117 | content: Notes.removeCodeBlocks(content) 118 | }) 119 | } 120 | } 121 | 122 | private static removeCodeBlocks(noteContent: string): string { 123 | const codeBlocks = [ 124 | ...Notes.getMatches(noteContent, /```.+```/gms), 125 | ...Notes.getMatches(noteContent, /`.+`/g) 126 | ] 127 | return codeBlocks.reduce((content, codeBlock) => content.replace(codeBlock, ''), noteContent) 128 | } 129 | 130 | private static getMatches(note: string, expression: RegExp): string[] { 131 | return Array.from(note.matchAll(expression)).flatMap(([x]) => x); 132 | } 133 | 134 | private static hasFileExtension(n: string) { 135 | // Test if ends with an extension from 1 to 5 characters long 136 | return !/.+\.\w{1,5}/g.test(n); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/views/MainView.tsx: -------------------------------------------------------------------------------- 1 | import {ItemView, Keymap, TFile, UserEvent, WorkspaceLeaf} from "obsidian"; 2 | import * as React from "react"; 3 | import * as ReactDOM from "react-dom"; 4 | import {createRoot} from "react-dom/client"; 5 | import {PendingNotesView} from "./PendingNotesView"; 6 | import {Note, NotePendingToBeCreated, Notes} from "../core/Notes"; 7 | 8 | export const VIEW_TYPE_MAIN = "pending-notes:main"; 9 | 10 | export class MainView extends ItemView { 11 | constructor(leaf: WorkspaceLeaf) { 12 | super(leaf); 13 | } 14 | 15 | getViewType() { 16 | return VIEW_TYPE_MAIN; 17 | } 18 | 19 | getDisplayText() { 20 | return "Pending notes"; 21 | } 22 | 23 | async onOpen() { 24 | const root = createRoot(this.containerEl.children[1]); 25 | const notes = await this.getPendingNotes() 26 | const onCreateNote = this.createNote.bind(this) 27 | const onSearchNote = this.searchNotes.bind(this) 28 | const onRefreshNotes = this.getPendingNotes.bind(this) 29 | 30 | root.render( 31 | 32 | 38 | 39 | ); 40 | } 41 | 42 | async onClose() { 43 | ReactDOM.unmountComponentAtNode(this.containerEl.children[1]); 44 | } 45 | 46 | private async searchNotes(title: string) { 47 | // https://forum.obsidian.md/t/api-endpoint-for-searching-file-content/11482/6 48 | 49 | // Perform the search 50 | // @ts-ignore 51 | app.internalPlugins.plugins['global-search'].instance.openGlobalSearch(`"${title}"`) 52 | const searchLeaf = app.workspace.getLeavesOfType('search')[0] 53 | const search = await searchLeaf.open(searchLeaf.view) 54 | await new Promise(resolve => setTimeout(() => { 55 | // @ts-ignore 56 | resolve(search.dom.resultDomLookup) 57 | }, 300)) // the delay here was specified in 'obsidian-text-expand' plugin; I assume they had a reason 58 | } 59 | 60 | private createNote(note: string, event: UserEvent): Promise { 61 | const noteFile = note + '.md' 62 | const defaultFolder = this.app.fileManager.getNewFileParent("") 63 | const pathDivider = defaultFolder.path.includes('\\') ? '\\' : '/' 64 | return this.app.vault.create(defaultFolder.path + pathDivider + noteFile,'') 65 | .then(() => { 66 | const mod = Keymap.isModEvent(event); 67 | this.app.workspace.openLinkText(noteFile, defaultFolder.path, mod) 68 | }) 69 | .then(() => this.getPendingNotes()) 70 | } 71 | 72 | private async getPendingNotes(): Promise { 73 | const notes = this.app.vault.getMarkdownFiles().map(f => this.readNote(f)) 74 | return Notes.getPendingToCreate(notes) 75 | } 76 | 77 | private readNote(file: TFile): Note { 78 | return { 79 | name: file.basename, 80 | content: this.app.vault.cachedRead(file), 81 | path: file.path, 82 | extension: file.extension 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/views/PendingNotesView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {FC, MouseEventHandler, useState} from "react"; 3 | import {NotePendingToBeCreated} from "../core/Notes"; 4 | 5 | interface Props { 6 | notes: NotePendingToBeCreated[] 7 | onCreateNote: (note: string, event: MouseEvent) => Promise 8 | onSearchNote: (title: string) => Promise 9 | onRefreshNotes: () => Promise 10 | } 11 | 12 | export const PendingNotesView: FC = ({notes, onCreateNote, onSearchNote, onRefreshNotes}) => { 13 | const [isLoading, setIsLoading] = useState(false) 14 | const [items, setItems] = useState(notes) 15 | const generateOnClick: (n: string) => MouseEventHandler = (note: string) => (event) => { 16 | onCreateNote(note, event.nativeEvent).then(setItems) 17 | } 18 | const generateOnSearch: (t: string) => MouseEventHandler = (title: string) => (event) => { 19 | onSearchNote(title).then() 20 | } 21 | const refreshNotes = () => { 22 | setIsLoading(true) 23 | onRefreshNotes().then((notes) => { 24 | setTimeout(() => { 25 | setIsLoading(false) 26 | setItems(notes) 27 | }, 1000) 28 | }) 29 | } 30 | return ( 31 |
32 |
33 |

Pending notes

34 | 37 |
38 | {items.length} notes linked, but not created yet. Times the note is linked is shown in parentheses. 39 |
    40 | {items.map(({title, timesLinked}) => ( 41 |
  • 42 | 43 | 50 | 53 | 54 | 55 | ({timesLinked}) 56 | {title} 57 | 58 |
  • 59 | ))} 60 |
61 |
62 | ) 63 | }; 64 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .pending-notes-view-container h1 { 2 | margin: 0; 3 | } 4 | 5 | .pending-notes-view-container ul { 6 | list-style: none; 7 | margin: 0; 8 | padding: 8px 0 32px; 9 | } 10 | 11 | .pending-notes-view-container ul li { 12 | align-items: center; 13 | display: flex; 14 | gap: 8px; 15 | margin: 0; 16 | padding: 4px; 17 | } 18 | 19 | 20 | .pending-notes-view-container button, 21 | .pending-notes-view-container ul li strong { 22 | display: block; 23 | } 24 | 25 | .pending-notes-view-container ul li a { 26 | text-decoration: none; 27 | } 28 | 29 | .pending-notes-view-container button { 30 | padding: 0 8px; 31 | } 32 | 33 | .pending-notes-view-container .title { 34 | align-items: center; 35 | display: flex; 36 | gap: 8px; 37 | margin-bottom: 4px; 38 | } 39 | 40 | 41 | .pending-notes-view-container .item { 42 | align-items: center; 43 | display: flex; 44 | gap: 4px; 45 | } 46 | 47 | 48 | .dots { 49 | position: relative; 50 | width: 20px; 51 | height: 20px; 52 | font-size: 10px; 53 | line-height: 10px; 54 | display: flex; 55 | align-items: center; 56 | justify-content: center; 57 | color: white; 58 | } 59 | 60 | .dots:after { 61 | position: absolute; 62 | content: ""; 63 | color: white; 64 | -webkit-animation: display-dots steps(1, end) 1s infinite; 65 | -moz-animation: display-dots steps(1, end) 1s infinite; 66 | -o-animation: display-dots steps(1, end) 1s infinite; 67 | animation: display-dots steps(1, end) 1s infinite; 68 | } 69 | 70 | @-webkit-keyframes display-dots { 71 | 0% { 72 | content: ""; 73 | } 74 | 25% { 75 | content: "\2022\00A0\2022\00A0\2022"; 76 | } 77 | 50% { 78 | content: "\2022\00A0\2022"; 79 | } 80 | 75% { 81 | content: "\2022"; 82 | } 83 | 100% { 84 | content: ""; 85 | } 86 | } 87 | @-moz-keyframes display-dots { 88 | 0% { 89 | content: ""; 90 | } 91 | 25% { 92 | content: "\2022"; 93 | } 94 | 50% { 95 | content: "\2022\00A0\2022"; 96 | } 97 | 75% { 98 | content: "\2022\00A0\2022\00A0\2022"; 99 | } 100 | 100% { 101 | content: ""; 102 | } 103 | } 104 | @-o-keyframes display-dots { 105 | 0% { 106 | content: ""; 107 | } 108 | 25% { 109 | content: "\2022"; 110 | } 111 | 50% { 112 | content: "\2022\00A0\2022"; 113 | } 114 | 75% { 115 | content: "\2022\00A0\2022\00A0\2022"; 116 | } 117 | 100% { 118 | content: ""; 119 | } 120 | } 121 | @keyframes display-dots { 122 | 0% { 123 | content: ""; 124 | } 125 | 25% { 126 | content: "\2022"; 127 | } 128 | 50% { 129 | content: "\2022\00A0\2022"; 130 | } 131 | 75% { 132 | content: "\2022\00A0\2022\00A0\2022"; 133 | } 134 | 100% { 135 | content: ""; 136 | } 137 | } 138 | 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /test/core/Notes.test.ts: -------------------------------------------------------------------------------- 1 | import {Notes} from "../../src/core/Notes"; 2 | import * as fs from "fs/promises"; 3 | 4 | describe('Notes should', () => { 5 | describe('extract notes pending to be created', () => { 6 | it('sort by times linked and then by title', async () => { 7 | const pending = await Notes.getPendingToCreate([ 8 | { 9 | "name": "Tiempo y Espacio", 10 | "content": Promise.resolve("[[Tiempo|El tiempo]] y espacio que me ha dado la excedencia me ha venido increíblemente bien. Sin embargo, recuerdo el [[Light and Space.pdf|poema]] de [[Charles Bukowski]] y me encuentro un poco en el medio.\n\nTambién es cierto que Bukowski lo critica desde el punto de vista de *crear*. En ese aspecto estoy de acuerdo con él. La creatividad no tiene nada que ver con el equipo que tengas, sino contigo mismo. Tu pasión y tu [[Actitud|actitud]] serán las que marquen la diferencia a la hora de crear.\n\nYo hablo del tiempo y en espacio para [[Pensar#Reflexionar|reflexionar]], pensar, conocerse a sí mismo. Eso sí que creo que lo es posible en medio de una tormenta.\n\n![[E30BB200-93F5-486D-B293-244788D253DC.mp3]]\n![[E30BB200-93F5-486D-B293-244788D253DC.jpg]]\n![[E30BB200-93F5-486D-B293-244788D253DC.jpeg]]\n![[E30BB200-93F5-486D-B293-244788D253DC.png]]\n![[E30BB200-93F5-486D-B293-244788D253DC.mp4]]\n![[E30BB200-93F5-486D-B293-244788D253DC.ogg]]\n![[E30BB200-93F5-486D-B293-244788D253DC.m4a]]\n![[E30BB200-93F5-486D-B293-244788D253DC.pdf]]\n![[E30BB200-93F5-486D-B293-244788D253DC.pdf]]"), 11 | "extension":"md", 12 | "path":"notes/Tiempo y Espacio.md", 13 | }, 14 | { 15 | "name": "Actitud", 16 | "content": Promise.resolve("Esta nota está creada, pero pendiente de ser rellenada. Hay que pararse a [[Pensar]]"), 17 | "extension":"md", 18 | "path":"notes/Actitud.md", 19 | } 20 | ]) 21 | expect(pending).toEqual([ 22 | { 23 | "timesLinked": 2, 24 | "title": "Pensar" 25 | }, 26 | { 27 | "timesLinked": 1, 28 | "title": "Charles Bukowski" 29 | }, 30 | { 31 | "timesLinked": 1, 32 | "title": "Tiempo" 33 | } 34 | ]) 35 | }); 36 | 37 | it('removing anchors from title', async () => { 38 | const pending = await Notes.getPendingToCreate([ 39 | { 40 | "name": "Test", 41 | "content": Promise.resolve("The [[Ideaverse#Definition]] is a very good [[Concept|concept]] \n\n![[E30BB200-93F5-486D-B293-244788D253DC.mp3]]\n![[E30BB200-93F5-486D-B293-244788D253DC.jpg]]\n![[E30BB200-93F5-486D-B293-244788D253DC.jpeg]]\n![[E30BB200-93F5-486D-B293-244788D253DC.png]]\n![[E30BB200-93F5-486D-B293-244788D253DC.mp4]]\n![[E30BB200-93F5-486D-B293-244788D253DC.ogg]]\n![[E30BB200-93F5-486D-B293-244788D253DC.m4a]]\n![[E30BB200-93F5-486D-B293-244788D253DC.pdf]]\n![[E30BB200-93F5-486D-B293-244788D253DC.pdf]]"), 42 | "extension":"md", 43 | "path":"notes/Test.md", 44 | }, 45 | ]) 46 | expect(pending).toEqual([ 47 | { 48 | "timesLinked": 1, 49 | "title": "Concept" 50 | }, 51 | { 52 | "timesLinked": 1, 53 | "title": "Ideaverse" 54 | } 55 | ]) 56 | }); 57 | 58 | it('skipping attachments', async () => { 59 | const pending = await Notes.getPendingToCreate([ 60 | { 61 | "name": "Test", 62 | "content": Promise.resolve("The [[Light and Space.pdf|poem]] from [[Charles Bukowski]] \n\n![[E30BB200-93F5-486D-B293-244788D253DC.mp3]]\n![[E30BB200-93F5-486D-B293-244788D253DC.jpg]]\n![[E30BB200-93F5-486D-B293-244788D253DC.jpeg]]\n![[E30BB200-93F5-486D-B293-244788D253DC.png]]\n![[E30BB200-93F5-486D-B293-244788D253DC.mp4]]\n![[E30BB200-93F5-486D-B293-244788D253DC.ogg]]\n![[E30BB200-93F5-486D-B293-244788D253DC.m4a]]\n![[E30BB200-93F5-486D-B293-244788D253DC.pdf]]\n![[E30BB200-93F5-486D-B293-244788D253DC.pdf]]"), 63 | "extension":"md", 64 | "path":"notes/Test.md", 65 | }, 66 | ]) 67 | expect(pending).toEqual([ 68 | { 69 | "timesLinked": 1, 70 | "title": "Charles Bukowski" 71 | } 72 | ]) 73 | }); 74 | 75 | it('skipping links with templater characters', async () => { 76 | const pending = await Notes.getPendingToCreate([ 77 | { 78 | "name": "Test", 79 | "content": Promise.resolve("[[Chocolate]] for my [[<%* tR += ${friend} %>]] and [[My <% ${emotion} %> family]]."), 80 | "extension":"md", 81 | "path":"notes/Test.md", 82 | }, 83 | ]) 84 | expect(pending).toEqual([ 85 | { 86 | "timesLinked": 1, 87 | "title": "Chocolate" 88 | } 89 | ]) 90 | }); 91 | 92 | it('skipping code blocks', async () => { 93 | const pending = await Notes.getPendingToCreate([ 94 | { 95 | "name": "Test", 96 | "content": (async () => (await fs.readFile('test/fixtures/code-blocks.md')).toString())(), 97 | "extension":"md", 98 | "path":"notes/Test.md", 99 | }, 100 | ]) 101 | expect(pending).toEqual([ 102 | { 103 | "timesLinked": 1, 104 | "title": "code blocks" 105 | }, 106 | { 107 | "timesLinked": 1, 108 | "title": "inline code" 109 | } 110 | ]) 111 | }); 112 | 113 | it('ignoring cases', async () => { 114 | const pending = await Notes.getPendingToCreate([ 115 | { 116 | "name": "Test", 117 | "content": Promise.resolve('This should recognize one [[pending note]]. Because [[Pending note]] title should be case-insensitive.'), 118 | "extension":"md", 119 | "path":"notes/Test.md", 120 | }, 121 | { 122 | "name": "Pending note", 123 | "content": Promise.resolve('Is note that is [[Outlinks|linked]], but not created yet. '), 124 | "extension":"md", 125 | "path":"notes/Pending note.md", 126 | }, 127 | ]) 128 | expect(pending).toEqual([ 129 | { 130 | "timesLinked": 1, 131 | "title": "Outlinks" 132 | } 133 | ]) 134 | }); 135 | 136 | it('handling wikilinks based on file paths', async () => { 137 | const pending = await Notes.getPendingToCreate([ 138 | { 139 | "name": "Test", 140 | "content": Promise.resolve('This should recognize one [[notes/Pending note]]. Because [[Pending note]] another [[notes/Pending note.md]].'), 141 | "extension":"md", 142 | "path":"notes/Test.md", 143 | }, 144 | { 145 | "name": "Pending note", 146 | "content": Promise.resolve('Is note that is [[Outlinks|linked]], but not created yet. '), 147 | "extension":"md", 148 | "path":"notes/Pending note.md", 149 | }, 150 | ]) 151 | expect(pending).toEqual([ 152 | { 153 | "timesLinked": 1, 154 | "title": "Outlinks" 155 | } 156 | ]) 157 | }); 158 | 159 | it('handling apostrophes', async () => { 160 | const pending = await Notes.getPendingToCreate([ 161 | { 162 | "name": "Test", 163 | "content": Promise.resolve("This should recognize one [[notes/Pending note]]. Because [[Pending note]] another [[notes/Pending note.md]]. Check [[Fulano's legend]]"), 164 | "extension":"md", 165 | "path":"notes/Test.md", 166 | }, 167 | { 168 | "name": "Pending note", 169 | "content": Promise.resolve("Is note that is [[Outlinks|linked]], but not created yet. Also, you can check [[notes/Ambrosio's legend]]"), 170 | "extension":"md", 171 | "path":"notes/Pending note.md", 172 | }, 173 | { 174 | "name": "Ambrosio's legend", 175 | "content": Promise.resolve("Ambrosio was an exceptional man. He can enter into a bar and keep quiet hearing cuñaos talking about solving the world."), 176 | "extension":"md", 177 | "path":"notes/Ambrosio's legend.md", 178 | }, 179 | { 180 | "name": "Fulano's legend", 181 | "content": Promise.resolve("Fulano was an exceptional man. Much more than [[notes/Mengano's story]] and [[Rodolfo's story]]"), 182 | "extension":"md", 183 | "path":"notes/Fulano's legend.md", 184 | }, 185 | ]) 186 | expect(pending).toEqual([ 187 | { 188 | "timesLinked": 1, 189 | "title": "notes/Mengano's story" 190 | }, 191 | { 192 | "timesLinked": 1, 193 | "title": "Outlinks" 194 | }, 195 | { 196 | "timesLinked": 1, 197 | "title": "Rodolfo's story" 198 | } 199 | ]) 200 | }); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /test/fixtures/code-blocks.md: -------------------------------------------------------------------------------- 1 | # Code blocks 2 | 3 | This are examples of [[code blocks]]: 4 | 5 | ``` 6 | # ${ZSH_CUSTOM}/volta.zsh 7 | VOLTA_HOME="$HOME/.volta" 8 | if [[ -d $VOLTA_HOME ]]; then 9 | path=("$VOLTA_HOME/bin" $path) 10 | export PATH 11 | export VOLTA_HOME 12 | fi 13 | ``` 14 | 15 | ```bash 16 | # ${ZSH_CUSTOM}/volta.zsh 17 | VOLTA_HOME="$HOME/.volta" 18 | if [[ -r $VOLTA_HOME ]]; then 19 | path=("$VOLTA_HOME/bin" $path) 20 | export PATH 21 | export VOLTA_HOME 22 | fi 23 | ``` 24 | 25 | This is [[inline code]] `[[ -i $VOLTA_HOME ]]`. 26 | -------------------------------------------------------------------------------- /test/scripts/test-deploy.mjs: -------------------------------------------------------------------------------- 1 | import {exec } from 'child_process' 2 | 3 | exec('npm run deploy', (err, _stdout, stderr) => { 4 | if (err) { 5 | console.error(stderr || err?.toString()) 6 | process.exitCode = 1; 7 | } else { 8 | console.log('Deploy completed successfully.') 9 | } 10 | }) -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "resolveJsonModule": true, 9 | "allowSyntheticDefaultImports": true, 10 | "allowJs": true, 11 | "noImplicitAny": true, 12 | "moduleResolution": "node", 13 | "importHelpers": true, 14 | "isolatedModules": true, 15 | "strictNullChecks": true, 16 | "jsx": "react", 17 | "lib": [ 18 | "DOM", 19 | "ES5", 20 | "ES6", 21 | "ES7" 22 | ] 23 | }, 24 | "include": [ 25 | "**/*.ts" 26 | ], 27 | "exclude": [ 28 | "test" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "0.15.0", 3 | "0.2.0": "0.15.0", 4 | "0.3.0": "0.15.0", 5 | "0.4.0": "0.15.0", 6 | "0.4.1": "0.15.0", 7 | "0.5.0": "0.15.0", 8 | "0.5.1": "0.15.0", 9 | "0.5.2": "0.15.0", 10 | "0.5.3": "0.15.0", 11 | "0.5.4": "0.15.0", 12 | "0.6.0": "0.15.0", 13 | "0.6.1": "0.15.0", 14 | "0.6.2": "0.15.0", 15 | "0.7.0": "0.15.0", 16 | "0.7.1": "0.15.0", 17 | "0.7.2": "0.15.0", 18 | "0.7.3": "0.15.0", 19 | "0.8.0": "0.15.0", 20 | "0.8.1": "0.15.0" 21 | } --------------------------------------------------------------------------------