├── .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 | [](https://GitHub.com/ulisesantana/obsidian-pending-notes/releases/)
3 | 
4 | [](https://codecov.io/github/ulisesantana/obsidian-pending-notes)
5 |
6 |
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 | 
13 |
14 | You can use it from left sidebar or through the command palette:
15 |
16 | 
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 | }
--------------------------------------------------------------------------------