├── .editorconfig ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── ci_jobs.yml │ └── dependabot_ci.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierrc ├── .tool-versions ├── .vscode ├── launch.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── codecov.yml ├── commitlint.config.js ├── help ├── Attachments │ ├── Automatic link synchronization 2.gif │ ├── Automatic link synchronization.gif │ ├── Backlinks panel.png │ ├── Creating links.gif │ ├── Creating notes from links.png │ ├── Dummy.pdf │ ├── Embed files.gif │ ├── Extracting range to a new note.gif │ ├── Find all references.png │ ├── Links labeling.png │ ├── Links navigation.gif │ ├── Notes and images preview.gif │ ├── Open daily note.gif │ ├── Open link command.png │ ├── Open link to the side.png │ ├── Open random note.gif │ ├── Opening common file types in the default app.gif │ ├── Opening links in the default app.gif │ ├── Paste HTML as Markdown.gif │ ├── Short and long links support 2.png │ ├── Short and long links support.png │ ├── Structure 1.png │ └── Structure 2.png ├── Daily │ ├── 2020-07-01.md │ ├── 2020-07-02.md │ ├── 2020-07-03.md │ ├── 2020-07-04.md │ └── 2020-07-05.md ├── Examples │ └── Demo (Non-Unique) │ │ ├── DemoNote.md │ │ └── Notes │ │ └── DemoNote.md ├── Features │ ├── Accepted File Formats.md │ ├── Automatic link synchronization.md │ ├── Backlinks panel.md │ ├── Commands.md │ ├── Creating links.md │ ├── Creating notes from links.md │ ├── Embed files.md │ ├── Features.md │ ├── Find all references.md │ ├── Opening links in the default app.md │ └── Short and long links support.md ├── How to │ ├── How to.md │ ├── Notes refactoring.md │ ├── Pasting HTML as Markdown.md │ └── Pasting images from clipboard.md └── README.md ├── media ├── fontello │ ├── css │ │ └── fontello.css │ └── font │ │ ├── fontello.eot │ │ ├── fontello.svg │ │ ├── fontello.ttf │ │ ├── fontello.woff │ │ └── fontello.woff2 ├── markdown.css └── memo.png ├── package.json ├── src ├── commands │ ├── commands.ts │ ├── extractRangeToNewNote.spec.ts │ ├── extractRangeToNewNote.ts │ ├── index.ts │ ├── openDailyNote.spec.ts │ ├── openDailyNote.ts │ ├── openDocumentByReference.spec.ts │ ├── openDocumentByReference.ts │ ├── openRandomNote.spec.ts │ ├── openRandomNote.ts │ ├── openReferenceBeside.spec.ts │ ├── openReferenceBeside.ts │ ├── openReferenceInDefaultApp.spec.ts │ ├── openReferenceInDefaultApp.ts │ └── pasteHtmlAsMarkdown.ts ├── declarations.d.ts ├── extension.spec.ts ├── extension.ts ├── features │ ├── BacklinksTreeDataProvider.spec.ts │ ├── BacklinksTreeDataProvider.ts │ ├── DocumentLinkProvider.spec.ts │ ├── DocumentLinkProvider.ts │ ├── ReferenceHoverProvider.spec.ts │ ├── ReferenceHoverProvider.ts │ ├── ReferenceProvider.spec.ts │ ├── ReferenceProvider.ts │ ├── ReferenceRenameProvider.spec.ts │ ├── ReferenceRenameProvider.ts │ ├── codeActionProvider.spec.ts │ ├── codeActionProvider.ts │ ├── completionProvider.spec.ts │ ├── completionProvider.ts │ ├── extendMarkdownIt.spec.ts │ ├── extendMarkdownIt.ts │ ├── index.ts │ ├── newVersionNotifier.spec.ts │ ├── newVersionNotifier.ts │ ├── referenceContextWatcher.spec.ts │ └── referenceContextWatcher.ts ├── logger.ts ├── test │ ├── config │ │ └── jestSetup.ts │ ├── env │ │ └── VsCodeEnvironment.js │ ├── runTest.ts │ ├── testRunner.ts │ └── utils │ │ ├── index.ts │ │ ├── utils.spec.ts │ │ └── utils.ts ├── types.ts ├── utils │ ├── clipboardUtils.ts │ ├── createDailyQuickPick.spec.ts │ ├── createDailyQuickPick.ts │ ├── externalUtils.spec.ts │ ├── externalUtils.ts │ ├── index.ts │ ├── replaceUtils.spec.ts │ ├── replaceUtils.ts │ ├── searchUtils.spec.ts │ ├── searchUtils.ts │ ├── utils.spec.ts │ └── utils.ts └── workspace │ ├── cache │ ├── cache.spec.ts │ ├── cache.ts │ └── index.ts │ ├── file-watcher │ ├── fileWatcher.spec.ts │ ├── fileWatcher.ts │ ├── handlers.ts │ └── index.ts │ └── index.ts ├── syntaxes └── injection.json ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | tab_width = 2 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "settings": { 9 | "import/resolver": { 10 | "node": { 11 | "paths": ["src"], 12 | "extensions": [".ts"] 13 | } 14 | } 15 | }, 16 | "extends": [ 17 | "prettier", 18 | "plugin:@typescript-eslint/recommended", 19 | "plugin:import/errors", 20 | "plugin:import/warnings" 21 | ], 22 | "plugins": ["@typescript-eslint", "prettier", "folders", "unicorn", "no-only-tests"], 23 | "rules": { 24 | // kebab-case 25 | "folders/match-regex": [2, "^[a-z-]+$", "/src/"], 26 | "unicorn/filename-case": [ 27 | "error", 28 | { 29 | "cases": { 30 | "camelCase": true, 31 | "pascalCase": true 32 | } 33 | } 34 | ], 35 | "import/no-internal-modules": [ 36 | "error", { 37 | "forbid": [ 38 | "commands/*", 39 | "features/*", 40 | "utils/*", 41 | "test/utils/*" 42 | ] 43 | } 44 | ], 45 | "eqeqeq": "warn", 46 | "no-throw-literal": "warn", 47 | "semi": "off", 48 | "prettier/prettier": "error", 49 | "camelcase": 0, 50 | "default-case": 0, 51 | "curly": ["error", "all"], 52 | "global-require": 0, 53 | "import/export": 0, 54 | "import/extensions": 0, 55 | "import/named": 0, 56 | "import/no-named-as-default-member": 0, 57 | "import/no-named-as-default": 0, 58 | "import/no-unresolved": 0, 59 | "import/prefer-default-export": 0, 60 | "import/order": [ 61 | "error", 62 | { 63 | "newlines-between": "always", 64 | "groups": [ 65 | ["external", "builtin"], 66 | ["internal", "index", "sibling", "parent"] 67 | ] 68 | } 69 | ], 70 | "max-classes-per-file": 0, 71 | "new-cap": ["error", { "capIsNew": false }], 72 | "no-restricted-globals": 0, 73 | "no-var-requires": 0, 74 | "no-only-tests/no-only-tests": "error", 75 | "prefer-object-spread": "error", 76 | "prefer-destructuring": 0, 77 | "strict": 0, 78 | "consistent-return": 0, 79 | "@typescript-eslint/no-unused-vars": ["warn", { "ignoreRestSiblings": true }], 80 | "@typescript-eslint/camelcase": 0, 81 | "@typescript-eslint/explicit-function-return-type": 0, 82 | "@typescript-eslint/prefer-interface": 0, 83 | "@typescript-eslint/no-var-requires": 0, 84 | "@typescript-eslint/no-explicit-any": 0, 85 | "@typescript-eslint/no-empty-function": 0, 86 | "@typescript-eslint/no-non-null-assertion": 0, 87 | "@typescript-eslint/ban-types": 0, 88 | "@typescript-eslint/no-inferrable-types": 0, 89 | "@typescript-eslint/no-empty-interface": 0, 90 | "@typescript-eslint/no-object-literal-type-assertion": 0, 91 | "@typescript-eslint/indent": 0, 92 | "@typescript-eslint/consistent-type-definitions": ["error", "type"], 93 | "@typescript-eslint/explicit-module-boundary-types": 0, 94 | "@typescript-eslint/semi": "warn" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - "dependabot/npm_and_yarn/**" 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | ci_jobs: 12 | name: CI Jobs 13 | uses: svsool/memo/.github/workflows/ci_jobs.yml@master 14 | secrets: 15 | VSCE_PAT: ${{ secrets.VSCE_PAT }} 16 | OVSX_PAT: ${{ secrets.OVSX_PAT }} 17 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/ci_jobs.yml: -------------------------------------------------------------------------------- 1 | name: CI Jobs 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | VSCE_PAT: 7 | required: true 8 | OVSX_PAT: 9 | required: true 10 | CODECOV_TOKEN: 11 | required: true 12 | 13 | env: 14 | NODE_VERSION: 16.14.2 15 | 16 | jobs: 17 | check-types: 18 | name: Check Types 19 | runs-on: ubuntu-22.04 20 | steps: 21 | - uses: actions/checkout@v1 22 | - name: Setup Node 23 | uses: actions/setup-node@v1.4.4 24 | with: 25 | node-version: ${{ env.NODE_VERSION }} 26 | - name: Install Dependencies 27 | run: yarn 28 | - name: Check Types 29 | run: npm run ts 30 | lint: 31 | name: Run Linter 32 | runs-on: ubuntu-22.04 33 | steps: 34 | - uses: actions/checkout@v1 35 | - name: Setup Node 36 | uses: actions/setup-node@v1.4.4 37 | with: 38 | node-version: ${{ env.NODE_VERSION }} 39 | - name: Install Dependencies 40 | run: yarn 41 | - name: Check Types 42 | run: npm run lint 43 | tests: 44 | name: Run Tests 45 | strategy: 46 | matrix: 47 | os: [macos-12, ubuntu-22.04, windows-2019] 48 | runs-on: ${{ matrix.os }} 49 | env: 50 | OS: ${{ matrix.os }} 51 | steps: 52 | - uses: actions/checkout@v1 53 | - name: Setup Node 54 | uses: actions/setup-node@v1.4.4 55 | with: 56 | node-version: ${{ env.NODE_VERSION }} 57 | - name: Install Dependencies 58 | run: yarn 59 | - name: Run Tests 60 | uses: GabrielBB/xvfb-action@v1.0 61 | with: 62 | run: yarn test:ci 63 | - name: Upload coverage to Codecov 64 | uses: codecov/codecov-action@v1 65 | with: 66 | token: ${{ secrets.CODECOV_TOKEN }} 67 | directory: ./coverage/ 68 | env_vars: OS 69 | fail_ci_if_error: true 70 | create_release: 71 | name: Create Release 72 | if: success() && startsWith(github.ref, 'refs/tags/v') 73 | runs-on: ubuntu-22.04 74 | needs: [check-types, lint, tests] 75 | steps: 76 | - uses: actions/checkout@v1 77 | - name: Setup Node 78 | uses: actions/setup-node@v1.4.4 79 | with: 80 | node-version: ${{ env.NODE_VERSION }} 81 | - name: Install Dependencies 82 | run: yarn 83 | - name: Package 84 | run: yarn package 85 | - uses: "marvinpinto/action-automatic-releases@latest" 86 | with: 87 | repo_token: ${{ secrets.GITHUB_TOKEN }} 88 | prerelease: false 89 | files: | 90 | markdown-memo-*.vsix 91 | publish: 92 | name: Publish Release 93 | runs-on: ubuntu-22.04 94 | needs: [create_release] 95 | steps: 96 | - uses: actions/checkout@v1 97 | - name: Setup Node 98 | uses: actions/setup-node@v1.4.4 99 | with: 100 | node-version: ${{ env.NODE_VERSION }} 101 | - name: Install Dependencies 102 | run: yarn 103 | - name: Publish to Visual Studio Marketplace 104 | env: 105 | VSCE_PAT: ${{ secrets.VSCE_PAT }} 106 | run: yarn deploy:vsce 107 | - name: Publish to Open VSX Registry 108 | env: 109 | OVSX_PAT: ${{ secrets.OVSX_PAT }} 110 | run: yarn deploy:ovsx 111 | -------------------------------------------------------------------------------- /.github/workflows/dependabot_ci.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot CI 2 | 3 | on: 4 | pull_request_target 5 | 6 | permissions: read-all 7 | 8 | jobs: 9 | ci_jobs: 10 | name: CI Jobs 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | uses: svsool/vscode-memo/.github/workflows/ci_jobs.yml@master 13 | secrets: 14 | VSCE_PAT: ${{ secrets.VSCE_PAT }} 15 | OVSX_PAT: ${{ secrets.OVSX_PAT }} 16 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | tmp 4 | node_modules 5 | .vscode-test/ 6 | *.vsix 7 | yarn-error.log 8 | VERSION 9 | coverage/ 10 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.14.2 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "overrides": [ 4 | { 5 | "files": ["*.ts", "*.js"], 6 | "options": { 7 | "singleQuote": true, 8 | "trailingComma": "all" 9 | } 10 | }, 11 | { 12 | "files": "*.json", 13 | "options": { 14 | "parser": "json" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 16.14.2 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": [ 14 | "--extensionDevelopmentPath=${workspaceFolder}" 15 | ], 16 | "outFiles": [ 17 | "${workspaceFolder}/dist/**/*.js" 18 | ], 19 | "preLaunchTask": "npm: compile" 20 | }, 21 | { 22 | "name": "Extension Tests", 23 | "type": "extensionHost", 24 | "request": "launch", 25 | "runtimeExecutable": "${execPath}", 26 | "args": [ 27 | "${workspaceFolder}/tmp/test-workspace", 28 | "--disable-extensions", 29 | "--extensionDevelopmentPath=${workspaceFolder}", 30 | "--extensionTestsPath=${workspaceFolder}/out/test/testRunner" 31 | ], 32 | "outFiles": [ 33 | "${workspaceFolder}/out/test/**/*.js" 34 | ], 35 | "preLaunchTask": "npm: compile:tests" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | **/*.spec.js 5 | src/** 6 | .gitignore 7 | .editorconfig 8 | .eslintrc.json 9 | .huskyrc.js 10 | .prettierrc 11 | CONTRIBUTION.md 12 | vsc-extension-quickstart.md 13 | yarn.lock 14 | yarn-error.log 15 | **/tsconfig.json 16 | **/webpack.config.js 17 | **/.eslintrc.json 18 | **/*.map 19 | **/*.ts 20 | node_modules 21 | VERSION 22 | coverage/ 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | ## Project structure 4 | 5 | ``` 6 | src 7 | ├── commands - contains internal / external commands, e.g. open today or random note commands 8 | ├── declarations.d.ts - global TS type declarations 9 | ├── extension.ts - plugin entrypoint 10 | ├── features - contains features, usually feature accepts plugin context and implements certain functionality 11 | ├── test - contains test runner and common test utils 12 | ├── types.ts - common types 13 | └── utils - common utils 14 | ``` 15 | 16 | ## Committing changes 17 | 18 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines and [Why Use Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#why-use-conventional-commits). 19 | 20 | Guidelines enforced via commit hooks, so commits MUST be prefixed with a type. 21 | 22 | ## Contributing 23 | 24 | 1. Fork this repository 25 | 2. Create your feature branch: `git checkout -b my-new-feature` 26 | 3. Commit your changes: `git commit -am 'feat: Add some feature'` 27 | 4. Push to the branch: `git push origin my-new-feature` 28 | 5. Submit a pull request 29 | 30 | For bigger features, please consider discussing your plans and ideas on GitHub first before implementing them. 31 | 32 | ## Development 33 | 34 | * `cd && yarn && yarn watch` 35 | * Open project in VSCode using `code ` or via `File -> Open...` and press `F5` to open a new window with the extension loaded. 36 | * After making modifications run `Developer: Restart Extension Host` command from the command palette to restart the extension and quickly pick up your changes. 37 | * Set breakpoints in your code inside `src/extension.ts` to debug the extension. 38 | * Find output from the extension in the debug console. 39 | 40 | ## Run tests 41 | 42 | ``` 43 | yarn test # runs all tests 44 | yarn test:watch # runs only changed tests, consider also using JEST_TEST_REGEX env var for running specific tests 45 | ``` 46 | 47 | *Note: Before running integration tests, please ensure that all VSCode instances are closed.* 48 | 49 | ## Releasing 50 | 51 | *You can skip this section if your contribution comes via PR from a forked repository.* 52 | 53 | 1. Remember to update [new version notifications](https://github.com/svsool/memo/blob/2d187fd65218473c4264e992aa4a2497666614f2/src/features/newVersionNotifier.ts#L6) if needed 54 | 1. Run `yarn release` 55 | 1. Push to origin with `git push --follow-tags origin master` 56 | 1. After push CI will automatically: 57 | - create new release 58 | - attach release artifacts 59 | - publish extension to the marketplace 60 | 61 | ## Conventions 62 | 63 | General: 64 | 65 | - Please prefer FP over OOP style where task permits to stay consistent with the existing codebase 66 | - Use random file names in tests to increase isolation 67 | - VSCode does not provide API to dispose text documents manually which can lead to flaky tests if random file names are not used 68 | - Use "_" prefix for internal command names 69 | - Put tests as `.spec.ts` next to the tested file 70 | 71 | Pull requests: 72 | 73 | - No linter or type errors 74 | - No failing tests 75 | - Try to make code better than it was before the pull request 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Svyatoslav Sobol 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 | # Memo 2 | 3 | Markdown knowledge base with bidirectional [[link]]s built on top of [VSCode](https://github.com/microsoft/vscode). 4 | 5 | Inspired by [Obsidian.md](https://obsidian.md/) and [RoamResearch](https://roamresearch.com/). 6 | 7 | [![Visual Studio Marketplace Version](https://img.shields.io/visual-studio-marketplace/v/svsool.markdown-memo?color=light-green&label=VS%20Marketplace)](https://marketplace.visualstudio.com/items?itemName=svsool.markdown-memo) 8 | [![Open VSX Version](https://img.shields.io/open-vsx/v/svsool/markdown-memo?color=salad&label=Open%20VSX)](https://open-vsx.org/extension/svsool/markdown-memo) 9 | [![Visual Studio Marketplace Rating](https://img.shields.io/visual-studio-marketplace/r/svsool.markdown-memo)](https://marketplace.visualstudio.com/items?itemName=svsool.markdown-memo&ssr=false#review-details) 10 | [![codecov](https://codecov.io/gh/svsool/memo/branch/master/graph/badge.svg)](https://codecov.io/gh/svsool/memo) 11 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/svsool/memo/blob/master/CONTRIBUTING.md) 12 | 13 | ## Why? 14 | 15 | Because your knowledge base deserves to be powered by open source. 16 | 17 | ## Getting started 18 | 19 | If you want to try out Memo just install it via marketplace using [this link](https://marketplace.visualstudio.com/items?itemName=svsool.markdown-memo) and open the [help](https://github.com/svsool/memo/tree/master/help) folder via `File > Open Folder...` or by dragging it onto VSCode. 20 | 21 | ## Features 22 | 23 | - 🔗 **Links support** 24 | 25 | - Creating links 26 | 27 | - ![Creating links](./help/Attachments/Creating%20links.gif) 28 | 29 | - Links navigation 30 | 31 | - ![Links navigation](./help/Attachments/Links%20navigation.gif) 32 | 33 | - Embedding notes and images 34 | 35 | - ![Embedding notes and images](./help/Attachments/Embed%20files.gif) 36 | 37 | - Automatic links synchronization on file rename 38 | 39 | - ![Automatic links synchronization](./help/Attachments/Automatic%20link%20synchronization.gif) 40 | 41 | - Links rename via `Rename Symbol` command 42 | 43 | - ![Links rename via command](./help/Attachments/Automatic%20link%20synchronization%202.gif) 44 | 45 | - Links labeling 46 | 47 | - ![Links labeling](./help/Attachments/Links%20labeling.png) 48 | 49 | - Support for short and full links on filename clash 50 | 51 | - ![Support short and full links on filename clash](./help/Attachments/Short%20and%20long%20links%20support%202.png) 52 | 53 | - Opening links with unsupported file formats in the system default app 54 | 55 | - ![Links labeling](./help/Attachments/Opening%20links%20in%20the%20default%20app.gif) 56 | 57 | - Find all references 58 | 59 | - ![Find all references](./help/Attachments/Find%20all%20references.png) 60 | 61 | - 🖼️ **Notes and images preview (built-in & on-hover)** 62 | 63 | ![Notes and images preview](./help/Attachments/Notes%20and%20images%20preview.gif) 64 | 65 | - 🦋 **Creating notes on the fly** 66 | 67 | ![Creating notes on the fly](./help/Attachments/Creating%20notes%20from%20links.png) 68 | 69 | - 🖇 **Backlinks panel** 70 | 71 | ![Backlinks panel](./help/Attachments/Backlinks%20panel.png) 72 | 73 | - 🕹 **Commands** 74 | 75 | - "Open link" command support for links following 76 | 77 | - "Open link to the side" command allows you to open link in the adjacent/new column of the editor 78 | 79 | - "Open daily note" command which creates a note with a title in `yyyy-mm-dd` format or opens already existing one 80 | 81 | - ![Open daily note command](./help/Attachments/Open%20daily%20note.gif) 82 | 83 | - "Open random note" command which allows you to explore your knowledge base a little bit 84 | 85 | - ![Open random note command](./help/Attachments/Open%20random%20note.gif) 86 | 87 | - "Open link in the default app" command for opening unsupported file formats in the system default app 88 | 89 | - "Paste HTML as Markdown" command which can partially replace a web clipper functionality 90 | 91 | - ![Paste HTML as Markdown](./help/Attachments/Paste%20HTML%20as%20Markdown.gif) 92 | 93 | - "Rename Symbol" command support for renaming links right in the editor 94 | 95 | - "Extract range to a new note" command to ease notes refactoring 96 | 97 | ## Contributing 98 | 99 | - File bugs, feature requests in [GitHub Issues](https://github.com/svsool/memo/issues). 100 | - Leave a review on [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=svsool.markdown-memo&ssr=false#review-details). 101 | - Read [CONTRIBUTING.md](CONTRIBUTING.md) for contributing to the code base. 102 | 103 | ## Changelog 104 | 105 | See changelog [here](CHANGELOG.md). 106 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 70..90 3 | round: down 4 | precision: 2 5 | status: 6 | project: 7 | default: 8 | threshold: 10% 9 | patch: 10 | default: 11 | threshold: 20% 12 | only_pulls: true 13 | 14 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'subject-case': [0, 'never', []], 5 | 'header-max-length': [2, 'always', 120], 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /help/Attachments/Automatic link synchronization 2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Automatic link synchronization 2.gif -------------------------------------------------------------------------------- /help/Attachments/Automatic link synchronization.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Automatic link synchronization.gif -------------------------------------------------------------------------------- /help/Attachments/Backlinks panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Backlinks panel.png -------------------------------------------------------------------------------- /help/Attachments/Creating links.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Creating links.gif -------------------------------------------------------------------------------- /help/Attachments/Creating notes from links.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Creating notes from links.png -------------------------------------------------------------------------------- /help/Attachments/Dummy.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Dummy.pdf -------------------------------------------------------------------------------- /help/Attachments/Embed files.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Embed files.gif -------------------------------------------------------------------------------- /help/Attachments/Extracting range to a new note.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Extracting range to a new note.gif -------------------------------------------------------------------------------- /help/Attachments/Find all references.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Find all references.png -------------------------------------------------------------------------------- /help/Attachments/Links labeling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Links labeling.png -------------------------------------------------------------------------------- /help/Attachments/Links navigation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Links navigation.gif -------------------------------------------------------------------------------- /help/Attachments/Notes and images preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Notes and images preview.gif -------------------------------------------------------------------------------- /help/Attachments/Open daily note.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Open daily note.gif -------------------------------------------------------------------------------- /help/Attachments/Open link command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Open link command.png -------------------------------------------------------------------------------- /help/Attachments/Open link to the side.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Open link to the side.png -------------------------------------------------------------------------------- /help/Attachments/Open random note.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Open random note.gif -------------------------------------------------------------------------------- /help/Attachments/Opening common file types in the default app.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Opening common file types in the default app.gif -------------------------------------------------------------------------------- /help/Attachments/Opening links in the default app.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Opening links in the default app.gif -------------------------------------------------------------------------------- /help/Attachments/Paste HTML as Markdown.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Paste HTML as Markdown.gif -------------------------------------------------------------------------------- /help/Attachments/Short and long links support 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Short and long links support 2.png -------------------------------------------------------------------------------- /help/Attachments/Short and long links support.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Short and long links support.png -------------------------------------------------------------------------------- /help/Attachments/Structure 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Structure 1.png -------------------------------------------------------------------------------- /help/Attachments/Structure 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/help/Attachments/Structure 2.png -------------------------------------------------------------------------------- /help/Daily/2020-07-01.md: -------------------------------------------------------------------------------- 1 | # 2020-07-01 2 | 3 | Daily note example for July 1, 2020. 4 | 5 | - Event 1 6 | - Event 2 7 | - Event 3 8 | -------------------------------------------------------------------------------- /help/Daily/2020-07-02.md: -------------------------------------------------------------------------------- 1 | # 2020-07-02 2 | 3 | Daily note example for July 2, 2020. 4 | 5 | - Event 1 6 | - Event 2 7 | - Event 3 8 | -------------------------------------------------------------------------------- /help/Daily/2020-07-03.md: -------------------------------------------------------------------------------- 1 | # 2020-07-03 2 | 3 | Daily note example for July 3, 2020. 4 | 5 | - Event 1 6 | - Event 2 7 | - Event 3 8 | -------------------------------------------------------------------------------- /help/Daily/2020-07-04.md: -------------------------------------------------------------------------------- 1 | # 2020-07-04 2 | 3 | Daily note example for July 4, 2020. 4 | 5 | - Event 1 6 | - Event 2 7 | - Event 3 8 | -------------------------------------------------------------------------------- /help/Daily/2020-07-05.md: -------------------------------------------------------------------------------- 1 | # 2020-07-05 2 | 3 | One of my daily notes 4 | 5 | Events: 6 | 7 | - Started working on Memo help 8 | -------------------------------------------------------------------------------- /help/Examples/Demo (Non-Unique)/DemoNote.md: -------------------------------------------------------------------------------- 1 | Demo note without content :). 2 | -------------------------------------------------------------------------------- /help/Examples/Demo (Non-Unique)/Notes/DemoNote.md: -------------------------------------------------------------------------------- 1 | Demo note without content :). 2 | -------------------------------------------------------------------------------- /help/Features/Accepted File Formats.md: -------------------------------------------------------------------------------- 1 | # Accepted File Formats 2 | 3 | Memo recognizes the following file formats: 4 | 5 | 1. Markdown files: `md`; 6 | 2. Image files: `png`, `jpg`, `jpeg`, `svg`, `gif`, `webp`; 7 | 3. Other formats: `doc`, `docx`, `rtf`, `txt`, `odt`, `xls`, `xlsx`, `ppt`, `pptm`, `pptx`, `pdf`. See full list of extensions [here](https://github.com/svsool/memo/blob/51d65f594978d30ee049feda710c3ce52ab64bad/src/utils/utils.ts#L12-L44). 8 | 9 | Markdown files and image files can be referenced via regular links `[[image.png]]` or attached using embed links `![[image.png]]`. These file types also support on-hover and built-in previews ([[Notes and images preview.gif|see how it works]]). 10 | 11 | Other formats can be referenced via regular links and opened in VSCode given plugin in the marketplace such as for instance [vscode-pdf](https://marketplace.visualstudio.com/items?itemName=tomoki1207.pdf) for PDFs or opened in the default app using `Open link in the default app` command. See [[Opening links in the default app]]. 12 | -------------------------------------------------------------------------------- /help/Features/Automatic link synchronization.md: -------------------------------------------------------------------------------- 1 | # Automatic link synchronization 2 | 3 | You have two options to sync links: 4 | 5 | 1. Rename file in Workspace Explorer and it will automatically synchronize all links pointing to it 6 | 1. Use `F2` or `Rename Symbol` command from command palette when link is under cursor 7 | 8 | Links sync on **file** rename: 9 | 10 | ![[Automatic link synchronization.gif]] 11 | 12 | Links sync on **symbol** rename: 13 | 14 | ![[Automatic link synchronization 2.gif]] 15 | -------------------------------------------------------------------------------- /help/Features/Backlinks panel.md: -------------------------------------------------------------------------------- 1 | # Backlinks panel 2 | 3 | Backlinks panel shows how the current note is referenced in other notes. 4 | 5 | ![[Backlinks panel.png]] 6 | 7 | All links are grouped by the filename and marked with `LINE:OFFSET`, so you can see within which line and at which offset a certain link is used. Clicking on the filename will bring you to beginning of the file that references the link and clicking on the link itself will bring your cursor to that link in the file. 8 | -------------------------------------------------------------------------------- /help/Features/Commands.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | ### `Open link` command which allows you to follow links or simply use `cmd / ctrl + click` 4 | 5 | ![[Open link command.png]] 6 | 7 | ### `Open link to the side` command which allows you to open link in the adjacent/new column of the editor `cmd / ctrl + shift + enter` 8 | 9 | ![[Open link to the side.png]] 10 | 11 | ### `Open daily note` command which creates a note with a title in `yyyy-mm-dd` format or opens an already existing one instead 12 | 13 | ![[Open daily note.gif]] 14 | 15 | ### `Open random note` command which allows your to explore you knowledge base a little bit 16 | 17 | ![[Open random note.gif]] 18 | 19 | ### `Open link in the default app` command allows you to open unsupported file formats in the system default app. 20 | 21 | ![[Opening links in the default app.gif]] 22 | 23 | ### `Paste HTML as Markdown` command which can partially replace a web clipper functionality 24 | 25 | ![[Paste HTML as Markdown.gif]] 26 | 27 | ### `Rename Symbol` command which allows you to rename links right in the editor 28 | 29 | ![[Automatic link synchronization 2.gif]] 30 | 31 | ### `Extract range to a new note` command to ease notes refactoring 32 | 33 | ![[Extracting range to a new note.gif]] 34 | -------------------------------------------------------------------------------- /help/Features/Creating links.md: -------------------------------------------------------------------------------- 1 | # Creating links 2 | 3 | You can use links to refer to any note in your personal knowledge base such as for instance a daily note [[2020-07-05]] or an image ![[Creating links.gif]]. 4 | 5 | You can assign label to the link using `|` pipe notation, as for instance done for [[2020-07-05|The day when I started writing this help]] note. 6 | 7 | Another possibility that Memo provides is embedding other files as part of the note. Read more on that in [[Embed files]]. 8 | -------------------------------------------------------------------------------- /help/Features/Creating notes from links.md: -------------------------------------------------------------------------------- 1 | # Creating notes from links 2 | 3 | You can automatically create a `[[Note]]` if does not exist yet just by clicking on the link using `cmd / ctrl + click` or using VSCode built-in "Open Link" command ![[Open link command.png]] which you can bind to a keyboard shortcut. 4 | 5 | ## Link formats 6 | 7 | Memo supports two link formats, `short` (by default) and `long` ones. 8 | 9 | ```json 10 | // settings.json 11 | { 12 | "memo.links.format": "long" 13 | } 14 | ``` 15 | 16 | ### Short links 17 | 18 | Short links are helpful for hierarchy-free knowledge bases where the path itself doesn't bring a lot of context. 19 | 20 | Memo will try to use `short` links whenever possible and fallback to the `long` ones otherwise. 21 | 22 | Let's learn how it works in the following two cases. 23 | 24 | 1. Workspace tree where `note.md` is a unique filename: 25 | 26 | ``` 27 | 28 | └── folder1 29 | └── folder2 30 | └── note.md 31 | ``` 32 | 33 | In this case on typing `[[not` autocomplete will offer only one result and Memo will insert a `short` link `[[note]]` pointing to `/folder1/folder2/note.md`. 34 | 35 | 2. Workspace tree where `note.md` is not a unique filename: 36 | 37 | ``` 38 | 39 | ├── folder1 40 | │   └── folder2 41 | │   └── note.md 42 | └── note.md 43 | ``` 44 | 45 | Autocomplete results on typing `[[not` in the editor: 46 | 1. note.md 47 | 1. folder1/folder2/note.md 48 | 49 | Autocomplete results are pre-sorted using a path sorting algorithm. For example, in this case `note.md` path comes before `folder1/folder2/note.md` because shallow paths are sorted before deep ones. 50 | 51 | On picking (1) item Memo will insert a `short` link `[[note]]` pointing to `/note.md` file. 52 | 53 | On picking (2) item Memo will insert a `long` link `[[folder1/folder2/note]]` pointing to `/folder1/folder2/note.md` file. 54 | 55 | Mixing short and long link behaviors helps to avoid clashes. However, with hierarchy-free knowledge bases, short links should be prevalent over long links, and such conflicts shouldn't happen very often. 56 | 57 | ### Long links 58 | 59 | Unlike short links long links always use the longest possible path. 60 | 61 | Workspace tree: 62 | 63 | ``` 64 | 65 | ├── folder1 66 | │   ├── folder2 67 | │   │   └── note.md 68 | │   └── note.md 69 | └── note.md 70 | ``` 71 | 72 | Autocomplete results on typing `[[not` in the editor: 73 | 1. note.md 74 | 1. folder1/note.md 75 | 1. folder1/folder2/note.md 76 | 77 | This is what Memo will insert on picking items accordingly: 78 | 79 | 1. `[[note]]` 80 | 1. `[[folder1/note]]` 81 | 1. `[[folder1/folder2/note]]` 82 | 83 | ## Link rules 84 | 85 | Memo supports configurable rules for short links to specify the destination of the newly created notes when you are clicking on the link. 86 | It enables the concept of an inbox, from which the user can decide the notes' final location. 87 | 88 | The following snippet would instruct Memo to create daily notes in `Daily` directory and all other notes in `Notes` upon clicking on a short link in the editor. 89 | ```json 90 | // settings.json 91 | { 92 | "memo.links.rules": [ 93 | { 94 | "rule": "([12]\\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01]))\\.md$", 95 | "comment": "Daily notes yyyy-mm-dd", 96 | "folder": "/Daily" 97 | }, 98 | { 99 | "rule": "\\.md$", 100 | "comment": "All other notes", 101 | "folder": "$CURRENT_FILE_DIRECTORY" 102 | } 103 | ] 104 | } 105 | ``` 106 | 107 | The following variables can be used as part of the `folder` value: 108 | 109 | - `$CURRENT_FILE_DIRECTORY` - directory of currently opened file relative to workspace 110 | - `$CURRENT_YEAR` - The current year (example '2021') 111 | - `$CURRENT_YEAR_SHORT` - The current year's last two digits 112 | - `$CURRENT_MONTH` - The month as two digits (example '02') 113 | - `$CURRENT_DATE` - The day of the month as two digits (example '08') 114 | - `$CURRENT_HOUR` - The current hour in 24-hour clock format 115 | - `$CURRENT_MINUTE` - The current minute as two digits 116 | - `$CURRENT_SECOND` - The current second as two digits 117 | - `$CURRENT_SECONDS_UNIX` - The number of seconds since the Unix epoch 118 | - `$0-$N` - RegExp capturing group based on the matched rule 119 | -------------------------------------------------------------------------------- /help/Features/Embed files.md: -------------------------------------------------------------------------------- 1 | # Embed files 2 | 3 | In [[Creating links]] you got acquainted with the links concept, and how one can create links to different resources using `[[Creating links]]` notation. 4 | 5 | Another feature that Memo has is attaching other notes and images as part of the current note by using similar notation to regular links but with `!` in the beginning as in `![[Features]]`. 6 | 7 | Let me show you how it works 😉. 8 | 9 | You can embed any other note, for instance: ![[Features]] 10 | 11 | Or you can embed an image: ![[Backlinks panel.png]] 12 | 13 | At this point you can run `Markdown: Open Preview` command using the VSCode command palette to see what it looks like in the built-in preview. 14 | 15 | See also: ![[Accepted File Formats]] 16 | -------------------------------------------------------------------------------- /help/Features/Features.md: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | - [[Creating links]] 4 | - [[Creating notes from links]] 5 | - [[Opening links in the default app]] 6 | - [[Embed files]] 7 | - [[Find all references]] 8 | - [[Short and long links support]] 9 | - [[Automatic link synchronization]] 10 | - [[Backlinks panel]] 11 | - [[Commands]] 12 | -------------------------------------------------------------------------------- /help/Features/Find all references.md: -------------------------------------------------------------------------------- 1 | # Find all references 2 | 3 | Memo implements an advanced language extension feature called Find usages / Find all references, which helps you find all usages of a link under the cursor. Just hover a [[link]], click the right mouse button, and select "Find all references" from the dropdown. 4 | 5 | See this screenshot for details: 6 | 7 | ![[Find all references.png]] 8 | -------------------------------------------------------------------------------- /help/Features/Opening links in the default app.md: -------------------------------------------------------------------------------- 1 | # Opening links in the default app 2 | 3 | One more feature that Memo has is opening links in the default app. For instance, you don't want to use 4 | [vscode-pdf](https://marketplace.visualstudio.com/items?itemName=tomoki1207.pdf) as your default PDF viewer, then you can use right-click on [[Dummy.pdf]] link and choose `Open link in the default app` or execute this command via VSCode command palette to open the link in the system default app. 5 | -------------------------------------------------------------------------------- /help/Features/Short and long links support.md: -------------------------------------------------------------------------------- 1 | # Short and long (relative to workspace root) links support 2 | 3 | In case when you have a few notes with the same filename in different directories Memo supports short and long links. 4 | 5 | For example, `Examples/Demo (Non-Unique)` folder in the help directory has the following structure: 6 | 7 | ``` 8 | ├── Examples 9 | │   └── Demo (Non-Unique) 10 | │   ├── DemoNote.md 11 | │   └── Notes 12 | │   └── DemoNote.md 13 | ``` 14 | 15 | If you want to link `DemoNote.md (#1)` Memo will use a short link like following [[DemoNote]], because this link comes first in the autocomplete results. 16 | 17 | ![[Short and long links support.png]] 18 | 19 | And if you want to link `DemoNote.md (#2)` Memo will use a long link like following [[Examples/Demo (Non-Unique)/Notes/DemoNote]]. 20 | 21 | This simple assumption helps to make links shorter in most cases. 22 | -------------------------------------------------------------------------------- /help/How to/How to.md: -------------------------------------------------------------------------------- 1 | # How to 2 | 3 | - [[Pasting images from clipboard]] 4 | - [[Pasting HTML as Markdown]] 5 | - [[Notes refactoring]] 6 | 7 | Continue to [[Features]]. 8 | -------------------------------------------------------------------------------- /help/How to/Notes refactoring.md: -------------------------------------------------------------------------------- 1 | # Notes refactoring 2 | 3 | ## Extract range to a new note 4 | 5 | Select the following fragment, hit `cmd/ctrl + .` and select `Extract range to a new note` [code action](https://code.visualstudio.com/docs/editor/refactoring#_code-actions-quick-fixes-and-refactorings). 6 | 7 | ![[Extracting range to a new note.gif]] 8 | -------------------------------------------------------------------------------- /help/How to/Pasting HTML as Markdown.md: -------------------------------------------------------------------------------- 1 | # Pasting HTML as Markdown 2 | 3 | For pasting HTML as Markdown you can use `Memo: Paste HTML as Markdown` command from the command palette. 4 | 5 | ![[Paste HTML as Markdown.gif]] 6 | -------------------------------------------------------------------------------- /help/How to/Pasting images from clipboard.md: -------------------------------------------------------------------------------- 1 | # Pasting images from clipboard 2 | 3 | For pasting images from clipboard you can install and use [vscode-paste-image](https://github.com/mushanshitiancai/vscode-paste-image). Thanks to VSCode Marketplace and the author of vscode-paste-image 💙. 4 | 5 | Example of `settings.json` for embedding images using `![[2020-08-12-20-11-46.png]]` format and saving them in `Attachments` folder automatically: 6 | 7 | ```json 8 | { 9 | "pasteImage.insertPattern": "![[${imageFileName}]]", 10 | "pasteImage.path": "${projectRoot}/Attachments" 11 | } 12 | ``` 13 | 14 | After configuring vscode-paste-image extension, just copy some image and execute `Paste image` from the command palette and you are good to go 👍. You can create a shortcut for this command as well! 15 | -------------------------------------------------------------------------------- /help/README.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | This help can be read using any Markdown reader. For instance, GitHub UI, however, don't expect links to work and images to be correctly embedded, and instead, it's recommended to open this help folder using VSCode and Memo. 4 | 5 | Memo is a markdown knowledge base with bidirectional links built on top of [VSCode](https://github.com/microsoft/vscode). 6 | 7 | One of the main things that Memo enables is creating links in the Markdown files. You can create links that refer to the same note, such as this one [[README|Start here]] or refer to any other [[Note]]. Use (cmd or ctrl) + click on the [[Note]] to create a new note to the disk on the fly. 8 | 9 | Memo supports everything that VSCode Markdown plugin does and many other Markdown plugins from the marketplace. There are [a lot](https://marketplace.visualstudio.com/search?term=tag%3Amarkdown&target=VSCode&category=All%20categories&sortBy=Relevance) of plugins to choose from 🙂. Enjoy discovering those that suits your writing practices most! 10 | 11 | I myself do prefer using Memo as a diary and adopted the following structure: 12 | 13 | ![[Structure 1.png]] 14 | 15 | ![[Structure 2.png]] 16 | 17 | As you probably noticed from the screenshots, I use `yyyy-mm-dd` format for naming my daily notes, which makes it easier to refer to certain dates or days throughout my diary. I hope you will find it useful too. 18 | 19 | Markdown is a well-known language for its flexibility, especially when it comes to writing and making notes, so you are free to choose your own and unique style of managing a personal knowledge base. 20 | 21 | This is pretty much it to start using Memo, and if you want to read more on what Memo can do for you, feel free to continue to [[Features]] and [[How to]]. 22 | -------------------------------------------------------------------------------- /media/fontello/css/fontello.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'fontello'; 3 | src: url('../font/fontello.eot?7840610'); 4 | src: url('../font/fontello.eot?7840610#iefix') format('embedded-opentype'), 5 | url('../font/fontello.woff2?7840610') format('woff2'), 6 | url('../font/fontello.woff?7840610') format('woff'), 7 | url('../font/fontello.ttf?7840610') format('truetype'), 8 | url('../font/fontello.svg?7840610#fontello') format('svg'); 9 | font-weight: normal; 10 | font-style: normal; 11 | } 12 | /* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */ 13 | /* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */ 14 | /* 15 | @media screen and (-webkit-min-device-pixel-ratio:0) { 16 | @font-face { 17 | font-family: 'fontello'; 18 | src: url('../font/fontello.svg?7840610#fontello') format('svg'); 19 | } 20 | } 21 | */ 22 | 23 | [class^="icon-"]:before, [class*=" icon-"]:before { 24 | font-family: "fontello"; 25 | font-style: normal; 26 | font-weight: normal; 27 | speak: never; 28 | 29 | display: inline-block; 30 | text-decoration: inherit; 31 | width: 1em; 32 | margin-right: .2em; 33 | text-align: center; 34 | /* opacity: .8; */ 35 | 36 | /* For safety - reset parent styles, that can break glyph codes*/ 37 | font-variant: normal; 38 | text-transform: none; 39 | 40 | /* fix buttons height, for twitter bootstrap */ 41 | line-height: 1em; 42 | 43 | /* Animation center compensation - margins should be symmetric */ 44 | /* remove if not needed */ 45 | margin-left: .2em; 46 | 47 | /* you can be more comfortable with increased icons size */ 48 | /* font-size: 120%; */ 49 | 50 | /* Font smoothing. That was taken from TWBS */ 51 | -webkit-font-smoothing: antialiased; 52 | -moz-osx-font-smoothing: grayscale; 53 | 54 | /* Uncomment for 3D effect */ 55 | /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ 56 | } 57 | 58 | .icon-link:before { content: '\e800'; } /* '' */ -------------------------------------------------------------------------------- /media/fontello/font/fontello.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/media/fontello/font/fontello.eot -------------------------------------------------------------------------------- /media/fontello/font/fontello.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Copyright (C) 2020 by original authors @ fontello.com 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /media/fontello/font/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/media/fontello/font/fontello.ttf -------------------------------------------------------------------------------- /media/fontello/font/fontello.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/media/fontello/font/fontello.woff -------------------------------------------------------------------------------- /media/fontello/font/fontello.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/media/fontello/font/fontello.woff2 -------------------------------------------------------------------------------- /media/markdown.css: -------------------------------------------------------------------------------- 1 | .memo-markdown-embed { 2 | border: 1px solid #ddd; 3 | border-radius: 6px; 4 | padding: 5px 20px 15px 20px; 5 | margin: 0 20px; 6 | position: relative; 7 | } 8 | 9 | .memo-markdown-embed-title { 10 | height: 36px; 11 | text-overflow: ellipsis; 12 | overflow: hidden; 13 | white-space: nowrap; 14 | font-size: 26px; 15 | line-height: 42px; 16 | top: 5px; 17 | left: 0; 18 | right: 0; 19 | width: 100%; 20 | text-align: center; 21 | font-weight: 900; 22 | } 23 | 24 | .memo-markdown-embed-link { 25 | position: absolute; 26 | top: 6px; 27 | right: 12px; 28 | cursor: pointer; 29 | } 30 | 31 | .memo-markdown-embed-link i { 32 | font-size: 18px; 33 | color: #535353; 34 | } 35 | 36 | .memo-markdown-embed-link:hover i { 37 | color: #000000; 38 | } 39 | 40 | .vscode-dark .memo-markdown-embed-link:hover i { 41 | color: #ffffff; 42 | } 43 | 44 | .memo-markdown-embed-content { 45 | max-height: 500px; 46 | overflow-y: auto; 47 | padding-right: 10px; 48 | } 49 | 50 | .memo-invalid-link { 51 | color: #cc0013 !important; 52 | cursor: not-allowed; 53 | } 54 | 55 | .memo-cyclic-link-warning { 56 | text-align: center; 57 | } 58 | -------------------------------------------------------------------------------- /media/memo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/memo/c19303c0e05756e96eb63f0413717494b21d878e/media/memo.png -------------------------------------------------------------------------------- /src/commands/commands.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | 3 | import openDocumentByReference from './openDocumentByReference'; 4 | import openRandomNote from './openRandomNote'; 5 | import openReferenceInDefaultApp from './openReferenceInDefaultApp'; 6 | import openReferenceBeside from './openReferenceBeside'; 7 | import openDailyNote from './openDailyNote'; 8 | import pasteHtmlAsMarkdown from './pasteHtmlAsMarkdown'; 9 | import extractRangeToNewNote from './extractRangeToNewNote'; 10 | import { cache } from '../workspace'; 11 | 12 | const commands = [ 13 | vscode.commands.registerCommand('_memo.openDocumentByReference', openDocumentByReference), 14 | vscode.commands.registerCommand('_memo.cacheWorkspace', cache.cacheWorkspace), 15 | vscode.commands.registerCommand('_memo.cleanWorkspaceCache', cache.cleanWorkspaceCache), 16 | vscode.commands.registerCommand('_memo.getWorkspaceCache', cache.getWorkspaceCache), 17 | vscode.commands.registerCommand('memo.openRandomNote', openRandomNote), 18 | vscode.commands.registerCommand('memo.openDailyNote', openDailyNote), 19 | vscode.commands.registerCommand('memo.openReferenceInDefaultApp', openReferenceInDefaultApp), 20 | vscode.commands.registerCommand('memo.openReferenceBeside', openReferenceBeside), 21 | vscode.commands.registerCommand('memo.extractRangeToNewNote', extractRangeToNewNote), 22 | vscode.commands.registerCommand('memo.pasteHtmlAsMarkdown', pasteHtmlAsMarkdown), 23 | ]; 24 | 25 | export default commands; 26 | -------------------------------------------------------------------------------- /src/commands/extractRangeToNewNote.spec.ts: -------------------------------------------------------------------------------- 1 | import vscode, { window } from 'vscode'; 2 | import path from 'path'; 3 | 4 | import extractRangeToNewNote from './extractRangeToNewNote'; 5 | import { getWorkspaceFolder } from '../utils'; 6 | import { 7 | closeEditorsAndCleanWorkspace, 8 | rndName, 9 | createFile, 10 | openTextDocument, 11 | } from '../test/utils'; 12 | 13 | describe('extractRangeToNewNote command', () => { 14 | beforeEach(closeEditorsAndCleanWorkspace); 15 | 16 | afterEach(closeEditorsAndCleanWorkspace); 17 | 18 | it('should extract range to a new note', async () => { 19 | const name0 = rndName(); 20 | const name1 = rndName(); 21 | 22 | await createFile(`${name0}.md`, 'Hello world.'); 23 | 24 | const doc = await openTextDocument(`${name0}.md`); 25 | 26 | const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox'); 27 | 28 | targetPathInputBoxSpy.mockReturnValue( 29 | Promise.resolve(path.join(getWorkspaceFolder()!, `${name1}.md`)), 30 | ); 31 | 32 | await extractRangeToNewNote(doc, new vscode.Range(0, 0, 0, 12)); 33 | 34 | expect(await doc.getText()).toBe(''); 35 | 36 | const newDoc = await openTextDocument(`${name1}.md`); 37 | 38 | expect(await newDoc.getText()).toBe('Hello world.'); 39 | 40 | targetPathInputBoxSpy.mockRestore(); 41 | }); 42 | 43 | it('should extract a multiline range to a new note', async () => { 44 | const name0 = rndName(); 45 | const name1 = rndName(); 46 | 47 | await createFile( 48 | `${name0}.md`, 49 | `Multiline 50 | Hello world.`, 51 | ); 52 | 53 | const doc = await openTextDocument(`${name0}.md`); 54 | 55 | const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox'); 56 | 57 | targetPathInputBoxSpy.mockReturnValue( 58 | Promise.resolve(path.join(getWorkspaceFolder()!, `${name1}.md`)), 59 | ); 60 | 61 | await extractRangeToNewNote(doc, new vscode.Range(0, 0, 1, 16)); 62 | 63 | expect(await doc.getText()).toBe(''); 64 | 65 | const newDoc = await openTextDocument(`${name1}.md`); 66 | 67 | expect(await newDoc.getText()).toMatchInlineSnapshot(` 68 | "Multiline 69 | Hello world." 70 | `); 71 | 72 | targetPathInputBoxSpy.mockRestore(); 73 | }); 74 | 75 | it('should extract range from active markdown file', async () => { 76 | const name0 = rndName(); 77 | const name1 = rndName(); 78 | 79 | await createFile(`${name0}.md`, 'Hello world.'); 80 | 81 | const doc = await openTextDocument(`${name0}.md`); 82 | const editor = await window.showTextDocument(doc); 83 | 84 | editor.selection = new vscode.Selection(0, 0, 0, 12); 85 | 86 | const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox'); 87 | 88 | targetPathInputBoxSpy.mockReturnValue( 89 | Promise.resolve(path.join(getWorkspaceFolder()!, `${name1}.md`)), 90 | ); 91 | 92 | await extractRangeToNewNote(); 93 | 94 | expect(await doc.getText()).toBe(''); 95 | 96 | const newDoc = await openTextDocument(`${name1}.md`); 97 | 98 | expect(await newDoc.getText()).toBe('Hello world.'); 99 | 100 | targetPathInputBoxSpy.mockRestore(); 101 | }); 102 | 103 | it('should not extract anything from unknown file format', async () => { 104 | const name0 = rndName(); 105 | 106 | await createFile(`${name0}.txt`, 'Hello world.'); 107 | 108 | const doc = await openTextDocument(`${name0}.txt`); 109 | const editor = await window.showTextDocument(doc); 110 | 111 | editor.selection = new vscode.Selection(0, 0, 0, 12); 112 | 113 | const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox'); 114 | 115 | await extractRangeToNewNote(); 116 | 117 | expect(await doc.getText()).toBe('Hello world.'); 118 | 119 | expect(targetPathInputBoxSpy).not.toBeCalled(); 120 | 121 | targetPathInputBoxSpy.mockRestore(); 122 | }); 123 | 124 | it('should fail when target path is outside of the workspace', async () => { 125 | const name0 = rndName(); 126 | 127 | await createFile(`${name0}.md`, 'Hello world.'); 128 | 129 | const doc = await openTextDocument(`${name0}.md`); 130 | 131 | const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox'); 132 | 133 | targetPathInputBoxSpy.mockReturnValue(Promise.resolve('/random-path/file.md')); 134 | 135 | expect(extractRangeToNewNote(doc, new vscode.Range(0, 0, 0, 12))).rejects.toThrowError( 136 | 'should be within the current workspace', 137 | ); 138 | 139 | targetPathInputBoxSpy.mockRestore(); 140 | }); 141 | 142 | it('should fail when entered file already exists', async () => { 143 | const name0 = rndName(); 144 | const name1 = rndName(); 145 | 146 | await createFile(`${name0}.md`, 'Hello world.'); 147 | await createFile(`${name1}.md`); 148 | 149 | const doc = await openTextDocument(`${name0}.md`); 150 | 151 | const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox'); 152 | 153 | targetPathInputBoxSpy.mockReturnValue( 154 | Promise.resolve(path.join(getWorkspaceFolder()!, `${name1}.md`)), 155 | ); 156 | 157 | expect(extractRangeToNewNote(doc, new vscode.Range(0, 0, 0, 12))).rejects.toThrowError( 158 | 'Such file or directory already exists. Please use unique filename instead.', 159 | ); 160 | 161 | targetPathInputBoxSpy.mockRestore(); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/commands/extractRangeToNewNote.ts: -------------------------------------------------------------------------------- 1 | import vscode, { Uri, window } from 'vscode'; 2 | import fs from 'fs-extra'; 3 | import path from 'path'; 4 | 5 | const filename = 'New File.md'; 6 | 7 | const prompt = 'New location within workspace'; 8 | 9 | const createFile = async (uri: vscode.Uri, content: string) => { 10 | const workspaceEdit = new vscode.WorkspaceEdit(); 11 | workspaceEdit.createFile(uri); 12 | workspaceEdit.set(uri, [new vscode.TextEdit(new vscode.Range(0, 0, 0, 0), content)]); 13 | 14 | await vscode.workspace.applyEdit(workspaceEdit); 15 | }; 16 | 17 | const showFile = async (uri: vscode.Uri) => 18 | await window.showTextDocument(await vscode.workspace.openTextDocument(uri)); 19 | 20 | const deleteRange = async (document: vscode.TextDocument, range: vscode.Range) => { 21 | const editor = await window.showTextDocument(document); 22 | await editor.edit((edit) => edit.delete(range)); 23 | }; 24 | 25 | const extractRangeToNewNote = async ( 26 | documentParam?: vscode.TextDocument, 27 | rangeParam?: vscode.Range, 28 | ) => { 29 | const document = documentParam ? documentParam : window.activeTextEditor?.document; 30 | 31 | if (!document || (document && document.languageId !== 'markdown')) { 32 | return; 33 | } 34 | 35 | const range = rangeParam ? rangeParam : window.activeTextEditor?.selection; 36 | 37 | if (!range || (range && range.isEmpty)) { 38 | return; 39 | } 40 | 41 | const filepath = path.join(path.dirname(document.uri.fsPath), filename); 42 | const targetPath = await window.showInputBox({ 43 | prompt, 44 | value: filepath, 45 | valueSelection: [filepath.lastIndexOf(filename), filepath.lastIndexOf('.md')], 46 | }); 47 | 48 | const targetUri = Uri.file(targetPath || ''); 49 | 50 | const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); 51 | 52 | if (!targetPath) { 53 | return; 54 | } 55 | 56 | if (!vscode.workspace.getWorkspaceFolder(targetUri)) { 57 | throw new Error( 58 | `New location "${targetUri.fsPath}" should be within the current workspace.${ 59 | workspaceFolder ? ` Example: ${path.join(workspaceFolder.uri.fsPath, filename)}` : '' 60 | }`, 61 | ); 62 | } 63 | 64 | if (await fs.pathExists(targetUri.fsPath)) { 65 | throw new Error('Such file or directory already exists. Please use unique filename instead.'); 66 | } 67 | 68 | // Order matters 69 | await createFile(targetUri, document.getText(range).trim()); 70 | 71 | await deleteRange(document, range); 72 | 73 | await showFile(targetUri); 74 | }; 75 | 76 | export default extractRangeToNewNote; 77 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './commands'; 2 | -------------------------------------------------------------------------------- /src/commands/openDailyNote.spec.ts: -------------------------------------------------------------------------------- 1 | import { commands, workspace } from 'vscode'; 2 | import moment from 'moment'; 3 | 4 | import openDailyNote from './openDailyNote'; 5 | import { closeEditorsAndCleanWorkspace } from '../test/utils'; 6 | 7 | describe('openDailyNote command', () => { 8 | beforeEach(closeEditorsAndCleanWorkspace); 9 | 10 | afterEach(closeEditorsAndCleanWorkspace); 11 | 12 | it('should not fail on direct call', async () => { 13 | expect(() => openDailyNote()).not.toThrow(); 14 | 15 | await commands.executeCommand('workbench.action.acceptSelectedQuickOpenItem'); 16 | }); 17 | 18 | it("should open today's note", async () => { 19 | const today = moment().format('YYYY-MM-DD'); 20 | 21 | openDailyNote(); 22 | 23 | await commands.executeCommand('workbench.action.acceptSelectedQuickOpenItem'); 24 | 25 | const uris = await workspace.findFiles('**/*.md')!; 26 | 27 | expect(uris).toHaveLength(1); 28 | 29 | expect(uris[0].fsPath.endsWith(`${today}.md`)).toBe(true); 30 | }); 31 | 32 | it("should open tomorrow's note", async () => { 33 | const tomorrow = moment().add(1, 'day').format('YYYY-MM-DD'); 34 | 35 | openDailyNote(); 36 | 37 | await commands.executeCommand('workbench.action.quickOpenSelectNext'); 38 | await commands.executeCommand('workbench.action.acceptSelectedQuickOpenItem'); 39 | 40 | const uris = await workspace.findFiles('**/*.md')!; 41 | 42 | expect(uris).toHaveLength(1); 43 | 44 | expect(uris[0].fsPath.endsWith(`${tomorrow}.md`)).toBe(true); 45 | }); 46 | 47 | it("should open yesterday's note", async () => { 48 | const yesterday = moment().add(-1, 'day').format('YYYY-MM-DD'); 49 | 50 | openDailyNote(); 51 | 52 | await commands.executeCommand('workbench.action.quickOpenSelectNext'); 53 | await commands.executeCommand('workbench.action.quickOpenSelectNext'); 54 | await commands.executeCommand('workbench.action.acceptSelectedQuickOpenItem'); 55 | 56 | const uris = await workspace.findFiles('**/*.md')!; 57 | 58 | expect(uris).toHaveLength(1); 59 | 60 | expect(uris[0].fsPath.endsWith(`${yesterday}.md`)).toBe(true); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/commands/openDailyNote.ts: -------------------------------------------------------------------------------- 1 | import { commands } from 'vscode'; 2 | 3 | import { createDailyQuickPick } from '../utils'; 4 | 5 | const openDailyNote = () => { 6 | const dailyQuickPick = createDailyQuickPick(); 7 | 8 | dailyQuickPick.onDidChangeSelection((selection) => 9 | commands.executeCommand('_memo.openDocumentByReference', { 10 | reference: selection[0].detail, 11 | }), 12 | ); 13 | 14 | dailyQuickPick.onDidHide(() => dailyQuickPick.dispose()); 15 | 16 | dailyQuickPick.show(); 17 | }; 18 | 19 | export default openDailyNote; 20 | -------------------------------------------------------------------------------- /src/commands/openDocumentByReference.spec.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { commands, ViewColumn } from 'vscode'; 3 | 4 | import openDocumentByReference from './openDocumentByReference'; 5 | import { 6 | createFile, 7 | rndName, 8 | getWorkspaceFolder, 9 | getOpenedFilenames, 10 | getOpenedPaths, 11 | closeEditorsAndCleanWorkspace, 12 | toPlainObject, 13 | updateMemoConfigProperty, 14 | } from '../test/utils'; 15 | 16 | describe('openDocumentByReference command', () => { 17 | beforeEach(closeEditorsAndCleanWorkspace); 18 | 19 | afterEach(closeEditorsAndCleanWorkspace); 20 | 21 | it('should open a text document', async () => { 22 | const name = rndName(); 23 | const filename = `${name}.md`; 24 | 25 | await createFile(filename); 26 | 27 | await openDocumentByReference({ reference: name }); 28 | 29 | expect(getOpenedPaths()).toContain(`${path.join(getWorkspaceFolder()!, filename)}`); 30 | }); 31 | 32 | it('should create a new text document if does not exist yet', async () => { 33 | const name = rndName(); 34 | 35 | expect(getOpenedFilenames()).not.toContain(`${name}.md`); 36 | 37 | await openDocumentByReference({ reference: name }); 38 | 39 | expect(getOpenedPaths()).toContain(`${path.join(getWorkspaceFolder()!, `${name}.md`)}`); 40 | }); 41 | 42 | it('should open a text document from a reference with label', async () => { 43 | const name = rndName(); 44 | 45 | expect(getOpenedFilenames()).not.toContain(`${name}.md`); 46 | 47 | await openDocumentByReference({ 48 | reference: `${name}|Test Label`, 49 | }); 50 | 51 | expect(getOpenedPaths()).toContain(`${path.join(getWorkspaceFolder()!, `${name}.md`)}`); 52 | }); 53 | 54 | it('should not open a reference on inexact filename match', async () => { 55 | const name = rndName(); 56 | const filename = `${name}-test.md`; 57 | 58 | await createFile(filename); 59 | 60 | await openDocumentByReference({ reference: 'test' }); 61 | 62 | expect(getOpenedFilenames()).not.toContain(filename); 63 | }); 64 | 65 | it('should open document regardless of reference case', async () => { 66 | const name = rndName(); 67 | const filename = `${name}.md`; 68 | 69 | await createFile(filename); 70 | 71 | await openDocumentByReference({ 72 | reference: name.toUpperCase(), 73 | }); 74 | 75 | expect(getOpenedPaths()).toContain(`${path.join(getWorkspaceFolder()!, filename)}`); 76 | }); 77 | 78 | it('should open document by a long ref', async () => { 79 | const name = rndName(); 80 | const filename = `${name}.md`; 81 | 82 | await createFile(filename); 83 | await createFile(`/folder1/${filename}`); 84 | 85 | await openDocumentByReference({ 86 | reference: `/folder1/${name}`, 87 | }); 88 | 89 | expect(getOpenedPaths()).toContain(`${path.join(getWorkspaceFolder()!, 'folder1', filename)}`); 90 | }); 91 | 92 | it('should open a note instead of an image on short ref', async () => { 93 | const name = rndName(); 94 | 95 | await createFile(`/a/${name}.png`); 96 | await createFile(`/b/${name}.md`); 97 | 98 | await openDocumentByReference({ 99 | reference: name, 100 | }); 101 | 102 | expect(getOpenedPaths()).toContain(`${path.join(getWorkspaceFolder()!, 'b', `${name}.md`)}`); 103 | }); 104 | 105 | it('should create note automatically including folder if does not exist yet', async () => { 106 | const name = rndName(); 107 | 108 | await openDocumentByReference({ 109 | reference: `folder1/folder2/${name}`, 110 | }); 111 | 112 | expect(getOpenedPaths()).toContain( 113 | `${path.join(getWorkspaceFolder()!, 'folder1', 'folder2', `${name}.md`)}`, 114 | ); 115 | }); 116 | 117 | it('should create note automatically even with leading slash in the reference', async () => { 118 | const name = rndName(); 119 | 120 | await openDocumentByReference({ 121 | reference: `/folder1/${name}`, 122 | }); 123 | 124 | expect(getOpenedPaths()).toContain( 125 | `${path.join(getWorkspaceFolder()!, 'folder1', `${name}.md`)}`, 126 | ); 127 | }); 128 | 129 | it('should open png ref with .png extension', async () => { 130 | const name = rndName(); 131 | 132 | const executeCommandSpy = jest.spyOn(commands, 'executeCommand'); 133 | 134 | await openDocumentByReference({ 135 | reference: `${name}.png`, 136 | }); 137 | 138 | expect( 139 | toPlainObject(executeCommandSpy.mock.calls.filter(([command]) => command === 'vscode.open')), 140 | ).toMatchObject([ 141 | [ 142 | 'vscode.open', 143 | expect.objectContaining({ 144 | $mid: 1, 145 | path: expect.toEndWith(`${name}.png`), 146 | scheme: 'file', 147 | }), 148 | ViewColumn.Active, 149 | ], 150 | ]); 151 | 152 | executeCommandSpy.mockRestore(); 153 | }); 154 | 155 | it('should open ref with explicit md extension', async () => { 156 | const name = rndName(); 157 | 158 | const executeCommandSpy = jest.spyOn(commands, 'executeCommand'); 159 | 160 | await openDocumentByReference({ 161 | reference: `${name}.md`, 162 | }); 163 | 164 | expect( 165 | toPlainObject(executeCommandSpy.mock.calls.filter(([command]) => command === 'vscode.open')), 166 | ).toMatchObject([ 167 | [ 168 | 'vscode.open', 169 | expect.objectContaining({ 170 | $mid: 1, 171 | path: expect.toEndWith(`${name}.md.md`), 172 | scheme: 'file', 173 | }), 174 | ViewColumn.Active, 175 | ], 176 | ]); 177 | 178 | executeCommandSpy.mockRestore(); 179 | }); 180 | 181 | it('should take showOption to open ref to the side', async () => { 182 | const executeCommandSpy = jest.spyOn(commands, 'executeCommand'); 183 | 184 | const name = rndName(); 185 | await openDocumentByReference({ 186 | reference: `${name}`, 187 | showOption: ViewColumn.Beside, 188 | }); 189 | expect( 190 | toPlainObject(executeCommandSpy.mock.calls.filter(([command]) => command === 'vscode.open')), 191 | ).toMatchObject([ 192 | [ 193 | 'vscode.open', 194 | expect.objectContaining({ 195 | $mid: 1, 196 | path: expect.toEndWith(`${name}.md`), 197 | scheme: 'file', 198 | }), 199 | ViewColumn.Beside, 200 | ], 201 | ]); 202 | 203 | executeCommandSpy.mockRestore(); 204 | }); 205 | 206 | describe('with custom links.rules config', () => { 207 | it('should create a note in a configured sub dir', async () => { 208 | await updateMemoConfigProperty('links.rules', [ 209 | { 210 | rule: '.*\\.md$', 211 | comment: 'all notes', 212 | folder: '/Notes', 213 | }, 214 | ]); 215 | 216 | const name = rndName(); 217 | 218 | expect(getOpenedFilenames()).not.toContain(`${name}.md`); 219 | 220 | await openDocumentByReference({ reference: name }); 221 | 222 | expect(getOpenedPaths()).toContain( 223 | `${path.join(getWorkspaceFolder()!, 'Notes', `${name}.md`)}`, 224 | ); 225 | }); 226 | 227 | it('should not create a note in a configured sub dir for long links', async () => { 228 | await updateMemoConfigProperty('links.rules', [ 229 | { 230 | rule: '.*\\.md$', 231 | comment: 'all notes', 232 | folder: '/Notes', 233 | }, 234 | ]); 235 | 236 | const name = `dir/${rndName()}`; 237 | 238 | expect(getOpenedFilenames()).not.toContain(`${name}.md`); 239 | 240 | await openDocumentByReference({ reference: name }); 241 | 242 | expect(getOpenedPaths()).toContain(`${path.join(getWorkspaceFolder()!, `${name}.md`)}`); 243 | }); 244 | 245 | it('should create a note in a root dir when no matching rule found', async () => { 246 | await updateMemoConfigProperty('links.rules', [ 247 | { 248 | rule: '.*\\.txt$', 249 | comment: 'all text', 250 | folder: '/Text', 251 | }, 252 | ]); 253 | 254 | const name = rndName(); 255 | 256 | expect(getOpenedFilenames()).not.toContain(`${name}.md`); 257 | 258 | await openDocumentByReference({ reference: name }); 259 | 260 | expect(getOpenedPaths()).toContain(`${path.join(getWorkspaceFolder()!, `${name}.md`)}`); 261 | }); 262 | 263 | it('should handle regexp capture groups properly', async () => { 264 | await updateMemoConfigProperty('links.rules', [ 265 | { 266 | rule: '(\\d{4})-(\\d{2})-(\\d{2})\\.md$', 267 | comment: 'Daily notes yyyy-mm-dd', 268 | folder: '/Daily/$1-$2-$3', 269 | }, 270 | ]); 271 | 272 | const name = `${rndName()}-2000-10-10`; 273 | 274 | expect(getOpenedFilenames()).not.toContain(`${name}.md`); 275 | 276 | await openDocumentByReference({ reference: name }); 277 | 278 | expect(getOpenedPaths()).toContain( 279 | `${path.join(getWorkspaceFolder()!, 'Daily/2000-10-10', `${name}.md`)}`, 280 | ); 281 | }); 282 | 283 | it('should handle known variables properly', async () => { 284 | await updateMemoConfigProperty('links.rules', [ 285 | { 286 | rule: '.*\\.md$', 287 | comment: 'all notes', 288 | folder: '/Notes/$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE', 289 | }, 290 | ]); 291 | 292 | const name = rndName(); 293 | 294 | expect(getOpenedFilenames()).not.toContain(`${name}.md`); 295 | 296 | await openDocumentByReference({ reference: name }); 297 | 298 | const date = new Date(); 299 | const currentYear = String(date.getFullYear()); 300 | const currentMonth = String(date.getMonth().valueOf() + 1).padStart(2, '0'); 301 | const currentDate = String(date.getDate().valueOf()).padStart(2, '0'); 302 | 303 | expect(getOpenedPaths()).toContain( 304 | `${path.join( 305 | getWorkspaceFolder()!, 306 | 'Notes', 307 | `${currentYear}-${currentMonth}-${currentDate}`, 308 | `${name}.md`, 309 | )}`, 310 | ); 311 | }); 312 | 313 | it('should not add "X_UNIX" suffix when $CURRENT_SECONDS_UNIX variable is used (#570)', async () => { 314 | await updateMemoConfigProperty('links.rules', [ 315 | { 316 | rule: '.*\\.md$', 317 | comment: 'all notes', 318 | folder: '/Notes/ByUnixTime/$CURRENT_SECONDS_UNIX', 319 | }, 320 | ]); 321 | 322 | const name = rndName(); 323 | 324 | expect(getOpenedFilenames()).not.toContain(`${name}.md`); 325 | 326 | await openDocumentByReference({ reference: name }); 327 | 328 | expect(getOpenedPaths()).not.toContain('X_UNIX'); 329 | }); 330 | }); 331 | }); 332 | -------------------------------------------------------------------------------- /src/commands/openDocumentByReference.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | import { cache } from '../workspace'; 6 | import { 7 | findUriByRef, 8 | ensureDirectoryExists, 9 | parseRef, 10 | getWorkspaceFolder, 11 | getRefWithExt, 12 | resolveShortRefFolder, 13 | } from '../utils'; 14 | 15 | let workspaceErrorShown = false; 16 | 17 | const openDocumentByReference = async ({ 18 | reference, 19 | showOption = vscode.ViewColumn.Active, 20 | }: { 21 | reference: string; 22 | showOption?: vscode.ViewColumn; 23 | }) => { 24 | const { ref } = parseRef(reference); 25 | 26 | const uri = findUriByRef(cache.getWorkspaceCache().allUris, ref); 27 | 28 | if (uri) { 29 | await vscode.commands.executeCommand('vscode.open', uri, showOption); 30 | } else { 31 | const workspaceFolder = getWorkspaceFolder()!; 32 | if (workspaceFolder) { 33 | const refWithExt = getRefWithExt(ref); 34 | const shortRefFolder = resolveShortRefFolder(ref); 35 | 36 | const filePath = path.join( 37 | workspaceFolder, 38 | ...(shortRefFolder ? [shortRefFolder, refWithExt] : [refWithExt]), 39 | ); 40 | 41 | // don't override file content if it already exists 42 | if (!fs.existsSync(filePath)) { 43 | ensureDirectoryExists(filePath); 44 | fs.writeFileSync(filePath, ''); 45 | } 46 | 47 | await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(filePath), showOption); 48 | } else if (!workspaceErrorShown) { 49 | workspaceErrorShown = true; 50 | 51 | vscode.window.showErrorMessage( 52 | `It seems that you are trying to use Memo in single file mode. 53 | 54 | Memo works best in folder/workspace mode. 55 | 56 | The easiest way to start is to create a new folder and drag it onto the VSCode or use File > Open Folder... from the menu bar. 57 | `, 58 | ); 59 | } 60 | } 61 | }; 62 | 63 | export default openDocumentByReference; 64 | -------------------------------------------------------------------------------- /src/commands/openRandomNote.spec.ts: -------------------------------------------------------------------------------- 1 | import openRandomNote from './openRandomNote'; 2 | import { 3 | createFile, 4 | rndName, 5 | getOpenedFilenames, 6 | closeEditorsAndCleanWorkspace, 7 | } from '../test/utils'; 8 | 9 | describe('openRandomNote command', () => { 10 | beforeEach(closeEditorsAndCleanWorkspace); 11 | 12 | afterEach(closeEditorsAndCleanWorkspace); 13 | 14 | it('should open random note', async () => { 15 | const filenames = [`${rndName()}.md`, `${rndName()}.md`, `${rndName()}.md`]; 16 | 17 | await Promise.all(filenames.map((filename) => createFile(filename))); 18 | 19 | await openRandomNote(); 20 | 21 | expect(getOpenedFilenames().some((filename) => filenames.includes(filename))).toBe(true); 22 | }); 23 | 24 | it('opens all random notes', async () => { 25 | const filenames = [`${rndName()}.md`, `${rndName()}.md`, `${rndName()}.md`]; 26 | 27 | await Promise.all(filenames.map((filename) => createFile(filename))); 28 | 29 | await openRandomNote(); 30 | await openRandomNote(); 31 | await openRandomNote(); 32 | 33 | expect(getOpenedFilenames()).toEqual(expect.arrayContaining(filenames)); 34 | }); 35 | 36 | it('should open existing note only once on executing command multiple times', async () => { 37 | const filename = `${rndName()}.md`; 38 | 39 | await createFile(filename); 40 | 41 | await openRandomNote(); 42 | await openRandomNote(); 43 | await openRandomNote(); 44 | 45 | expect(getOpenedFilenames()).toContain(filename); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/commands/openRandomNote.ts: -------------------------------------------------------------------------------- 1 | import { commands, workspace } from 'vscode'; 2 | import fs from 'fs'; 3 | 4 | import { cache } from '../workspace'; 5 | 6 | const openRandomNote = async () => { 7 | const openedFileNames = workspace.textDocuments.map((d) => d.fileName); 8 | const markdownUris = cache 9 | .getWorkspaceCache() 10 | .markdownUris.filter(({ fsPath }) => !openedFileNames.includes(fsPath)); 11 | const randomUriIndex = Math.floor(Math.random() * markdownUris.length); 12 | const randomUri = markdownUris[randomUriIndex]; 13 | 14 | if (randomUri && fs.existsSync(randomUri.fsPath)) { 15 | await commands.executeCommand('vscode.open', randomUri, { preview: false }); 16 | } 17 | }; 18 | 19 | export default openRandomNote; 20 | -------------------------------------------------------------------------------- /src/commands/openReferenceBeside.spec.ts: -------------------------------------------------------------------------------- 1 | import { commands, window, Selection, ViewColumn } from 'vscode'; 2 | 3 | import openReferenceBeside from './openReferenceBeside'; 4 | import { 5 | closeEditorsAndCleanWorkspace, 6 | createFile, 7 | openTextDocument, 8 | rndName, 9 | toPlainObject, 10 | waitForExpect, 11 | } from '../test/utils'; 12 | 13 | describe('openReferenceBeside command', () => { 14 | beforeEach(closeEditorsAndCleanWorkspace); 15 | afterEach(closeEditorsAndCleanWorkspace); 16 | 17 | it('should execute vscode.open when editor selection is within the reference', async () => { 18 | const executeCommandSpy = jest.spyOn(commands, 'executeCommand'); 19 | 20 | const name0 = rndName(); 21 | const name1 = rndName(); 22 | 23 | await createFile(`${name0}.md`); 24 | await createFile(`${name1}.md`, `[[${name0}]]`); 25 | 26 | const doc = await openTextDocument(`${name1}.md`); 27 | const editor = await window.showTextDocument(doc); 28 | 29 | editor.selection = new Selection(0, 2, 0, 2); 30 | 31 | await openReferenceBeside(); 32 | 33 | expect( 34 | toPlainObject(executeCommandSpy.mock.calls.filter(([command]) => command === 'vscode.open')), 35 | ).toMatchObject([ 36 | [ 37 | 'vscode.open', 38 | expect.objectContaining({ 39 | $mid: 1, 40 | path: expect.toEndWith(`${name0}.md`), 41 | scheme: 'file', 42 | }), 43 | ViewColumn.Beside, 44 | ], 45 | ]); 46 | 47 | executeCommandSpy.mockRestore(); 48 | }); 49 | 50 | it('should NOT execute vscode.open when editor selection is outside of the reference', async () => { 51 | const executeCommandSpy = jest.spyOn(commands, 'executeCommand'); 52 | 53 | const name0 = rndName(); 54 | const name1 = rndName(); 55 | 56 | await createFile(`${name0}.md`); 57 | await createFile(`${name1}.md`, ` [[${name0}]]`); 58 | 59 | const doc = await openTextDocument(`${name1}.md`); 60 | const editor = await window.showTextDocument(doc); 61 | 62 | editor.selection = new Selection(0, 0, 0, 0); 63 | 64 | await openReferenceBeside(); 65 | 66 | expect( 67 | toPlainObject(executeCommandSpy.mock.calls.filter(([command]) => command === 'vscode.open')), 68 | ).toMatchObject([]); 69 | 70 | executeCommandSpy.mockRestore(); 71 | }); 72 | 73 | it('should increase the viewColumn# of active editor after opening a reference to the side', async () => { 74 | const name0 = rndName(); 75 | const name1 = rndName(); 76 | 77 | await createFile(`${name0}.md`); 78 | await createFile(`${name1}.md`, `[[${name0}]]`); 79 | 80 | const doc = await openTextDocument(`${name1}.md`); 81 | const editor = await window.showTextDocument(doc); 82 | 83 | editor.selection = new Selection(0, 2, 0, 2); 84 | 85 | expect(window.activeTextEditor === editor).toBeTrue(); 86 | expect(window.activeTextEditor!.viewColumn === ViewColumn.One).toBeTrue(); 87 | 88 | await openReferenceBeside(); 89 | 90 | await waitForExpect(() => expect(window.visibleTextEditors.length === 2).toBeTrue()); 91 | expect(window.activeTextEditor!.viewColumn === ViewColumn.Two).toBeTrue(); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/commands/openReferenceBeside.ts: -------------------------------------------------------------------------------- 1 | import vscode, { commands } from 'vscode'; 2 | 3 | import { getReferenceAtPosition } from '../utils'; 4 | 5 | const openReferenceBeside = async () => { 6 | const activeTextEditor = vscode.window.activeTextEditor; 7 | if (!activeTextEditor) { 8 | return; 9 | } 10 | 11 | const refAtPos = getReferenceAtPosition( 12 | activeTextEditor.document, 13 | activeTextEditor.selection.start, 14 | ); 15 | 16 | if (refAtPos) { 17 | commands.executeCommand('_memo.openDocumentByReference', { 18 | reference: refAtPos.ref, 19 | showOption: vscode.ViewColumn.Beside, 20 | }); 21 | } 22 | }; 23 | 24 | export default openReferenceBeside; 25 | -------------------------------------------------------------------------------- /src/commands/openReferenceInDefaultApp.spec.ts: -------------------------------------------------------------------------------- 1 | import { window, Selection } from 'vscode'; 2 | import open from 'open'; 3 | import path from 'path'; 4 | 5 | import openReferenceInDefaultApp from './openReferenceInDefaultApp'; 6 | import { 7 | createFile, 8 | rndName, 9 | closeEditorsAndCleanWorkspace, 10 | openTextDocument, 11 | getWorkspaceFolder, 12 | } from '../test/utils'; 13 | 14 | describe('openReferenceInDefaultApp command', () => { 15 | beforeEach(async () => { 16 | await closeEditorsAndCleanWorkspace(); 17 | (open as unknown as jest.Mock).mockClear(); 18 | }); 19 | 20 | afterEach(async () => { 21 | await closeEditorsAndCleanWorkspace(); 22 | (open as unknown as jest.Mock).mockClear(); 23 | }); 24 | 25 | it('should call open command-line tool when editor selection is within the reference', async () => { 26 | const name0 = rndName(); 27 | const name1 = rndName(); 28 | 29 | await createFile(`${name0}.md`); 30 | await createFile(`${name1}.md`, `[[${name0}]]`); 31 | 32 | const doc = await openTextDocument(`${name1}.md`); 33 | const editor = await window.showTextDocument(doc); 34 | 35 | editor.selection = new Selection(0, 2, 0, 2); 36 | 37 | await openReferenceInDefaultApp(); 38 | 39 | expect(open).toHaveBeenCalledWith(path.join(getWorkspaceFolder()!, `${name0}.md`)); 40 | }); 41 | 42 | it('should call open command-line tool when editor selection is outside of the reference', async () => { 43 | const name0 = rndName(); 44 | const name1 = rndName(); 45 | 46 | await createFile(`${name0}.md`); 47 | await createFile(`${name1}.md`, ` [[${name0}]]`); 48 | 49 | const doc = await openTextDocument(`${name1}.md`); 50 | const editor = await window.showTextDocument(doc); 51 | 52 | editor.selection = new Selection(0, 0, 0, 0); 53 | 54 | await openReferenceInDefaultApp(); 55 | 56 | expect(open).not.toBeCalled(); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/commands/openReferenceInDefaultApp.ts: -------------------------------------------------------------------------------- 1 | import open from 'open'; 2 | import * as vscode from 'vscode'; 3 | 4 | import { cache } from '../workspace'; 5 | import { getReferenceAtPosition, findUriByRef } from '../utils'; 6 | 7 | const openReferenceInDefaultApp = async () => { 8 | const activeTextEditor = vscode.window.activeTextEditor; 9 | 10 | if (activeTextEditor) { 11 | const refAtPos = getReferenceAtPosition( 12 | activeTextEditor.document, 13 | activeTextEditor.selection.start, 14 | ); 15 | 16 | if (refAtPos) { 17 | const uri = findUriByRef(cache.getWorkspaceCache().allUris, refAtPos.ref); 18 | 19 | if (uri) { 20 | await open(uri.fsPath); 21 | } else { 22 | vscode.window.showWarningMessage( 23 | 'Linked file does not exist yet. Try to create a new one by clicking on the link.', 24 | ); 25 | } 26 | } 27 | } 28 | }; 29 | 30 | export default openReferenceInDefaultApp; 31 | -------------------------------------------------------------------------------- /src/commands/pasteHtmlAsMarkdown.ts: -------------------------------------------------------------------------------- 1 | import TurndownService from 'turndown'; 2 | import vscode from 'vscode'; 3 | 4 | import { readClipboard } from '../utils'; 5 | 6 | const tdSettings = { 7 | headingStyle: 'atx' as const, 8 | codeBlockStyle: 'fenced' as const, 9 | }; 10 | 11 | const pasteHtmlAsMarkdown = async () => { 12 | try { 13 | const tdService = new TurndownService(tdSettings); 14 | 15 | const clipboard = await readClipboard(); 16 | 17 | const markdown = tdService.turndown(clipboard); 18 | 19 | const editor = vscode.window.activeTextEditor; 20 | 21 | if (!editor) { 22 | return; 23 | } 24 | 25 | editor.edit((edit) => { 26 | const current = editor.selection; 27 | 28 | editor.selections.forEach((selection) => { 29 | if (selection.isEmpty) { 30 | edit.insert(selection.start, markdown); 31 | } else { 32 | edit.replace(current, markdown); 33 | } 34 | }); 35 | }); 36 | } catch (error) { 37 | console.error(error); 38 | } 39 | }; 40 | 41 | export default pasteHtmlAsMarkdown; 42 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'markdown-it-regex'; 2 | 3 | declare module 'cross-path-sort' { 4 | type SortOptions = { 5 | pathKey?: string; 6 | shallowFirst?: boolean; 7 | deepFirst?: boolean; 8 | homePathsSupported?: boolean; 9 | posixOrder?: ('rel' | 'home' | 'abs')[]; 10 | windowsOrder?: ('rel' | 'home' | 'abs' | 'drel' | 'dabs' | 'unc' | 'nms')[]; 11 | segmentCompareFn?: (a: string, b: string) => number; 12 | }; 13 | 14 | export function sort(paths: T[], options?: SortOptions): T[]; 15 | } 16 | -------------------------------------------------------------------------------- /src/extension.spec.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import { closeEditorsAndCleanWorkspace } from './test/utils'; 4 | 5 | const MEMO_EXTENSION_ID = 'svsool.markdown-memo'; 6 | 7 | describe('extension', () => { 8 | beforeEach(closeEditorsAndCleanWorkspace); 9 | 10 | it('should find extension in extensions list', () => { 11 | expect(vscode.extensions.all.some((extension) => extension.id === MEMO_EXTENSION_ID)).toBe( 12 | true, 13 | ); 14 | }); 15 | 16 | it('should not find not existing extension', () => { 17 | expect( 18 | vscode.extensions.all.some((extension) => { 19 | return extension.id === 'memo.any-extension'; 20 | }), 21 | ).toBe(false); 22 | }); 23 | 24 | it('should have extension active on load', () => { 25 | const memoExtension = vscode.extensions.all.find( 26 | (extension) => extension.id === MEMO_EXTENSION_ID, 27 | ); 28 | expect(memoExtension!.isActive).toBe(true); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import { fileWatcher, cache } from './workspace'; 4 | import { 5 | referenceContextWatcher, 6 | completionProvider, 7 | DocumentLinkProvider, 8 | ReferenceHoverProvider, 9 | ReferenceProvider, 10 | ReferenceRenameProvider, 11 | BacklinksTreeDataProvider, 12 | extendMarkdownIt, 13 | newVersionNotifier, 14 | codeActionProvider, 15 | } from './features'; 16 | import commands from './commands'; 17 | import logger from './logger'; 18 | import { getMemoConfigProperty, MemoBoolConfigProp, isDefined } from './utils'; 19 | 20 | const mdLangSelector = { language: 'markdown', scheme: '*' }; 21 | 22 | const when = (configKey: MemoBoolConfigProp, cb: () => R): undefined | R => 23 | getMemoConfigProperty(configKey, true) ? cb() : undefined; 24 | 25 | export const activate = async ( 26 | context: vscode.ExtensionContext, 27 | ): Promise => { 28 | newVersionNotifier.activate(context); 29 | 30 | if (process.env.DISABLE_FILE_WATCHER !== 'true') { 31 | fileWatcher.activate(context); 32 | } 33 | 34 | when('links.completion.enabled', () => completionProvider.activate(context)); 35 | 36 | referenceContextWatcher.activate(context); 37 | 38 | await cache.cacheWorkspace(); 39 | 40 | context.subscriptions.push(logger.logger); 41 | 42 | context.subscriptions.push( 43 | ...commands, 44 | vscode.languages.registerCodeActionsProvider(mdLangSelector, codeActionProvider), 45 | vscode.workspace.onDidChangeConfiguration(async (configChangeEvent) => { 46 | if (configChangeEvent.affectsConfiguration('search.exclude')) { 47 | await cache.cacheWorkspace(); 48 | } 49 | }), 50 | ...[ 51 | when('links.following.enabled', () => 52 | vscode.languages.registerDocumentLinkProvider(mdLangSelector, new DocumentLinkProvider()), 53 | ), 54 | when('links.preview.enabled', () => 55 | vscode.languages.registerHoverProvider(mdLangSelector, new ReferenceHoverProvider()), 56 | ), 57 | when('links.references.enabled', () => 58 | vscode.languages.registerReferenceProvider(mdLangSelector, new ReferenceProvider()), 59 | ), 60 | when('links.sync.enabled', () => 61 | vscode.languages.registerRenameProvider(mdLangSelector, new ReferenceRenameProvider()), 62 | ), 63 | ].filter(isDefined), 64 | ); 65 | 66 | vscode.commands.executeCommand( 67 | 'setContext', 68 | 'memo:backlinksPanel.enabled', 69 | getMemoConfigProperty('backlinksPanel.enabled', true), 70 | ); 71 | 72 | when('backlinksPanel.enabled', () => { 73 | const backlinksTreeDataProvider = new BacklinksTreeDataProvider(); 74 | 75 | vscode.window.onDidChangeActiveTextEditor( 76 | async () => await backlinksTreeDataProvider.refresh(), 77 | ); 78 | context.subscriptions.push( 79 | vscode.window.createTreeView('memo.backlinksPanel', { 80 | treeDataProvider: backlinksTreeDataProvider, 81 | showCollapseAll: true, 82 | }), 83 | ); 84 | }); 85 | 86 | logger.info('Memo extension successfully initialized! 🎉'); 87 | 88 | return when('markdownPreview.enabled', () => ({ 89 | extendMarkdownIt, 90 | })); 91 | }; 92 | -------------------------------------------------------------------------------- /src/features/BacklinksTreeDataProvider.ts: -------------------------------------------------------------------------------- 1 | import vscode, { workspace } from 'vscode'; 2 | import path from 'path'; 3 | import groupBy from 'lodash.groupby'; 4 | 5 | import { cache } from '../workspace'; 6 | import { 7 | containsMarkdownExt, 8 | findReferences, 9 | trimSlashes, 10 | sortPaths, 11 | getMemoConfigProperty, 12 | fsPathToRef, 13 | isDefined, 14 | } from '../utils'; 15 | import { FoundRefT } from '../types'; 16 | 17 | class Backlink extends vscode.TreeItem { 18 | constructor( 19 | public readonly label: string, 20 | public refs: FoundRefT[] | undefined, 21 | public readonly collapsibleState: vscode.TreeItemCollapsibleState, 22 | ) { 23 | super(label, collapsibleState); 24 | } 25 | } 26 | 27 | export default class BacklinksTreeDataProvider implements vscode.TreeDataProvider { 28 | private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter< 29 | Backlink | undefined 30 | >(); 31 | readonly onDidChangeTreeData: vscode.Event = 32 | this._onDidChangeTreeData.event; 33 | 34 | refresh(): void { 35 | this._onDidChangeTreeData.fire(undefined); 36 | } 37 | 38 | public getTreeItem(element: Backlink) { 39 | return element; 40 | } 41 | 42 | public async getChildren(element?: Backlink) { 43 | if (!element) { 44 | const uri = vscode.window.activeTextEditor?.document.uri; 45 | const fsPath = uri?.fsPath; 46 | 47 | if (!uri || !fsPath || (fsPath && !containsMarkdownExt(fsPath))) { 48 | return []; 49 | } 50 | 51 | const workspaceFolder = workspace.getWorkspaceFolder(uri); 52 | 53 | if (!workspaceFolder) { 54 | return []; 55 | } 56 | 57 | const shortRef = fsPathToRef({ 58 | path: uri.fsPath, 59 | keepExt: false, 60 | }); 61 | 62 | const longRef = fsPathToRef({ 63 | path: uri.fsPath, 64 | basePath: workspaceFolder.uri.fsPath, 65 | keepExt: false, 66 | }); 67 | 68 | if (!shortRef || !longRef) { 69 | return []; 70 | } 71 | 72 | const urisByPathBasename = groupBy(cache.getWorkspaceCache().markdownUris, ({ fsPath }) => 73 | path.basename(fsPath).toLowerCase(), 74 | ); 75 | 76 | const urisGroup = urisByPathBasename[path.basename(fsPath).toLowerCase()] || []; 77 | 78 | const isFirstUriInGroup = urisGroup.findIndex((uriParam) => uriParam.fsPath === fsPath) === 0; 79 | 80 | const referencesByPath = groupBy( 81 | await findReferences( 82 | [shortRef !== longRef && isFirstUriInGroup ? shortRef : undefined, longRef].filter( 83 | isDefined, 84 | ), 85 | [fsPath], 86 | ), 87 | ({ location }) => location.uri.fsPath, 88 | ); 89 | 90 | const pathsSorted = sortPaths(Object.keys(referencesByPath), { shallowFirst: true }); 91 | 92 | if (!pathsSorted.length) { 93 | return []; 94 | } 95 | 96 | const collapsibleState = getMemoConfigProperty('backlinksPanel.collapseParentItems', false) 97 | ? vscode.TreeItemCollapsibleState.Collapsed 98 | : vscode.TreeItemCollapsibleState.Expanded; 99 | 100 | return pathsSorted.map((pathParam) => { 101 | const backlink = new Backlink( 102 | path.basename(pathParam), 103 | referencesByPath[pathParam], 104 | collapsibleState, 105 | ); 106 | backlink.description = [ 107 | `(${referencesByPath[pathParam].length})`, 108 | trimSlashes( 109 | pathParam.replace(workspaceFolder.uri.fsPath, '').replace(path.basename(pathParam), ''), 110 | ).trim(), 111 | ] 112 | .filter(Boolean) 113 | .join(' '); 114 | backlink.tooltip = pathParam; 115 | backlink.command = { 116 | command: 'vscode.open', 117 | arguments: [vscode.Uri.file(pathParam), { selection: new vscode.Range(0, 0, 0, 0) }], 118 | title: 'Open File', 119 | }; 120 | return backlink; 121 | }); 122 | } 123 | 124 | const refs = element?.refs; 125 | 126 | if (!refs) { 127 | return []; 128 | } 129 | 130 | return refs.map((ref) => { 131 | const backlink = new Backlink( 132 | `${ref.location.range.start.line + 1}:${ref.location.range.start.character}`, 133 | undefined, 134 | vscode.TreeItemCollapsibleState.None, 135 | ); 136 | 137 | backlink.description = ref.matchText; 138 | backlink.tooltip = ref.matchText; 139 | backlink.command = { 140 | command: 'vscode.open', 141 | arguments: [ref.location.uri, { selection: ref.location.range }], 142 | title: 'Open File', 143 | }; 144 | 145 | return backlink; 146 | }); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/features/DocumentLinkProvider.spec.ts: -------------------------------------------------------------------------------- 1 | import DocumentLinkProvider from './DocumentLinkProvider'; 2 | import { 3 | createFile, 4 | rndName, 5 | openTextDocument, 6 | closeEditorsAndCleanWorkspace, 7 | toPlainObject, 8 | } from '../test/utils'; 9 | 10 | describe('DocumentLinkProvider', () => { 11 | beforeEach(closeEditorsAndCleanWorkspace); 12 | 13 | afterEach(closeEditorsAndCleanWorkspace); 14 | 15 | it('should not return anything for empty document', async () => { 16 | const filename = `${rndName()}.md`; 17 | 18 | await createFile(filename); 19 | 20 | const doc = await openTextDocument(filename); 21 | 22 | const linkProvider = new DocumentLinkProvider(); 23 | 24 | expect(linkProvider.provideDocumentLinks(doc)).toHaveLength(0); 25 | }); 26 | 27 | it('should not provide a link for the invalid ref', async () => { 28 | const filename = `${rndName()}.md`; 29 | 30 | await createFile(filename, '[[]]'); 31 | 32 | const doc = await openTextDocument(filename); 33 | 34 | const linkProvider = new DocumentLinkProvider(); 35 | 36 | expect(linkProvider.provideDocumentLinks(doc)).toHaveLength(0); 37 | }); 38 | 39 | it('should provide a correct link to existing note even when brackets are unbalanced', async () => { 40 | const noteName0 = rndName(); 41 | const noteName1 = rndName(); 42 | 43 | await createFile(`${noteName0}.md`, `[[[[${noteName1}]]]]]]`); 44 | await createFile(`${noteName1}.md`); 45 | 46 | const doc = await openTextDocument(`${noteName0}.md`); 47 | 48 | const linkProvider = new DocumentLinkProvider(); 49 | 50 | const links = linkProvider.provideDocumentLinks(doc); 51 | 52 | expect(links).toHaveLength(1); 53 | expect(toPlainObject(links[0])).toMatchObject({ 54 | range: [ 55 | { 56 | line: 0, 57 | character: expect.any(Number), 58 | }, 59 | { 60 | line: 0, 61 | character: expect.any(Number), 62 | }, 63 | ], 64 | target: { 65 | $mid: 1, 66 | path: '_memo.openDocumentByReference', 67 | scheme: 'command', 68 | query: `{"reference":"${noteName1}"}`, 69 | }, 70 | tooltip: 'Follow link', 71 | }); 72 | }); 73 | 74 | it('should provide link to existing note', async () => { 75 | const noteName0 = rndName(); 76 | const noteName1 = rndName(); 77 | 78 | await createFile(`${noteName0}.md`, `[[${noteName1}]]`); 79 | await createFile(`${noteName1}.md`); 80 | 81 | const doc = await openTextDocument(`${noteName0}.md`); 82 | 83 | const linkProvider = new DocumentLinkProvider(); 84 | 85 | const links = linkProvider.provideDocumentLinks(doc); 86 | 87 | expect(links).toHaveLength(1); 88 | expect(toPlainObject(links[0])).toMatchObject({ 89 | range: [ 90 | { 91 | line: 0, 92 | character: expect.any(Number), 93 | }, 94 | { 95 | line: 0, 96 | character: expect.any(Number), 97 | }, 98 | ], 99 | target: { 100 | $mid: 1, 101 | path: '_memo.openDocumentByReference', 102 | scheme: 'command', 103 | query: `{"reference":"${noteName1}"}`, 104 | }, 105 | tooltip: 'Follow link', 106 | }); 107 | }); 108 | 109 | it('should provide link to existing image', async () => { 110 | const noteName = rndName(); 111 | const imageName = rndName(); 112 | 113 | await createFile(`${noteName}.md`, `![[${imageName}.png]]`); 114 | await createFile(`${imageName}.png`); 115 | 116 | const doc = await openTextDocument(`${noteName}.md`); 117 | 118 | const linkProvider = new DocumentLinkProvider(); 119 | 120 | const links = linkProvider.provideDocumentLinks(doc); 121 | 122 | expect(links).toHaveLength(1); 123 | expect(toPlainObject(links[0])).toMatchObject({ 124 | range: [ 125 | { 126 | line: 0, 127 | character: expect.any(Number), 128 | }, 129 | { 130 | line: 0, 131 | character: expect.any(Number), 132 | }, 133 | ], 134 | target: { 135 | $mid: 1, 136 | path: '_memo.openDocumentByReference', 137 | scheme: 'command', 138 | query: `{"reference":"${imageName}.png"}`, 139 | }, 140 | tooltip: 'Follow link', 141 | }); 142 | }); 143 | 144 | it('should provide nothing for link within code span', async () => { 145 | const noteName0 = rndName(); 146 | const noteName1 = rndName(); 147 | 148 | await createFile(`${noteName0}.md`, `\`[[${noteName1}]]\``); 149 | await createFile(`${noteName1}.md`); 150 | 151 | const doc = await openTextDocument(`${noteName0}.md`); 152 | 153 | const linkProvider = new DocumentLinkProvider(); 154 | 155 | const links = linkProvider.provideDocumentLinks(doc); 156 | 157 | expect(links).toHaveLength(0); 158 | }); 159 | 160 | it('should provide nothing for link within fenced code block', async () => { 161 | const noteName0 = rndName(); 162 | const noteName1 = rndName(); 163 | 164 | await createFile( 165 | `${noteName0}.md`, 166 | ` 167 | \`\`\` 168 | Preceding text 169 | [[1234512345]] 170 | Following text 171 | \`\`\` 172 | `, 173 | ); 174 | await createFile(`${noteName1}.md`); 175 | 176 | const doc = await openTextDocument(`${noteName0}.md`); 177 | 178 | const linkProvider = new DocumentLinkProvider(); 179 | 180 | const links = linkProvider.provideDocumentLinks(doc); 181 | 182 | expect(links).toHaveLength(0); 183 | }); 184 | 185 | it('should provide reference only for last link in the file', async () => { 186 | const noteName0 = rndName(); 187 | 188 | await createFile( 189 | `${noteName0}.md`, 190 | ` 191 | To create a link to any resource you can use \`[[DemoNote]]\` notation and for embedding resource to show it in the built-in preview (only images supported at the moment) please use \`![[]]\` notation with \`!\` in the beginning. 192 | 193 | \`[[DemoNote]]\` \`[[DemoNote]] [[DemoNote]]\` 194 | 195 | \`\`\` 196 | [[DemoNote]] 197 | \`\`\` 198 | 199 | [[DemoNote]] 200 | `, 201 | ); 202 | 203 | const doc = await openTextDocument(`${noteName0}.md`); 204 | 205 | const linkProvider = new DocumentLinkProvider(); 206 | 207 | const links = linkProvider.provideDocumentLinks(doc); 208 | 209 | expect(toPlainObject(links)).toMatchInlineSnapshot(` 210 | [ 211 | { 212 | "range": [ 213 | { 214 | "character": 6, 215 | "line": 9, 216 | }, 217 | { 218 | "character": 14, 219 | "line": 9, 220 | }, 221 | ], 222 | "target": { 223 | "$mid": 1, 224 | "path": "_memo.openDocumentByReference", 225 | "query": "{"reference":"DemoNote"}", 226 | "scheme": "command", 227 | }, 228 | "tooltip": "Follow link", 229 | }, 230 | ] 231 | `); 232 | }); 233 | }); 234 | -------------------------------------------------------------------------------- /src/features/DocumentLinkProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import { extractRefsFromText } from '../utils'; 4 | 5 | export default class DocumentLinkProvider implements vscode.DocumentLinkProvider { 6 | private readonly refPattern = new RegExp('\\[\\[([^\\[\\]]+?)\\]\\]', 'g'); 7 | 8 | public provideDocumentLinks(document: vscode.TextDocument): vscode.DocumentLink[] { 9 | return extractRefsFromText(this.refPattern, document.getText()).map(({ ref }) => { 10 | const link = new vscode.DocumentLink( 11 | new vscode.Range(ref.position.start, ref.position.end), 12 | vscode.Uri.parse('command:_memo.openDocumentByReference').with({ 13 | query: JSON.stringify({ reference: encodeURIComponent(ref.text) }), 14 | }), 15 | ); 16 | 17 | link.tooltip = 'Follow link'; 18 | 19 | return link; 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/features/ReferenceHoverProvider.spec.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | import path from 'path'; 3 | 4 | import ReferenceHoverProvider from './ReferenceHoverProvider'; 5 | import { 6 | createFile, 7 | rndName, 8 | getWorkspaceFolder, 9 | openTextDocument, 10 | closeEditorsAndCleanWorkspace, 11 | toPlainObject, 12 | } from '../test/utils'; 13 | 14 | describe('ReferenceHoverProvider', () => { 15 | beforeEach(closeEditorsAndCleanWorkspace); 16 | 17 | afterEach(closeEditorsAndCleanWorkspace); 18 | 19 | it('should not return anything for empty document', async () => { 20 | const filename = `${rndName()}.md`; 21 | 22 | await createFile(filename); 23 | 24 | const doc = await openTextDocument(filename); 25 | 26 | const referenceHoverProvider = new ReferenceHoverProvider(); 27 | 28 | expect(referenceHoverProvider.provideHover(doc, new vscode.Position(0, 0))).toBeNull(); 29 | }); 30 | 31 | it('should provide hover for note', async () => { 32 | const name0 = rndName(); 33 | const name1 = rndName(); 34 | 35 | await createFile(`${name0}.md`, `[[${name1}]]`); 36 | await createFile(`${name1}.md`, '# Hello world'); 37 | 38 | const doc = await openTextDocument(`${name0}.md`); 39 | 40 | const referenceHoverProvider = new ReferenceHoverProvider(); 41 | 42 | expect( 43 | toPlainObject(referenceHoverProvider.provideHover(doc, new vscode.Position(0, 4))), 44 | ).toEqual({ 45 | contents: ['# Hello world'], 46 | range: [ 47 | { character: expect.any(Number), line: 0 }, 48 | { character: expect.any(Number), line: 0 }, 49 | ], 50 | }); 51 | }); 52 | 53 | it('should provide hover for image', async () => { 54 | const name0 = rndName(); 55 | const name1 = rndName(); 56 | 57 | await createFile(`${name0}.md`, `![[${name1}.png]]`); 58 | await createFile(`${name1}.png`); 59 | 60 | const doc = await openTextDocument(`${name0}.md`); 61 | 62 | const referenceHoverProvider = new ReferenceHoverProvider(); 63 | 64 | const imagePath = path.join(getWorkspaceFolder()!, `${name1}.png`); 65 | 66 | expect( 67 | toPlainObject(referenceHoverProvider.provideHover(doc, new vscode.Position(0, 4))), 68 | ).toEqual({ 69 | contents: [`![](${vscode.Uri.file(imagePath).toString()}|height=200)`], 70 | range: [ 71 | { character: expect.any(Number), line: 0 }, 72 | { character: expect.any(Number), line: 0 }, 73 | ], 74 | }); 75 | }); 76 | 77 | it('should provide hover for a note instead of an image on filenames clash', async () => { 78 | const name0 = rndName(); 79 | const name1 = rndName(); 80 | 81 | await createFile(`${name0}.md`, `[[${name1}]]`); 82 | await createFile(`/a/${name1}.png`); 83 | await createFile(`/b/${name1}.md`, '# Hello world'); 84 | 85 | const doc = await openTextDocument(`${name0}.md`); 86 | 87 | const referenceHoverProvider = new ReferenceHoverProvider(); 88 | 89 | expect( 90 | toPlainObject(referenceHoverProvider.provideHover(doc, new vscode.Position(0, 4))), 91 | ).toEqual({ 92 | contents: ['# Hello world'], 93 | range: [ 94 | { character: expect.any(Number), line: 0 }, 95 | { character: expect.any(Number), line: 0 }, 96 | ], 97 | }); 98 | }); 99 | 100 | it('should provide hover with a warning about unknown extension', async () => { 101 | const name0 = rndName(); 102 | const name1 = rndName(); 103 | 104 | await createFile(`${name0}.md`, `[[${name1}.unknown]]`); 105 | await createFile(`${name1}.unknown`, '# Hello world'); 106 | 107 | const doc = await openTextDocument(`${name0}.md`); 108 | 109 | const referenceHoverProvider = new ReferenceHoverProvider(); 110 | 111 | expect(toPlainObject(referenceHoverProvider.provideHover(doc, new vscode.Position(0, 4)))) 112 | .toMatchInlineSnapshot(` 113 | { 114 | "contents": [ 115 | "Link contains unknown extension: .unknown. Please use common file extensions .md,.png,.jpg,.jpeg,.svg,.gif,.doc,.docx,.rtf,.txt,.odt,.xls,.xlsx,.ppt,.pptm,.pptx,.pdf to enable full support.", 116 | ], 117 | "range": [ 118 | { 119 | "character": 2, 120 | "line": 0, 121 | }, 122 | { 123 | "character": 15, 124 | "line": 0, 125 | }, 126 | ], 127 | } 128 | `); 129 | }); 130 | 131 | it('should provide hover with a warning that file is not created yet', async () => { 132 | const name0 = rndName(); 133 | 134 | await createFile(`${name0}.md`, `[[any-link]]`); 135 | 136 | const doc = await openTextDocument(`${name0}.md`); 137 | 138 | const referenceHoverProvider = new ReferenceHoverProvider(); 139 | 140 | expect(toPlainObject(referenceHoverProvider.provideHover(doc, new vscode.Position(0, 4)))) 141 | .toMatchInlineSnapshot(` 142 | { 143 | "contents": [ 144 | ""any-link" is not created yet. Click to create.", 145 | ], 146 | "range": [ 147 | { 148 | "character": 2, 149 | "line": 0, 150 | }, 151 | { 152 | "character": 10, 153 | "line": 0, 154 | }, 155 | ], 156 | } 157 | `); 158 | }); 159 | 160 | it('should not provide hover for a link within code span', async () => { 161 | const name0 = rndName(); 162 | const name1 = rndName(); 163 | 164 | await createFile(`${name0}.md`, `\`[[${name1}]]\``); 165 | await createFile(`/b/${name1}.md`, '# Hello world'); 166 | 167 | const doc = await openTextDocument(`${name0}.md`); 168 | 169 | const referenceHoverProvider = new ReferenceHoverProvider(); 170 | 171 | expect(referenceHoverProvider.provideHover(doc, new vscode.Position(0, 4))).toBeNull(); 172 | }); 173 | 174 | it('should not provide hover for a link within fenced code block', async () => { 175 | const name0 = rndName(); 176 | const name1 = rndName(); 177 | 178 | await createFile( 179 | `${name0}.md`, 180 | ` 181 | \`\`\` 182 | Preceding text 183 | [[${name1}]] 184 | Following text 185 | \`\`\` 186 | `, 187 | ); 188 | await createFile(`/b/${name1}.md`, '# Hello world'); 189 | 190 | const doc = await openTextDocument(`${name0}.md`); 191 | 192 | const referenceHoverProvider = new ReferenceHoverProvider(); 193 | 194 | expect(referenceHoverProvider.provideHover(doc, new vscode.Position(0, 4))).toBeNull(); 195 | }); 196 | 197 | it('should provide hover for a link to dot file note', async () => { 198 | const name0 = rndName(); 199 | const name1 = rndName(); 200 | 201 | await createFile(`${name0}.md`, `[[.${name1}]]`); 202 | await createFile(`.${name1}.md`, '# Hello world'); 203 | 204 | const doc = await openTextDocument(`${name0}.md`); 205 | 206 | const referenceHoverProvider = new ReferenceHoverProvider(); 207 | 208 | expect( 209 | toPlainObject(referenceHoverProvider.provideHover(doc, new vscode.Position(0, 4))), 210 | ).toEqual({ 211 | contents: ['# Hello world'], 212 | range: [ 213 | { character: expect.any(Number), line: 0 }, 214 | { character: expect.any(Number), line: 0 }, 215 | ], 216 | }); 217 | }); 218 | 219 | it('should provide hover for a link with explicit markdown extension in the ref', async () => { 220 | const name0 = rndName(); 221 | const name1 = rndName(); 222 | 223 | await createFile(`${name0}.md`, `[[${name1}.md]]`); 224 | await createFile(`${name1}.md.md`, '# Hello world'); 225 | 226 | const doc = await openTextDocument(`${name0}.md`); 227 | 228 | const referenceHoverProvider = new ReferenceHoverProvider(); 229 | 230 | expect( 231 | toPlainObject(referenceHoverProvider.provideHover(doc, new vscode.Position(0, 4))), 232 | ).toEqual({ 233 | contents: ['# Hello world'], 234 | range: [ 235 | { character: expect.any(Number), line: 0 }, 236 | { character: expect.any(Number), line: 0 }, 237 | ], 238 | }); 239 | }); 240 | 241 | it('should provide hover for a link with escape symbol for the label', async () => { 242 | const name0 = rndName(); 243 | const name1 = rndName(); 244 | 245 | await createFile(`${name0}.md`, `[[${name1}\\|Label]]`); 246 | await createFile(`${name1}.md`, '# Hello world'); 247 | 248 | const doc = await openTextDocument(`${name0}.md`); 249 | 250 | const referenceHoverProvider = new ReferenceHoverProvider(); 251 | 252 | expect( 253 | toPlainObject(referenceHoverProvider.provideHover(doc, new vscode.Position(0, 4))), 254 | ).toEqual({ 255 | contents: ['# Hello world'], 256 | range: [ 257 | { character: expect.any(Number), line: 0 }, 258 | { character: expect.any(Number), line: 0 }, 259 | ], 260 | }); 261 | }); 262 | }); 263 | -------------------------------------------------------------------------------- /src/features/ReferenceHoverProvider.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | import { cache } from '../workspace'; 6 | import { 7 | containsImageExt, 8 | containsUnknownExt, 9 | containsOtherKnownExts, 10 | getMemoConfigProperty, 11 | getReferenceAtPosition, 12 | isUncPath, 13 | findUriByRef, 14 | commonExtsHint, 15 | } from '../utils'; 16 | 17 | export default class ReferenceHoverProvider implements vscode.HoverProvider { 18 | public provideHover(document: vscode.TextDocument, position: vscode.Position) { 19 | const refAtPos = getReferenceAtPosition(document, position); 20 | 21 | if (refAtPos) { 22 | const { ref, range } = refAtPos; 23 | const hoverRange = new vscode.Range( 24 | new vscode.Position(range.start.line, range.start.character + 2), 25 | new vscode.Position(range.end.line, range.end.character - 2), 26 | ); 27 | 28 | const uris = cache.getWorkspaceCache().allUris; 29 | const foundUri = findUriByRef(uris, ref); 30 | 31 | if (!foundUri && containsUnknownExt(ref)) { 32 | return new vscode.Hover( 33 | `Link contains unknown extension: ${ 34 | path.parse(ref).ext 35 | }. Please use common file extensions ${commonExtsHint} to enable full support.`, 36 | hoverRange, 37 | ); 38 | } 39 | 40 | if (foundUri && fs.existsSync(foundUri.fsPath)) { 41 | const imageMaxHeight = Math.max( 42 | getMemoConfigProperty('links.preview.imageMaxHeight', 200), 43 | 10, 44 | ); 45 | const getContent = () => { 46 | if (containsImageExt(foundUri.fsPath)) { 47 | if (isUncPath(foundUri.fsPath)) { 48 | return 'UNC paths are not supported for images preview due to VSCode Content Security Policy. Use markdown preview or open image via cmd (ctrl) + click instead.'; 49 | } 50 | 51 | return `![](${vscode.Uri.file(foundUri.fsPath).toString()}|height=${imageMaxHeight})`; 52 | } else if (containsOtherKnownExts(foundUri.fsPath)) { 53 | const ext = path.parse(foundUri.fsPath).ext; 54 | return `Preview is not supported for "${ext}" file type. Click to open in the default app.`; 55 | } 56 | 57 | return fs.readFileSync(foundUri.fsPath).toString(); 58 | }; 59 | 60 | return new vscode.Hover(getContent(), hoverRange); 61 | } 62 | 63 | return new vscode.Hover(`"${ref}" is not created yet. Click to create.`, hoverRange); 64 | } 65 | 66 | return null; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/features/ReferenceProvider.spec.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | 3 | import ReferenceProvider from './ReferenceProvider'; 4 | import { 5 | createFile, 6 | rndName, 7 | openTextDocument, 8 | closeEditorsAndCleanWorkspace, 9 | toPlainObject, 10 | } from '../test/utils'; 11 | 12 | describe('ReferenceProvider', () => { 13 | beforeEach(closeEditorsAndCleanWorkspace); 14 | 15 | afterEach(closeEditorsAndCleanWorkspace); 16 | 17 | it('should provide references', async () => { 18 | const note0 = `a-${rndName()}`; 19 | const note1 = `b-${rndName()}`; 20 | 21 | await createFile(`${note0}.md`, `[[${note0}]]`); 22 | await createFile(`${note1}.md`, `[[${note0}]]`); 23 | 24 | const doc = await openTextDocument(`${note0}.md`); 25 | 26 | const referenceProvider = new ReferenceProvider(); 27 | 28 | const links = await referenceProvider.provideReferences(doc, new vscode.Position(0, 2)); 29 | 30 | expect(toPlainObject(links)).toMatchObject([ 31 | { 32 | range: [ 33 | { 34 | character: expect.any(Number), 35 | line: 0, 36 | }, 37 | { 38 | character: expect.any(Number), 39 | line: 0, 40 | }, 41 | ], 42 | uri: { 43 | path: expect.toEndWith(`${note0}.md`), 44 | scheme: 'file', 45 | }, 46 | }, 47 | { 48 | range: [ 49 | { 50 | character: expect.any(Number), 51 | line: 0, 52 | }, 53 | { 54 | character: expect.any(Number), 55 | line: 0, 56 | }, 57 | ], 58 | uri: { 59 | path: expect.toEndWith(`${note1}.md`), 60 | scheme: 'file', 61 | }, 62 | }, 63 | ]); 64 | }); 65 | 66 | it('should provide no references for link within code span', async () => { 67 | const note0 = rndName(); 68 | const note1 = rndName(); 69 | 70 | await createFile(`${note0}.md`, `[[${note0}]]`); 71 | await createFile(`${note1}.md`, `\`[[${note0}]]\``); 72 | 73 | const doc = await openTextDocument(`${note1}.md`); 74 | 75 | const referenceProvider = new ReferenceProvider(); 76 | 77 | const links = await referenceProvider.provideReferences(doc, new vscode.Position(0, 2)); 78 | 79 | expect(links).toHaveLength(0); 80 | }); 81 | 82 | it('should provide no references for link within fenced code block', async () => { 83 | const note0 = rndName(); 84 | const note1 = rndName(); 85 | 86 | await createFile(`${note0}.md`, `[[${note0}]]`); 87 | await createFile( 88 | `${note1}.md`, 89 | ` 90 | \`\`\` 91 | Preceding text 92 | [[${note0}]] 93 | Following text 94 | \`\`\` 95 | `, 96 | ); 97 | 98 | const doc = await openTextDocument(`${note1}.md`); 99 | 100 | const referenceProvider = new ReferenceProvider(); 101 | 102 | const links = await referenceProvider.provideReferences(doc, new vscode.Position(3, 6)); 103 | 104 | expect(links).toHaveLength(0); 105 | }); 106 | 107 | it('should provide references from a file itself', async () => { 108 | const note = rndName(); 109 | 110 | await createFile(`${note}.md`, `[[ref1]] [[ref1]] [[ref2]]`); 111 | 112 | const doc = await openTextDocument(`${note}.md`); 113 | 114 | const referenceProvider = new ReferenceProvider(); 115 | 116 | const links = await referenceProvider.provideReferences(doc, new vscode.Position(0, 2)); 117 | 118 | expect(toPlainObject(links)).toMatchObject([ 119 | { 120 | range: [ 121 | { 122 | character: 2, 123 | line: 0, 124 | }, 125 | { 126 | character: 6, 127 | line: 0, 128 | }, 129 | ], 130 | uri: { 131 | path: expect.toEndWith(`${note}.md`), 132 | scheme: 'file', 133 | }, 134 | }, 135 | { 136 | range: [ 137 | { 138 | character: 11, 139 | line: 0, 140 | }, 141 | { 142 | character: 15, 143 | line: 0, 144 | }, 145 | ], 146 | uri: { 147 | path: expect.toEndWith(`${note}.md`), 148 | scheme: 'file', 149 | }, 150 | }, 151 | ]); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /src/features/ReferenceProvider.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | 3 | import { getReferenceAtPosition, findReferences } from '../utils'; 4 | 5 | export default class ReferenceProvider implements vscode.ReferenceProvider { 6 | public async provideReferences(document: vscode.TextDocument, position: vscode.Position) { 7 | const refAtPos = getReferenceAtPosition(document, position); 8 | 9 | return refAtPos ? (await findReferences(refAtPos.ref)).map(({ location }) => location) : []; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/features/ReferenceRenameProvider.spec.ts: -------------------------------------------------------------------------------- 1 | import vscode, { Position, window, workspace } from 'vscode'; 2 | 3 | import ReferenceRenameProvider from './ReferenceRenameProvider'; 4 | import { 5 | createFile, 6 | fileExists, 7 | rndName, 8 | openTextDocument, 9 | closeEditorsAndCleanWorkspace, 10 | } from '../test/utils'; 11 | 12 | describe('ReferenceRenameProvider', () => { 13 | beforeEach(closeEditorsAndCleanWorkspace); 14 | 15 | afterEach(closeEditorsAndCleanWorkspace); 16 | 17 | it('should not provide rename for dangling link', async () => { 18 | const docName = `${rndName()}.md`; 19 | 20 | await createFile(docName, '[[nonexistenlink]]'); 21 | 22 | const doc = await openTextDocument(docName); 23 | 24 | const referenceRenameProvider = new ReferenceRenameProvider(); 25 | 26 | await expect( 27 | referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2)), 28 | ).rejects.toThrow('Rename is not available for nonexistent links.'); 29 | }); 30 | 31 | it('should not provide rename for file with unsaved changes', async () => { 32 | const docName = `${rndName()}.md`; 33 | 34 | await createFile(docName, '[[nonexistenlink]]'); 35 | 36 | const doc = await openTextDocument(docName); 37 | 38 | const editor = await window.showTextDocument(doc); 39 | 40 | await editor.edit((edit) => edit.insert(new Position(0, 5), 'test')); 41 | 42 | const referenceRenameProvider = new ReferenceRenameProvider(); 43 | 44 | await expect( 45 | referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2)), 46 | ).rejects.toThrow('Rename is not available for unsaved files.'); 47 | }); 48 | 49 | it('should not provide rename for multiline link', async () => { 50 | const docName = `${rndName()}.md`; 51 | 52 | await createFile(docName, '[[nonexisten\nlink]]'); 53 | 54 | const doc = await openTextDocument(docName); 55 | 56 | const referenceRenameProvider = new ReferenceRenameProvider(); 57 | 58 | await expect( 59 | referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2)), 60 | ).rejects.toThrow('Rename is not available.'); 61 | }); 62 | 63 | it('should provide rename for a link to the existing file', async () => { 64 | const docName = rndName(); 65 | const existingName = rndName(); 66 | 67 | await createFile(`${docName}.md`, `[[${existingName}]]`); 68 | await createFile(`${existingName}.md`); 69 | 70 | const doc = await openTextDocument(`${docName}.md`); 71 | 72 | const referenceRenameProvider = new ReferenceRenameProvider(); 73 | 74 | expect(await referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2))) 75 | .toMatchInlineSnapshot(` 76 | [ 77 | { 78 | "character": 2, 79 | "line": 0, 80 | }, 81 | { 82 | "character": 7, 83 | "line": 0, 84 | }, 85 | ] 86 | `); 87 | }); 88 | 89 | it('should provide rename for a link to the existing file with an unknown extension', async () => { 90 | const docName = rndName(); 91 | const existingFilenameWithUnknownExt = `${rndName()}.unknown`; 92 | 93 | await createFile(`${docName}.md`, `[[${existingFilenameWithUnknownExt}]]`); 94 | await createFile(existingFilenameWithUnknownExt); 95 | 96 | const doc = await openTextDocument(`${docName}.md`); 97 | 98 | const referenceRenameProvider = new ReferenceRenameProvider(); 99 | 100 | expect(await referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2))) 101 | .toMatchInlineSnapshot(` 102 | [ 103 | { 104 | "character": 2, 105 | "line": 0, 106 | }, 107 | { 108 | "character": 15, 109 | "line": 0, 110 | }, 111 | ] 112 | `); 113 | }); 114 | 115 | it('should provide rename edit and apply it to workspace', async () => { 116 | const docName = rndName(); 117 | const actualName = rndName(); 118 | const nextName = rndName(); 119 | 120 | await createFile(`${docName}.md`, `[[${actualName}]]`); 121 | await createFile(`${actualName}.md`); 122 | 123 | const doc = await openTextDocument(`${docName}.md`); 124 | 125 | const referenceRenameProvider = new ReferenceRenameProvider(); 126 | 127 | const workspaceEdit = await referenceRenameProvider.provideRenameEdits( 128 | doc, 129 | new vscode.Position(0, 2), 130 | nextName, 131 | ); 132 | 133 | expect(fileExists(`${docName}.md`)).toBe(true); 134 | expect(fileExists(`${actualName}.md`)).toBe(true); 135 | 136 | await workspace.applyEdit(workspaceEdit); 137 | 138 | expect(fileExists(`${docName}.md`)).toBe(true); 139 | expect(fileExists(`${actualName}.md`)).toBe(false); 140 | expect(fileExists(`${nextName}.md`)).toBe(true); 141 | }); 142 | 143 | it('should provide rename edit for a link to the existing file with unknown extension', async () => { 144 | const docName = rndName(); 145 | const actualName = rndName(); 146 | const nextName = rndName(); 147 | 148 | await createFile(`${docName}.md`, `[[${actualName}.unknown]]`); 149 | await createFile(`${actualName}.unknown`); 150 | 151 | const doc = await openTextDocument(`${docName}.md`); 152 | 153 | const referenceRenameProvider = new ReferenceRenameProvider(); 154 | 155 | const workspaceEdit = await referenceRenameProvider.provideRenameEdits( 156 | doc, 157 | new vscode.Position(0, 2), 158 | nextName, 159 | ); 160 | 161 | expect(fileExists(`${docName}.md`)).toBe(true); 162 | expect(fileExists(`${actualName}.unknown`)).toBe(true); 163 | 164 | await workspace.applyEdit(workspaceEdit); 165 | 166 | expect(fileExists(`${docName}.md`)).toBe(true); 167 | expect(fileExists(`${actualName}.unknown`)).toBe(false); 168 | expect(fileExists(`${nextName}.md`)).toBe(true); 169 | }); 170 | 171 | it('should provide rename for markdown file with a dot in the filename', async () => { 172 | const docName = rndName(); 173 | const actualName = rndName(); 174 | const nextNameWithDot = `${rndName()} v1.0 release`; 175 | 176 | await createFile(`${docName}.md`, `[[${actualName}]]`); 177 | await createFile(`${actualName}.md`); 178 | 179 | const doc = await openTextDocument(`${docName}.md`); 180 | 181 | const referenceRenameProvider = new ReferenceRenameProvider(); 182 | 183 | const workspaceEdit = await referenceRenameProvider.provideRenameEdits( 184 | doc, 185 | new vscode.Position(0, 2), 186 | nextNameWithDot, 187 | ); 188 | 189 | expect(fileExists(`${docName}.md`)).toBe(true); 190 | expect(fileExists(`${actualName}.md`)).toBe(true); 191 | 192 | await workspace.applyEdit(workspaceEdit); 193 | 194 | expect(fileExists(`${docName}.md`)).toBe(true); 195 | expect(fileExists(`${actualName}.md`)).toBe(false); 196 | expect(fileExists(`${nextNameWithDot}.md`)).toBe(true); 197 | }); 198 | 199 | it('should not provide rename for a link within code span', async () => { 200 | const docName = rndName(); 201 | const someLink = rndName(); 202 | 203 | await createFile(`${docName}.md`, `\`[[${someLink}]]\``); 204 | await createFile(`${someLink}.md`); 205 | 206 | const doc = await openTextDocument(`${docName}.md`); 207 | 208 | const referenceRenameProvider = new ReferenceRenameProvider(); 209 | 210 | await expect( 211 | referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2)), 212 | ).rejects.toThrow('Rename is not available.'); 213 | }); 214 | 215 | it('should not provide rename for a link within fenced code block', async () => { 216 | const docName = rndName(); 217 | const someLink = rndName(); 218 | 219 | await createFile( 220 | `${docName}.md`, 221 | ` 222 | \`\`\` 223 | Preceding text 224 | [[${someLink}]] 225 | Following text 226 | \`\`\` 227 | `, 228 | ); 229 | await createFile(`${someLink}.md`); 230 | 231 | const doc = await openTextDocument(`${docName}.md`); 232 | 233 | const referenceRenameProvider = new ReferenceRenameProvider(); 234 | 235 | await expect( 236 | referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2)), 237 | ).rejects.toThrow('Rename is not available.'); 238 | }); 239 | }); 240 | -------------------------------------------------------------------------------- /src/features/ReferenceRenameProvider.ts: -------------------------------------------------------------------------------- 1 | import { RenameProvider, TextDocument, Position, Range, WorkspaceEdit, Uri } from 'vscode'; 2 | import path from 'path'; 3 | 4 | import { cache } from '../workspace'; 5 | import { 6 | getReferenceAtPosition, 7 | findUriByRef, 8 | isLongRef, 9 | getWorkspaceFolder, 10 | containsMarkdownExt, 11 | containsUnknownExt, 12 | findFilesByExts, 13 | extractExt, 14 | sortPaths, 15 | } from '../utils'; 16 | 17 | const openingBracketsLength = 2; 18 | 19 | export default class ReferenceRenameProvider implements RenameProvider { 20 | public async prepareRename( 21 | document: TextDocument, 22 | position: Position, 23 | ): Promise { 24 | if (document.isDirty) { 25 | throw new Error('Rename is not available for unsaved files. Please save your changes first.'); 26 | } 27 | 28 | const refAtPos = getReferenceAtPosition(document, position); 29 | 30 | if (refAtPos) { 31 | const { range, ref } = refAtPos; 32 | 33 | const unknownUris = containsUnknownExt(ref) ? await findFilesByExts([extractExt(ref)]) : []; 34 | 35 | const augmentedUris = unknownUris.length 36 | ? sortPaths([...cache.getWorkspaceCache().allUris, ...unknownUris], { 37 | pathKey: 'path', 38 | shallowFirst: true, 39 | }) 40 | : cache.getWorkspaceCache().allUris; 41 | 42 | if (!findUriByRef(augmentedUris, ref)) { 43 | throw new Error( 44 | 'Rename is not available for nonexistent links. Create file first by clicking on the link.', 45 | ); 46 | } 47 | 48 | return new Range( 49 | new Position(range.start.line, range.start.character + openingBracketsLength), 50 | new Position(range.start.line, range.start.character + openingBracketsLength + ref.length), 51 | ); 52 | } 53 | 54 | throw new Error('Rename is not available. Please try when focused on the link.'); 55 | } 56 | 57 | public async provideRenameEdits( 58 | document: TextDocument, 59 | position: Position, 60 | newName: string, 61 | ): Promise { 62 | const refAtPos = getReferenceAtPosition(document, position); 63 | 64 | if (refAtPos) { 65 | const { ref } = refAtPos; 66 | 67 | const workspaceEdit = new WorkspaceEdit(); 68 | 69 | const unknownUris = containsUnknownExt(ref) ? await findFilesByExts([extractExt(ref)]) : []; 70 | 71 | const augmentedUris = unknownUris.length 72 | ? sortPaths([...cache.getWorkspaceCache().allUris, ...unknownUris], { 73 | pathKey: 'path', 74 | shallowFirst: true, 75 | }) 76 | : cache.getWorkspaceCache().allUris; 77 | 78 | const fsPath = findUriByRef(augmentedUris, ref)?.fsPath; 79 | 80 | if (fsPath) { 81 | const ext = path.parse(newName).ext; 82 | const newRelativePath = `${newName}${ 83 | ext === '' || (containsUnknownExt(newName) && containsMarkdownExt(fsPath)) ? '.md' : '' 84 | }`; 85 | const newUri = Uri.file( 86 | isLongRef(ref) 87 | ? path.join(getWorkspaceFolder()!, newRelativePath) 88 | : path.join(path.dirname(fsPath), newRelativePath), 89 | ); 90 | 91 | workspaceEdit.renameFile(Uri.file(fsPath), newUri); 92 | } 93 | 94 | return workspaceEdit; 95 | } 96 | 97 | throw new Error('Rename is not available. Please try when focused on the link.'); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/features/codeActionProvider.spec.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | 3 | import codeActionProvider from './codeActionProvider'; 4 | import { rndName, createFile, openTextDocument } from '../test/utils'; 5 | 6 | describe('codeActionProvider', () => { 7 | it('should provide code actions', async () => { 8 | const name0 = rndName(); 9 | 10 | await createFile(`${name0}.md`, 'Hello world!'); 11 | 12 | const doc = await openTextDocument(`${name0}.md`); 13 | const range = new vscode.Range(0, 0, 0, 12); 14 | 15 | expect( 16 | codeActionProvider.provideCodeActions(doc, range, undefined as any, undefined as any), 17 | ).toEqual([ 18 | { 19 | title: 'Extract range to a new note', 20 | command: 'memo.extractRangeToNewNote', 21 | arguments: [doc, range], 22 | }, 23 | ]); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/features/codeActionProvider.ts: -------------------------------------------------------------------------------- 1 | import { CodeActionProvider } from 'vscode'; 2 | 3 | const codeActionProvider: CodeActionProvider = { 4 | provideCodeActions(document, range) { 5 | if (range.isEmpty) { 6 | return []; 7 | } 8 | 9 | return [ 10 | { 11 | title: 'Extract range to a new note', 12 | command: 'memo.extractRangeToNewNote', 13 | arguments: [document, range], 14 | }, 15 | ]; 16 | }, 17 | }; 18 | 19 | export default codeActionProvider; 20 | -------------------------------------------------------------------------------- /src/features/completionProvider.spec.ts: -------------------------------------------------------------------------------- 1 | import { window, Position, CompletionItem, CompletionItemKind, MarkdownString, Uri } from 'vscode'; 2 | 3 | import { 4 | MemoCompletionItem, 5 | provideCompletionItems, 6 | resolveCompletionItem, 7 | } from './completionProvider'; 8 | import { 9 | createFile, 10 | rndName, 11 | cacheWorkspace, 12 | openTextDocument, 13 | closeEditorsAndCleanWorkspace, 14 | updateMemoConfigProperty, 15 | } from '../test/utils'; 16 | 17 | describe('provideCompletionItems()', () => { 18 | beforeEach(closeEditorsAndCleanWorkspace); 19 | 20 | afterEach(closeEditorsAndCleanWorkspace); 21 | 22 | it('should provide links to notes and images', async () => { 23 | const name0 = `a-${rndName()}`; 24 | const name1 = `b-${rndName()}`; 25 | const name2 = `c-${rndName()}`; 26 | 27 | await createFile(`${name0}.md`); 28 | await createFile(`${name1}.md`); 29 | await createFile(`${name2}.png`); 30 | 31 | await cacheWorkspace(); 32 | 33 | const doc = await openTextDocument(`${name0}.md`); 34 | 35 | const editor = await window.showTextDocument(doc); 36 | 37 | await editor.edit((edit) => edit.insert(new Position(0, 0), '[[')); 38 | 39 | const completionItems = provideCompletionItems(doc, new Position(0, 2)); 40 | 41 | expect(completionItems).toEqual([ 42 | expect.objectContaining({ insertText: name0, label: name0 }), 43 | expect.objectContaining({ insertText: name1, label: name1 }), 44 | expect.objectContaining({ insertText: `${name2}.png`, label: `${name2}.png` }), 45 | ]); 46 | }); 47 | 48 | it('should provide short and long links on name clash', async () => { 49 | const name0 = `a-${rndName()}`; 50 | const name1 = `b-${rndName()}`; 51 | const name2 = `c-${rndName()}`; 52 | 53 | await createFile(`${name0}.md`); 54 | await createFile(`${name1}.md`); 55 | await createFile(`/folder1/${name1}.md`); 56 | await createFile(`/folder1/subfolder1/${name1}.md`); 57 | await createFile(`${name2}.png`); 58 | 59 | await cacheWorkspace(); 60 | 61 | const doc = await openTextDocument(`${name0}.md`); 62 | 63 | const editor = await window.showTextDocument(doc); 64 | 65 | await editor.edit((edit) => edit.insert(new Position(0, 0), '[[')); 66 | 67 | const completionItems = provideCompletionItems(doc, new Position(0, 2)); 68 | 69 | expect(completionItems).toEqual([ 70 | expect.objectContaining({ insertText: name0, label: name0 }), 71 | expect.objectContaining({ insertText: name1, label: name1 }), 72 | expect.objectContaining({ insertText: `folder1/${name1}`, label: `folder1/${name1}` }), 73 | expect.objectContaining({ 74 | insertText: `folder1/subfolder1/${name1}`, 75 | label: `folder1/subfolder1/${name1}`, 76 | }), 77 | // images expected to come after notes in autocomplete due to sortPaths logic 78 | expect.objectContaining({ 79 | insertText: `${name2}.png`, 80 | label: `${name2}.png`, 81 | }), 82 | ]); 83 | }); 84 | 85 | it('should provide sorted links', async () => { 86 | const name0 = `a-${rndName()}`; 87 | const name1 = `b-${rndName()}`; 88 | 89 | await createFile(`/folder1/subfolder1/${name1}.md`); 90 | await createFile(`/folder1/${name1}.md`); 91 | await createFile(`${name1}.md`); 92 | await createFile(`${name0}.md`); 93 | 94 | await cacheWorkspace(); 95 | 96 | const doc = await openTextDocument(`${name0}.md`); 97 | 98 | const editor = await window.showTextDocument(doc); 99 | 100 | await editor.edit((edit) => edit.insert(new Position(0, 0), '[[')); 101 | 102 | const completionItems = provideCompletionItems(doc, new Position(0, 2)); 103 | 104 | expect(completionItems).toEqual([ 105 | expect.objectContaining({ 106 | insertText: `${name0}`, 107 | label: `${name0}`, 108 | }), 109 | expect.objectContaining({ 110 | insertText: `${name1}`, 111 | label: `${name1}`, 112 | }), 113 | expect.objectContaining({ 114 | insertText: `folder1/${name1}`, 115 | label: `folder1/${name1}`, 116 | }), 117 | expect.objectContaining({ 118 | insertText: `folder1/subfolder1/${name1}`, 119 | label: `folder1/subfolder1/${name1}`, 120 | }), 121 | ]); 122 | }); 123 | 124 | it('should provide links to images and notes on embedding', async () => { 125 | const name0 = `a-${rndName()}`; 126 | const name1 = `b-${rndName()}`; 127 | const name2 = `c-${rndName()}`; 128 | 129 | await createFile(`${name0}.md`); 130 | await createFile(`${name1}.png`); 131 | await createFile(`${name2}.png`); 132 | await createFile(`/folder1/${name2}.png`); 133 | 134 | await cacheWorkspace(); 135 | 136 | const doc = await openTextDocument(`${name0}.md`); 137 | 138 | const editor = await window.showTextDocument(doc); 139 | 140 | await editor.edit((edit) => edit.insert(new Position(0, 0), '![[')); 141 | 142 | const completionItems = provideCompletionItems(doc, new Position(0, 3)); 143 | 144 | expect(completionItems).toEqual([ 145 | expect.objectContaining({ 146 | insertText: `${name1}.png`, 147 | label: `${name1}.png`, 148 | }), 149 | expect.objectContaining({ 150 | insertText: `${name2}.png`, 151 | label: `${name2}.png`, 152 | }), 153 | expect.objectContaining({ 154 | insertText: `folder1/${name2}.png`, 155 | label: `folder1/${name2}.png`, 156 | }), 157 | expect.objectContaining({ 158 | insertText: name0, 159 | label: name0, 160 | }), 161 | ]); 162 | }); 163 | 164 | it('should provide dangling references', async () => { 165 | const name0 = `a-${rndName()}`; 166 | const name1 = `b-${rndName()}`; 167 | 168 | await createFile( 169 | `${name0}.md`, 170 | ` 171 | [[dangling-ref]] 172 | [[dangling-ref]] 173 | [[dangling-ref2|Test Label]] 174 | [[folder1/long-dangling-ref]] 175 | ![[dangling-ref3]] 176 | \`[[dangling-ref-within-code-span]]\` 177 | \`\`\` 178 | Preceding text 179 | [[dangling-ref-within-fenced-code-block]] 180 | Following text 181 | \`\`\` 182 | `, 183 | ); 184 | await createFile(`${name1}.md`); 185 | 186 | const doc = await openTextDocument(`${name1}.md`); 187 | 188 | const editor = await window.showTextDocument(doc); 189 | 190 | await editor.edit((edit) => edit.insert(new Position(0, 0), '![[')); 191 | 192 | const completionItems = provideCompletionItems(doc, new Position(0, 3)); 193 | 194 | expect(completionItems).toEqual([ 195 | expect.objectContaining({ 196 | insertText: name0, 197 | label: name0, 198 | }), 199 | expect.objectContaining({ 200 | insertText: name1, 201 | label: name1, 202 | }), 203 | expect.objectContaining({ 204 | insertText: 'dangling-ref', 205 | label: 'dangling-ref', 206 | }), 207 | expect.objectContaining({ 208 | insertText: 'dangling-ref2', 209 | label: 'dangling-ref2', 210 | }), 211 | expect.objectContaining({ 212 | insertText: 'dangling-ref3', 213 | label: 'dangling-ref3', 214 | }), 215 | expect.objectContaining({ 216 | insertText: 'folder1/long-dangling-ref', 217 | label: 'folder1/long-dangling-ref', 218 | }), 219 | ]); 220 | }); 221 | 222 | describe('with links.format = long', () => { 223 | it('should provide only long links', async () => { 224 | const name0 = `a-${rndName()}`; 225 | const name1 = `b-${rndName()}`; 226 | 227 | await updateMemoConfigProperty('links.format', 'long'); 228 | 229 | await createFile(`${name0}.md`); 230 | await createFile(`/folder1/${name1}.md`); 231 | await createFile(`/folder1/subfolder1/${name1}.md`); 232 | 233 | await cacheWorkspace(); 234 | 235 | const doc = await openTextDocument(`${name0}.md`); 236 | 237 | const editor = await window.showTextDocument(doc); 238 | 239 | await editor.edit((edit) => edit.insert(new Position(0, 0), '[[')); 240 | 241 | const completionItems = provideCompletionItems(doc, new Position(0, 2)); 242 | 243 | expect(completionItems).toEqual([ 244 | expect.objectContaining({ insertText: name0, label: name0 }), 245 | expect.objectContaining({ insertText: `folder1/${name1}`, label: `folder1/${name1}` }), 246 | expect.objectContaining({ 247 | insertText: `folder1/subfolder1/${name1}`, 248 | label: `folder1/subfolder1/${name1}`, 249 | }), 250 | ]); 251 | }); 252 | }); 253 | }); 254 | 255 | describe('resolveCompletionItem()', () => { 256 | beforeEach(closeEditorsAndCleanWorkspace); 257 | 258 | afterEach(closeEditorsAndCleanWorkspace); 259 | 260 | it('should add documentation for a markdown completion item', async () => { 261 | const noteName = `note-${rndName()}`; 262 | 263 | const noteUri = await createFile(`${noteName}.md`, 'Test documentation'); 264 | 265 | const completionItem: MemoCompletionItem = new CompletionItem( 266 | noteName, 267 | CompletionItemKind.File, 268 | ); 269 | 270 | completionItem.fsPath = noteUri!.fsPath; 271 | 272 | expect( 273 | ((await resolveCompletionItem(completionItem)).documentation as MarkdownString).value, 274 | ).toBe('Test documentation'); 275 | }); 276 | 277 | it('should add documentation for an image completion item', async () => { 278 | const imageName = `image-${rndName()}`; 279 | 280 | const imageUri = await createFile(`${imageName}.png`); 281 | 282 | const completionItem: MemoCompletionItem = new CompletionItem( 283 | imageName, 284 | CompletionItemKind.File, 285 | ); 286 | 287 | completionItem.fsPath = imageUri!.fsPath; 288 | 289 | expect( 290 | ((await resolveCompletionItem(completionItem)).documentation as MarkdownString).value, 291 | ).toBe(`![](${Uri.file(completionItem.fsPath).toString()})`); 292 | }); 293 | }); 294 | -------------------------------------------------------------------------------- /src/features/completionProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | languages, 3 | TextDocument, 4 | Position, 5 | CompletionItem, 6 | workspace, 7 | CompletionItemKind, 8 | Uri, 9 | ExtensionContext, 10 | MarkdownString, 11 | } from 'vscode'; 12 | import util from 'util'; 13 | import path from 'path'; 14 | import groupBy from 'lodash.groupby'; 15 | import fs from 'fs'; 16 | 17 | import { cache } from '../workspace'; 18 | import { 19 | fsPathToRef, 20 | containsMarkdownExt, 21 | containsImageExt, 22 | containsOtherKnownExts, 23 | getMemoConfigProperty, 24 | isUncPath, 25 | } from '../utils'; 26 | 27 | const padWithZero = (n: number): string => (n < 10 ? '0' + n : String(n)); 28 | 29 | export type MemoCompletionItem = CompletionItem & { 30 | fsPath?: string; 31 | }; 32 | 33 | const readFile = util.promisify(fs.readFile); 34 | 35 | export const provideCompletionItems = (document: TextDocument, position: Position) => { 36 | const linePrefix = document.lineAt(position).text.substr(0, position.character); 37 | 38 | const isResourceAutocomplete = linePrefix.match(/\!\[\[\w*$/); 39 | const isDocsAutocomplete = linePrefix.match(/\[\[\w*$/); 40 | 41 | if (!isDocsAutocomplete && !isResourceAutocomplete) { 42 | return undefined; 43 | } 44 | 45 | const completionItems: MemoCompletionItem[] = []; 46 | 47 | const uris: Uri[] = [ 48 | ...(isResourceAutocomplete 49 | ? [...cache.getWorkspaceCache().imageUris, ...cache.getWorkspaceCache().markdownUris] 50 | : []), 51 | ...(!isResourceAutocomplete 52 | ? [ 53 | ...cache.getWorkspaceCache().markdownUris, 54 | ...cache.getWorkspaceCache().imageUris, 55 | ...cache.getWorkspaceCache().otherUris, 56 | ] 57 | : []), 58 | ]; 59 | 60 | const urisByPathBasename = groupBy(uris, ({ fsPath }) => path.basename(fsPath).toLowerCase()); 61 | 62 | uris.forEach((uri, index) => { 63 | const workspaceFolder = workspace.getWorkspaceFolder(uri); 64 | 65 | if (!workspaceFolder) { 66 | return; 67 | } 68 | 69 | const longRef = fsPathToRef({ 70 | path: uri.fsPath, 71 | basePath: workspaceFolder.uri.fsPath, 72 | keepExt: containsImageExt(uri.fsPath) || containsOtherKnownExts(uri.fsPath), 73 | }); 74 | 75 | const shortRef = fsPathToRef({ 76 | path: uri.fsPath, 77 | keepExt: containsImageExt(uri.fsPath) || containsOtherKnownExts(uri.fsPath), 78 | }); 79 | 80 | const urisGroup = urisByPathBasename[path.basename(uri.fsPath).toLowerCase()] || []; 81 | 82 | const isFirstUriInGroup = 83 | urisGroup.findIndex((uriParam) => uriParam.fsPath === uri.fsPath) === 0; 84 | 85 | if (!longRef || !shortRef) { 86 | return; 87 | } 88 | 89 | const item = new CompletionItem(longRef, CompletionItemKind.File) as MemoCompletionItem; 90 | 91 | const linksFormat = getMemoConfigProperty('links.format', 'short'); 92 | 93 | item.insertText = linksFormat === 'long' || !isFirstUriInGroup ? longRef : shortRef; 94 | 95 | // prepend index with 0, so a lexicographic sort doesn't mess things up 96 | item.sortText = padWithZero(index); 97 | 98 | item.fsPath = uri.fsPath; 99 | 100 | completionItems.push(item); 101 | }); 102 | 103 | const danglingRefs = cache.getWorkspaceCache().danglingRefs; 104 | 105 | const completionItemsLength = completionItems.length; 106 | 107 | danglingRefs.forEach((ref, index) => { 108 | const item = new CompletionItem(ref, CompletionItemKind.File); 109 | 110 | item.insertText = ref; 111 | 112 | // prepend index with 0, so a lexicographic sort doesn't mess things up 113 | item.sortText = padWithZero(completionItemsLength + index); 114 | 115 | completionItems.push(item); 116 | }); 117 | 118 | return completionItems; 119 | }; 120 | 121 | export const resolveCompletionItem = async (item: MemoCompletionItem) => { 122 | if (item.fsPath) { 123 | try { 124 | if (containsMarkdownExt(item.fsPath)) { 125 | item.documentation = new MarkdownString((await readFile(item.fsPath)).toString()); 126 | } else if (containsImageExt(item.fsPath) && !isUncPath(item.fsPath)) { 127 | item.documentation = new MarkdownString(`![](${Uri.file(item.fsPath).toString()})`); 128 | } 129 | } catch (error) { 130 | console.error(error); 131 | } 132 | } 133 | 134 | return item; 135 | }; 136 | 137 | export const activate = (context: ExtensionContext) => 138 | context.subscriptions.push( 139 | languages.registerCompletionItemProvider( 140 | 'markdown', 141 | { 142 | provideCompletionItems, 143 | resolveCompletionItem, 144 | }, 145 | '[', 146 | ), 147 | ); 148 | -------------------------------------------------------------------------------- /src/features/extendMarkdownIt.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it'; 2 | import markdownItRegex from 'markdown-it-regex'; 3 | import path from 'path'; 4 | import fs from 'fs'; 5 | 6 | import { cache } from '../workspace'; 7 | import { 8 | getImgUrlForMarkdownPreview, 9 | getFileUrlForMarkdownPreview, 10 | containsImageExt, 11 | containsUnknownExt, 12 | findUriByRef, 13 | extractEmbedRefs, 14 | parseRef, 15 | commonExtsHint, 16 | } from '../utils'; 17 | 18 | const getInvalidRefAnchor = (text: string) => 19 | `${text}`; 20 | 21 | const getUnknownExtRefAnchor = (text: string, ref: string) => 22 | `${text}`; 25 | 26 | const getRefAnchor = (href: string, text: string) => 27 | `${text}`; 28 | 29 | const extendMarkdownIt = (md: MarkdownIt) => { 30 | const refsStack: string[] = []; 31 | 32 | const mdExtended = md 33 | .use(markdownItRegex, { 34 | name: 'ref-resource', 35 | regex: /!\[\[([^\[\]]+?)\]\]/, 36 | replace: (rawRef: string) => { 37 | const { ref, label } = parseRef(rawRef); 38 | 39 | if (containsImageExt(ref)) { 40 | const imagePath = findUriByRef(cache.getWorkspaceCache().imageUris, ref)?.fsPath; 41 | 42 | if (imagePath) { 43 | return `
${
 46 |               label || ref
 47 |             }
`; 48 | } 49 | } 50 | 51 | const fsPath = findUriByRef(cache.getWorkspaceCache().markdownUris, ref)?.fsPath; 52 | 53 | if (!fsPath && containsUnknownExt(ref)) { 54 | return getUnknownExtRefAnchor(label || ref, ref); 55 | } 56 | 57 | if (!fsPath || !fs.existsSync(fsPath)) { 58 | return getInvalidRefAnchor(label || ref); 59 | } 60 | 61 | const previewFileUrl = getFileUrlForMarkdownPreview(fsPath); 62 | 63 | const name = path.parse(fsPath).name; 64 | 65 | const content = fs.readFileSync(fsPath).toString(); 66 | 67 | const refs = extractEmbedRefs(content).map((ref) => ref.toLowerCase()); 68 | 69 | const cyclicLinkDetected = 70 | refs.includes(ref.toLowerCase()) || refs.some((ref) => refsStack.includes(ref)); 71 | 72 | if (!cyclicLinkDetected) { 73 | refsStack.push(ref.toLowerCase()); 74 | } 75 | 76 | const html = `
77 |
${name}
78 | 83 |
84 | ${ 85 | !cyclicLinkDetected 86 | ? (mdExtended as any).render(content, undefined, true) 87 | : '' 88 | } 89 |
90 |
`; 91 | 92 | if (!cyclicLinkDetected) { 93 | refsStack.pop(); 94 | } 95 | 96 | return html; 97 | }, 98 | }) 99 | .use(markdownItRegex, { 100 | name: 'ref-document', 101 | regex: /\[\[([^\[\]]+?)\]\]/, 102 | replace: (rawRef: string) => { 103 | const { ref, label } = parseRef(rawRef); 104 | 105 | const fsPath = findUriByRef(cache.getWorkspaceCache().allUris, ref)?.fsPath; 106 | 107 | if (!fsPath && containsUnknownExt(ref)) { 108 | return getUnknownExtRefAnchor(label || ref, ref); 109 | } 110 | 111 | if (!fsPath) { 112 | return getInvalidRefAnchor(label || ref); 113 | } 114 | 115 | return getRefAnchor(getFileUrlForMarkdownPreview(fsPath), label || ref); 116 | }, 117 | }); 118 | 119 | return mdExtended; 120 | }; 121 | 122 | export default extendMarkdownIt; 123 | -------------------------------------------------------------------------------- /src/features/index.ts: -------------------------------------------------------------------------------- 1 | export * as completionProvider from './completionProvider'; 2 | export { default as DocumentLinkProvider } from './DocumentLinkProvider'; 3 | export { default as ReferenceHoverProvider } from './ReferenceHoverProvider'; 4 | export { default as ReferenceProvider } from './ReferenceProvider'; 5 | export { default as ReferenceRenameProvider } from './ReferenceRenameProvider'; 6 | export { default as BacklinksTreeDataProvider } from './BacklinksTreeDataProvider'; 7 | export { default as extendMarkdownIt } from './extendMarkdownIt'; 8 | export { default as codeActionProvider } from './codeActionProvider'; 9 | export * as referenceContextWatcher from './referenceContextWatcher'; 10 | export * as newVersionNotifier from './newVersionNotifier'; 11 | -------------------------------------------------------------------------------- /src/features/newVersionNotifier.spec.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from 'vscode'; 2 | import path from 'path'; 3 | 4 | import * as newVersionNotifier from './newVersionNotifier'; 5 | import { closeEditorsAndCleanWorkspace } from '../test/utils'; 6 | 7 | describe('newVersionNotifier feature', () => { 8 | beforeEach(closeEditorsAndCleanWorkspace); 9 | 10 | afterEach(closeEditorsAndCleanWorkspace); 11 | 12 | it('should not fail on activate', () => { 13 | expect(() => { 14 | const mockContext = { 15 | subscriptions: [], 16 | extensionPath: path.resolve(path.join(__dirname, '..', '..')), 17 | } as unknown as ExtensionContext; 18 | newVersionNotifier.activate(mockContext); 19 | mockContext.subscriptions.forEach((sub) => sub.dispose()); 20 | }).not.toThrow(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/features/newVersionNotifier.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { window, commands, Uri, ExtensionContext } from 'vscode'; 4 | 5 | // Add new version teasers for showing notification on extension update if needed 6 | const teasers: { [key: string]: string } = { 7 | '0.3.3': 'Memo v0.3.3! New "Open link to the side" command added.', 8 | '0.3.1': 9 | 'Memo v0.3.1! Links format "absolute" deprecated, please consider changing it to "long" in the settings.', 10 | '0.1.11': 'Memo v0.1.11! New "Open daily note" command added.', 11 | '0.1.10': 'Memo v0.1.10! Links rename, opening links in the default app and more.', 12 | }; 13 | 14 | const showChangelogAction = 'Show Changelog'; 15 | 16 | export const activate = (context: ExtensionContext) => { 17 | try { 18 | const versionPath = path.join(context.extensionPath, 'VERSION'); 19 | const data = fs.readFileSync(path.join(context.extensionPath, 'package.json')).toString(); 20 | const currentVersion: string = JSON.parse(data).version; 21 | 22 | const teaserMsg = teasers[currentVersion]; 23 | 24 | if ( 25 | !teaserMsg || 26 | (fs.existsSync(versionPath) && fs.readFileSync(versionPath).toString() === currentVersion) 27 | ) { 28 | return; 29 | } 30 | 31 | fs.writeFileSync(versionPath, currentVersion); 32 | 33 | window.showInformationMessage(teaserMsg, showChangelogAction, 'Dismiss').then((option) => { 34 | if (option === showChangelogAction) { 35 | commands.executeCommand( 36 | 'vscode.open', 37 | Uri.parse('https://github.com/svsool/memo/blob/master/CHANGELOG.md'), 38 | ); 39 | } 40 | }); 41 | } catch (error) { 42 | console.log(error); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/features/referenceContextWatcher.spec.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from 'vscode'; 2 | 3 | import * as referenceContextWatcher from './referenceContextWatcher'; 4 | import { closeEditorsAndCleanWorkspace } from '../test/utils'; 5 | 6 | describe('referenceContextWatcher feature', () => { 7 | beforeEach(closeEditorsAndCleanWorkspace); 8 | 9 | afterEach(closeEditorsAndCleanWorkspace); 10 | 11 | it('should not fail on activate', () => { 12 | expect(() => { 13 | const mockContext = { subscriptions: [] } as unknown as ExtensionContext; 14 | referenceContextWatcher.activate(mockContext); 15 | expect(mockContext.subscriptions.length).toBeGreaterThan(0); 16 | mockContext.subscriptions.forEach((sub) => sub.dispose()); 17 | }).not.toThrow(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/features/referenceContextWatcher.ts: -------------------------------------------------------------------------------- 1 | import { window, workspace, commands, ExtensionContext } from 'vscode'; 2 | 3 | import { getRefUriUnderCursor, getRefUnderCursor } from '../utils'; 4 | 5 | const updateRefExistsContext = () => { 6 | commands.executeCommand('setContext', 'memo:refUnderCursorExists', !!getRefUriUnderCursor()); 7 | commands.executeCommand('setContext', 'memo:refFocusedOrHovered', !!getRefUnderCursor()); 8 | }; 9 | 10 | export const activate = (context: ExtensionContext) => { 11 | context.subscriptions.push( 12 | window.onDidChangeTextEditorSelection(updateRefExistsContext), 13 | window.onDidChangeActiveTextEditor(updateRefExistsContext), 14 | workspace.onDidChangeTextDocument(updateRefExistsContext), 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { window } from 'vscode'; 2 | import util from 'util'; 3 | 4 | export const logger = window.createOutputChannel('Memo'); 5 | 6 | const createLogFn = 7 | (level: string) => 8 | (...params: (string | object | unknown)[]) => 9 | logger.appendLine( 10 | `[${new Date().toISOString()}] [${level}] ${params 11 | .map((param) => (typeof param === 'string' ? param : util.inspect(param))) 12 | .join(' ')}`, 13 | ); 14 | 15 | const info = createLogFn('info'); 16 | 17 | const debug = createLogFn('debug'); 18 | 19 | const warn = createLogFn('warn'); 20 | 21 | const error = createLogFn('error'); 22 | 23 | export default { info, warn, debug, error, logger }; 24 | -------------------------------------------------------------------------------- /src/test/config/jestSetup.ts: -------------------------------------------------------------------------------- 1 | jest.mock('open'); 2 | jest.mock('vscode', () => (global as any).vscode, { virtual: true }); 3 | -------------------------------------------------------------------------------- /src/test/env/VsCodeEnvironment.js: -------------------------------------------------------------------------------- 1 | const { TestEnvironment } = require('jest-environment-node'); 2 | const vscode = require('vscode'); 3 | 4 | class VsCodeEnvironment extends TestEnvironment { 5 | async setup() { 6 | await super.setup(); 7 | 8 | this.global.vscode = vscode; 9 | 10 | // Expose RegExp otherwise document.getWordRangeAtPosition won't work as supposed. 11 | // Implementation of getWordRangeAtPosition uses "instanceof RegExp" which returns false 12 | // due to Jest running tests in the different vm context. 13 | // See https://github.com/nodejs/node-v0.x-archive/issues/1277. 14 | this.global.RegExp = RegExp; 15 | } 16 | 17 | async teardown() { 18 | this.global.vscode = {}; 19 | await super.teardown(); 20 | } 21 | 22 | runScript(script) { 23 | return super.runScript(script); 24 | } 25 | } 26 | 27 | module.exports = VsCodeEnvironment; 28 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { runTests } from '@vscode/test-electron'; 3 | 4 | process.env.FORCE_COLOR = '1'; 5 | 6 | async function main() { 7 | try { 8 | // The folder containing the Extension Manifest package.json 9 | // Passed to `--extensionDevelopmentPath` 10 | const extensionDevelopmentPath = path.resolve(__dirname, '../../'); 11 | 12 | // The path to the extension test script 13 | // Passed to --extensionTestsPath 14 | const extensionTestsPath = path.resolve(__dirname, './testRunner'); 15 | 16 | // Temp workspace dir re-created automatically see precompile:tests script in package.json 17 | const tmpWorkspaceDir = path.join(extensionDevelopmentPath, 'tmp', 'test-workspace'); 18 | 19 | // Download VS Code, unzip it and run the integration test 20 | await runTests({ 21 | extensionDevelopmentPath, 22 | extensionTestsPath, 23 | launchArgs: [tmpWorkspaceDir, '--disable-extensions', '--disable-workspace-trust'], 24 | }); 25 | } catch (err) { 26 | console.error('Failed to run tests'); 27 | process.exit(1); 28 | } 29 | } 30 | 31 | main(); 32 | -------------------------------------------------------------------------------- /src/test/testRunner.ts: -------------------------------------------------------------------------------- 1 | import { runCLI } from '@jest/core'; 2 | import { AggregatedResult } from '@jest/test-result'; 3 | import util from 'util'; 4 | import path from 'path'; 5 | 6 | const getFailureMessages = (results: AggregatedResult): string[] | undefined => { 7 | const failures = results.testResults.reduce( 8 | (acc, { failureMessage }) => (failureMessage ? [...acc, failureMessage] : acc), 9 | [], 10 | ); 11 | 12 | return failures.length > 0 ? failures : undefined; 13 | }; 14 | 15 | const rootDir = path.resolve(__dirname, '../..'); 16 | 17 | // vscode uses special mechanism to require node modules which interferes with jest-runtime and mocks functionality 18 | // see https://bitly.com/ and https://bit.ly/3EmrV7c 19 | const fixVscodeRuntime = () => { 20 | const globalTyped = global as unknown as { _VSCODE_NODE_MODULES: typeof Proxy }; 21 | 22 | if (globalTyped._VSCODE_NODE_MODULES && util.types.isProxy(globalTyped._VSCODE_NODE_MODULES)) { 23 | globalTyped._VSCODE_NODE_MODULES = new Proxy(globalTyped._VSCODE_NODE_MODULES, { 24 | get(target, prop, receiver) { 25 | if (prop === '_isMockFunction') { 26 | return false; 27 | } 28 | 29 | return Reflect.get(target, prop, receiver); 30 | }, 31 | }); 32 | } 33 | }; 34 | 35 | export function run(): Promise { 36 | fixVscodeRuntime(); 37 | 38 | process.stderr.write = (buffer: string) => { 39 | // ideally console.error should be used, but not possible due to stack overflow and how console methods are patched in vscode, see http://bit.ly/3vilufz 40 | // using original stdout/stderr not possible either, see this issue https://github.com/microsoft/vscode/issues/74173 41 | // child process simply swallows logs on using stdout/stderr.write, so parent process can't intercept test results. 42 | console.log(buffer); 43 | return true; 44 | }; 45 | 46 | process.env.NODE_ENV = 'test'; 47 | process.env.DISABLE_FILE_WATCHER = 'true'; 48 | 49 | return new Promise(async (resolve, reject) => { 50 | try { 51 | const { results } = await runCLI( 52 | { 53 | rootDir, 54 | roots: ['/src'], 55 | verbose: true, 56 | colors: true, 57 | transform: JSON.stringify({ 58 | '\\.ts$': ['@swc/jest'], 59 | }), 60 | runInBand: true, 61 | testRegex: process.env.JEST_TEST_REGEX || '\\.(test|spec)\\.ts$', 62 | testEnvironment: '/src/test/env/VsCodeEnvironment.js', 63 | setupFiles: ['/src/test/config/jestSetup.ts'], 64 | setupFilesAfterEnv: ['jest-extended/all'], 65 | ci: process.env.JEST_CI === 'true', 66 | testTimeout: 30000, 67 | watch: process.env.JEST_WATCH === 'true', 68 | collectCoverage: process.env.JEST_COLLECT_COVERAGE === 'true', 69 | useStderr: true, 70 | // Jest's runCLI requires special args to pass.. 71 | _: [], 72 | $0: '', 73 | }, 74 | [rootDir], 75 | ); 76 | 77 | const failureMessages = getFailureMessages(results); 78 | 79 | if (failureMessages?.length) { 80 | return reject(`${failureMessages?.length} tests failed!`); 81 | } 82 | 83 | return resolve(); 84 | } catch (error) { 85 | return reject(error); 86 | } 87 | }); 88 | } 89 | -------------------------------------------------------------------------------- /src/test/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils'; 2 | -------------------------------------------------------------------------------- /src/test/utils/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import { createFile, rndName, cleanWorkspace, closeEditorsAndCleanWorkspace } from './utils'; 4 | 5 | describe('cleanWorkspace()', () => { 6 | beforeEach(closeEditorsAndCleanWorkspace); 7 | 8 | afterEach(closeEditorsAndCleanWorkspace); 9 | 10 | it('should clean workspace after adding a new file', async () => { 11 | const uri = await createFile(`${rndName()}.md`); 12 | 13 | expect(fs.existsSync(uri!.fsPath)).toBe(true); 14 | 15 | await cleanWorkspace(); 16 | 17 | expect(fs.existsSync(uri!.fsPath)).toBe(false); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/test/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import del from 'del'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { workspace, Uri, commands, ConfigurationTarget } from 'vscode'; 5 | export { default as waitForExpect } from 'wait-for-expect'; 6 | 7 | import { cache } from '../../workspace'; 8 | import * as utils from '../../utils'; 9 | import { WorkspaceCache } from '../../types'; 10 | 11 | const { 12 | getWorkspaceFolder, 13 | getImgUrlForMarkdownPreview, 14 | getFileUrlForMarkdownPreview, 15 | escapeForRegExp, 16 | getMemoConfigProperty, 17 | } = utils; 18 | 19 | export { 20 | getMemoConfigProperty, 21 | getWorkspaceFolder, 22 | getImgUrlForMarkdownPreview, 23 | getFileUrlForMarkdownPreview, 24 | escapeForRegExp, 25 | }; 26 | 27 | export const cleanWorkspace = () => { 28 | const workspaceFolder = utils.getWorkspaceFolder(); 29 | 30 | if (workspaceFolder) { 31 | del.sync(['**/!(.vscode)'], { 32 | force: true, 33 | cwd: workspaceFolder, 34 | }); 35 | } 36 | }; 37 | 38 | export const cacheWorkspace = async () => { 39 | await cache.cacheWorkspace(); 40 | await commands.executeCommand('_memo.cacheWorkspace'); 41 | }; 42 | 43 | export const cleanWorkspaceCache = async () => { 44 | cache.cleanWorkspaceCache(); 45 | await commands.executeCommand('_memo.cleanWorkspaceCache'); 46 | }; 47 | 48 | export const createFile = async ( 49 | filename: string, 50 | content: string = '', 51 | syncCache: boolean = true, 52 | ): Promise => { 53 | const workspaceFolder = utils.getWorkspaceFolder(); 54 | 55 | if (!workspaceFolder) { 56 | return; 57 | } 58 | 59 | const filepath = path.join(workspaceFolder, ...filename.split('/')); 60 | const dirname = path.dirname(filepath); 61 | 62 | utils.ensureDirectoryExists(filepath); 63 | 64 | if (!fs.existsSync(dirname)) { 65 | throw new Error(`Directory ${dirname} does not exist`); 66 | } 67 | 68 | fs.writeFileSync(filepath, content); 69 | 70 | if (syncCache) { 71 | await cacheWorkspace(); 72 | } 73 | 74 | return Uri.file(path.join(workspaceFolder, ...filename.split('/'))); 75 | }; 76 | 77 | export const fileExists = (filename: string) => { 78 | const workspaceFolder = utils.getWorkspaceFolder(); 79 | 80 | if (!workspaceFolder) { 81 | return; 82 | } 83 | 84 | const filepath = path.join(workspaceFolder, ...filename.split('/')); 85 | 86 | return fs.existsSync(filepath); 87 | }; 88 | 89 | export const removeFile = (filename: string) => 90 | fs.unlinkSync(path.join(utils.getWorkspaceFolder()!, ...filename.split('/'))); 91 | 92 | export const rndName = (): string => { 93 | const name = Math.random() 94 | .toString(36) 95 | .replace(/[^a-z]+/g, '') 96 | .substr(0, 5); 97 | 98 | return name.length !== 5 ? rndName() : name; 99 | }; 100 | 101 | export const openTextDocument = (filename: string) => { 102 | const filePath = path.join(utils.getWorkspaceFolder()!, filename); 103 | 104 | if (!fs.existsSync(filePath)) { 105 | throw new Error(`File ${filePath} does not exist`); 106 | } 107 | 108 | return workspace.openTextDocument(filePath); 109 | }; 110 | 111 | export const getOpenedFilenames = () => 112 | workspace.textDocuments.map(({ uri: { fsPath } }) => path.basename(fsPath)); 113 | 114 | export const getOpenedPaths = () => workspace.textDocuments.map(({ uri: { fsPath } }) => fsPath); 115 | 116 | export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 117 | 118 | export const closeAllEditors = async () => { 119 | await commands.executeCommand('workbench.action.closeAllEditors'); 120 | await delay(100); 121 | }; 122 | 123 | const getDefaultConfigProperties = (): { 124 | default?: any; 125 | scope?: string; 126 | description?: string; 127 | type?: string; 128 | }[] => { 129 | return require('../../../package.json').contributes.configuration.properties; 130 | }; 131 | 132 | export const updateConfigProperty = async ( 133 | property: string, 134 | value: unknown, 135 | target = ConfigurationTarget.Workspace, 136 | ) => { 137 | await workspace.getConfiguration().update(property, value, target); 138 | }; 139 | 140 | export const updateMemoConfigProperty = async (property: string, value: unknown) => 141 | await updateConfigProperty(`memo.${property}`, value); 142 | 143 | const resetMemoConfigProps = async () => 144 | await Promise.all( 145 | Object.entries(getDefaultConfigProperties()).map(([propName, propConfig]) => 146 | propConfig.default !== undefined 147 | ? updateConfigProperty(propName, propConfig.default) 148 | : undefined, 149 | ), 150 | ); 151 | 152 | export const closeEditorsAndCleanWorkspace = async () => { 153 | await resetMemoConfigProps(); 154 | await closeAllEditors(); 155 | cleanWorkspace(); 156 | await cleanWorkspaceCache(); 157 | }; 158 | 159 | export const getWorkspaceCache = async (): Promise => 160 | (await commands.executeCommand('_memo.getWorkspaceCache')) as WorkspaceCache; 161 | 162 | export const toPlainObject = (value: unknown): R => 163 | value !== undefined ? JSON.parse(JSON.stringify(value)) : value; 164 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Uri, Location, Position } from 'vscode'; 2 | 3 | export type WorkspaceCache = { 4 | imageUris: Uri[]; 5 | markdownUris: Uri[]; 6 | otherUris: Uri[]; 7 | allUris: Uri[]; 8 | danglingRefs: string[]; 9 | danglingRefsByFsPath: { [key: string]: string[] }; 10 | }; 11 | 12 | export type RefT = { 13 | label: string; 14 | ref: string; 15 | }; 16 | 17 | export type FoundRefT = { 18 | location: Location; 19 | matchText: string; 20 | }; 21 | 22 | export type ExtractedRefT = { 23 | ref: { 24 | position: { start: Position; end: Position }; 25 | text: string; 26 | }; 27 | line: { 28 | trailingText: string; 29 | }; 30 | }; 31 | 32 | export type LinkRuleT = { 33 | rule: string; 34 | comment?: string; 35 | folder: string; 36 | }; 37 | -------------------------------------------------------------------------------- /src/utils/clipboardUtils.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import { spawn } from 'child_process'; 4 | import { inspect } from 'util'; 5 | import fs from 'fs-extra'; 6 | 7 | /* These utils borrowed from https://github.com/andrewdotn/2md */ 8 | 9 | export function run(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> { 10 | const proc = spawn(cmd, args, { 11 | stdio: ['ignore', 'pipe', 'pipe'], 12 | }); 13 | 14 | const output = { stdout: '', stderr: '' }; 15 | 16 | proc.stdio[1]!.on('data', (data) => (output.stdout += data)); 17 | proc.stdio[2]!.on('data', (data) => (output.stderr += data)); 18 | 19 | return new Promise((resolve, reject) => { 20 | proc.on('error', (e) => reject(e)); 21 | proc.on('exit', (code, signal) => { 22 | if (code === 1 && /\(-1700\)$/m.test(output.stderr)) { 23 | return reject(new Error('The clipboard does not currently contain HTML-formatted data.')); 24 | } 25 | 26 | if (code !== 0 || signal) { 27 | return reject( 28 | new Error(`${inspect(cmd)} returned [${code}, ${signal}]; output was ${inspect(output)}`), 29 | ); 30 | } 31 | 32 | return resolve(output); 33 | }); 34 | }); 35 | } 36 | 37 | async function readClipboardMac() { 38 | const osaOutput = await run('osascript', ['-e', 'the clipboard as «class HTML»']); 39 | const hexEncodedHtml = osaOutput.stdout; 40 | const match = /^«data HTML((?:[0-9A-F]{2})+)»$\n/m.exec(hexEncodedHtml); 41 | 42 | if (!match) { 43 | throw new Error('Could not parse osascript output'); 44 | } 45 | 46 | return Buffer.from(match[1], 'hex').toString(); 47 | } 48 | 49 | async function readClipboardUnix() { 50 | const output = await run('xclip', ['-o', '-selection', 'clipboard', '-t', 'text/html']); 51 | 52 | if ((output.stderr ?? '') !== '') { 53 | throw new Error(`xclip printed an error: ${output.stderr}`); 54 | } 55 | 56 | return output.stdout; 57 | } 58 | 59 | // This does match https://en.wikipedia.org/wiki/Windows-1252 but was computed 60 | // by making a UTF-8 webpage with all characters from U+0000 to U+00FE, copying 61 | // and pasting, and writing some comparison code to see what got mangled. 62 | const cp1252Inverse: { [unicode: number]: number } = { 63 | 0x20ac: 0x80, 64 | 0x201a: 0x82, 65 | 0x192: 0x83, 66 | 0x201e: 0x84, 67 | 0x2026: 0x85, 68 | 0x2020: 0x86, 69 | 0x2021: 0x87, 70 | 0x2c6: 0x88, 71 | 0x2030: 0x89, 72 | 0x160: 0x8a, 73 | 0x2039: 0x8b, 74 | 0x152: 0x8c, 75 | 0x17d: 0x8e, 76 | 0x2018: 0x91, 77 | 0x2019: 0x92, 78 | 0x201c: 0x93, 79 | 0x201d: 0x94, 80 | 0x2022: 0x95, 81 | 0x2013: 0x96, 82 | 0x2014: 0x97, 83 | 0x2dc: 0x98, 84 | 0x2122: 0x99, 85 | 0x161: 0x9a, 86 | 0x203a: 0x9b, 87 | 0x153: 0x9c, 88 | 0x17e: 0x9e, 89 | 0x178: 0x9f, 90 | }; 91 | 92 | /* Turn a UTF-8+cp1252+UTF-16LE+BOM-encoded mess into a UTF-8 string. */ 93 | export function unMojibake(s: Buffer) { 94 | if (s[0] !== 0xff || s[1] !== 0xfe) { 95 | throw new Error('No BOM in clipboard output'); 96 | } 97 | 98 | // Turn UTF-16LE pairs into integers, ignoring endianness for now 99 | const array = new Uint16Array(s.buffer, s.byteOffset + 2, s.length / 2 - 1); 100 | 101 | // The string was UTF-8 encoded before getting the UTF-16 treatment, so 102 | // anything that doesn't fit in 8 bits has been mangled through cp1252. 103 | for (let i = 0; i < array.length; i++) { 104 | if (array[i] > 0xff) { 105 | const v = cp1252Inverse[array[i]]; 106 | 107 | if (v === undefined) { 108 | throw new Error(`Unknown cp1252 code point at ${i}: 0x${array[i].toString(16)}`); 109 | } 110 | 111 | array[i] = v; 112 | } 113 | } 114 | 115 | const decoded = Buffer.from(array); 116 | 117 | return decoded.toString('utf-8'); 118 | } 119 | 120 | async function readClipboardWindows() { 121 | const output = await run('powershell.exe', [ 122 | '-c', 123 | // When printing to the console, the encoding gets even more messed up, so 124 | // we use a temporary file instead. 125 | ` 126 | $tmp = New-TemporaryFile 127 | Get-Clipboard -TextFormatType Html > $tmp 128 | $tmp.ToString() 129 | `, 130 | ]); 131 | const tmpFilename = output.stdout.trim(); 132 | 133 | if ((output.stderr ?? '') !== '') { 134 | if (await fs.pathExists(tmpFilename)) { 135 | fs.unlink(tmpFilename); 136 | } 137 | throw new Error(`Powershell returned an error: ${output.stderr}`); 138 | } 139 | 140 | const tmpfileContent = await fs.readFile(tmpFilename); 141 | 142 | fs.unlink(tmpFilename); 143 | 144 | if (tmpfileContent[0] !== 0xff || tmpfileContent[1] !== 0xfe) { 145 | throw new Error('No BOM in clipboard output'); 146 | } 147 | 148 | const clipboardFormatHtml = unMojibake(tmpfileContent); 149 | const match = /^Version:([0-9]+)\.([0-9]+)\r?\nStartHTML:([0-9]+)/.exec(clipboardFormatHtml); 150 | 151 | if (!match || match.index !== 0) { 152 | throw new Error('Get-Clipboard did not return CF_HTML output'); 153 | } 154 | 155 | const htmlStartIndex = parseInt(match[3]); 156 | 157 | return clipboardFormatHtml.slice(htmlStartIndex); 158 | } 159 | 160 | export async function readClipboard() { 161 | for (const c of [readClipboardMac, readClipboardUnix, readClipboardWindows]) { 162 | try { 163 | return await c(); 164 | } catch (e) { 165 | if ((e as NodeJS.ErrnoException).code === 'ENOENT') { 166 | continue; 167 | } 168 | throw e; 169 | } 170 | } 171 | throw new Error('Unable to find a clipboard-reading program, please try' + ' file input instead'); 172 | } 173 | -------------------------------------------------------------------------------- /src/utils/createDailyQuickPick.spec.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | import createDailyQuickPick from './createDailyQuickPick'; 4 | import { closeEditorsAndCleanWorkspace, createFile } from '../test/utils'; 5 | 6 | describe('createDailyQuickPick()', () => { 7 | beforeEach(closeEditorsAndCleanWorkspace); 8 | 9 | afterEach(closeEditorsAndCleanWorkspace); 10 | 11 | it('should not fail on call', async () => { 12 | expect(createDailyQuickPick).not.toThrow(); 13 | }); 14 | 15 | it.each(['Today', 'Yesterday', 'Tomorrow'])( 16 | 'should contain %s in the item label', 17 | (labelSubstr) => { 18 | const dailyQuickPick = createDailyQuickPick(); 19 | 20 | expect(dailyQuickPick.items.some((item) => item.label.includes(labelSubstr))).toBe(true); 21 | }, 22 | ); 23 | 24 | it('should return 60 items (days) + 1 day (today)', () => { 25 | const dailyQuickPick = createDailyQuickPick(); 26 | 27 | expect(dailyQuickPick.items).toHaveLength(63); 28 | }); 29 | 30 | it('should contain an item with an indicator about note existence', async () => { 31 | const dateInYYYYMMDDFormat = moment().format('YYYY-MM-DD'); 32 | 33 | await createFile(`${dateInYYYYMMDDFormat}.md`); 34 | 35 | const dailyQuickPick = createDailyQuickPick(); 36 | 37 | const quickPickItem = dailyQuickPick.items.find((item) => item.description === 'Exists')!; 38 | 39 | expect(quickPickItem).not.toBeFalsy(); 40 | 41 | expect([...quickPickItem.label][0]).toBe('✓'); 42 | }); 43 | 44 | it('should return all items with an indicator about missing note', async () => { 45 | const dailyQuickPick = createDailyQuickPick(); 46 | 47 | expect(dailyQuickPick.items.every((item) => item.description === 'Missing')).toBe(true); 48 | 49 | expect([...dailyQuickPick.items[0].label][0]).toBe('✕'); 50 | }); 51 | 52 | it('should be able to provide items older than one month', async () => { 53 | await createFile('2000-01-01.md'); 54 | await createFile('2000-01-02.md'); 55 | await createFile('2000-01-03.md'); 56 | 57 | const dailyQuickPick = createDailyQuickPick(); 58 | 59 | const item1 = dailyQuickPick.items.find((item) => item.detail === '2000-01-01')!; 60 | const item2 = dailyQuickPick.items.find((item) => item.detail === '2000-01-02')!; 61 | const item3 = dailyQuickPick.items.find((item) => item.detail === '2000-01-03')!; 62 | 63 | expect(dailyQuickPick.items).toHaveLength(66); 64 | 65 | expect(item1).not.toBeFalsy(); 66 | expect(item2).not.toBeFalsy(); 67 | expect(item3).not.toBeFalsy(); 68 | }); 69 | 70 | it('should be able to provide items newer than one month', async () => { 71 | const now = moment(); 72 | 73 | const note1 = `${now.clone().add(60, 'days').format('YYYY-MM-DD')}`; 74 | const note2 = `${now.clone().add(61, 'days').format('YYYY-MM-DD')}`; 75 | const note3 = `${now.clone().add(62, 'days').format('YYYY-MM-DD')}`; 76 | 77 | await createFile(`${note1}.md`); 78 | await createFile(`${note2}.md`); 79 | await createFile(`${note3}.md`); 80 | 81 | const dailyQuickPick = createDailyQuickPick(); 82 | 83 | const item1 = dailyQuickPick.items.find((item) => item.detail === note1)!; 84 | const item2 = dailyQuickPick.items.find((item) => item.detail === note2)!; 85 | const item3 = dailyQuickPick.items.find((item) => item.detail === note3)!; 86 | 87 | expect(dailyQuickPick.items).toHaveLength(66); 88 | 89 | expect(item1).not.toBeFalsy(); 90 | expect(item2).not.toBeFalsy(); 91 | expect(item3).not.toBeFalsy(); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/utils/createDailyQuickPick.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import range from 'lodash.range'; 3 | import path from 'path'; 4 | import { window } from 'vscode'; 5 | 6 | import { findUriByRef } from './utils'; 7 | import { cache } from '../workspace'; 8 | 9 | const toOffsetLabel = (dayOffset: number) => { 10 | if (dayOffset === -1) { 11 | return '-1 day Yesterday'; 12 | } else if (dayOffset === 0) { 13 | return 'Today'; 14 | } else if (dayOffset === 1) { 15 | return '+1 day Tomorrow'; 16 | } 17 | 18 | return `${dayOffset > 0 ? '+' : ''}${dayOffset} days`; 19 | }; 20 | 21 | const yyyymmddRegExp = /([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))/; 22 | 23 | const createDailyQuickPick = () => { 24 | const now = moment().startOf('day'); 25 | const allDailyDates = cache 26 | .getWorkspaceCache() 27 | .markdownUris.map((uri) => path.parse(uri.fsPath).name) 28 | .filter((name) => yyyymmddRegExp.exec(name) && moment(name).isValid()); 29 | const existingDayOffsets = allDailyDates.map((dateStr) => 30 | moment(dateStr).startOf('day').diff(now, 'days'), 31 | ); 32 | const pastDayOffsets = existingDayOffsets 33 | .filter((dayOffset) => dayOffset <= -32) 34 | .sort((a, b) => b - a); 35 | const futureDayOffsets = existingDayOffsets 36 | .filter((dayOffset) => dayOffset >= 32) 37 | .sort((a, b) => a - b); 38 | 39 | const dayOffsets = [ 40 | 0, // Today 41 | 1, // Tomorrow 42 | -1, // Yesterday 43 | ...range(2, 32), // Next month 44 | ...futureDayOffsets, 45 | ...range(-2, -32), // Prev month 46 | ...pastDayOffsets, 47 | ]; 48 | const quickPick = window.createQuickPick(); 49 | 50 | quickPick.matchOnDescription = true; 51 | quickPick.matchOnDetail = true; 52 | 53 | quickPick.items = dayOffsets.map((dayOffset) => { 54 | const date = now.clone().add(dayOffset, 'day'); 55 | const dateYYYYMMDD = date.format('YYYY-MM-DD'); 56 | const ref = findUriByRef(cache.getWorkspaceCache().markdownUris, dateYYYYMMDD); 57 | 58 | return { 59 | label: `${ref ? '✓' : '✕'} ${toOffsetLabel(dayOffset)} | ${date.format( 60 | 'dddd, MMMM D, YYYY', 61 | )}`, 62 | description: ref ? 'Exists' : 'Missing', 63 | detail: dateYYYYMMDD, 64 | }; 65 | }); 66 | 67 | return quickPick; 68 | }; 69 | 70 | export default createDailyQuickPick; 71 | -------------------------------------------------------------------------------- /src/utils/externalUtils.spec.ts: -------------------------------------------------------------------------------- 1 | import { window, workspace } from 'vscode'; 2 | import fs from 'fs'; 3 | 4 | import { 5 | lineBreakOffsetsByLineIndex, 6 | positionToOffset, 7 | isInFencedCodeBlock, 8 | isInCodeSpan, 9 | isMdEditor, 10 | isFileTooLarge, 11 | cleanFileSizesCache, 12 | } from './externalUtils'; 13 | import { 14 | closeEditorsAndCleanWorkspace, 15 | createFile, 16 | openTextDocument, 17 | rndName, 18 | } from '../test/utils'; 19 | 20 | describe('lineBreakOffsetsByLineIndex()', () => { 21 | it('should return offset for a single empty line', () => { 22 | expect(lineBreakOffsetsByLineIndex('')).toEqual([1]); 23 | }); 24 | 25 | it('should return offset for multiline string', () => { 26 | expect(lineBreakOffsetsByLineIndex('test\r\ntest\rtest\ntest')).toEqual([6, 16, 21]); 27 | }); 28 | }); 29 | 30 | describe('positionToOffset()', () => { 31 | it('should through with illegal arguments', () => { 32 | expect(() => positionToOffset('', { line: -1, column: 0 })).toThrowError(); 33 | expect(() => positionToOffset('', { line: 0, column: -1 })).toThrowError(); 34 | }); 35 | 36 | it('should transform position to offset', () => { 37 | expect(positionToOffset('', { line: 0, column: 0 })).toEqual(0); 38 | expect(positionToOffset('test\r\ntest\rtest\ntest', { line: 1, column: 0 })).toEqual(6); 39 | expect(positionToOffset('test\r\ntest\rtest\ntest', { line: 2, column: 0 })).toEqual(16); 40 | }); 41 | 42 | it('should handle line and column overflow properly', () => { 43 | expect(positionToOffset('', { line: 10, column: 10 })).toEqual(0); 44 | }); 45 | }); 46 | 47 | describe('isInFencedCodeBlock()', () => { 48 | it('should return false when within outside of fenced code block', async () => { 49 | expect(isInFencedCodeBlock('\n```Fenced code block```', 0)).toBe(false); 50 | }); 51 | 52 | it('should return true when within fenced code block', async () => { 53 | expect(isInFencedCodeBlock(`\n\`\`\`\nFenced code block\n\`\`\``, 2)).toBe(true); 54 | }); 55 | }); 56 | 57 | describe('isInCodeSpan()', () => { 58 | it('should return false when outside of code span', async () => { 59 | expect(isInCodeSpan(' `test`', 0, 0)).toBe(false); 60 | }); 61 | 62 | it('should return true when within code span', async () => { 63 | expect(isInCodeSpan('`test`', 0, 1)).toBe(true); 64 | }); 65 | 66 | it('should return true when within code span on the next line', async () => { 67 | expect(isInCodeSpan('\n`test`', 1, 1)).toBe(true); 68 | }); 69 | }); 70 | 71 | describe('isMdEditor()', () => { 72 | beforeEach(closeEditorsAndCleanWorkspace); 73 | 74 | afterEach(closeEditorsAndCleanWorkspace); 75 | 76 | it('should return false when editor is not markdown', async () => { 77 | const doc = await workspace.openTextDocument({ language: 'html', content: '' }); 78 | const editor = await window.showTextDocument(doc); 79 | 80 | expect(isMdEditor(editor)).toBe(false); 81 | }); 82 | 83 | it('should return true when editor is for markdown', async () => { 84 | const doc = await workspace.openTextDocument({ language: 'markdown', content: '' }); 85 | const editor = await window.showTextDocument(doc); 86 | 87 | expect(isMdEditor(editor)).toBe(true); 88 | }); 89 | }); 90 | 91 | describe('isFileTooLarge()', () => { 92 | beforeEach(closeEditorsAndCleanWorkspace); 93 | 94 | afterEach(closeEditorsAndCleanWorkspace); 95 | 96 | it('should return false when editor language other than markdown', async () => { 97 | const doc = await workspace.openTextDocument({ language: 'html', content: '' }); 98 | const editor = await window.showTextDocument(doc); 99 | 100 | expect(isMdEditor(editor)).toBe(false); 101 | }); 102 | 103 | it('should return true when editor language is markdown', async () => { 104 | const doc = await workspace.openTextDocument({ language: 'markdown', content: '' }); 105 | const editor = await window.showTextDocument(doc); 106 | 107 | expect(isMdEditor(editor)).toBe(true); 108 | }); 109 | }); 110 | 111 | describe('isFileTooLarge()', () => { 112 | beforeEach(async () => { 113 | await closeEditorsAndCleanWorkspace(); 114 | cleanFileSizesCache(); 115 | }); 116 | 117 | afterEach(async () => { 118 | await closeEditorsAndCleanWorkspace(); 119 | cleanFileSizesCache(); 120 | }); 121 | 122 | it('should return false if file does not exist', async () => { 123 | const doc = await workspace.openTextDocument({ content: '' }); 124 | expect(isFileTooLarge(doc)).toBe(false); 125 | }); 126 | 127 | it('should not call statSync and use cached file size', async () => { 128 | const name = rndName(); 129 | 130 | await createFile(`${name}.md`, 'test'); 131 | 132 | const doc = await openTextDocument(`${name}.md`); 133 | 134 | const fsStatSyncSpy = jest.spyOn(fs, 'statSync'); 135 | 136 | isFileTooLarge(doc, 100); 137 | 138 | expect(fsStatSyncSpy).toBeCalledTimes(1); 139 | 140 | fsStatSyncSpy.mockClear(); 141 | 142 | isFileTooLarge(doc, 100); 143 | 144 | expect(fsStatSyncSpy).not.toBeCalled(); 145 | 146 | fsStatSyncSpy.mockRestore(); 147 | }); 148 | 149 | it('should return false when file is not too large', async () => { 150 | const name = rndName(); 151 | 152 | await createFile(`${name}.md`, 'test'); 153 | 154 | const doc = await openTextDocument(`${name}.md`); 155 | 156 | expect(isFileTooLarge(doc, 100)).toBe(false); 157 | }); 158 | 159 | it('should return true when file is too large', async () => { 160 | const name = rndName(); 161 | 162 | await createFile(`${name}.md`, 'test'.repeat(10)); 163 | 164 | const doc = await openTextDocument(`${name}.md`); 165 | 166 | expect(isFileTooLarge(doc, 35)).toBe(true); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /src/utils/externalUtils.ts: -------------------------------------------------------------------------------- 1 | import { Position, Range, TextDocument, TextEditor } from 'vscode'; 2 | import fs from 'fs'; 3 | 4 | /* 5 | Some of these utils borrowed from https://github.com/yzhang-gh/vscode-markdown 6 | */ 7 | 8 | export function isMdEditor(editor: TextEditor) { 9 | return editor && editor.document && editor.document.languageId === 'markdown'; 10 | } 11 | 12 | export const REGEX_FENCED_CODE_BLOCK = /^( {0,3}|\t)```[^`\r\n]*$[\w\W]+?^( {0,3}|\t)``` *$/gm; 13 | 14 | const REGEX_CODE_SPAN = /`[^`]*?`/gm; 15 | 16 | export const lineBreakOffsetsByLineIndex = (value: string): number[] => { 17 | const result = []; 18 | let index = value.indexOf('\n'); 19 | 20 | while (index !== -1) { 21 | result.push(index + 1); 22 | index = value.indexOf('\n', index + 1); 23 | } 24 | 25 | result.push(value.length + 1); 26 | 27 | return result; 28 | }; 29 | 30 | export const positionToOffset = (content: string, position: { line: number; column: number }) => { 31 | if (position.line < 0) { 32 | throw new Error('Illegal argument: line must be non-negative'); 33 | } 34 | 35 | if (position.column < 0) { 36 | throw new Error('Illegal argument: column must be non-negative'); 37 | } 38 | 39 | const lineBreakOffsetsByIndex = lineBreakOffsetsByLineIndex(content); 40 | if (lineBreakOffsetsByIndex[position.line] !== undefined) { 41 | return (lineBreakOffsetsByIndex[position.line - 1] || 0) + position.column || 0; 42 | } 43 | 44 | return 0; 45 | }; 46 | 47 | export const isInFencedCodeBlock = ( 48 | documentOrContent: TextDocument | string, 49 | lineNum: number, 50 | ): boolean => { 51 | const content = 52 | typeof documentOrContent === 'string' ? documentOrContent : documentOrContent.getText(); 53 | const textBefore = content 54 | .slice(0, positionToOffset(content, { line: lineNum, column: 0 })) 55 | .replace(REGEX_FENCED_CODE_BLOCK, '') 56 | .replace(//g, ''); 57 | // So far `textBefore` should contain no valid fenced code block or comment 58 | return /^( {0,3}|\t)```[^`\r\n]*$[\w\W]*$/gm.test(textBefore); 59 | }; 60 | 61 | export const isInCodeSpan = ( 62 | documentOrContent: TextDocument | string, 63 | lineNum: number, 64 | offset: number, 65 | ): boolean => { 66 | const content = 67 | typeof documentOrContent === 'string' ? documentOrContent : documentOrContent.getText(); 68 | const textBefore = content 69 | .slice(0, positionToOffset(content, { line: lineNum, column: offset })) 70 | .replace(REGEX_CODE_SPAN, '') 71 | .trim(); 72 | 73 | return /`[^`]*$/gm.test(textBefore); 74 | }; 75 | 76 | export const mathEnvCheck = (doc: TextDocument, pos: Position): string => { 77 | const lineTextBefore = doc.lineAt(pos.line).text.substring(0, pos.character); 78 | const lineTextAfter = doc.lineAt(pos.line).text.substring(pos.character); 79 | 80 | if (/(^|[^\$])\$(|[^ \$].*)\\\w*$/.test(lineTextBefore) && lineTextAfter.includes('$')) { 81 | // Inline math 82 | return 'inline'; 83 | } else { 84 | const textBefore = doc.getText(new Range(new Position(0, 0), pos)); 85 | const textAfter = doc.getText().substr(doc.offsetAt(pos)); 86 | let matches; 87 | if ( 88 | (matches = textBefore.match(/\$\$/g)) !== null && 89 | matches.length % 2 !== 0 && 90 | textAfter.includes('$$') 91 | ) { 92 | // $$ ... $$ 93 | return 'display'; 94 | } else { 95 | return ''; 96 | } 97 | } 98 | }; 99 | 100 | let fileSizesCache: { [path: string]: [number, boolean] } = {}; 101 | 102 | export const cleanFileSizesCache = () => { 103 | fileSizesCache = {}; 104 | }; 105 | 106 | export const isFileTooLarge = ( 107 | document: TextDocument, 108 | sizeLimit: number = 50000 /* ~50 KB */, 109 | ): boolean => { 110 | const filePath = document.uri.fsPath; 111 | if (!filePath || !fs.existsSync(filePath)) { 112 | return false; 113 | } 114 | const version = document.version; 115 | if (fileSizesCache.hasOwnProperty(filePath) && fileSizesCache[filePath][0] === version) { 116 | return fileSizesCache[filePath][1]; 117 | } else { 118 | const isTooLarge = fs.statSync(filePath)['size'] > sizeLimit; 119 | fileSizesCache[filePath] = [version, isTooLarge]; 120 | return isTooLarge; 121 | } 122 | }; 123 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { sort as sortPaths } from 'cross-path-sort'; 2 | 3 | import { default as createDailyQuickPick } from './createDailyQuickPick'; 4 | import { readClipboard } from './clipboardUtils'; 5 | 6 | export { sortPaths, createDailyQuickPick, readClipboard }; 7 | 8 | export * from './utils'; 9 | export * from './externalUtils'; 10 | export * from './replaceUtils'; 11 | export * from './searchUtils'; 12 | -------------------------------------------------------------------------------- /src/utils/replaceUtils.spec.ts: -------------------------------------------------------------------------------- 1 | import { workspace } from 'vscode'; 2 | 3 | import { replaceRefsInDoc } from './replaceUtils'; 4 | 5 | describe('replaceRefsInDoc()', () => { 6 | it('should return null if nothing to replace', async () => { 7 | expect( 8 | replaceRefsInDoc( 9 | [{ old: 'test-ref', new: 'new-test-ref' }], 10 | await workspace.openTextDocument({ 11 | language: 'markdown', 12 | content: '[[some-ref]]', 13 | }), 14 | ), 15 | ).toBe(null); 16 | }); 17 | 18 | it('should replace short ref with short ref', async () => { 19 | expect( 20 | replaceRefsInDoc( 21 | [{ old: 'test-ref', new: 'new-test-ref' }], 22 | await workspace.openTextDocument({ 23 | language: 'markdown', 24 | content: '[[test-ref]]', 25 | }), 26 | ), 27 | ).toBe('[[new-test-ref]]'); 28 | }); 29 | 30 | it('should replace short ref with label with short ref with label', async () => { 31 | expect( 32 | replaceRefsInDoc( 33 | [{ old: 'test-ref', new: 'new-test-ref' }], 34 | await workspace.openTextDocument({ 35 | language: 'markdown', 36 | content: '[[test-ref|Test Label]]', 37 | }), 38 | ), 39 | ).toBe('[[new-test-ref|Test Label]]'); 40 | }); 41 | 42 | it('should replace long ref with long ref', async () => { 43 | expect( 44 | replaceRefsInDoc( 45 | [{ old: 'folder1/test-ref', new: 'folder1/new-test-ref' }], 46 | await workspace.openTextDocument({ 47 | language: 'markdown', 48 | content: '[[folder1/test-ref]]', 49 | }), 50 | ), 51 | ).toBe('[[folder1/new-test-ref]]'); 52 | }); 53 | 54 | it('should replace long ref with long ref', async () => { 55 | expect( 56 | replaceRefsInDoc( 57 | [{ old: 'folder1/test-ref', new: 'folder1/new-test-ref' }], 58 | await workspace.openTextDocument({ 59 | language: 'markdown', 60 | content: '[[folder1/test-ref]]', 61 | }), 62 | ), 63 | ).toBe('[[folder1/new-test-ref]]'); 64 | }); 65 | 66 | it('should replace long ref + label with long ref + label', async () => { 67 | expect( 68 | replaceRefsInDoc( 69 | [{ old: 'folder1/test-ref', new: 'folder1/new-test-ref' }], 70 | await workspace.openTextDocument({ 71 | language: 'markdown', 72 | content: '[[folder1/test-ref|Test Label]]', 73 | }), 74 | ), 75 | ).toBe('[[folder1/new-test-ref|Test Label]]'); 76 | }); 77 | 78 | it('should replace long ref + label with short ref + label', async () => { 79 | expect( 80 | replaceRefsInDoc( 81 | [{ old: 'folder1/test-ref', new: 'new-test-ref' }], 82 | await workspace.openTextDocument({ 83 | language: 'markdown', 84 | content: '[[folder1/test-ref|Test Label]]', 85 | }), 86 | ), 87 | ).toBe('[[new-test-ref|Test Label]]'); 88 | }); 89 | 90 | it('should replace short ref + label with long ref + label', async () => { 91 | expect( 92 | replaceRefsInDoc( 93 | [{ old: 'test-ref', new: 'folder1/new-test-ref' }], 94 | await workspace.openTextDocument({ 95 | language: 'markdown', 96 | content: '[[test-ref|Test Label]]', 97 | }), 98 | ), 99 | ).toBe('[[folder1/new-test-ref|Test Label]]'); 100 | }); 101 | 102 | it('should replace short ref with short ref with unknown extension', async () => { 103 | expect( 104 | replaceRefsInDoc( 105 | [{ old: 'test-ref', new: 'new-test-ref.unknown' }], 106 | await workspace.openTextDocument({ 107 | language: 'markdown', 108 | content: '[[test-ref]]', 109 | }), 110 | ), 111 | ).toBe('[[new-test-ref.unknown]]'); 112 | }); 113 | 114 | it('should replace short ref with unknown extension with short ref ', async () => { 115 | expect( 116 | replaceRefsInDoc( 117 | [{ old: 'test-ref.unknown', new: 'new-test-ref' }], 118 | await workspace.openTextDocument({ 119 | language: 'markdown', 120 | content: '[[test-ref.unknown]]', 121 | }), 122 | ), 123 | ).toBe('[[new-test-ref]]'); 124 | }); 125 | 126 | it('should replace long ref with short ref with unknown extension', async () => { 127 | expect( 128 | replaceRefsInDoc( 129 | [{ old: 'folder1/test-ref', new: 'new-test-ref.unknown' }], 130 | await workspace.openTextDocument({ 131 | language: 'markdown', 132 | content: '[[folder1/test-ref]]', 133 | }), 134 | ), 135 | ).toBe('[[new-test-ref.unknown]]'); 136 | }); 137 | 138 | it('should replace long ref with unknown extension with short ref ', async () => { 139 | expect( 140 | replaceRefsInDoc( 141 | [{ old: 'folder1/test-ref.unknown', new: 'new-test-ref' }], 142 | await workspace.openTextDocument({ 143 | language: 'markdown', 144 | content: '[[folder1/test-ref.unknown]]', 145 | }), 146 | ), 147 | ).toBe('[[new-test-ref]]'); 148 | }); 149 | 150 | it('should not replace ref within code span', async () => { 151 | const doc = await workspace.openTextDocument({ 152 | language: 'markdown', 153 | content: '`[[test-ref]]`', 154 | }); 155 | 156 | expect(replaceRefsInDoc([{ old: 'test-ref', new: 'new-test-ref' }], doc)).toBe( 157 | '`[[test-ref]]`', 158 | ); 159 | }); 160 | 161 | it('should not replace ref within code span 2', async () => { 162 | const content = ` 163 | Preceding text 164 | \`[[test-ref]]\` 165 | Following text 166 | `; 167 | const doc = await workspace.openTextDocument({ 168 | language: 'markdown', 169 | content: content, 170 | }); 171 | 172 | expect(replaceRefsInDoc([{ old: 'test-ref', new: 'new-test-ref' }], doc)).toBe(content); 173 | }); 174 | 175 | it('should not replace ref within fenced code block', async () => { 176 | const initialContent = ` 177 | \`\`\` 178 | Preceding text 179 | [[test-ref]] 180 | Following text 181 | \`\`\` 182 | `; 183 | 184 | const doc = await workspace.openTextDocument({ 185 | language: 'markdown', 186 | content: initialContent, 187 | }); 188 | 189 | expect(replaceRefsInDoc([{ old: 'test-ref', new: 'new-test-ref' }], doc)).toBe(initialContent); 190 | }); 191 | 192 | it('should replace multiple links at once', async () => { 193 | expect( 194 | replaceRefsInDoc( 195 | [ 196 | { old: 'test-ref', new: 'folder2/new-test-ref' }, 197 | { old: 'folder1/test-ref', new: 'folder2/new-test-ref' }, 198 | ], 199 | await workspace.openTextDocument({ 200 | language: 'markdown', 201 | content: '[[test-ref]] [[folder1/test-ref|Test Label]]', 202 | }), 203 | ), 204 | ).toBe('[[folder2/new-test-ref]] [[folder2/new-test-ref|Test Label]]'); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /src/utils/replaceUtils.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { Uri, TextDocument } from 'vscode'; 3 | import groupBy from 'lodash.groupby'; 4 | import { sort as sortPaths } from 'cross-path-sort'; 5 | 6 | import { cache } from '../workspace'; 7 | import { 8 | fsPathToRef, 9 | getWorkspaceFolder, 10 | containsMarkdownExt, 11 | isDefined, 12 | getMemoConfigProperty, 13 | findAllUrisWithUnknownExts, 14 | escapeForRegExp, 15 | } from './utils'; 16 | import { isInCodeSpan, isInFencedCodeBlock } from './externalUtils'; 17 | import { search, refsToSearchRegExpStr } from './searchUtils'; 18 | 19 | type RenamedFile = { 20 | readonly oldUri: Uri; 21 | readonly newUri: Uri; 22 | }; 23 | 24 | type RefsReplaceEntry = { old: string; new: string }; 25 | 26 | type RefsReplaceMap = { 27 | [fsPath: string]: RefsReplaceEntry[]; 28 | }; 29 | 30 | const getBasename = (pathParam: string) => path.basename(pathParam).toLowerCase(); 31 | 32 | // Short ref allowed when non-unique filename comes first in the list of sorted uris. 33 | // /a.md - <-- can be referenced via short ref as [[a]], since it comes first according to paths sorting 34 | // /folder1/a.md - can be referenced only via long ref as [[folder1/a]] 35 | // /folder2/subfolder1/a.md - can be referenced only via long ref as [[folder2/subfolder1/a]] 36 | const isFirstUriInGroup = (pathParam: string, urisGroup: Uri[] = []) => 37 | urisGroup.findIndex((uriParam) => uriParam.fsPath === pathParam) === 0; 38 | 39 | export const resolveRefsReplaceMap = async ( 40 | renamedFiles: ReadonlyArray, 41 | ): Promise => { 42 | const linksFormat = getMemoConfigProperty('links.format', 'short'); 43 | 44 | const oldFsPaths = renamedFiles.map(({ oldUri }) => oldUri.fsPath); 45 | 46 | const oldUrisGroupedByBasename = groupBy( 47 | sortPaths( 48 | [ 49 | ...cache.getWorkspaceCache().allUris.filter((uri) => !oldFsPaths.includes(uri.fsPath)), 50 | ...renamedFiles.map(({ oldUri }) => oldUri), 51 | ], 52 | { 53 | pathKey: 'path', 54 | shallowFirst: true, 55 | }, 56 | ), 57 | ({ fsPath }) => path.basename(fsPath).toLowerCase(), 58 | ); 59 | 60 | const newFsPaths = renamedFiles.map(({ newUri }) => newUri.fsPath); 61 | 62 | const allUris = [ 63 | ...cache.getWorkspaceCache().allUris.filter((uri) => !newFsPaths.includes(uri.fsPath)), 64 | ...renamedFiles.map(({ newUri }) => newUri), 65 | ]; 66 | 67 | const urisWithUnknownExts = await findAllUrisWithUnknownExts( 68 | renamedFiles.map(({ newUri }) => newUri), 69 | ); 70 | 71 | const newUris = sortPaths( 72 | [...allUris, ...(urisWithUnknownExts.length ? urisWithUnknownExts : [])], 73 | { 74 | pathKey: 'path', 75 | shallowFirst: true, 76 | }, 77 | ); 78 | 79 | const newUrisGroupedByBasename = groupBy(newUris, ({ fsPath }) => 80 | path.basename(fsPath).toLowerCase(), 81 | ); 82 | 83 | const refsReplaceMap: RefsReplaceMap = {}; 84 | 85 | for (const { oldUri, newUri } of renamedFiles) { 86 | const preserveOldExtension = !containsMarkdownExt(oldUri.fsPath); 87 | const preserveNewExtension = !containsMarkdownExt(newUri.fsPath); 88 | const workspaceFolder = getWorkspaceFolder()!; 89 | const oldShortRef = fsPathToRef({ 90 | path: oldUri.fsPath, 91 | keepExt: preserveOldExtension, 92 | }); 93 | const oldLongRef = fsPathToRef({ 94 | path: oldUri.fsPath, 95 | basePath: workspaceFolder, 96 | keepExt: preserveOldExtension, 97 | }); 98 | const newShortRef = fsPathToRef({ 99 | path: newUri.fsPath, 100 | keepExt: preserveNewExtension, 101 | }); 102 | const newLongRef = fsPathToRef({ 103 | path: newUri.fsPath, 104 | basePath: workspaceFolder, 105 | keepExt: preserveNewExtension, 106 | }); 107 | const oldUriIsShortRef = isFirstUriInGroup( 108 | oldUri.fsPath, 109 | oldUrisGroupedByBasename[getBasename(oldUri.fsPath)], 110 | ); 111 | const newUriIsShortRef = isFirstUriInGroup( 112 | newUri.fsPath, 113 | newUrisGroupedByBasename[getBasename(newUri.fsPath)], 114 | ); 115 | 116 | if (!oldShortRef || !newShortRef || !oldLongRef || !newLongRef) { 117 | return {}; 118 | } 119 | 120 | const fsPaths = await search( 121 | refsToSearchRegExpStr([`[[${oldShortRef}]]`, `[[${oldLongRef}]]`]), 122 | workspaceFolder, 123 | ); 124 | 125 | const searchUris = fsPaths.length 126 | ? newUris.filter(({ fsPath }) => fsPaths.includes(fsPath)) 127 | : newUris; 128 | 129 | for (const { fsPath } of searchUris) { 130 | if (!containsMarkdownExt(fsPath)) { 131 | continue; 132 | } 133 | 134 | if (linksFormat === 'long') { 135 | refsReplaceMap[fsPath] = [ 136 | // when links format = long re-sync short links with the long ones 137 | oldUriIsShortRef ? { old: oldShortRef, new: newLongRef } : undefined, 138 | { old: oldLongRef, new: newLongRef }, 139 | ].filter(isDefined); 140 | } else if (!oldUriIsShortRef && !newUriIsShortRef) { 141 | // replace long ref with long ref 142 | refsReplaceMap[fsPath] = [{ old: oldLongRef, new: newLongRef }]; 143 | } else if (!oldUriIsShortRef && newUriIsShortRef) { 144 | // replace long ref with short ref 145 | refsReplaceMap[fsPath] = [{ old: oldLongRef, new: newShortRef }]; 146 | } else if (oldUriIsShortRef && !newUriIsShortRef) { 147 | // replace short ref with long ref 148 | refsReplaceMap[fsPath] = [{ old: oldShortRef, new: newLongRef }]; 149 | } else { 150 | // replace short ref with the short ref 151 | refsReplaceMap[fsPath] = [ 152 | { old: oldShortRef, new: newShortRef }, 153 | // sync long refs to short ones (might be the case on switching between long & short link formats) 154 | { old: oldLongRef, new: newShortRef }, 155 | ]; 156 | } 157 | } 158 | } 159 | 160 | return refsReplaceMap; 161 | }; 162 | 163 | export const replaceRefsInDoc = ( 164 | refs: RefsReplaceEntry[], 165 | document: TextDocument, 166 | { onMatch, onReplace }: { onMatch?: () => void; onReplace?: () => void } = {}, 167 | ): string | null => { 168 | const content = document.getText(); 169 | 170 | const { updatedOnce, nextContent } = refs.reduce( 171 | ({ updatedOnce, nextContent }, ref) => { 172 | const pattern = `\\[\\[${escapeForRegExp(ref.old)}(\\|.*)?\\]\\]`; 173 | 174 | if (new RegExp(pattern, 'i').exec(content)) { 175 | let replacedOnce = false; 176 | 177 | const content = nextContent.replace(new RegExp(pattern, 'gi'), ($0, $1, offset) => { 178 | const pos = document.positionAt(offset); 179 | 180 | if ( 181 | isInFencedCodeBlock(document, pos.line) || 182 | isInCodeSpan(document, pos.line, pos.character) 183 | ) { 184 | return $0; 185 | } 186 | 187 | if (!replacedOnce) { 188 | onMatch && onMatch(); 189 | } 190 | 191 | onReplace && onReplace(); 192 | 193 | replacedOnce = true; 194 | 195 | return `[[${ref.new}${$1 || ''}]]`; 196 | }); 197 | 198 | return { 199 | updatedOnce: true, 200 | nextContent: content, 201 | }; 202 | } 203 | 204 | return { 205 | updatedOnce: updatedOnce, 206 | nextContent: nextContent, 207 | }; 208 | }, 209 | { updatedOnce: false, nextContent: content }, 210 | ); 211 | 212 | return updatedOnce ? nextContent : null; 213 | }; 214 | -------------------------------------------------------------------------------- /src/utils/searchUtils.spec.ts: -------------------------------------------------------------------------------- 1 | import { search, refsToSearchRegExpStr } from './searchUtils'; 2 | import { 3 | closeEditorsAndCleanWorkspace, 4 | createFile, 5 | getWorkspaceFolder, 6 | rndName, 7 | } from '../test/utils'; 8 | 9 | describe('search()', () => { 10 | beforeEach(closeEditorsAndCleanWorkspace); 11 | 12 | afterEach(closeEditorsAndCleanWorkspace); 13 | 14 | it('should find regular ref', async () => { 15 | const name = rndName(); 16 | 17 | const uri = await createFile(`${name}.md`, '[[ref]]'); 18 | 19 | const [path] = await search(refsToSearchRegExpStr(['[[ref]]']), getWorkspaceFolder()); 20 | 21 | expect(uri?.fsPath).toBe(path); 22 | }); 23 | 24 | it('should find image ref', async () => { 25 | const name = rndName(); 26 | 27 | const uri = await createFile(`${name}.md`, '[[image.png]]'); 28 | 29 | const [path] = await search(refsToSearchRegExpStr(['[[image.png]]']), getWorkspaceFolder()); 30 | 31 | expect(uri?.fsPath).toBe(path); 32 | }); 33 | 34 | it('should find embedded image ref', async () => { 35 | const name = rndName(); 36 | 37 | const uri = await createFile(`${name}.md`, '![[image.png]]'); 38 | 39 | const [path] = await search(refsToSearchRegExpStr(['[[image.png]]']), getWorkspaceFolder()); 40 | 41 | expect(uri?.fsPath).toBe(path); 42 | }); 43 | 44 | it('should find ref in multiline file', async () => { 45 | const name0 = rndName(); 46 | const name1 = rndName(); 47 | 48 | const uri = await createFile( 49 | `${name0}.md`, 50 | ` 51 | Lorem Ipsum is simply dummy text of the printing and typesetting industry. 52 | [[ref]] [[ref]] [[ref]] 53 | Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. 54 | `, 55 | ); 56 | 57 | await createFile(`${name1}.md`); 58 | 59 | const paths = await search(refsToSearchRegExpStr(['[[ref]]']), getWorkspaceFolder()); 60 | 61 | expect(paths.length).toBe(1); 62 | expect(uri?.fsPath).toBe(paths[0]); 63 | }); 64 | 65 | it('should not search inside non-md files', async () => { 66 | const name = rndName(); 67 | 68 | await createFile(`${name}.txt`, '[[ref]]'); 69 | 70 | const paths = await search(refsToSearchRegExpStr(['[[ref]]']), getWorkspaceFolder()); 71 | 72 | expect(paths.length).toBe(0); 73 | }); 74 | 75 | it('should find multiple refs', async () => { 76 | const name = rndName(); 77 | 78 | await createFile(`${name}-1.md`, '[[ref0]]'); 79 | await createFile(`${name}-2.md`, '[[ref1]]'); 80 | 81 | const paths = await search( 82 | refsToSearchRegExpStr(['[[ref0]]', '[[ref1]]']), 83 | getWorkspaceFolder(), 84 | ); 85 | 86 | expect(paths.length).toBe(2); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/utils/searchUtils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import * as vscode from 'vscode'; 3 | import { execa, ExecaError } from 'execa'; 4 | import path from 'path'; 5 | 6 | import { escapeForRegExp } from './utils'; 7 | import logger from '../logger'; 8 | 9 | let ripgrepPathGlobal: string | undefined | false; 10 | 11 | const findRipgrepPath = async (): Promise => { 12 | if (ripgrepPathGlobal) { 13 | return ripgrepPathGlobal; 14 | } 15 | 16 | const name = /^win/.test(process.platform) ? 'rg.exe' : 'rg'; 17 | const vscodePath = vscode.env.appRoot; 18 | 19 | const ripgrepPaths = [ 20 | name, 21 | path.join(vscodePath, `node_modules.asar.unpacked/@vscode/ripgrep/bin/${name}`), 22 | path.join(vscodePath, `node_modules/@vscode/ripgrep/bin/${name}`), 23 | ]; 24 | 25 | for (const ripgrepPath of ripgrepPaths) { 26 | try { 27 | logger.info(`Trying ripgrep at path "${ripgrepPath}"`); 28 | 29 | await execa(ripgrepPath, ['--version']); 30 | 31 | ripgrepPathGlobal = ripgrepPath; 32 | 33 | logger.info(`Ripgrep detected at path "${ripgrepPath}"`); 34 | 35 | return ripgrepPath; 36 | } catch (e) { 37 | logger.warn(`No rigrep bin found at path "${ripgrepPath}"`); 38 | } 39 | } 40 | 41 | logger.warn('No rigrep bin detected!'); 42 | 43 | ripgrepPathGlobal = false; 44 | 45 | return; 46 | }; 47 | 48 | export const refsToSearchRegExpStr = (refs: string[]) => `(${refs.map(escapeForRegExp).join('|')})`; 49 | 50 | export const search = async (regExpStr: string, dirPath: string = '.'): Promise => { 51 | const ripgrepPath = await findRipgrepPath(); 52 | 53 | if (!ripgrepPath || !regExpStr) { 54 | return []; 55 | } 56 | 57 | try { 58 | const res = await execa(ripgrepPath, [ 59 | '-l', 60 | '--ignore-case', 61 | '--color', 62 | 'never', 63 | '-g', 64 | `*.md`, 65 | regExpStr, 66 | dirPath, 67 | ]); 68 | 69 | const paths = res.stdout 70 | .toString() 71 | .split(/\r?\n/g) 72 | .filter((path) => path); 73 | 74 | if (paths.length && (await fs.pathExists(paths[0]))) { 75 | return paths; 76 | } 77 | } catch (e) { 78 | if ((e as ExecaError).exitCode !== 1) { 79 | console.error(e); 80 | } 81 | } 82 | 83 | return []; 84 | }; 85 | -------------------------------------------------------------------------------- /src/workspace/cache/cache.ts: -------------------------------------------------------------------------------- 1 | import { sort as sortPaths } from 'cross-path-sort'; 2 | import vscode from 'vscode'; 3 | 4 | import { WorkspaceCache } from '../../types'; 5 | 6 | const workspaceCache: WorkspaceCache = { 7 | imageUris: [], 8 | markdownUris: [], 9 | otherUris: [], 10 | allUris: [], 11 | danglingRefsByFsPath: {}, 12 | danglingRefs: [], 13 | }; 14 | 15 | // lazy require avoids cyclic dependencies between utils and cache 16 | const utils = () => require('../../utils'); 17 | 18 | // private methods (not exported via workspace/index.ts and should not be used outside of workspace folder) 19 | 20 | export const cacheRefs = async () => { 21 | const { search, getWorkspaceFolder } = utils(); 22 | 23 | const workspaceFolder = getWorkspaceFolder(); 24 | 25 | const fsPaths = workspaceFolder ? await search('\\[\\[([^\\[\\]]+?)\\]\\]', workspaceFolder) : []; 26 | 27 | const searchUris = fsPaths.length 28 | ? workspaceCache.markdownUris.filter(({ fsPath }) => fsPaths.includes(fsPath)) 29 | : workspaceCache.markdownUris; 30 | 31 | workspaceCache.danglingRefsByFsPath = await utils().findDanglingRefsByFsPath(searchUris); 32 | workspaceCache.danglingRefs = sortPaths( 33 | Array.from(new Set(Object.values(workspaceCache.danglingRefsByFsPath).flatMap((refs) => refs))), 34 | { shallowFirst: true }, 35 | ); 36 | }; 37 | 38 | export const addCachedRefs = async (uris: vscode.Uri[]) => { 39 | const danglingRefsByFsPath = await utils().findDanglingRefsByFsPath(uris); 40 | 41 | workspaceCache.danglingRefsByFsPath = { 42 | ...workspaceCache.danglingRefsByFsPath, 43 | ...danglingRefsByFsPath, 44 | }; 45 | 46 | workspaceCache.danglingRefs = sortPaths( 47 | Array.from(new Set(Object.values(workspaceCache.danglingRefsByFsPath).flatMap((refs) => refs))), 48 | { shallowFirst: true }, 49 | ); 50 | }; 51 | 52 | export const removeCachedRefs = async (uris: vscode.Uri[]) => { 53 | const fsPaths = uris.map(({ fsPath }) => fsPath); 54 | 55 | workspaceCache.danglingRefsByFsPath = Object.entries(workspaceCache.danglingRefsByFsPath).reduce<{ 56 | [key: string]: string[]; 57 | }>((refsByFsPath, [fsPath, refs]) => { 58 | if (fsPaths.some((p) => fsPath.startsWith(p))) { 59 | return refsByFsPath; 60 | } 61 | 62 | refsByFsPath[fsPath] = refs; 63 | 64 | return refsByFsPath; 65 | }, {}); 66 | 67 | workspaceCache.danglingRefs = sortPaths( 68 | Array.from(new Set(Object.values(workspaceCache.danglingRefsByFsPath).flatMap((refs) => refs))), 69 | { shallowFirst: true }, 70 | ); 71 | }; 72 | 73 | // public methods (exported via workspace/index.ts) 74 | 75 | export const getWorkspaceCache = (): WorkspaceCache => workspaceCache; 76 | 77 | export const cacheWorkspace = async () => { 78 | await cacheUris(); 79 | await cacheRefs(); 80 | }; 81 | 82 | export const cleanWorkspaceCache = () => { 83 | workspaceCache.imageUris = []; 84 | workspaceCache.markdownUris = []; 85 | workspaceCache.otherUris = []; 86 | workspaceCache.allUris = []; 87 | workspaceCache.danglingRefsByFsPath = {}; 88 | workspaceCache.danglingRefs = []; 89 | }; 90 | 91 | export const cacheUris = async () => { 92 | const { findNonIgnoredFiles, imageExts, otherExts } = utils(); 93 | const markdownUris = await findNonIgnoredFiles('**/*.md'); 94 | const imageUris = await findNonIgnoredFiles(`**/*.{${imageExts.join(',')}}`); 95 | const otherUris = await findNonIgnoredFiles(`**/*.{${otherExts.join(',')}}`); 96 | 97 | workspaceCache.markdownUris = sortPaths(markdownUris, { pathKey: 'path', shallowFirst: true }); 98 | workspaceCache.imageUris = sortPaths(imageUris, { pathKey: 'path', shallowFirst: true }); 99 | workspaceCache.otherUris = sortPaths(otherUris, { pathKey: 'path', shallowFirst: true }); 100 | workspaceCache.allUris = sortPaths([...markdownUris, ...imageUris, ...otherUris], { 101 | pathKey: 'path', 102 | shallowFirst: true, 103 | }); 104 | }; 105 | -------------------------------------------------------------------------------- /src/workspace/cache/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cache'; 2 | -------------------------------------------------------------------------------- /src/workspace/file-watcher/fileWatcher.ts: -------------------------------------------------------------------------------- 1 | import { workspace, ExtensionContext } from 'vscode'; 2 | import debounce from 'lodash.debounce'; 3 | 4 | import { handleFileCreate, handleFileDelete, handleDocChange, handleFilesRename } from './handlers'; 5 | import * as cache from '../cache'; 6 | 7 | export const activate = ( 8 | context: ExtensionContext, 9 | options: { uriCachingDelay?: number; documentChangeDelay?: number } = {}, 10 | ) => { 11 | const { uriCachingDelay = 1000, documentChangeDelay = 500 } = options; 12 | 13 | const cacheUrisDebounced = debounce(cache.cacheUris, uriCachingDelay); 14 | const handleDocChangeDebounced = debounce(handleDocChange, documentChangeDelay); 15 | 16 | const fileWatcher = workspace.createFileSystemWatcher('**/*'); 17 | 18 | const createListenerDisposable = fileWatcher.onDidCreate((newUri) => 19 | handleFileCreate(newUri, cacheUrisDebounced), 20 | ); 21 | const deleteListenerDisposable = fileWatcher.onDidDelete((removedUri) => 22 | handleFileDelete(removedUri, cacheUrisDebounced), 23 | ); 24 | const changeTextDocumentDisposable = workspace.onDidChangeTextDocument(({ document }) => 25 | handleDocChangeDebounced(document), 26 | ); 27 | const renameFilesDisposable = workspace.onDidRenameFiles(handleFilesRename); 28 | 29 | context.subscriptions.push( 30 | createListenerDisposable, 31 | deleteListenerDisposable, 32 | renameFilesDisposable, 33 | changeTextDocumentDisposable, 34 | ); 35 | 36 | return () => { 37 | cacheUrisDebounced.cancel(); 38 | handleDocChangeDebounced.cancel(); 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /src/workspace/file-watcher/handlers.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { FileRenameEvent, TextDocument, Uri, window, workspace } from 'vscode'; 3 | 4 | import * as cache from '../cache'; 5 | import { 6 | containsMarkdownExt, 7 | getMemoConfigProperty, 8 | replaceRefsInDoc, 9 | resolveRefsReplaceMap, 10 | } from '../../utils'; 11 | 12 | export const handleFileCreate = async ( 13 | newUri: Uri, 14 | cacheUrisFn: () => Promise = cache.cacheUris, 15 | ) => { 16 | await cacheUrisFn(); 17 | await cache.addCachedRefs([newUri]); 18 | }; 19 | 20 | export const handleFileDelete = async ( 21 | removedUri: Uri, 22 | cacheUrisFn: () => Promise = cache.cacheUris, 23 | ) => { 24 | await cacheUrisFn(); 25 | await cache.removeCachedRefs([removedUri]); 26 | }; 27 | 28 | export const handleDocChange = async ({ uri }: TextDocument) => { 29 | if (containsMarkdownExt(uri.fsPath)) { 30 | await cache.addCachedRefs([uri]); 31 | } 32 | }; 33 | 34 | export const handleFilesRename = async ({ files }: FileRenameEvent) => { 35 | await cache.cacheUris(); 36 | 37 | if (!getMemoConfigProperty('links.sync.enabled', true)) { 38 | return; 39 | } 40 | 41 | if (files.some(({ newUri }) => fs.lstatSync(newUri.fsPath).isDirectory())) { 42 | window.showWarningMessage( 43 | 'Recursive links update on directory rename is currently not supported.', 44 | ); 45 | } 46 | 47 | let pathsUpdated: string[] = []; 48 | 49 | let refsUpdated: number = 0; 50 | 51 | const addToPathsUpdated = (path: string) => 52 | (pathsUpdated = [...new Set([...pathsUpdated, path])]); 53 | 54 | const incrementRefsCounter = () => (refsUpdated += 1); 55 | 56 | const refsReplaceMap = await resolveRefsReplaceMap(files); 57 | 58 | for (const fsPath in refsReplaceMap) { 59 | const doc = await workspace.openTextDocument(Uri.file(fsPath)); 60 | const refsReplaceEntry = refsReplaceMap[fsPath]; 61 | 62 | const nextContent = replaceRefsInDoc(refsReplaceEntry, doc, { 63 | onMatch: () => addToPathsUpdated(fsPath), 64 | onReplace: incrementRefsCounter, 65 | }); 66 | 67 | if (nextContent !== null) { 68 | fs.writeFileSync(fsPath, nextContent); 69 | } 70 | } 71 | 72 | if (pathsUpdated.length > 0) { 73 | window.showInformationMessage( 74 | `Updated ${refsUpdated} link${refsUpdated === 0 || refsUpdated === 1 ? '' : 's'} in ${ 75 | pathsUpdated.length 76 | } file${pathsUpdated.length === 0 || pathsUpdated.length === 1 ? '' : 's'}`, 77 | ); 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /src/workspace/file-watcher/index.ts: -------------------------------------------------------------------------------- 1 | export { activate } from './fileWatcher'; 2 | -------------------------------------------------------------------------------- /src/workspace/index.ts: -------------------------------------------------------------------------------- 1 | import { cacheWorkspace, getWorkspaceCache, cleanWorkspaceCache } from './cache'; 2 | 3 | export * as fileWatcher from './file-watcher'; 4 | 5 | // public cache interface 6 | export const cache = { 7 | cacheWorkspace, 8 | getWorkspaceCache, 9 | cleanWorkspaceCache, 10 | }; 11 | -------------------------------------------------------------------------------- /syntaxes/injection.json: -------------------------------------------------------------------------------- 1 | { 2 | "scopeName": "memo.wikilink.injection", 3 | "injectionSelector": "L:meta.paragraph.markdown, L:markup.heading.markdown", 4 | "patterns": [ 5 | { 6 | "contentName": "string.other.link.title.markdown.memo", 7 | "begin": "\\[\\[", 8 | "beginCaptures": { 9 | "0": { "name": "punctuation.definition.metadata.markdown.memo" } 10 | }, 11 | "end": "\\]\\]", 12 | "endCaptures": { 13 | "0": { "name": "punctuation.definition.metadata.markdown.memo" } 14 | } 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "esModuleInterop": true, 6 | "outDir": "out", 7 | "sourceMap": true, 8 | "rootDir": "src", 9 | "lib": ["es2019"], 10 | "types": ["jest-extended", "jest"], 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true 15 | }, 16 | "exclude": ["node_modules", ".vscode-test"], 17 | "include": ["src/**/*"] 18 | } 19 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const TerserPlugin = require('terser-webpack-plugin'); 4 | 5 | module.exports = (env, argv) => ({ 6 | target: 'node', 7 | entry: './src/extension.ts', 8 | output: { 9 | path: path.resolve(__dirname, 'dist'), 10 | filename: 'extension.js', 11 | libraryTarget: 'commonjs2', 12 | devtoolModuleFilenameTemplate: '../[resource-path]', 13 | }, 14 | devtool: 'source-map', 15 | externals: { 16 | vscode: 'commonjs vscode', 17 | }, 18 | resolve: { 19 | extensions: ['.ts', '.js'], 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.ts$/, 25 | exclude: /node_modules/, 26 | use: [ 27 | { 28 | loader: 'ts-loader', 29 | }, 30 | ], 31 | }, 32 | ], 33 | }, 34 | optimization: 35 | argv.mode === 'production' 36 | ? { 37 | minimize: true, 38 | minimizer: [ 39 | new TerserPlugin({ 40 | terserOptions: { 41 | keep_fnames: /^(HTML|SVG)/, // https://github.com/fgnass/domino/issues/144, 42 | compress: { 43 | passes: 2, 44 | }, 45 | }, 46 | }), 47 | ], 48 | } 49 | : undefined, 50 | plugins: [ 51 | new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en/), 52 | new webpack.IgnorePlugin({ resourceRegExp: /canvas|bufferutil|utf-8-validate/ }), 53 | ], 54 | }); 55 | --------------------------------------------------------------------------------