├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .mise.toml ├── .npmrc ├── .prettierrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bin ├── prepare-route-docs.sh └── tag-release.fish ├── docs ├── 404.md ├── _config.yml ├── _includes │ └── head_custom.html ├── anatomy.md ├── callbacks.md ├── changes.md ├── faq.md ├── index.md ├── installation.md ├── license.md ├── parameters.md ├── routes.md └── routes │ ├── command.md │ ├── dataview.md │ ├── file.md │ ├── folder.md │ ├── info.md │ ├── note-properties.md │ ├── note.md │ ├── omnisearch.md │ ├── root.md │ ├── search.md │ ├── tags.md │ └── vault.md ├── esbuild.config.mjs ├── jest.config.js ├── manifest.json ├── package.json ├── pnpm-lock.yaml ├── readme-assets ├── actions-uri-128.png ├── actions-uri-256.png └── actions-uri-64.png ├── src ├── constants.ts ├── main.ts ├── plugin-info.json ├── plugin-info.ts ├── routes.ts ├── routes │ ├── command.ts │ ├── dataview.ts │ ├── file.ts │ ├── folder.ts │ ├── info.ts │ ├── note-properties.ts │ ├── note.ts │ ├── note │ │ └── create.ts │ ├── omnisearch.ts │ ├── root.ts │ ├── search.ts │ ├── settings.ts │ ├── tags.ts │ └── vault.ts ├── schemata.ts ├── settings.ts ├── types.d.ts ├── types │ ├── handlers.d.ts │ ├── obsidian-objects.d.ts │ ├── plugins.d.ts │ └── results.d.ts └── utils │ ├── callbacks.ts │ ├── file-handling.ts │ ├── parameters.ts │ ├── periodic-notes-handling.ts │ ├── plugins.ts │ ├── results-handling.ts │ ├── routing.ts │ ├── search.ts │ ├── self.ts │ ├── string-handling.ts │ ├── time.ts │ ├── ui.ts │ └── zod.ts ├── tests ├── README.md ├── callback-server.ts ├── global-setup.ts ├── global-teardown.ts ├── helpers.ts ├── periodic-notes.ts ├── plugin-test-vault.original │ ├── .obsidian │ │ ├── app.json │ │ ├── appearance.json │ │ ├── community-plugins.json │ │ ├── core-plugins.json │ │ ├── graph.json │ │ ├── plugins │ │ │ ├── actions-uri │ │ │ │ └── manifest.json │ │ │ ├── auto-periodic-notes │ │ │ │ ├── data.json │ │ │ │ ├── main.js │ │ │ │ └── manifest.json │ │ │ ├── logstravaganza │ │ │ │ ├── data.json │ │ │ │ ├── main.js │ │ │ │ └── manifest.json │ │ │ ├── periodic-notes │ │ │ │ ├── data.json │ │ │ │ ├── main.js │ │ │ │ ├── manifest.json │ │ │ │ └── styles.css │ │ │ └── templater-obsidian │ │ │ │ ├── main.js │ │ │ │ ├── manifest.json │ │ │ │ └── styles.css │ │ └── workspace.json │ ├── 2024.md │ ├── 2025-04.md │ ├── 2025-05-18.md │ ├── 2025-Q1.md │ ├── 2025-W20.md │ ├── Welcome.md │ ├── _templates │ │ ├── Daily Note.md │ │ ├── Monthly Note.md │ │ ├── Quarterly Note.md │ │ ├── Weekly Note.md │ │ └── Yearly Note.md │ ├── any │ │ └── standard-parameters.test.ts │ └── note │ │ ├── append │ │ └── noteAppend.test.ts │ │ ├── create │ │ └── noteCreate.test.ts │ │ ├── delete │ │ └── noteDelete.test.ts │ │ ├── get-active │ │ └── noteGetActive.test.ts │ │ ├── get-first-named │ │ └── noteGetFirstNamed.test.ts │ │ ├── get │ │ ├── note-1.md │ │ └── noteGet.test.ts │ │ ├── list │ │ └── noteList.test.ts │ │ ├── open │ │ ├── note-1.md │ │ ├── note-2.md │ │ └── noteOpen.test.ts │ │ ├── prepend │ │ └── notePrepend.test.ts │ │ ├── rename │ │ └── noteRename.test.ts │ │ ├── search-regex-and-replace │ │ └── noteSearchRegexAndReplace.test.ts │ │ ├── search-string-and-replace │ │ └── noteSearchStringAndReplace.test.ts │ │ ├── touch │ │ └── noteTouch.test.ts │ │ └── trash │ │ └── noteTrash.test.ts └── types.d.ts ├── tsconfig.json └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = spaces 9 | indent_size = 2 10 | tab_width = 2 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian Plugin 2 | on: 3 | push: 4 | # Sequence of patterns matched against refs/tags 5 | tags: 6 | - "*" # Push events to matching any tag format, i.e. 1.0, 20.15.10 7 | permissions: 8 | contents: write 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 # otherwise, you will failed to push refs to dest repo 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: "18.x" # You might need to adjust this value to your own version 19 | 20 | # Build the plugin 21 | - name: Build 22 | run: | 23 | yarn 24 | yarn run build 25 | 26 | # Get the version number and put it in a variable 27 | - name: Get version info 28 | id: version 29 | run: | 30 | echo "name=$(git describe --abbrev=0 --tags)" >> $GITHUB_OUTPUT 31 | 32 | # Package the required files into a zip 33 | - name: Package plugin archive 34 | run: | 35 | mkdir ${{ github.event.repository.name }} 36 | cp main.js manifest.json README.md ${{ github.event.repository.name }} 37 | zip -r ${{ github.event.repository.name }}-${{ steps.version.outputs.name }}.zip ${{ github.event.repository.name }} 38 | 39 | # Create the release on github 40 | - name: Create release 41 | uses: softprops/action-gh-release@v1 42 | if: startsWith(github.ref, 'refs/tags/') 43 | with: 44 | draft: true 45 | files: | 46 | ${{ github.event.repository.name }}-${{ steps.version.outputs.name }}.zip 47 | main.js 48 | manifest.json 49 | name: ${{ steps.version.outputs.name }} 50 | prerelease: false 51 | tag_name: ${{ github.ref }} 52 | token: ${{ secrets.GITHUB_TOKEN }} 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | /main.js 14 | **/actions-uri/main.js 15 | 16 | # Exclude sourcemaps 17 | *.map 18 | 19 | # Exclude macOS Finder (System Explorer) View States 20 | .DS_Store 21 | 22 | # Exclude files used for local development 23 | x-callback-test.txt 24 | sandbox.js 25 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | deno = "latest" 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | tag-version-prefix="" 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "embeddedLanguageFormatting": "auto", 6 | "htmlWhitespaceSensitivity": "css", 7 | "insertPragma": false, 8 | "printWidth": 80, 9 | "proseWrap": "preserve", 10 | "quoteProps": "preserve", 11 | "requirePragma": false, 12 | "semi": true, 13 | "singleAttributePerLine": false, 14 | "singleQuote": false, 15 | "trailingComma": "all", 16 | "vueIndentScriptAndStyle": false, 17 | "withNodeModules": false 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2022-present Carlo Zottmann, https://zottmann.co/ 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 | Plugin logo thingie: an app icon, a two-way communications icon, a note icon 2 | 3 | # Actions URI 4 | 5 | This plugin adds additional `x-callback-url` endpoints to [Obsidian](https://obsidian.md) for common actions — it's a clean, super-charged addition to the built-in [Obsidian URIs](https://help.obsidian.md/Advanced+topics/Using+obsidian+URI#Using+Obsidian+URIs), for working with [daily notes, notes, getting search results](https://czottmann.github.io/obsidian-actions-uri/routes/) etc. 6 | 7 | ## Documentation 8 | 9 | For information about available features and routes please see the [documentation](https://czottmann.github.io/obsidian-actions-uri/). 10 | 11 | Bug reports and feature requests are welcome, feel free to [open an issue](https://github.com/czottmann/obsidian-actions-uri/issues) here on GitHub. For discussions, please visit the [Plugin Forum](https://forum.actions.work/c/obsidian-actions-uri/6) ("Log in with GitHub" is enabled). 12 | 13 | 14 | ## Plugin Project Status 15 | 16 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/czottmann/obsidian-actions-uri?label=current+release&color=09f) 17 | ![Maturity: Stable](https://img.shields.io/badge/maturity-stable-09f) 18 | ![Development: Active](https://img.shields.io/badge/development-active-09f) 19 | ![Support: Active](https://img.shields.io/badge/support-active-09f) 20 | 21 | (Please see Don McCurdy's post ["Healthy expectations in open source"](https://www.donmccurdy.com/2023/07/03/expectations-in-open-source/) for information about the different statuses.) 22 | 23 | 24 | ## Installation 25 | 26 | 1. Search for "Actions URI" in Obsidian's community plugins browser. ([This link should bring it up.](https://obsidian.md/plugins?id=zottmann)) 27 | 2. Install it. 28 | 3. Enable the plugin in your Obsidian settings under "Community plugins". 29 | 30 | That's it. 31 | 32 | 33 | ## Installation via BRAT (for pre-releases or betas) 34 | 35 | 1. Install [BRAT](https://github.com/TfTHacker/obsidian42-brat). 36 | 2. Add "Actions URI" to BRAT: 37 | 1. Open "Obsidian42 - BRAT" via Settings → Community Plugins 38 | 2. Click "Add Beta plugin" 39 | 3. Use the repository address `czottmann/obsidian-actions-uri` 40 | 3. Enable "Actions URI" under Settings → Options → Community Plugins 41 | 42 | 43 | ## Development 44 | 45 | Clone the repository, run `pnpm install` OR `npm install` to install the dependencies. Afterwards, run `pnpm dev` OR `npm run dev` to compile and have it watch for file changes. 46 | 47 | 48 | ## Author 49 | 50 | Carlo Zottmann, , https://c.zottmann.dev, https://github.com/czottmann 51 | 52 | 53 | ## Projects using Actions URI 54 | 55 | - [Actions for Obsidian](https://obsidian.actions.work/): Useful new Obsidian actions for the Shortcuts app on macOS and iOS, bridging the gap between your notes and your workflows. 56 | 57 | Want to see your project here? Drop me a line! (See "Author" section.) 58 | 59 | 60 | ## Thanks to … 61 | 62 | - the [obsidian-tasks](https://github.com/obsidian-tasks-group/obsidian-tasks) crew for the "starter templates" for the GitHub Action workflow and the handy `release.sh` script 63 | 64 | 65 | ## License 66 | 67 | MIT, see [LICENSE.md](LICENSE.md). 68 | -------------------------------------------------------------------------------- /bin/prepare-route-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Dependencies: https://github.com/chmln/sd 4 | 5 | for F in src/routes/*.ts; do 6 | ls $F 7 | grep ' \/\/' $F \ 8 | | sd ' //' '' \ 9 | | sd '^\s*\}' '' \ 10 | | sd '^\s*\{' "\n| Parameter | Value | optional | |\n| --- | --- | --- |" \ 11 | | sd '^\s*"*(.+?)"*(\?*): (.+);' '| `$1` | $3 | $2 |' \ 12 | | sd --string-mode '| undefined ' '' \ 13 | | sd --string-mode '| ? |' '| yes |' \ 14 | | sd '^ +' '' \ 15 | > docs/route--$(basename $F .ts).md 16 | done 17 | -------------------------------------------------------------------------------- /bin/tag-release.fish: -------------------------------------------------------------------------------- 1 | #!/opt/homebrew/bin/fish --login 2 | 3 | function allow_or_exit 4 | read -P "$argv[1] Continue? [y/n] " -l response 5 | switch $response 6 | case y Y 7 | echo 8 | # We're good to go 9 | case '*' 10 | echo "Aborting!" 11 | exit 0 12 | end 13 | end 14 | 15 | argparse \ 16 | "platform=" "patch-version=" "obsidian-version=" help \ 17 | -- $argv 18 | or return 19 | 20 | if test -n "$_flag_help" 21 | echo " 22 | Only works in `release/` branches, e.g. `release/1.0.x` or `release/2.1.x`. 23 | 24 | Commits the current changes and tags the commit, effectively marking the commit 25 | as the release commit for the version contained in the branch name. 26 | 27 | EXAMPLE: 28 | - If the branch name is `release/1.2.x`, and the patch version is '3', then the 29 | tag `1.2.3` will be created. 30 | 31 | FLAGS: 32 | --patch-version Will be added to the branch release number. REQUIRED. 33 | --obsidian-version The minimum obsidian version for this release. REQUIRED. 34 | --help This usage description. 35 | " 36 | exit 1 37 | end 38 | 39 | if test -z "$_flag_patch_version" 40 | echo "ERROR: --patch-version must be set, exiting" 41 | exit 1 42 | end 43 | 44 | if test -z "$_flag_obsidian_version" 45 | echo "ERROR: --obsidian-version must be set, exiting" 46 | exit 1 47 | end 48 | 49 | set git_branch (git branch --show-current) 50 | set release_tag ( 51 | echo $git_branch | cut -d "/" -f 2 | string replace ".x" ".$_flag_patch_version" 52 | ) 53 | 54 | allow_or_exit "New tag will be named '$release_tag', minimum Obsidian version is $_flag_obsidian_version." 55 | 56 | echo "Updating package.json" 57 | set TEMP_FILE (mktemp) 58 | jq ".version |= \"$release_tag\"" package.json >"$TEMP_FILE"; or exit 1 59 | mv "$TEMP_FILE" package.json 60 | 61 | echo "Updating manifest.json" 62 | set TEMP_FILE (mktemp) 63 | jq ".version |= \"$release_tag\" | .minAppVersion |= \"$_flag_obsidian_version\"" \ 64 | manifest.json >"$TEMP_FILE"; or exit 1 65 | mv "$TEMP_FILE" manifest.json 66 | 67 | echo "Updating versions.json" 68 | set TEMP_FILE (mktemp) 69 | jq ". += {\"$release_tag\": \"$_flag_obsidian_version\"}" \ 70 | versions.json >"$TEMP_FILE"; or exit 1 71 | mv "$TEMP_FILE" versions.json 72 | 73 | echo "Updating src/plugin-info.json & src/plugin-info.ts" 74 | set TEMP_FILE (mktemp) 75 | set DATE_NOW (date +%FT%T%z) 76 | jq ".pluginVersion |= \"$release_tag\" | .pluginReleasedAt |= \"$DATE_NOW\"" \ 77 | src/plugin-info.json >"$TEMP_FILE"; or exit 1 78 | mv "$TEMP_FILE" src/plugin-info.json 79 | echo -n "/* File will be overwritten by bin/release.sh! */ 80 | export const PLUGIN_INFO = " >src/plugin-info.ts 81 | cat src/plugin-info.json >>src/plugin-info.ts 82 | 83 | echo "Committing the following files with a message of '[REL] Release $release_tag':" 84 | echo 85 | git status --porcelain | sed -E "s/^/ /" 86 | echo 87 | 88 | allow_or_exit 89 | 90 | git commit -m "[REL] Release $release_tag" -a 91 | git tag $release_tag 92 | echo "Done!" 93 | echo 94 | 95 | allow_or_exit "Now pushing the commit and tag to the remote …" 96 | 97 | git push --tags 98 | echo "Done!" 99 | echo 100 | 101 | allow_or_exit "Now merging branch '$git_branch' into 'main' …" 102 | 103 | git checkout main 104 | git pull --tags 105 | git merge -m "[MRG] Merges release '$release_tag'" --no-edit --no-ff $git_branch 106 | 107 | allow_or_exit "Push main to remote?" 108 | git push 109 | 110 | echo "Done!" 111 | echo 112 | -------------------------------------------------------------------------------- /docs/404.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Page Not Found 3 | description: Whatever you thought was here, isn't. The plot thickens, the game is afoot! 4 | permalink: /404.html 5 | nav_exclude: true 6 | --- 7 | 8 | Whatever you thought was here, isn't. The plot thickens, the game is afoot! 9 | 10 | `404 Not Found` 11 | 12 | 17 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | remote_theme: just-the-docs/just-the-docs 2 | title: Actions URI 3 | description: A plugin for Obsidian.md adding additional `x-callback-url` endpoints to the app for common actions — it's a clean, super-charged addition to Obsidian URI. 4 | 5 | baseurl: "/obsidian-actions-uri" 6 | url: "https://zottmann.dev" 7 | 8 | permalink: pretty 9 | 10 | aux_links: 11 | "Actions URI on GitHub": 12 | - "https://github.com/czottmann/obsidian-actions-uri" 13 | 14 | # External navigation links 15 | nav_external_links: 16 | - title: "Actions URI on GitHub" 17 | url: "https://github.com/czottmann/obsidian-actions-uri" 18 | - title: "Actions URI in Community Plugins" 19 | url: "https://obsidian.md/plugins?id=actions-uri" 20 | - title: "🪳 Issues or Bugs?" 21 | url: "https://github.com/czottmann/obsidian-actions-uri/issues" 22 | - title: "💡 Ideas & suggestions?" 23 | url: "https://forum.actions.work/c/obsidian-actions-uri/6" 24 | 25 | include: 26 | - license.md 27 | 28 | # Back to top link 29 | back_to_top: false 30 | back_to_top_text: "Back to top" 31 | 32 | footer_content: 'Copyright © 2022 Carlo Zottmann. MIT licensed. This plugin and its author are neither affiliated with nor endorsed by Obsidian.' 33 | -------------------------------------------------------------------------------- /docs/_includes/head_custom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 55 | 56 | 92 | -------------------------------------------------------------------------------- /docs/anatomy.md: -------------------------------------------------------------------------------- 1 | --- 2 | nav_order: 5 3 | --- 4 | 5 | # Anatomy of an Actions URI… URL 6 | 7 | An Action URI-provided URL doesn't look much different from a standard Obsidian URI. Its host "actions-uri" tells Obsidian which plugin is taking care of the incoming call: 8 | 9 | > obsidian://**actions-uri**/daily-note/get-current?parameter=value 10 | 11 | … and the path (a.k.a. a route) specifies what to do: 12 | 13 | > obsidian://actions-uri/**daily-note/get-current**?parameter=value 14 | 15 | Both data and configuration are passed as URL search parameters: 16 | 17 | > obsidian://actions-uri/daily-note/get-current?**parameter=value** 18 | 19 | **Please note:** all parameter data must be properly encoded (see [Wikipedia](https://en.wikipedia.org/wiki/Percent-encoding) for a short intro), as Actions URI makes no attempts to correct malformed input. 20 | -------------------------------------------------------------------------------- /docs/callbacks.md: -------------------------------------------------------------------------------- 1 | --- 2 | nav_order: 4 3 | --- 4 | 5 | # Getting data back from Actions URI 6 | 7 | All routes support return calls back to the sender. This is done by passing callback URLs as parameters, e.g.: 8 | 9 | ``` 10 | obsidian://actions-uri/note/get 11 | ?vault=My%20Vault 12 | &file=My%20super%20note 13 | &x-success=my-app%3A%2F%2Fsuccess%3Frequest-id%3D123456789 14 | &x-error=my-app%3A%2F%2Ferror 15 | ``` 16 | 17 | This example call, formatted for better readability, contains four parameters: `vault`, `file`, `x-success` and `x-error`. The latter two are used to provide callbacks to the sender. 18 | 19 | - `x-success` contains a base URL for returning success information — in the above example, that's `my-app://success?request-id=123456789` 20 | - `x-error` contains a base URL for returning failure information — in the above example, that's `my-app://error` 21 | 22 | When Actions URI has completed the work requested by the incoming call, it'll build a callback URL from the value of either `x-success` or `x-error`. The search parameters containing the requested data (prefixed with `result-`) will be added to the URL, then the outgoing call is made. The `x-success`/`x-error` URL may contain a path and/or parameters, those will be used as-is. 23 | 24 | Let's continue with the above example. Assuming the file `My super note.md` exists in vault `My Vault` and contains both front matter and the note body *"Actions URI is ready for action!"*, Actions URI would make a callback to the following URL, formatted for better readability: 25 | 26 | ``` 27 | my-app://success 28 | ?request-id=123456789 29 | &result-body=%0AActions+URI+is+ready+for+action%21 30 | &result-content=---%0Atags%3A+test%0A---%0A%0AActions+URI+is+ready+for+action%21 31 | &result-filepath=My+super+note.md 32 | &result-front-matter=tags%3A+test%0A 33 | ``` 34 | 35 | The successful callback contains the full note content (`result-content`), the note body (`result-body`), the note's path (`result-filepath`) and its front matter (`result-front-matter`). 36 | 37 | Assuming the note does **not** exist, the resulting call would be: 38 | 39 | ``` 40 | my-app://error 41 | ?errorCode=404 42 | &errorMessage=Note+couldn%27t+be+found 43 | ``` 44 | 45 | `errorCode` contains a HTTP status, `errorMessage` contains a simple explanation. 46 | 47 | 48 | ## Important note on callback parameters 49 | **The on-success callback parameter structure varies depending on the endpoints.** See the relevant [routes descriptions](routes.md) for details. 50 | 51 | On-error callbacks always have the same parameter structure. 52 | 53 | 54 | ## Debug mode 55 | With `debug-mode` enabled in the incoming request (see ["Parameters required in/ accepted by all calls"](parameters.md)), the on-success callback of the above example would look like this: 56 | 57 | ``` 58 | my-app://success 59 | ?request-id=123456789 60 | &result-body=%0AActions+URI+is+ready+for+action%21 61 | &result-content=---%0Atags%3A+test%0A---%0A%0AActions+URI+is+ready+for+action%21 62 | &result-filepath=My+super+note.md 63 | &result-front-matter=tags%3A+test%0A 64 | &input-action=actions-uri%2Fnote%2Fget 65 | &input-file=My+super+note.md 66 | &input-silent=false 67 | &input-vault=Testbed 68 | ``` 69 | 70 | It's called "debug mode" because it's helpful when developing an external *whatever* communicating with Obsidian via Actions URI. In production you'll probably want to pair the callbacks to your original requests, that's where the `request-id` parameter (or something similar) in the `x-success` URL comes into play. I'm not aware of any drawbacks keeping debug mode on in live code, however. You do you! 🖖🏼 71 | -------------------------------------------------------------------------------- /docs/changes.md: -------------------------------------------------------------------------------- 1 | # Release history 2 | 3 | Please see [CHANGELOG.md](https://github.com/czottmann/obsidian-actions-uri/blob/main/CHANGELOG.md). 4 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | --- 2 | nav_order: 99 3 | --- 4 | 5 | # FAQ 6 | 7 | ## Why does this exist? 8 | One major reason is an upcoming project of mine, for which I need a way to access my vault data from "the outside". The existing options either didn't fully cut it — like [Obsidian URI](https://help.obsidian.md/Advanced+topics/Using+obsidian+URI) — or were pretty full of features but left me wanting anyways, like [Advanced URI](https://github.com/Vinzent03/obsidian-advanced-uri) which does *a lot* but in a way and format that didn't quite gel with me. (Additionally, its author doesn't actually use it anymore themselves[^1] which in my eyes makes it a gamble to rely on it for a new project.) This is not meant as a diss, mind; it's just not the right thing for me, personally. 9 | 10 | [^1]: Source: [vinzent03.github.io/obsidian-advanced-uri](https://vinzent03.github.io/obsidian-advanced-uri/) 11 | 12 | So, here we are! 😀 13 | 14 | 15 | ## *"I have an idea for this!"* 16 | Cool! If you want to discuss it, either [post it to the Ideas discussion board](https://github.com/czottmann/obsidian-actions-uri/discussions/categories/ideas) or [hit me up on Mastodon](https://actions.work/@obsidian). I'm all ears! 👂🏼 17 | 18 | 19 | ## *"There's a bug!"*, *"There's something wrong"* etc. 20 | Oh no! Please [file a bug report](https://github.com/czottmann/obsidian-actions-uri/issues) here or (if you're unsure about it) [ping me on Mastodon](https://actions.work/@obsidian). 21 | 22 | --- 23 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | nav_order: 0 3 | --- 4 | 5 | Plugin logo thingie: an app icon, a two-way communications icon, a note icon 6 | 7 | # Actions URI 8 | 9 | Obsidian natively supports a custom URI protocol `obsidian://` which can trigger various actions within the app. This is commonly used on macOS and mobile apps for automation and cross-app workflows. 10 | 11 | **This plugin adds new `x-callback-url` endpoints** to Obsidian so that external sources can better interact with an Obsidian instance by making `GET` requests to a `obsidian://actions-uri/*` URL. All new routes support `x-success` and `x-error` parameters as a way of communicating back to the sender. 12 | 13 | It's a clean, somewhat super-charged addition to Obsidian's [own URI scheme](https://help.obsidian.md/Advanced+topics/Using+obsidian+URI#Using+Obsidian+URIs). 14 | 15 | 16 | ## Author 17 | 18 | Carlo Zottmann, 19 | 20 | - GitHub: [@czottmann](https://github.com/czottmann) 21 | - Mastodon: 22 | - [@czottmann@norden.social](https://norden.social/@czottmann) 23 | - [@actionsdotwork@pkm.social/](https://pkm.social/@actionsdotwork) 24 | - Bluesky: [@zottmann.dev](https://bsky.app/profile/zottmann.dev) 25 | - Obsidian: [@czottmann](https://forum.obsidian.md/u/czottmann) 26 | - Website: [c.zottmann.dev](https://c.zottmann.dev/) 27 | 28 | 29 | ## Projects using Actions URI 30 | 31 | - [Actions for Obsidian](https://obsidian.actions.work/): Useful new Obsidian actions for the Shortcuts app on macOS and iOS, bridging the gap between your notes and your workflows. 32 | 33 | Want to see your project here? Drop me a line! (See "Author" section.) 34 | 35 | 36 | ## Plugin project status 37 | 38 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/czottmann/obsidian-actions-uri?label=current+release&color=09f) 39 | ![Maturity: Stable](https://img.shields.io/badge/maturity-stable-09f) 40 | ![Development: Active](https://img.shields.io/badge/development-active-09f) 41 | ![Support: Active](https://img.shields.io/badge/support-active-09f) 42 | 43 | (Please see Don McCurdy's post ["Healthy expectations in open source"](https://www.donmccurdy.com/2023/07/03/expectations-in-open-source/) for information about the different statuses.) 44 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | nav_order: 1 3 | --- 4 | 5 | # Installation 6 | 7 | 1. Search for "Actions URI" in Obsidian's community plugins browser and install it. ([This link should bring it up.](https://obsidian.md/plugins?id=zottmann)) 8 | 2. Enable the plugin in your Obsidian settings under "Community plugins". 9 | 10 | That's it. 11 | 12 | 13 | # Installation via BRAT (for pre-releases or betas) 14 | 15 | 1. Install [BRAT](https://github.com/TfTHacker/obsidian42-brat). 16 | 2. Add "Actions URI" to BRAT: 17 | 1. Open "Obsidian42 - BRAT" via Settings → Community Plugins 18 | 2. Click "Add Beta plugin" 19 | 3. Use the repository address `czottmann/obsidian-actions-uri` 20 | 3. Enable "Actions URI" under Settings → Options → Community Plugins 21 | 22 | 23 | # Development 24 | 25 | Clone the repository, run `pnpm install` OR `npm install` to install the dependencies. Afterwards, run `pnpm dev` OR `npm run dev` to compile and have it watch for file changes. 26 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Please see [LICENSE.md](https://github.com/czottmann/obsidian-actions-uri/blob/main/LICENSE.md). 4 | -------------------------------------------------------------------------------- /docs/parameters.md: -------------------------------------------------------------------------------- 1 | --- 2 | nav_order: 3 3 | --- 4 | 5 | # Parameters required & accepted by all endpoints 6 | 7 | | Parameter | Value type | Optional? | Description 8 | | ------------------------- | ---------- | :-------: | ----------------------------------------------------------------------------------------------------------------------------------------------- 9 | | `vault` | string | | The name of the target vault. 10 | | `x-success` | string | mostly | Base URL for on-success callbacks, see [Getting data back from Actions URI](callbacks.md). 11 | | `x-error` | string | mostly | Base URL for on-error callbacks, see [Getting data back from Actions URI](callbacks.md). 12 | | `debug-mode` | boolean | yes | When enabled, Actions URI will include all parameters of the original request in the return calls, prefixed with `input-`. Defaults to `false`. 13 | | `hide-ui-notice-on-error` | boolean | yes | v1.8+ When enabled, the UI notice will not be shown on "note not found" errors etc. Defaults to `false`. 14 | 15 | ## Notes about parameters 16 | 17 |
18 |
"mostly"
19 |
optional unless specified otherwise in the detailed route description
20 |
"boolean"
21 |
Actions URI uses what I call "benevolent booleans": the absence of the parameter, an empty string or the string "false" are considered false, everything else is true
22 |
23 | -------------------------------------------------------------------------------- /docs/routes.md: -------------------------------------------------------------------------------- 1 | --- 2 | nav_order: 2 3 | has_children: true 4 | has_toc: false 5 | --- 6 | 7 | # New Routes 8 | 9 | - [`/command`](routes/command.md): Querying and triggering Obsidian commands. 10 | - [`/dataview`](routes/dataview.md): Running Dataview DQL queries. 11 | - [`/file`](routes/file.md): Working with non-note files. 12 | - [`/folder`](routes/folder.md): Dealing with folders. 13 | - [`/info`](routes/info.md): Plugin & Obsidian environment info. 14 | - [`/note`](routes/note.md): Reading, writing, updating notes (including periodic notes). 15 | - [`/note-properties`](routes/note-properties.md): 16 | - [`/omnisearch`](routes/omnisearch.md): Running Omnisearch searches in Obsidian. 17 | - [`/search`](routes/search.md): Running searches in Obsidian. 18 | - [`/tags`](routes/tags.md): Reading tags. 19 | - [`/vault`](routes/vault.md): Dealing with the current vault. 20 | - [`/`](routes/root.md): The root note. Not much is happening here. 21 | -------------------------------------------------------------------------------- /docs/routes/command.md: -------------------------------------------------------------------------------- 1 | --- 2 | parent: New Routes 3 | --- 4 | 5 | # `/command` 6 | v1.3+ 7 | 8 | These routes deal with getting the list of available Obsidian commands (think Command Palette) and executing them. Their URLs start with `obsidian://actions-uri/command`. 9 | 10 |
11 | 12 | 13 |   14 | 15 | 16 | ## Root, i.e. `/command` 17 | Does nothing but say hello. 18 | 19 | ### Parameters 20 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 21 | 22 | ### Return values 23 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 24 | 25 | On success: 26 | 27 | | Parameter | Description | 28 | | ---------------- | --------------------------------- | 29 | | `result-message` | A short summary of what was done. | 30 | 31 | 32 |   33 | 34 | 35 | ## `/command/list` 36 | Returns list of all Obsidian Commands available in the queried vault. 37 | 38 | | Parameter | Value | Optional? | Description | 39 | | ----------- | ------ | :-------: | --------------------------------- | 40 | | `x-success` | string | | base URL for on-success callbacks | 41 | | `x-error` | string | | base URL for on-error callbacks | 42 | 43 | ### Return values 44 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 45 | 46 | On success: 47 | 48 | | Parameter | Description | 49 | | ----------------- | ------------------------------------------------------------ | 50 | | `result-commands` | JSON-encoded array of objects (`{id: string, name: string}`) | 51 | 52 | On failure: 53 | 54 | | Parameter | Description | 55 | | -------------- | ----------------------------------- | 56 | | `errorCode` | A HTTP status code. | 57 | | `errorMessage` | A short summary of what went wrong. | 58 | 59 | 60 |   61 | 62 | 63 | ## `/command/execute` 64 | Triggers the passed-in command or commands in sequence, in the specified vault. 65 | 66 | | Parameter | Value | Optional? | Description | 67 | | --------------- | ------ | :-------: | ---------------------------------------------------------------- | 68 | | `commands` | string | | Comma-separated list of command IDs. | 69 | | `pause-in-secs` | number | optional | Length of the pause in seconds between commands. Default: `0.2`. | 70 | | `x-success` | string | optional | base URL for on-success callbacks | 71 | | `x-error` | string | optional | base URL for on-error callbacks | 72 | 73 | ### Return values 74 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 75 | 76 | On failure: 77 | 78 | | Parameter | Description | 79 | | -------------- | ----------------------------------- | 80 | | `errorCode` | A HTTP status code. | 81 | | `errorMessage` | A short summary of what went wrong. | 82 | -------------------------------------------------------------------------------- /docs/routes/dataview.md: -------------------------------------------------------------------------------- 1 | --- 2 | parent: New Routes 3 | --- 4 | 5 | # `/dataview` 6 | v0.14+ 7 | 8 | These routes allow for running [Dataview DQL queries](https://blacksmithgu.github.io/obsidian-dataview/queries/structure/). Their URLs start with `obsidian://actions-uri/dataview`. 9 | 10 | Currently, only [`LIST`](https://blacksmithgu.github.io/obsidian-dataview/queries/query-types/#list-queries) and [`TABLE`](https://blacksmithgu.github.io/obsidian-dataview/queries/query-types/#table-queries) DQL queries are supported. 11 | 12 |
13 | 14 | 15 |   16 | 17 | 18 | ## Root, i.e. `/dataview` 19 | Does nothing but say hello. 20 | 21 | ### Parameters 22 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 23 | 24 | ### Return values 25 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 26 | 27 | On success: 28 | 29 | | Parameter | Description | 30 | | ---------------- | --------------------------------- | 31 | | `result-message` | A short summary of what was done. | 32 | 33 | 34 |   35 | 36 | 37 | ## `/dataview/list-query` 38 | 39 | ### Parameters 40 | In addition to the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)): 41 | 42 | | Parameter | Value type | Optional? | Description | 43 | | --------- | ---------- | :-------: | ------------------- | 44 | | `dql` | string | | A DQL `LIST` query. | 45 | 46 | 47 | ### Return values 48 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 49 | 50 | On success: 51 | 52 | | Parameter | Description | 53 | | ------------- | ----------------------------------------------------------------------------------------------------------------- | 54 | | `result-data` | An array containing strings (the list results), encoded as JSON string. Every result is returned as string array. | 55 | 56 | On failure: 57 | 58 | | Parameter | Description | 59 | | -------------- | ----------------------------------- | 60 | | `errorCode` | A HTTP status code. | 61 | | `errorMessage` | A short summary of what went wrong. | 62 | 63 | 64 |   65 | 66 | 67 | ## `/dataview/table-query` 68 | 69 | ### Parameters 70 | In addition to the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)): 71 | 72 | | Parameter | Value type | Optional? | Description | 73 | | --------- | ---------- | :-------: | -------------------- | 74 | | `dql` | string | | A DQL `TABLE` query. | 75 | 76 | 77 | ### Return values 78 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 79 | 80 | On success: 81 | 82 | | Parameter | Description | 83 | | ------------- | --------------------------------------------------------------------------------- | 84 | | `result-data` | An array containing arrays of strings (the result table), encoded as JSON string. | 85 | 86 | On failure: 87 | 88 | | Parameter | Description | 89 | | -------------- | ----------------------------------- | 90 | | `errorCode` | A HTTP status code. | 91 | | `errorMessage` | A short summary of what went wrong. | 92 | -------------------------------------------------------------------------------- /docs/routes/folder.md: -------------------------------------------------------------------------------- 1 | --- 2 | parent: New Routes 3 | --- 4 | 5 | # `/folder` 6 | v0.16+ 7 | 8 | These routes deal with folders. Their URLs start with `obsidian://actions-uri/folder/…`. 9 | 10 |
11 | 12 | 13 |   14 | 15 | 16 | ## Root, i.e. `/folder` 17 | 18 | Does nothing but say hello. 19 | 20 | ### Parameters 21 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 22 | 23 | ### Return values 24 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 25 | 26 | On success: 27 | 28 | | Parameter | Description | 29 | | ---------------- | --------------------------------- | 30 | | `result-message` | A short summary of what was done. | 31 | 32 | 33 |   34 | 35 | 36 | ## `/folder/list` 37 | Returns a list of folder paths. 38 | 39 | ### Parameters 40 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 41 | 42 | | Parameter | Value type | Optional? | Description | 43 | | ----------- | ---------- | :-------: | --------------------------------- | 44 | | `x-success` | string | | base URL for on-success callbacks | 45 | | `x-error` | string | | base URL for on-error callbacks | 46 | 47 | ### Return values 48 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 49 | 50 | On success: 51 | 52 | | Parameter | Description | 53 | | -------------- | --------------------------------------------------------- | 54 | | `result-paths` | Array containing all folder paths encoded as JSON string. | 55 | 56 | On failure: 57 | 58 | | Parameter | Description | 59 | | -------------- | ----------------------------------- | 60 | | `errorCode` | A HTTP status code. | 61 | | `errorMessage` | A short summary of what went wrong. | 62 | 63 | 64 |   65 | 66 | 67 | ## `/folder/create` 68 | Creates a new folder or folder structure. In case the folder already exists, nothing will happen. 69 | 70 | ### Parameters 71 | In addition to the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)): 72 | 73 | | Parameter | Value type | Optional? | Description | 74 | | --------- | ---------- | :-------: | ------------------------------------------------ | 75 | | `folder` | string | | The folder path, relative from the vault's root. | 76 | 77 | ### Return values 78 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 79 | 80 | On success: 81 | 82 | | Parameter | Description | 83 | | ---------------- | --------------------------------- | 84 | | `result-message` | A short summary of what was done. | 85 | 86 | On failure: 87 | 88 | | Parameter | Description | 89 | | -------------- | ----------------------------------- | 90 | | `errorCode` | A HTTP status code. | 91 | | `errorMessage` | A short summary of what went wrong. | 92 | 93 | 94 |   95 | 96 | 97 | ## `/folder/rename` 98 | Renames or moves a folder. If the new folder path already exists, an error will be returned. If the new folder path is the same as the original one, nothing will happen. Any folder structure in `new-foldername` will **not** be created automatically. If a folder is specified that does not exist, an error will be returned. 99 | 100 | ### Parameters 101 | In addition to the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)): 102 | 103 | | Parameter | Value type | Optional? | Description | 104 | | ---------------- | ---------- | :-------: | ---------------------------------------------------- | 105 | | `folder` | string | | The folder path, relative from the vault's root. | 106 | | `new-foldername` | string | | The new folder path, relative from the vault's root. | 107 | 108 | ### Return values 109 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 110 | 111 | On success: 112 | 113 | | Parameter | Description | 114 | | ---------------- | ------------------------ | 115 | | `result-message` | A short success message. | 116 | 117 | On failure: 118 | 119 | | Parameter | Description | 120 | | -------------- | ----------------------------------- | 121 | | `errorCode` | A HTTP status code. | 122 | | `errorMessage` | A short summary of what went wrong. | 123 | 124 | 125 |   126 | 127 | 128 | ## `/folder/delete` 129 | Immediately deletes a folder and all its contents. 130 | 131 | ### Parameters 132 | In addition to the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)): 133 | 134 | | Parameter | Value type | Optional? | Description | 135 | | --------- | ---------- | :-------: | ------------------------------------------------ | 136 | | `folder` | string | | The folder path, relative from the vault's root. | 137 | 138 | ### Return values 139 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 140 | 141 | On success: 142 | 143 | | Parameter | Description | 144 | | ---------------- | ------------------------ | 145 | | `result-message` | A short success message. | 146 | 147 | On failure: 148 | 149 | | Parameter | Description | 150 | | -------------- | ----------------------------------- | 151 | | `errorCode` | A HTTP status code. | 152 | | `errorMessage` | A short summary of what went wrong. | 153 | 154 | 155 |   156 | 157 | 158 | ## `/folder/trash` 159 | Moves a folder to the trash (either vault-local trash or system trash, depending on the configuration made in _Settings_ → _Files & Links_ → _Deleted Files_). 160 | 161 | ### Parameters 162 | In addition to the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)): 163 | 164 | | Parameter | Value type | Optional? | Description | 165 | | --------- | ---------- | :-------: | ------------------------------------------------ | 166 | | `folder` | string | | The folder path, relative from the vault's root. | 167 | 168 | ### Return values 169 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 170 | 171 | On success: 172 | 173 | | Parameter | Description | 174 | | ---------------- | ------------------------ | 175 | | `result-message` | A short success message. | 176 | 177 | On failure: 178 | 179 | | Parameter | Description | 180 | | -------------- | ----------------------------------- | 181 | | `errorCode` | A HTTP status code. | 182 | | `errorMessage` | A short summary of what went wrong. | 183 | -------------------------------------------------------------------------------- /docs/routes/info.md: -------------------------------------------------------------------------------- 1 | --- 2 | parent: New Routes 3 | --- 4 | 5 | # `/info` 6 | 7 | These routes deal with plugin & Obsidian environment info. Their URLs start with `obsidian://actions-uri/info`. 8 | 9 |
10 | 11 | 12 |   13 | 14 | 15 | ## `/info` 16 | Returns information about the plugin and the current Obsidian instance. 17 | 18 | | Parameter | Value | Optional? | Description | 19 | | ----------- | ------ | :-------: | --------------------------------- | 20 | | `x-success` | string | | base URL for on-success callbacks | 21 | | `x-error` | string | | base URL for on-error callbacks | 22 | 23 | ### Return values 24 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 25 | 26 | On success: 27 | 28 | | Parameter | Description | 29 | | --------------------------- | --------------------------------------------------------------------------------------------------- | 30 | | `result-plugin-version` | The version of the responding Action URI plugin | 31 | | `result-plugin-released-at` | The release timestamp of the responding Action URI plugin (ISO 8601) | 32 | | `result-api-version` | The API version of the app, which follows the release cycle of the desktop app | 33 | | `result-node-version` | The version of Node running the plugin, e.g. "16.13.2" | 34 | | `result-os` | OS information gathered from Obsidian's user agent string, e.g. "Macintosh; Intel Mac OS X 10_15_7" | 35 | | `result-platform` | Returns "macOS", "Windows/Linux" "iOS" or "Android" | 36 | -------------------------------------------------------------------------------- /docs/routes/omnisearch.md: -------------------------------------------------------------------------------- 1 | --- 2 | parent: New Routes 3 | --- 4 | 5 | # `/omnisearch` 6 | v1.1+ 7 | 8 | These routes deal with running searches through the 9 | [Omnisearch plugin](https://publish.obsidian.md/omnisearch/Index) in Obsidian. 10 | Their URLs start with `obsidian://actions-uri/omnisearch/…`. 11 | 12 | (Omnisearch isn't installed by default, but it is a superior choice for 13 | searching through your vault.) 14 | 15 |
16 | 17 | 18 |   19 | 20 | 21 | ## Root, i.e. `/omnisearch` 22 | 23 | Does nothing but say hello. 24 | 25 | ### Parameters 26 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 27 | 28 | ### Return values 29 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 30 | 31 | On success: 32 | 33 | | Parameter | Description | 34 | | ---------------- | --------------------------------- | 35 | | `result-message` | A short summary of what was done. | 36 | 37 | 38 |   39 | 40 | 41 | ## `/omnisearch/all-notes` 42 | Returns Omnisearch results (file paths) for a given search query. 43 | 44 | | Parameter | Value | Optional? | Description | 45 | | ----------- | ------ | :-------: | --------------------------------- | 46 | | `query` | string | | A valid Omnisearch query | 47 | | `x-success` | string | | base URL for on-success callbacks | 48 | | `x-error` | string | | base URL for on-error callbacks | 49 | 50 | ### Return values 51 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 52 | 53 | On success: 54 | 55 | | Parameter | Description | 56 | | ------------- | --------------------------------------------------- | 57 | | `result-hits` | Array with found file paths encoded as JSON string. | 58 | 59 | On failure: 60 | 61 | | Parameter | Description | 62 | | -------------- | ----------------------------------- | 63 | | `errorCode` | A HTTP status code. | 64 | | `errorMessage` | A short summary of what went wrong. | 65 | 66 | 67 |   68 | 69 | 70 | ## `/omnisearch/open` 71 | Opens Omnisearch for a given query in Obsidian. 72 | 73 | | Parameter | Value | Optional? | Description | 74 | | --------- | ------ | :-------: | ------------------------ | 75 | | `query` | string | | A valid Omnisearch query | 76 | 77 | ### Return values 78 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 79 | 80 | On success: 81 | 82 | | Parameter | Description | 83 | | ---------------- | --------------------------------- | 84 | | `result-message` | A short summary of what was done. | 85 | -------------------------------------------------------------------------------- /docs/routes/root.md: -------------------------------------------------------------------------------- 1 | --- 2 | parent: New Routes 3 | --- 4 | 5 | # `/` 6 | 7 | All URLs start with `obsidian://actions-uri`. 8 | 9 |
10 | 11 | 12 |   13 | 14 | 15 | ## `obsidian://actions-uri` 16 | Does nothing but say hello (display a wee Notice toast in Obsidian.) 17 | 18 | ### Parameters 19 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 20 | 21 | ### Return values 22 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 23 | 24 | On success: 25 | 26 | | Parameter | Description | 27 | | ---------------- | --------------------------------- | 28 | | `result-message` | A short summary of what was done. | 29 | -------------------------------------------------------------------------------- /docs/routes/search.md: -------------------------------------------------------------------------------- 1 | --- 2 | parent: New Routes 3 | --- 4 | 5 | # `/search` 6 | 7 | These routes deal with running searches in Obsidian. Their URLs start with `obsidian://actions-uri/search/…`. 8 | 9 |
10 | 11 | 12 |   13 | 14 | 15 | ## Root, i.e. `/search` 16 | 17 | Does nothing but say hello. 18 | 19 | ### Parameters 20 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 21 | 22 | ### Return values 23 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 24 | 25 | On success: 26 | 27 | | Parameter | Description | 28 | | ---------------- | --------------------------------- | 29 | | `result-message` | A short summary of what was done. | 30 | 31 | 32 |   33 | 34 | 35 | ## `/search/all-notes` 36 | Desktop only 37 | Returns search results (file paths) for a given search query. 38 | 39 | | Parameter | Value | Optional? | Description | 40 | | ----------- | ------ | :-------: | --------------------------------- | 41 | | `query` | string | | | 42 | | `x-success` | string | | base URL for on-success callbacks | 43 | | `x-error` | string | | base URL for on-error callbacks | 44 | 45 | ### Return values 46 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 47 | 48 | On success: 49 | 50 | | Parameter | Description | 51 | | ------------- | -------------------------------------------------------------------------------------------------------------- | 52 | | `result-hits` | Array with found file paths encoded as JSON string. (The max number of results varies, in my tests it was 36.) | 53 | 54 | On failure: 55 | 56 | | Parameter | Description | 57 | | -------------- | ----------------------------------- | 58 | | `errorCode` | A HTTP status code. | 59 | | `errorMessage` | A short summary of what went wrong. | 60 | 61 | 62 |   63 | 64 | 65 | ## `/search/open` 66 | Opens the search for a given query in Obsidian. 67 | 68 | | Parameter | Value | Optional? | Description | 69 | | --------- | ------ | :-------: | ----------------------------- | 70 | | `query` | string | | A valid Obsidian search query | 71 | 72 | ### Return values 73 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 74 | 75 | On success: 76 | 77 | | Parameter | Description | 78 | | ---------------- | --------------------------------- | 79 | | `result-message` | A short summary of what was done. | 80 | -------------------------------------------------------------------------------- /docs/routes/tags.md: -------------------------------------------------------------------------------- 1 | --- 2 | parent: New Routes 3 | --- 4 | 5 | # `/tags` 6 | v0.13+ 7 | 8 | These routes deal with a vault's tags. Their URLs start with `obsidian://actions-uri/tags`. 9 | 10 |
11 | 12 | 13 |   14 | 15 | 16 | ## Root, i.e. `/tags` 17 | Does nothing but say hello. 18 | 19 | ### Parameters 20 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 21 | 22 | ### Return values 23 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 24 | 25 | On success: 26 | 27 | | Parameter | Description | 28 | | ---------------- | --------------------------------- | 29 | | `result-message` | A short summary of what was done. | 30 | 31 | 32 |   33 | 34 | 35 | ## `/tags/list` 36 | Returns list of all tags used in the queried vault. 37 | 38 | | Parameter | Value | Optional? | Description | 39 | | ----------- | ------ | :-------: | --------------------------------- | 40 | | `x-success` | string | | base URL for on-success callbacks | 41 | | `x-error` | string | | base URL for on-error callbacks | 42 | 43 | ### Return values 44 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 45 | 46 | On success: 47 | 48 | | Parameter | Description | 49 | | ------------- | -------------------------------------------------------------------------------------------------------------- | 50 | | `result-tags` | JSON-encoded string array, sorted alphabetically. The tags are returned as-is, i.e. including the leading `#`. | 51 | 52 | On failure: 53 | 54 | | Parameter | Description | 55 | | -------------- | ----------------------------------- | 56 | | `errorCode` | A HTTP status code. | 57 | | `errorMessage` | A short summary of what went wrong. | 58 | -------------------------------------------------------------------------------- /docs/routes/vault.md: -------------------------------------------------------------------------------- 1 | --- 2 | parent: New Routes 3 | --- 4 | 5 | # `/vault` 6 | v0.12+ 7 | 8 | These routes deal with handling an Obsidian vault. Their URLs start with `obsidian://actions-uri/vault`. 9 | 10 |
11 | 12 | 13 |   14 | 15 | 16 | ## Root, i.e. `/vault` 17 | Does nothing but say hello. 18 | 19 | ### Parameters 20 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 21 | 22 | ### Return values 23 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 24 | 25 | On success: 26 | 27 | | Parameter | Description | 28 | | ---------------- | --------------------------------- | 29 | | `result-message` | A short summary of what was done. | 30 | 31 | 32 |   33 | 34 | 35 | ## `/vault/open` 36 | Opens a specific vault. For this to work, the vault must be in the list of vaults that Obsidian knows about and Actions URI needs to be active in that vault. 37 | 38 | ### Parameters 39 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 40 | 41 | ### Return values 42 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 43 | 44 | On success: 45 | 46 | | Parameter | Description | 47 | | --------- | ----------- | 48 | | / | | 49 | 50 | On failure: 51 | 52 | | Parameter | Description | 53 | | -------------- | ----------------------------------- | 54 | | `errorCode` | A HTTP status code. | 55 | | `errorMessage` | A short summary of what went wrong. | 56 | 57 | 58 |   59 | 60 | 61 | ## `/vault/close` 62 | Desktop only 63 | Closes a specific vault. For this to work, the vault must be in the list of vaults that Obsidian knows about and Actions URI needs to be active in that vault. 64 | 65 | ### Parameters 66 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 67 | 68 | ### Return values 69 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 70 | 71 | On success: 72 | 73 | | Parameter | Description | 74 | | --------- | ----------- | 75 | | / | | 76 | 77 | On failure: 78 | 79 | | Parameter | Description | 80 | | -------------- | ----------------------------------- | 81 | | `errorCode` | A HTTP status code. | 82 | | `errorMessage` | A short summary of what went wrong. | 83 | 84 | 85 |   86 | 87 | 88 | ## `/vault/info` 89 | v0.13+ 90 | Returns the full filesystem paths for the vault, its media folder and the "new note" folder. 91 | 92 | ### Parameters 93 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 94 | 95 | ### Return values 96 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 97 | 98 | On success: 99 | 100 | | Parameter | Description | 101 | | ------------------------------- | ---------------------------------------------------------- | 102 | | `result-base-path` | The full filesystem path to the vault. | 103 | | `result-attachment-folder-path` | The full filesystem path to the vault's media folder. | 104 | | `result-new-file-folder-path` | The full filesystem path to the vault's "new note" folder. | 105 | 106 | On failure: 107 | 108 | | Parameter | Description | 109 | | -------------- | ----------------------------------- | 110 | | `errorCode` | A HTTP status code. | 111 | | `errorMessage` | A short summary of what went wrong. | 112 | 113 | 114 |   115 | 116 | 117 | ## `/vault/list-all-files` 118 | v0.14+ 119 | Returns a list of all files in the vault. 120 | 121 | ### Parameters 122 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 123 | 124 | ### Return values 125 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 126 | 127 | On success: 128 | 129 | | Parameter | Description | 130 | | -------------- | ------------------------------------------------------- | 131 | | `result-paths` | Array containing all file paths encoded as JSON string. | 132 | 133 | On failure: 134 | 135 | | Parameter | Description | 136 | | -------------- | ----------------------------------- | 137 | | `errorCode` | A HTTP status code. | 138 | | `errorMessage` | A short summary of what went wrong. | 139 | 140 | 141 |   142 | 143 | 144 | ## `/vault/list-non-notes-files` 145 | v0.14+ 146 | Returns a list of all non Markdown files in the vault. 147 | 148 | ### Parameters 149 | Only supports the base parameters (see section ["Parameters required in/ accepted by all calls"](../parameters.md)). 150 | 151 | ### Return values 152 | These parameters will be added to the callbacks used for [getting data back from Actions URI](../callbacks.md). 153 | 154 | On success: 155 | 156 | | Parameter | Description | 157 | | -------------- | ------------------------------------------------------- | 158 | | `result-paths` | Array containing all file paths encoded as JSON string. | 159 | 160 | On failure: 161 | 162 | | Parameter | Description | 163 | | -------------- | ----------------------------------- | 164 | | `errorCode` | A HTTP status code. | 165 | | `errorMessage` | A short summary of what went wrong. | 166 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import { exec } from "child_process"; 5 | 6 | const banner = `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const isProduction = process.argv[2] === "production"; 13 | const rsyncPlugin = { 14 | name: "rsyncPlugin", 15 | setup(build) { 16 | build.onEnd((_) => { 17 | if (process.env.USER !== "czottmann" || isProduction) { 18 | return; 19 | } 20 | 21 | exec( 22 | "../bin/sync-current-plugins-to-workbench-vault.fish", 23 | (error, _, stderr) => { 24 | if (error) { 25 | console.log(`exec error: ${error}`); 26 | } 27 | 28 | console.log( 29 | stderr 30 | ? stderr 31 | : "[watch] sync'd via `../bin/sync-current-plugins-to-workbench-vault.fish`", 32 | ); 33 | }, 34 | ); 35 | }); 36 | }, 37 | }; 38 | 39 | const config = { 40 | banner: { js: banner }, 41 | bundle: true, 42 | entryPoints: ["src/main.ts"], 43 | external: [ 44 | "obsidian", 45 | "electron", 46 | "@codemirror/autocomplete", 47 | "@codemirror/collab", 48 | "@codemirror/commands", 49 | "@codemirror/language", 50 | "@codemirror/lint", 51 | "@codemirror/search", 52 | "@codemirror/state", 53 | "@codemirror/view", 54 | "@lezer/common", 55 | "@lezer/highlight", 56 | "@lezer/lr", 57 | ...builtins, 58 | ], 59 | format: "cjs", 60 | logLevel: "info", 61 | minify: isProduction, 62 | outfile: "main.js", 63 | plugins: [rsyncPlugin], 64 | sourcemap: isProduction ? false : "inline", 65 | target: "es2022", 66 | treeShaking: true, 67 | }; 68 | 69 | if (isProduction) { 70 | await esbuild.build(config); 71 | } else { 72 | const ctx = await esbuild.context(config); 73 | ctx.watch(); 74 | await ctx.rebuild().catch(() => process.exit(1)); 75 | } 76 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | transform: { 4 | // Suppresses message TS151001: "If you have issues related to imports, you 5 | // should consider setting `esModuleInterop` to `true` …" 6 | "^.+\\.ts$": ["ts-jest", { diagnostics: { ignoreCodes: ["TS151001"] } }], 7 | }, 8 | preset: "ts-jest", 9 | testEnvironment: "node", 10 | testMatch: ["**/tests/**/*.test.ts"], 11 | globalSetup: "./tests/global-setup.ts", 12 | globalTeardown: "./tests/global-teardown.ts", 13 | 14 | // Disables parallelization of tests to avoid "callback server not initialized" errors 15 | maxConcurrency: 1, 16 | maxWorkers: 1, 17 | }; 18 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "actions-uri", 3 | "name": "Actions URI", 4 | "version": "1.8.1", 5 | "minAppVersion": "1.8.0", 6 | "description": "Adds additional `x-callback-url` endpoints to the app for common actions — it's a clean, super-charged addition to Obsidian URI.", 7 | "author": "Carlo Zottmann", 8 | "authorUrl": "https://github.com/czottmann", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-actions-uri", 3 | "version": "1.8.1", 4 | "description": "This plugin for Obsidian (https://obsidian.md) adds additional `x-callback-url` endpoints to the app for common actions — it's a clean, super-charged addition to Obsidian URI.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs ", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "test": "npm run build && jest" 10 | }, 11 | "keywords": [], 12 | "author": "Carlo Zottmann, https://github.com/czottmann/", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@jest/globals": "^29.7.0", 16 | "@types/jest": "^29.5.14", 17 | "@types/node": "^18.19.67", 18 | "@typescript-eslint/eslint-plugin": "^8.17.0", 19 | "@typescript-eslint/parser": "^8.17.0", 20 | "builtin-modules": "^3.3.0", 21 | "chokidar": "^4.0.3", 22 | "esbuild": "^0.25.0", 23 | "filter-obj": "^5.1.0", 24 | "jest": "^29.7.0", 25 | "moment": "^2.30.1", 26 | "obsidian": "^1.8.0", 27 | "obsidian-daily-notes-interface": "^0.9.4", 28 | "obsidian-dataview": "^0.5.67", 29 | "ts-jest": "^29.3.3", 30 | "tslib": "2.5.2", 31 | "typescript": "^5.0.4", 32 | "zod": "^3.23.8" 33 | }, 34 | "imports": { 35 | "#*": "./*.ts" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /readme-assets/actions-uri-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/czottmann/obsidian-actions-uri/5b6d22ddc3b16f51ac50017653a8660f9560dffc/readme-assets/actions-uri-128.png -------------------------------------------------------------------------------- /readme-assets/actions-uri-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/czottmann/obsidian-actions-uri/5b6d22ddc3b16f51ac50017653a8660f9560dffc/readme-assets/actions-uri-256.png -------------------------------------------------------------------------------- /readme-assets/actions-uri-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/czottmann/obsidian-actions-uri/5b6d22ddc3b16f51ac50017653a8660f9560dffc/readme-assets/actions-uri-64.png -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const URI_NAMESPACE = "actions-uri"; 2 | 3 | export const STRINGS = { 4 | append_done: "Note was appended", 5 | command_not_found: (command: string) => `Unknown command ${command}`, 6 | daily_note: { 7 | create_note_already_exists: "Daily note already exists", 8 | create_note_no_content: 9 | "Daily note couldn't be overwritten, no content specified", 10 | feature_not_available: "Daily Notes feature is not active", 11 | }, 12 | dataview_dql_must_start_with_list: 'DQL must start with "LIST"', 13 | dataview_dql_must_start_with_table: 'DQL must start with "TABLE"', 14 | dataview_plugin_not_available: "Dataview plugin is not active", 15 | delete_done: "Successfully deleted", 16 | faulty_apply_parameter: "Unexpected 'apply' parameter", 17 | faulty_note_targeting: 18 | "Either 'file', 'uid', or 'periodic-note' must be provided", 19 | file_not_found: "File couldn't be found", 20 | file_opened: "File opened", 21 | folder_created: "Folder created", 22 | global_search_feature_not_available: "Global Search plugin is not active", 23 | headline_not_found: "Headline not found", 24 | monthly_note: { 25 | create_note_already_exists: "Monthly note already exists", 26 | create_note_no_content: 27 | "Monthly note couldn't be overwritten, no content specified", 28 | feature_not_available: "Periodic Notes' Monthly feature is not active", 29 | }, 30 | not_available_on_mobile: "This action is not available on mobile", 31 | not_found: "Not found", 32 | note_not_found: "Note couldn't be found", 33 | note_opened: "Note opened", 34 | omnisearch_plugin_not_available: "Omnisearch plugin is not active", 35 | prepend_done: "Note was prepended", 36 | properties: { 37 | key_not_found: "Key not found", 38 | }, 39 | quarterly_note: { 40 | create_note_already_exists: "Quarterly note already exists", 41 | create_note_no_content: 42 | "Quarterly note couldn't be overwritten, no content specified", 43 | feature_not_available: "Periodic Notes' Quarterly feature is not active", 44 | }, 45 | rename_done: "Note was renamed/moved", 46 | replacement_done: "Replacement done, note updated", 47 | search_pattern_empty: "Search pattern is empty", 48 | search_pattern_invalid: "Search pattern must start with a forward slash", 49 | search_pattern_not_found: "Search pattern wasn't found, nothing replaced", 50 | search_pattern_unparseable: "Search pattern is not correctly formed", 51 | search_string_not_found: "Search string wasn't found, nothing replaced", 52 | template_not_found: "Template not found", 53 | templater: { 54 | feature_not_available: "Templater plugin is not active", 55 | }, 56 | templates: { 57 | feature_not_available: "Templates core plugin is not active", 58 | }, 59 | touch_done: "Successfully touched", 60 | trash_done: "Successfully moved to trash", 61 | unable_to_read_note: "Can't read note file", 62 | unable_to_write_note: "Can't write note file", 63 | vault_internals_not_found: "Vault didn't return config info", 64 | weekly_note: { 65 | create_note_already_exists: "Weekly note already exists", 66 | create_note_no_content: 67 | "Weekly note couldn't be overwritten, no content specified", 68 | feature_not_available: "Periodic Notes' Weekly feature is not active", 69 | }, 70 | yearly_note: { 71 | create_note_already_exists: "Yearly note already exists", 72 | create_note_no_content: 73 | "Yearly note couldn't be overwritten, no content specified", 74 | feature_not_available: "Periodic Notes' Yearly feature is not active", 75 | }, 76 | }; 77 | 78 | export const XCALLBACK_RESULT_PREFIX = "result"; 79 | -------------------------------------------------------------------------------- /src/plugin-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginVersion": "1.8.1", 3 | "pluginReleasedAt": "2025-05-22T13:50:22+0200" 4 | } 5 | -------------------------------------------------------------------------------- /src/plugin-info.ts: -------------------------------------------------------------------------------- 1 | /* File will be overwritten by bin/release.sh! */ 2 | export const PLUGIN_INFO = { 3 | "pluginVersion": "1.8.1", 4 | "pluginReleasedAt": "2025-05-22T13:50:22+0200" 5 | } 6 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { 3 | AnyLocalParams as AnyCommandParams, 4 | routePath as commandRoutes, 5 | } from "src/routes/command"; 6 | import { 7 | AnyLocalParams as AnyDataviewParams, 8 | routePath as dataviewRoutes, 9 | } from "src/routes/dataview"; 10 | import { 11 | AnyLocalParams as AnyFileParams, 12 | routePath as fileRoutes, 13 | } from "src/routes/file"; 14 | import { 15 | AnyLocalParams as AnyFolderParams, 16 | routePath as folderRoutes, 17 | } from "src/routes/folder"; 18 | import { 19 | AnyLocalParams as AnyInfoParams, 20 | routePath as infoRoutes, 21 | } from "src/routes/info"; 22 | import { 23 | AnyLocalParams as AnyNoteParams, 24 | routePath as noteRoutes, 25 | } from "src/routes/note"; 26 | import { 27 | AnyLocalParams as AnyNotePropertiesParams, 28 | routePath as notePropertiesRoutes, 29 | } from "src/routes/note-properties"; 30 | import { 31 | AnyLocalParams as AnyOmnisearchParams, 32 | routePath as omnisearchRoutes, 33 | } from "src/routes/omnisearch"; 34 | import { routePath as rootRoutes } from "src/routes/root"; 35 | import { 36 | AnyLocalParams as AnySearchParams, 37 | routePath as searchRoutes, 38 | } from "src/routes/search"; 39 | import { 40 | AnyLocalParams as AnySettingsParams, 41 | routePath as settingsRoutes, 42 | } from "src/routes/settings"; 43 | import { 44 | AnyLocalParams as AnyVaultParams, 45 | routePath as vaultRoutes, 46 | } from "src/routes/vault"; 47 | import { 48 | AnyLocalParams as AnyTagsParams, 49 | routePath as tagsRoutes, 50 | } from "src/routes/tags"; 51 | import { IncomingBaseParams } from "src/schemata"; 52 | import { HandlerFunction } from "src/types"; 53 | 54 | export const routes: RoutePath = { 55 | ...rootRoutes, 56 | ...commandRoutes, 57 | ...dataviewRoutes, 58 | ...fileRoutes, 59 | ...folderRoutes, 60 | ...infoRoutes, 61 | ...notePropertiesRoutes, 62 | ...noteRoutes, 63 | ...omnisearchRoutes, 64 | ...searchRoutes, 65 | ...settingsRoutes, 66 | ...tagsRoutes, 67 | ...vaultRoutes, 68 | }; 69 | 70 | /** 71 | * A `RoutePath` describes a routing branch coming off from the root node (`/`). 72 | * It's an object with properties, each key containing a route `path` and its 73 | * related value is an array of object each describing a sub-path. 74 | * 75 | * Example: 76 | * 77 | * { "daily-note": [ 78 | * { path: "create", schema: …, handler: … }, 79 | * { path: "get", schema: …, handler: … }, … 80 | * ] } 81 | * => builds routes `/daily-note/create` and `/daily-note/get` 82 | * 83 | * A `RouteSubpath` describes a sub-path (`path`), the Zod schema for validation 84 | * and a `handler` function. It defines which handler function is responsible 85 | * for which route path, and what data structure the function can expect. 86 | */ 87 | export type RoutePath = { 88 | [path: string]: RouteSubpath[]; 89 | }; 90 | 91 | export type RouteSubpath = { 92 | path: string; 93 | schema: 94 | | z.AnyZodObject 95 | | z.ZodDiscriminatedUnion 96 | | z.ZodEffects 97 | | z.ZodUnion; 98 | handler: HandlerFunction; 99 | }; 100 | 101 | export type AnyParams = 102 | | AnyCommandParams 103 | | AnyDataviewParams 104 | | AnyFileParams 105 | | AnyFolderParams 106 | | AnyInfoParams 107 | | AnyNoteParams 108 | | AnyNotePropertiesParams 109 | | AnyOmnisearchParams 110 | | AnySearchParams 111 | | AnySettingsParams 112 | | AnyTagsParams 113 | | AnyVaultParams 114 | | IncomingBaseParams; 115 | 116 | export enum NoteTargetingParameterKey { 117 | File = "file", 118 | UID = "uid", 119 | PeriodicNote = "periodic-note", 120 | } 121 | -------------------------------------------------------------------------------- /src/routes/command.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { STRINGS } from "src/constants"; 3 | import { RoutePath } from "src/routes"; 4 | import { incomingBaseParams } from "src/schemata"; 5 | import { 6 | HandlerCommandsExecutionSuccess, 7 | HandlerCommandsSuccess, 8 | HandlerFailure, 9 | RealLifePlugin, 10 | } from "src/types"; 11 | import { ErrorCode, failure, success } from "src/utils/results-handling"; 12 | import { helloRoute } from "src/utils/routing"; 13 | import { pause } from "src/utils/time"; 14 | import { zodCommaSeparatedStrings } from "src/utils/zod"; 15 | 16 | // SCHEMATA ---------------------------------------- 17 | 18 | const listParams = incomingBaseParams.extend({ 19 | "x-error": z.string().url(), 20 | "x-success": z.string().url(), 21 | }); 22 | 23 | const executeParams = incomingBaseParams.extend({ 24 | commands: zodCommaSeparatedStrings, 25 | "pause-in-secs": z.coerce.number().optional(), 26 | }); 27 | 28 | // TYPES ---------------------------------------- 29 | 30 | type ListParams = z.infer; 31 | type ExecuteParams = z.infer; 32 | 33 | export type AnyLocalParams = 34 | | ListParams 35 | | ExecuteParams; 36 | 37 | // ROUTES ---------------------------------------- 38 | 39 | export const routePath: RoutePath = { 40 | "/command": [ 41 | helloRoute(), 42 | { path: "/list", schema: listParams, handler: handleList }, 43 | { 44 | path: "/execute", 45 | schema: executeParams, 46 | handler: handleExecute, 47 | }, 48 | ], 49 | }; 50 | 51 | // HANDLERS ---------------------------------------- 52 | 53 | async function handleList( 54 | this: RealLifePlugin, 55 | params: ListParams, 56 | ): Promise { 57 | const commands = this.app.commands 58 | .listCommands() 59 | .map((cmd) => ({ id: cmd.id, name: cmd.name })); 60 | 61 | return success({ commands: JSON.stringify(commands) }); 62 | } 63 | 64 | async function handleExecute( 65 | this: RealLifePlugin, 66 | params: ExecuteParams, 67 | ): Promise { 68 | const { commands } = params; 69 | const pauseInMilliseconds = (params["pause-in-secs"] || 0.2) * 1000; 70 | 71 | for (let idx = 0; idx < commands.length; idx++) { 72 | const cmd = commands[idx]; 73 | const wasSuccess = this.app.commands.executeCommandById(cmd); 74 | 75 | // If this call wasn't successful, stop the sequence and return an error. 76 | if (!wasSuccess) { 77 | return failure(ErrorCode.notFound, STRINGS.command_not_found(cmd)); 78 | } 79 | 80 | // Unless this was the last command of the sequence, put in a short pause. 81 | if (idx < commands.length - 1) { 82 | await pause(pauseInMilliseconds); 83 | } 84 | } 85 | 86 | return success({}); 87 | } 88 | -------------------------------------------------------------------------------- /src/routes/dataview.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DataviewApi, 3 | getAPI, 4 | isPluginEnabled as isDataviewEnabled, 5 | } from "obsidian-dataview"; 6 | import { z } from "zod"; 7 | import { STRINGS } from "src/constants"; 8 | import { RoutePath } from "src/routes"; 9 | import { incomingBaseParams } from "src/schemata"; 10 | import { 11 | HandlerDataviewSuccess, 12 | HandlerFailure, 13 | RealLifePlugin, 14 | } from "src/types"; 15 | import { ErrorCode, failure, success } from "src/utils/results-handling"; 16 | import { helloRoute } from "src/utils/routing"; 17 | 18 | // SCHEMATA ---------------------------------------- 19 | 20 | const readParams = incomingBaseParams.extend({ 21 | "dql": z.string(), 22 | "x-error": z.string().url(), 23 | "x-success": z.string().url(), 24 | }); 25 | 26 | // TYPES ---------------------------------------- 27 | 28 | type ReadParams = z.infer; 29 | 30 | export type AnyLocalParams = ReadParams; 31 | 32 | // ROUTES ---------------------------------------- 33 | 34 | export const routePath: RoutePath = { 35 | "/dataview": [ 36 | helloRoute(), 37 | { path: "/table-query", schema: readParams, handler: handleTableQuery }, 38 | { path: "/list-query", schema: readParams, handler: handleListQuery }, 39 | // { path: "/task-query", schema: readParams, handler: handleTaskQuery }, 40 | ], 41 | }; 42 | 43 | // HANDLERS ---------------------------------------- 44 | 45 | async function handleTableQuery( 46 | this: RealLifePlugin, 47 | params: ReadParams, 48 | ): Promise { 49 | return await executeDataviewQuery.bind(this)("table", params); 50 | } 51 | 52 | async function handleListQuery( 53 | this: RealLifePlugin, 54 | params: ReadParams, 55 | ): Promise { 56 | return await executeDataviewQuery.bind(this)("list", params); 57 | } 58 | 59 | // HELPERS ---------------------------------------- 60 | 61 | function dqlValuesMapper(dataview: DataviewApi, v: any): any { 62 | return Array.isArray(v) 63 | ? v.map((v1) => dqlValuesMapper(dataview, v1)) 64 | : dataview.value.toString(v); 65 | } 66 | 67 | async function executeDataviewQuery( 68 | this: RealLifePlugin, 69 | type: "table" | "list", 70 | params: ReadParams, 71 | ): Promise { 72 | const dataview = getAPI(this.app); 73 | 74 | if (!isDataviewEnabled(this.app) || !dataview) { 75 | return failure( 76 | ErrorCode.featureUnavailable, 77 | STRINGS.dataview_plugin_not_available, 78 | ); 79 | } 80 | 81 | const dql = params.dql.trim() + "\n"; 82 | if (!dql.toLowerCase().startsWith(type)) { 83 | return failure( 84 | ErrorCode.invalidInput, 85 | STRINGS[`dataview_dql_must_start_with_${type}`], 86 | ); 87 | } 88 | 89 | const res = await dataview.query(dql); 90 | if (!res.successful) { 91 | return failure(ErrorCode.unknownError, res.error); 92 | } 93 | 94 | // For some TABLE queries, DV will return a three-dimensional array instead of 95 | // a two-dimensional one. Not sure what's the cause but I'll need to account 96 | // for this. (https://github.com/czottmann/obsidian-actions-uri/issues/79) 97 | if (type === "table") { 98 | return (getArrayDimensions(res.value.values) > 2) 99 | ? success({ data: dqlValuesMapper(dataview, res.value.values[0]) }) 100 | : success({ data: dqlValuesMapper(dataview, res.value.values) }); 101 | } 102 | 103 | // For LIST queries, DV will return a two-dimensional array instead of a one- 104 | // dimensional one *if* one of the queried files returns more than one hit. 105 | // This is inconsistent, and AFO will nope out. So we'll need to make it 106 | // consistent before rendering out the result. 107 | // 108 | // Example: If you query for an inline field (`whatever::`), and one file 109 | // contains two of this field, e.g. `whatever:: something 1` and 110 | // `whatever:: something 2`, while another file contains just one 111 | // (e.g., `whatever:: something 3`), DV will return: 112 | // 113 | // [ 114 | // ["something 1", "something 2"], 115 | // "something 3" 116 | // ] 117 | if (type === "list") { 118 | res.value.values = res.value.values 119 | .map((v: any) => Array.isArray(v) ? v : [v]); 120 | return success({ 121 | data: dqlValuesMapper(dataview, res.value.values) 122 | .map((v: any) => v.join(", ")), 123 | }); 124 | } 125 | 126 | return failure(ErrorCode.invalidInput, "Neither LIST nor TABLE query"); 127 | } 128 | 129 | function getArrayDimensions(input: any[]) { 130 | if (!Array.isArray(input)) { 131 | return 0; 132 | } 133 | 134 | let dimensions = 1; 135 | input.forEach((item) => { 136 | if (Array.isArray(item)) { 137 | dimensions = Math.max(dimensions, getArrayDimensions(item) + 1); 138 | } 139 | }); 140 | 141 | return dimensions; 142 | } 143 | -------------------------------------------------------------------------------- /src/routes/file.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { STRINGS } from "src/constants"; 3 | import { RoutePath } from "src/routes"; 4 | import { incomingBaseParams } from "src/schemata"; 5 | import { 6 | HandlerFailure, 7 | HandlerFilePathSuccess, 8 | HandlerPathsSuccess, 9 | HandlerTextSuccess, 10 | RealLifePlugin, 11 | } from "src/types"; 12 | import { 13 | getFile, 14 | renameFilepath, 15 | trashFilepath, 16 | } from "src/utils/file-handling"; 17 | import { helloRoute } from "src/utils/routing"; 18 | import { ErrorCode, failure, success } from "src/utils/results-handling"; 19 | import { 20 | zodExistingFilePath, 21 | zodOptionalBoolean, 22 | zodSanitizedFilePath, 23 | } from "src/utils/zod"; 24 | 25 | // SCHEMATA ---------------------------------------- 26 | 27 | const defaultParams = incomingBaseParams.extend({ 28 | "x-error": z.string().url(), 29 | "x-success": z.string().url(), 30 | }); 31 | 32 | const openParams = incomingBaseParams.extend({ 33 | file: zodExistingFilePath, 34 | }); 35 | 36 | const deleteParams = incomingBaseParams.extend({ 37 | file: zodExistingFilePath, 38 | }); 39 | 40 | const renameParams = incomingBaseParams.extend({ 41 | file: zodExistingFilePath, 42 | "new-filename": zodSanitizedFilePath, 43 | silent: zodOptionalBoolean, 44 | }); 45 | 46 | // TYPES ---------------------------------------- 47 | 48 | type DefaultParams = z.infer; 49 | type OpenParams = z.infer; 50 | type DeleteParams = z.infer; 51 | type RenameParams = z.infer; 52 | 53 | export type AnyLocalParams = 54 | | DefaultParams 55 | | OpenParams 56 | | DeleteParams 57 | | RenameParams; 58 | 59 | // ROUTES ---------------------------------------- 60 | 61 | export const routePath: RoutePath = { 62 | "/file": [ 63 | helloRoute(), 64 | { path: "/list", schema: defaultParams, handler: handleList }, 65 | { path: "/get-active", schema: defaultParams, handler: handleGetActive }, 66 | { path: "/open", schema: openParams, handler: handleOpen }, 67 | { path: "/delete", schema: deleteParams, handler: handleDelete }, 68 | { path: "/trash", schema: deleteParams, handler: handleTrash }, 69 | { path: "/rename", schema: renameParams, handler: handleRename }, 70 | ], 71 | }; 72 | 73 | // HANDLERS ---------------------------------------- 74 | 75 | async function handleList( 76 | this: RealLifePlugin, 77 | params: DefaultParams, 78 | ): Promise { 79 | return success({ 80 | paths: this.app.vault.getFiles().map((t) => t.path).sort(), 81 | }); 82 | } 83 | 84 | async function handleGetActive( 85 | this: RealLifePlugin, 86 | params: DefaultParams, 87 | ): Promise { 88 | const res = this.app.workspace.getActiveFile(); 89 | return res 90 | ? success({ filepath: res.path }) 91 | : failure(ErrorCode.notFound, "No active file"); 92 | } 93 | 94 | async function handleOpen( 95 | params: OpenParams, 96 | ): Promise { 97 | const { file } = params; 98 | const res = await getFile(file.path); 99 | return res.isSuccess 100 | ? success({ message: STRINGS.file_opened }, file.path) 101 | : res; 102 | } 103 | 104 | async function handleDelete( 105 | params: DeleteParams, 106 | ): Promise { 107 | const { file } = params; 108 | const res = await trashFilepath(file.path, true); 109 | return res.isSuccess ? success({ message: res.result }, file.path) : res; 110 | } 111 | 112 | async function handleTrash( 113 | params: DeleteParams, 114 | ): Promise { 115 | const { file } = params; 116 | const res = await trashFilepath(file.path); 117 | return res.isSuccess ? success({ message: res.result }, file.path) : res; 118 | } 119 | 120 | async function handleRename( 121 | params: RenameParams, 122 | ): Promise { 123 | const { file } = params; 124 | const res = await renameFilepath(file.path, params["new-filename"]); 125 | return res.isSuccess ? success({ message: res.result }, file.path) : res; 126 | } 127 | -------------------------------------------------------------------------------- /src/routes/folder.ts: -------------------------------------------------------------------------------- 1 | import { TFolder } from "obsidian"; 2 | import { z } from "zod"; 3 | import { STRINGS } from "src/constants"; 4 | import { RoutePath } from "src/routes"; 5 | import { incomingBaseParams } from "src/schemata"; 6 | import { 7 | HandlerFailure, 8 | HandlerPathsSuccess, 9 | HandlerTextSuccess, 10 | } from "src/types"; 11 | import { 12 | createFolderIfNecessary, 13 | getFileMap, 14 | renameFilepath, 15 | trashFilepath, 16 | } from "src/utils/file-handling"; 17 | import { helloRoute } from "src/utils/routing"; 18 | import { zodExistingFolderPath, zodSanitizedFolderPath } from "src/utils/zod"; 19 | import { success } from "src/utils/results-handling"; 20 | 21 | // SCHEMATA ---------------------------------------- 22 | 23 | const listParams = incomingBaseParams.extend({ 24 | "x-error": z.string().url(), 25 | "x-success": z.string().url(), 26 | }); 27 | 28 | const createParams = incomingBaseParams.extend({ 29 | folder: zodSanitizedFolderPath, 30 | }); 31 | 32 | const deleteParams = incomingBaseParams.extend({ 33 | folder: zodExistingFolderPath, 34 | }); 35 | 36 | const renameParams = incomingBaseParams.extend({ 37 | folder: zodExistingFolderPath, 38 | "new-foldername": zodSanitizedFolderPath, 39 | }); 40 | 41 | // TYPES ---------------------------------------- 42 | 43 | type ListParams = z.infer; 44 | type CreateParams = z.infer; 45 | type DeleteParams = z.infer; 46 | type RenameParams = z.infer; 47 | 48 | export type AnyLocalParams = 49 | | ListParams 50 | | CreateParams 51 | | DeleteParams; 52 | 53 | // ROUTES ---------------------------------------- 54 | 55 | export const routePath: RoutePath = { 56 | "/folder": [ 57 | helloRoute(), 58 | { path: "/list", schema: listParams, handler: handleList }, 59 | { path: "/create", schema: createParams, handler: handleCreate }, 60 | { path: "/rename", schema: renameParams, handler: handleRename }, 61 | { path: "/delete", schema: deleteParams, handler: handleDelete }, 62 | { path: "/trash", schema: deleteParams, handler: handleTrash }, 63 | ], 64 | }; 65 | 66 | // HANDLERS ---------------------------------------- 67 | 68 | async function handleList( 69 | params: ListParams, 70 | ): Promise { 71 | return success({ 72 | paths: getFileMap() 73 | .filter((t) => t instanceof TFolder) 74 | .map((t) => t.path.endsWith("/") ? t.path : `${t.path}/`).sort(), 75 | }); 76 | } 77 | 78 | async function handleCreate( 79 | params: CreateParams, 80 | ): Promise { 81 | const { folder } = params; 82 | await createFolderIfNecessary(folder); 83 | return success({ message: STRINGS.folder_created }, folder); 84 | } 85 | 86 | async function handleRename( 87 | params: RenameParams, 88 | ): Promise { 89 | const { folder } = params; 90 | const res = await renameFilepath(folder.path, params["new-foldername"]); 91 | return res.isSuccess ? success({ message: res.result }, folder.path) : res; 92 | } 93 | 94 | async function handleDelete( 95 | params: DeleteParams, 96 | ): Promise { 97 | const { folder } = params; 98 | const res = await trashFilepath(folder.path, true); 99 | return res.isSuccess ? success({ message: res.result }, folder.path) : res; 100 | } 101 | 102 | async function handleTrash( 103 | params: DeleteParams, 104 | ): Promise { 105 | const { folder } = params; 106 | const res = await trashFilepath(folder.path); 107 | return res.isSuccess ? success({ message: res.result }, folder.path) : res; 108 | } 109 | -------------------------------------------------------------------------------- /src/routes/info.ts: -------------------------------------------------------------------------------- 1 | import { apiVersion, Platform } from "obsidian"; 2 | import { z } from "zod"; 3 | import { PLUGIN_INFO } from "src/plugin-info"; 4 | import { RoutePath } from "src/routes"; 5 | import { incomingBaseParams } from "src/schemata"; 6 | import { HandlerInfoSuccess } from "src/types"; 7 | import { success } from "src/utils/results-handling"; 8 | 9 | // SCHEMATA -------------------- 10 | 11 | const defaultParams = incomingBaseParams.extend({ 12 | "x-error": z.string().url(), 13 | "x-success": z.string().url(), 14 | }); 15 | type DefaultParams = z.infer; 16 | 17 | export type AnyLocalParams = DefaultParams; 18 | 19 | // ROUTES -------------------- 20 | 21 | export const routePath: RoutePath = { 22 | "/info": [ 23 | { path: "/", schema: defaultParams, handler: handleInfo }, 24 | ], 25 | }; 26 | 27 | // HANDLERS -------------------- 28 | 29 | async function handleInfo( 30 | params: DefaultParams, 31 | ): Promise { 32 | const uaMatch = navigator.userAgent.match(/\((.+?)\)/); 33 | const os: string = uaMatch ? uaMatch[1] : "unknown"; 34 | const { isAndroidApp, isDesktopApp, isIosApp, isMacOS } = Platform; 35 | 36 | let platform = ""; 37 | if (isDesktopApp && isMacOS) { 38 | platform = "macOS"; 39 | } else if (isDesktopApp) { 40 | platform = "Windows/Linux"; 41 | } else if (isIosApp) { 42 | platform = "iOS"; 43 | } else if (isAndroidApp) { 44 | platform = "Android"; 45 | } 46 | 47 | return success({ 48 | ...PLUGIN_INFO, 49 | apiVersion, 50 | nodeVersion: window.process?.version?.replace(/^v/, "") || "N/A", 51 | platform, 52 | os, 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /src/routes/note-properties.ts: -------------------------------------------------------------------------------- 1 | import { stringifyYaml } from "obsidian"; 2 | import { z } from "zod"; 3 | import { RoutePath } from "src/routes"; 4 | import { incomingBaseParams, noteTargetingParams } from "src/schemata"; 5 | import { 6 | HandlerFailure, 7 | HandlerFileSuccess, 8 | HandlerPropertiesSuccess, 9 | Prettify, 10 | } from "src/types"; 11 | import { propertiesForFile, updateNote } from "src/utils/file-handling"; 12 | import { resolveNoteTargetingStrict } from "src/utils/parameters"; 13 | import { helloRoute } from "src/utils/routing"; 14 | import { success } from "src/utils/results-handling"; 15 | import { 16 | zodJsonPropertiesObject, 17 | zodJsonStringArray, 18 | zodOptionalBoolean, 19 | } from "src/utils/zod"; 20 | 21 | // SCHEMATA ---------------------------------------- 22 | 23 | const getParams = incomingBaseParams 24 | .merge(noteTargetingParams) 25 | .extend({ 26 | silent: zodOptionalBoolean, 27 | "x-error": z.string().url(), 28 | "x-success": z.string().url(), 29 | }) 30 | .transform(resolveNoteTargetingStrict); 31 | 32 | const setParams = incomingBaseParams 33 | .merge(noteTargetingParams) 34 | .extend({ 35 | properties: zodJsonPropertiesObject, 36 | mode: z.enum(["overwrite", "update"]).optional(), 37 | }) 38 | .transform(resolveNoteTargetingStrict); 39 | 40 | const removeKeysParams = incomingBaseParams 41 | .merge(noteTargetingParams) 42 | .extend({ 43 | keys: zodJsonStringArray, 44 | }) 45 | .transform(resolveNoteTargetingStrict); 46 | 47 | // TYPES ---------------------------------------- 48 | 49 | type GetParams = Prettify>; 50 | type SetParams = Prettify>; 51 | type RemoveKeysParams = Prettify>; 52 | 53 | export type AnyLocalParams = 54 | | GetParams 55 | | SetParams 56 | | RemoveKeysParams; 57 | 58 | // ROUTES -------------------- 59 | 60 | export const routePath: RoutePath = { 61 | "/note-properties": [ 62 | helloRoute(), 63 | { path: "/get", schema: getParams, handler: handleGet }, 64 | { path: "/set", schema: setParams, handler: handleSet }, 65 | { path: "/clear", schema: getParams, handler: handleClear }, 66 | { 67 | path: "/remove-keys", 68 | schema: removeKeysParams, 69 | handler: handleRemoveKeys, 70 | }, 71 | ], 72 | }; 73 | 74 | // HANDLERS -------------------- 75 | 76 | async function handleGet( 77 | params: GetParams, 78 | ): Promise { 79 | const { _resolved: { inputFile } } = params; 80 | return success( 81 | { properties: await propertiesForFile(inputFile!) }, 82 | inputFile?.path, 83 | ); 84 | } 85 | 86 | async function handleSet( 87 | params: SetParams, 88 | ): Promise { 89 | const { _resolved: { inputFile }, mode, properties } = params; 90 | const props = mode === "update" 91 | ? { ...await propertiesForFile(inputFile!), ...properties } 92 | : properties; 93 | 94 | return updateNote(inputFile!.path, sanitizedStringifyYaml(props)); 95 | } 96 | 97 | async function handleClear( 98 | params: GetParams, 99 | ): Promise { 100 | const { _resolved: { inputPath: path } } = params; 101 | return updateNote(path, ""); 102 | } 103 | 104 | async function handleRemoveKeys( 105 | params: RemoveKeysParams, 106 | ): Promise { 107 | const { _resolved: { inputPath: path, inputFile }, keys } = params; 108 | 109 | const props = await propertiesForFile(inputFile!)!; 110 | ( keys).forEach((key) => delete props[key]); 111 | 112 | return updateNote(path, sanitizedStringifyYaml(props)); 113 | } 114 | 115 | function sanitizedStringifyYaml(props: any): string { 116 | return Object.keys(props).length > 0 ? stringifyYaml(props).trim() : ""; 117 | } 118 | -------------------------------------------------------------------------------- /src/routes/omnisearch.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { RoutePath } from "src/routes"; 3 | import { incomingBaseParams } from "src/schemata"; 4 | import { 5 | HandlerFailure, 6 | HandlerSearchSuccess, 7 | HandlerTextSuccess, 8 | RealLifePlugin, 9 | } from "src/types"; 10 | import { success } from "src/utils/results-handling"; 11 | import { helloRoute } from "src/utils/routing"; 12 | import { doOmnisearch } from "src/utils/search"; 13 | 14 | // SCHEMATA -------------------- 15 | 16 | const defaultParams = incomingBaseParams.extend({ 17 | query: z.string().min(1, { message: "can't be empty" }), 18 | "x-error": z.string().url(), 19 | "x-success": z.string().url(), 20 | }); 21 | 22 | const openParams = incomingBaseParams.extend({ 23 | query: z.string().min(1, { message: "can't be empty" }), 24 | }); 25 | 26 | // TYPES ---------------------------------------- 27 | 28 | type DefaultParams = z.infer; 29 | type OpenParams = z.infer; 30 | 31 | export type AnyLocalParams = 32 | | DefaultParams 33 | | OpenParams; 34 | 35 | // ROUTES -------------------- 36 | 37 | export const routePath: RoutePath = { 38 | "/omnisearch": [ 39 | helloRoute(), 40 | { path: "/all-notes", schema: defaultParams, handler: handleSearch }, 41 | { path: "/open", schema: openParams, handler: handleOpen }, 42 | ], 43 | }; 44 | 45 | // HANDLERS -------------------- 46 | 47 | async function handleSearch( 48 | params: DefaultParams, 49 | ): Promise { 50 | const res = await doOmnisearch(params.query); 51 | return res.isSuccess ? success(res.result) : res; 52 | } 53 | 54 | async function handleOpen( 55 | this: RealLifePlugin, 56 | params: DefaultParams, 57 | ): Promise { 58 | // Let's open the search in the simplest way possible. 59 | window.open( 60 | "obsidian://omnisearch?" + 61 | "vault=" + encodeURIComponent(this.app.vault.getName()) + 62 | "&query=" + encodeURIComponent(params.query.trim()), 63 | ); 64 | 65 | return success({ message: "Opened search" }); 66 | } 67 | -------------------------------------------------------------------------------- /src/routes/root.ts: -------------------------------------------------------------------------------- 1 | import { RoutePath } from "src/routes"; 2 | import { helloRoute } from "src/utils/routing"; 3 | 4 | // ROUTES -------------------- 5 | 6 | export const routePath: RoutePath = { 7 | "/": [ 8 | helloRoute(), 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /src/routes/search.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { RoutePath } from "src/routes"; 3 | import { incomingBaseParams } from "src/schemata"; 4 | import { 5 | HandlerFailure, 6 | HandlerSearchSuccess, 7 | HandlerTextSuccess, 8 | RealLifePlugin, 9 | } from "src/types"; 10 | import { success } from "src/utils/results-handling"; 11 | import { helloRoute } from "src/utils/routing"; 12 | import { doSearch } from "src/utils/search"; 13 | 14 | // SCHEMATA -------------------- 15 | 16 | const defaultParams = incomingBaseParams.extend({ 17 | query: z.string().min(1, { message: "can't be empty" }), 18 | "x-error": z.string().url(), 19 | "x-success": z.string().url(), 20 | }); 21 | 22 | const openParams = incomingBaseParams.extend({ 23 | query: z.string().min(1, { message: "can't be empty" }), 24 | }); 25 | 26 | // TYPES ---------------------------------------- 27 | 28 | type DefaultParams = z.infer; 29 | type OpenParams = z.infer; 30 | 31 | export type AnyLocalParams = 32 | | DefaultParams 33 | | OpenParams; 34 | 35 | // ROUTES -------------------- 36 | 37 | export const routePath: RoutePath = { 38 | "/search": [ 39 | helloRoute(), 40 | { path: "/all-notes", schema: defaultParams, handler: handleSearch }, 41 | { path: "/open", schema: openParams, handler: handleOpen }, 42 | ], 43 | }; 44 | 45 | // HANDLERS -------------------- 46 | 47 | async function handleSearch( 48 | params: DefaultParams, 49 | ): Promise { 50 | const res = await doSearch(params.query); 51 | return res.isSuccess ? success(res.result) : res; 52 | } 53 | 54 | async function handleOpen( 55 | this: RealLifePlugin, 56 | params: DefaultParams, 57 | ): Promise { 58 | // Let's open the search in the simplest way possible. 59 | window.open( 60 | "obsidian://search?" + 61 | "vault=" + encodeURIComponent(this.app.vault.getName()) + 62 | "&query=" + encodeURIComponent(params.query.trim()), 63 | ); 64 | 65 | return success({ message: "Opened search" }); 66 | } 67 | -------------------------------------------------------------------------------- /src/routes/settings.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { RoutePath } from "src/routes"; 3 | import { incomingBaseParams } from "src/schemata"; 4 | import { HandlerTextSuccess, RealLifePlugin } from "src/types"; 5 | import { success } from "src/utils/results-handling"; 6 | import { helloRoute } from "src/utils/routing"; 7 | 8 | // SCHEMATA -------------------- 9 | 10 | const defaultParams = incomingBaseParams; 11 | 12 | // TYPES ---------------------------------------- 13 | 14 | type DefaultParams = z.infer; 15 | 16 | export type AnyLocalParams = DefaultParams; 17 | 18 | // ROUTES -------------------- 19 | 20 | export const routePath: RoutePath = { 21 | "/settings": [ 22 | helloRoute(), 23 | { path: "/open", schema: defaultParams, handler: handleOpen }, 24 | ], 25 | }; 26 | 27 | // HANDLERS -------------------- 28 | 29 | async function handleOpen( 30 | this: RealLifePlugin, 31 | params: DefaultParams, 32 | ): Promise { 33 | const setting = this.app.setting; 34 | setting.open(); 35 | setting.openTabById(this.manifest.id); 36 | return success({ message: "Opened settings UI" }); 37 | } 38 | -------------------------------------------------------------------------------- /src/routes/tags.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { RoutePath } from "src/routes"; 3 | import { incomingBaseParams } from "src/schemata"; 4 | import { HandlerFailure, HandlerTagsSuccess, RealLifePlugin } from "src/types"; 5 | import { success } from "src/utils/results-handling"; 6 | import { helloRoute } from "src/utils/routing"; 7 | 8 | // SCHEMATA ---------------------------------------- 9 | 10 | const listParams = incomingBaseParams.extend({ 11 | "x-error": z.string().url(), 12 | "x-success": z.string().url(), 13 | }); 14 | 15 | // TYPES ---------------------------------------- 16 | 17 | type ListParams = z.infer; 18 | 19 | export type AnyLocalParams = ListParams; 20 | 21 | // ROUTES ---------------------------------------- 22 | 23 | export const routePath: RoutePath = { 24 | "/tags": [ 25 | helloRoute(), 26 | { path: "/list", schema: listParams, handler: handleList }, 27 | ], 28 | }; 29 | 30 | // HANDLERS ---------------------------------------- 31 | 32 | async function handleList( 33 | this: RealLifePlugin, 34 | params: ListParams, 35 | ): Promise { 36 | return success({ 37 | tags: Object.keys(this.app.metadataCache.getTags()) 38 | .sort((a, b) => a.localeCompare(b)), 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/routes/vault.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from "obsidian"; 2 | import { z } from "zod"; 3 | import { STRINGS } from "src/constants"; 4 | import { RoutePath } from "src/routes"; 5 | import { IncomingBaseParams, incomingBaseParams } from "src/schemata"; 6 | import { 7 | HandlerFailure, 8 | HandlerPathsSuccess, 9 | HandlerVaultInfoSuccess, 10 | HandlerVaultSuccess, 11 | RealLifeDataAdapter, 12 | RealLifePlugin, 13 | RealLifeVault, 14 | } from "src/types"; 15 | import { ErrorCode, failure, success } from "src/utils/results-handling"; 16 | import { helloRoute } from "src/utils/routing"; 17 | 18 | // SCHEMATA -------------------- 19 | 20 | const defaultParams = incomingBaseParams.extend({ 21 | "x-error": z.string().url(), 22 | "x-success": z.string().url(), 23 | }); 24 | 25 | // TYPES ---------------------------------------- 26 | 27 | type DefaultParams = z.infer; 28 | 29 | export type AnyLocalParams = DefaultParams; 30 | 31 | // ROUTES -------------------- 32 | 33 | export const routePath: RoutePath = { 34 | "/vault": [ 35 | helloRoute(), 36 | { path: "/open", schema: incomingBaseParams, handler: handleOpen }, 37 | { path: "/close", schema: incomingBaseParams, handler: handleClose }, 38 | { path: "/info", schema: defaultParams, handler: handleInfo }, 39 | { 40 | path: "/list-all-files", 41 | schema: defaultParams, 42 | handler: handleListFiles, 43 | }, 44 | { 45 | path: "/list-non-notes-files", 46 | schema: defaultParams, 47 | handler: handleListFilesExceptNotes, 48 | }, 49 | ], 50 | }; 51 | 52 | // HANDLERS -------------------- 53 | 54 | async function handleOpen( 55 | params: IncomingBaseParams, 56 | ): Promise { 57 | // If we're here, then the vault is already open. 58 | return success({}); 59 | } 60 | 61 | async function handleClose( 62 | params: IncomingBaseParams, 63 | ): Promise { 64 | if (Platform.isMobileApp) { 65 | return failure( 66 | ErrorCode.featureUnavailable, 67 | STRINGS.not_available_on_mobile, 68 | ); 69 | } 70 | 71 | // This feels wonky, like a race condition waiting to happen. 72 | window.setTimeout(window.close, 600); 73 | return success({}); 74 | } 75 | 76 | async function handleInfo( 77 | this: RealLifePlugin, 78 | params: DefaultParams, 79 | ): Promise { 80 | const { vault } = this.app; 81 | const { config } = vault; 82 | const basePath = ( vault.adapter).basePath; 83 | 84 | if (!config || !basePath) { 85 | return failure(ErrorCode.notFound, STRINGS.vault_internals_not_found); 86 | } 87 | 88 | return success({ 89 | basePath, 90 | attachmentFolderPath: `${basePath}/${config.attachmentFolderPath}` 91 | .replace(/\/$/, ""), 92 | newFileFolderPath: ( 93 | config.newFileLocation === "folder" 94 | ? `${basePath}/${config.newFileFolderPath}`.replace(/\/$/, "") 95 | : basePath 96 | ), 97 | }); 98 | } 99 | 100 | async function handleListFiles( 101 | this: RealLifePlugin, 102 | params: DefaultParams, 103 | ): Promise { 104 | return success({ 105 | paths: this.app.vault.getFiles().map((t) => t.path).sort(), 106 | }); 107 | } 108 | 109 | async function handleListFilesExceptNotes( 110 | this: RealLifePlugin, 111 | params: DefaultParams, 112 | ): Promise { 113 | const { vault } = this.app; 114 | const files = vault.getFiles().map((t) => t.path); 115 | const notes = vault.getMarkdownFiles().map((t) => t.path); 116 | 117 | return success({ 118 | paths: files.filter((path) => !notes.includes(path)).sort(), 119 | }); 120 | } 121 | -------------------------------------------------------------------------------- /src/schemata.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { zodOptionalBoolean, zodSanitizedNotePath } from "src/utils/zod"; 3 | import { 4 | PeriodicNoteType, 5 | PeriodicNoteTypeWithRecents, 6 | } from "src/utils/periodic-notes-handling"; 7 | 8 | export const incomingBaseParams = z.object({ 9 | action: z.string(), 10 | vault: z.string().min(1, { message: "can't be empty" }), 11 | 12 | // When enabled, the plugin will return all input call parameters as part of 13 | // its `x-success` or `x-error` callbacks. 14 | "debug-mode": zodOptionalBoolean, 15 | 16 | // When enabled, the plugin will not show any error notices in Obsidian. For 17 | // example, if a requested note isn't available, the plugin would normally 18 | // show a notice in Obsidian. This can be disabled by setting this to `true`. 19 | "hide-ui-notice-on-error": zodOptionalBoolean, 20 | 21 | "x-error": z.string().url().optional(), 22 | "x-success": z.string().url().optional(), 23 | "x-source": z.string().optional(), 24 | }); 25 | export type IncomingBaseParams = z.output; 26 | 27 | export const noteTargetingParams = z.object({ 28 | file: zodSanitizedNotePath.optional(), 29 | uid: z.string().optional(), 30 | "periodic-note": z.nativeEnum(PeriodicNoteType).optional(), 31 | }); 32 | export type NoteTargetingParams = z.output; 33 | 34 | export const noteTargetingWithRecentsParams = z.object({ 35 | file: zodSanitizedNotePath.optional(), 36 | uid: z.string().optional(), 37 | "periodic-note": z.nativeEnum(PeriodicNoteTypeWithRecents).optional(), 38 | }); 39 | 40 | export type NoteTargetingWithRecentsParams = z.output< 41 | typeof noteTargetingWithRecentsParams 42 | >; 43 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { App, debounce, PluginSettingTab, Setting } from "obsidian"; 2 | import ActionsURI from "src/main"; 3 | 4 | export class SettingsTab extends PluginSettingTab { 5 | plugin: ActionsURI; 6 | 7 | constructor(app: App, plugin: ActionsURI) { 8 | super(app, plugin); 9 | this.plugin = plugin; 10 | } 11 | 12 | display(): void { 13 | const { 14 | containerEl, 15 | plugin, 16 | plugin: { settings, defaultSettings }, 17 | } = this; 18 | const debounceOnChange = debounce( 19 | async (val: string) => { 20 | settings.frontmatterKey = val.trim() || defaultSettings.frontmatterKey; 21 | await plugin.saveSettings(); 22 | }, 23 | 400, 24 | ); 25 | 26 | containerEl.empty(); 27 | 28 | new Setting(containerEl) 29 | .setName("UID frontmatter key") 30 | .setDesc(` 31 | Actions URI is able to find notes by their UID. 32 | This unique identifier is stored in the note's frontmatter. 33 | The plugin needs to know under which frontmatter key it can find the UID. (Default: "uid".) 34 | `) 35 | .addText((input) => { 36 | input 37 | .setPlaceholder(defaultSettings.frontmatterKey) 38 | .setValue(settings.frontmatterKey) 39 | .onChange(debounceOnChange); 40 | }); 41 | 42 | // Sponsoring 43 | const afoURL = 44 | "https://actions.work/actions-for-obsidian?ref=plugin-actions-uri"; 45 | containerEl.createEl("div", { 46 | attr: { 47 | style: ` 48 | border-radius: 0.5rem; 49 | border: 1px dashed var(--text-muted); 50 | color: var(--text-muted); 51 | display: grid; 52 | font-size: 85%; 53 | grid-gap: 1rem; 54 | grid-template-columns: auto 1fr; 55 | margin-top: 4rem; 56 | opacity: 0.75; 57 | padding: 1rem; 58 | `, 59 | }, 60 | }) 61 | .innerHTML = ` 62 | 63 | Actions for Obsidian icon, a cog wheel on a glossy black background 67 | 68 | 69 | Actions URI is brought to you by 70 | Actions for Obsidian, 71 | a macOS/iOS app made by the same developer as this plugin. AFO is the 72 | missing link between Obsidian and macOS / iOS: 50+ Shortcuts 73 | actions to bring your notes and your automations together. 74 | Take a look! 75 | 76 | `; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | export * from "src/types/handlers"; 2 | export * from "src/types/obsidian-objects"; 3 | export * from "src/types/plugins"; 4 | export * from "src/types/results"; 5 | 6 | export type PluginSettings = { 7 | frontmatterKey: string; 8 | }; 9 | 10 | /** 11 | * A TypeScript type alias called `Prettify`. 12 | * It takes a type as its argument and returns a new type that has the same properties as the original type, 13 | * but the properties are not intersected. This means that the new type is easier to read and understand. 14 | */ 15 | export type Prettify = 16 | & { [K in keyof T]: T[K] extends object ? Prettify : T[K] } 17 | // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents 18 | & unknown; 19 | -------------------------------------------------------------------------------- /src/types/handlers.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A handler function is a function that is responsible for dealing with a 3 | * particular route. It takes a payload (i.e. the parameters from the incoming 4 | * `x-callback-url` call) and a vault and returns any handler result object. 5 | * 6 | * @param incomingParams - The parameters from the incoming `x-callback-url` 7 | * 8 | * @returns A handler result object 9 | */ 10 | export type HandlerFunction = ( 11 | incomingParams: any, 12 | ) => Promise; 13 | 14 | type HandlerResult = { 15 | isSuccess: boolean; 16 | }; 17 | 18 | type HandlerSuccess = 19 | & HandlerResult 20 | & { processedFilepath?: string }; 21 | 22 | export type HandlerFailure = Readonly< 23 | & HandlerResult 24 | & { 25 | errorCode: number; 26 | errorMessage: string; 27 | } 28 | >; 29 | 30 | export type HandlerTextSuccess = Readonly< 31 | & HandlerSuccess 32 | & { 33 | result: { 34 | message: string; 35 | }; 36 | } 37 | >; 38 | 39 | export type HandlerFileSuccess = Readonly< 40 | & HandlerSuccess 41 | & { 42 | result: { 43 | content: string; 44 | filepath: string; 45 | uriPath: string; 46 | uriUID?: string; 47 | body?: string; 48 | frontMatter?: string; 49 | properties?: NoteProperties; 50 | selection?: string; 51 | uid?: string | string[]; 52 | }; 53 | } 54 | >; 55 | export type HandlerFilePathSuccess = Readonly< 56 | & HandlerSuccess 57 | & { 58 | result: { 59 | filepath: string; 60 | }; 61 | } 62 | >; 63 | 64 | export type HandlerDataviewSuccess = Readonly< 65 | & HandlerSuccess 66 | & { 67 | result: { 68 | data: string; 69 | }; 70 | } 71 | >; 72 | 73 | export type HandlerPathsSuccess = Readonly< 74 | & HandlerSuccess 75 | & { 76 | result: { 77 | paths: string[]; 78 | }; 79 | } 80 | >; 81 | 82 | export type HandlerSearchSuccess = Readonly< 83 | & HandlerSuccess 84 | & { 85 | result: { 86 | hits: string[]; 87 | }; 88 | } 89 | >; 90 | 91 | export type HandlerTagsSuccess = Readonly< 92 | & HandlerSuccess 93 | & { 94 | result: { 95 | tags: string[]; 96 | }; 97 | } 98 | >; 99 | 100 | export type HandlerInfoSuccess = Readonly< 101 | & HandlerSuccess 102 | & { 103 | result: { 104 | pluginVersion: string; 105 | pluginReleasedAt: string; 106 | apiVersion: string; 107 | nodeVersion: string; 108 | os: string; 109 | platform: string; 110 | }; 111 | } 112 | >; 113 | 114 | export type HandlerVaultSuccess = Readonly< 115 | & HandlerSuccess 116 | & { result: {} } 117 | >; 118 | 119 | export type HandlerVaultInfoSuccess = Readonly< 120 | & HandlerSuccess 121 | & { 122 | result: { 123 | basePath: string; 124 | attachmentFolderPath: string; 125 | newFileFolderPath: string; 126 | }; 127 | } 128 | >; 129 | 130 | export type HandlerCommandsSuccess = Readonly< 131 | & HandlerSuccess 132 | & { 133 | result: { 134 | commands: string; 135 | }; 136 | } 137 | >; 138 | 139 | export type HandlerCommandsExecutionSuccess = Readonly< 140 | & HandlerSuccess 141 | & { result: {} } 142 | >; 143 | 144 | export type HandlerPropertiesSuccess = Readonly< 145 | & HandlerSuccess 146 | & { 147 | result: { 148 | properties: NoteProperties; 149 | }; 150 | } 151 | >; 152 | 153 | export type NoteProperties = Record< 154 | string, 155 | string | string[] | number | boolean | null 156 | >; 157 | 158 | export type AnyHandlerSuccess = 159 | | HandlerCommandsExecutionSuccess 160 | | HandlerCommandsSuccess 161 | | HandlerDataviewSuccess 162 | | HandlerFileSuccess 163 | | HandlerFilePathSuccess 164 | | HandlerInfoSuccess 165 | | HandlerPathsSuccess 166 | | HandlerPropertiesSuccess 167 | | HandlerSearchSuccess 168 | | HandlerTextSuccess 169 | | HandlerVaultSuccess; 170 | 171 | export type AnyHandlerResult = 172 | | AnyHandlerSuccess 173 | | HandlerFailure; 174 | -------------------------------------------------------------------------------- /src/types/obsidian-objects.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | CachedMetadata, 4 | Command, 5 | DataAdapter, 6 | MetadataCache, 7 | PluginManifest, 8 | TAbstractFile, 9 | TFile, 10 | Vault, 11 | } from "obsidian"; 12 | import { PluginSettings } from "src/types"; 13 | 14 | export interface RealLifePlugin extends App { 15 | app: RealLifeApp; 16 | manifest: PluginManifest; 17 | settings: PluginSettings; 18 | vault: RealLifeVault; 19 | } 20 | 21 | export interface RealLifeApp extends App { 22 | commands: { 23 | executeCommandById(id: string): boolean; 24 | listCommands(): Command[]; 25 | }; 26 | internalPlugins: any; 27 | metadataCache: RealLifeMetadataCache; 28 | plugins: any; 29 | setting: { 30 | open: () => void; 31 | openTabById: (pluginName: string) => void; 32 | }; 33 | } 34 | 35 | export interface RealLifeVault extends Vault { 36 | fileMap: Record; 37 | config: { 38 | attachmentFolderPath: string; 39 | newFileLocation: "root" | "current" | "folder"; 40 | newFileFolderPath: string; 41 | }; 42 | } 43 | 44 | export interface RealLifeDataAdapter extends DataAdapter { 45 | basePath: string; 46 | } 47 | 48 | export interface RealLifeMetadataCache extends MetadataCache { 49 | getTags(): Record; 50 | 51 | /** 52 | * The default type signature is `getFileCache(file: TFile): CachedMetadata | null;`. 53 | * However, I've checked the actual implementation, and the function in `app.js` 54 | * only accesses `file.path` – which is present in both `TFile` and `TAbstractFile`. 55 | * 56 | * *"Bold move, Cotton. Let's see if it pays off."* 57 | */ 58 | getFileCache(file: TFile | TAbstractFile): CachedMetadata | null; 59 | fileCache: Record; 60 | metadataCache: Record }>; 61 | } 62 | -------------------------------------------------------------------------------- /src/types/plugins.d.ts: -------------------------------------------------------------------------------- 1 | // Source: https://publish.obsidian.md/omnisearch/Public+API+%26+URL+Scheme 2 | export type OmnisearchAPI = { 3 | // Returns a promise that will contain the same results as the Vault modal 4 | search: (query: string) => Promise; 5 | // Refreshes the index 6 | refreshIndex: () => Promise; 7 | // Register a callback that will be called when the indexing is done 8 | registerOnIndexed: (callback: () => void) => void; 9 | // Unregister a callback that was previously registered 10 | unregisterOnIndexed: (callback: () => void) => void; 11 | }; 12 | 13 | type OmnisearchResultNoteApi = { 14 | score: number; 15 | path: string; 16 | basename: string; 17 | foundWords: string[]; 18 | matches: OmnisearchSearchMatchApi[]; 19 | }; 20 | 21 | type OmnisearchSearchMatchApi = { 22 | match: string; 23 | offset: number; 24 | }; 25 | -------------------------------------------------------------------------------- /src/types/results.d.ts: -------------------------------------------------------------------------------- 1 | import { TFile } from "obsidian"; 2 | import { AnyParams } from "src/routes"; 3 | import { AnyHandlerResult, NoteProperties } from "src/types"; 4 | 5 | type ErrorObject = { 6 | isSuccess: false; 7 | errorCode: number; 8 | errorMessage: string; 9 | }; 10 | 11 | type ResultObject = { 12 | isSuccess: true; 13 | result: T; 14 | processedFilepath?: string; 15 | }; 16 | 17 | export type TFileResultObject = ResultObject | ErrorObject; 18 | export type RegexResultObject = ResultObject | ErrorObject; 19 | export type SearchResultObject = 20 | | ResultObject<{ hits: string[] }> 21 | | ErrorObject; 22 | export type StringResultObject = ResultObject | ErrorObject; 23 | export type PluginResultObject = ResultObject | ErrorObject; 24 | export type BooleanResultObject = ResultObject | ErrorObject; 25 | 26 | export type ProcessingResult = { 27 | params: AnyParams; 28 | handlerResult: AnyHandlerResult; 29 | sendCallbackResult: StringResultObject; 30 | openResult: StringResultObject; 31 | }; 32 | 33 | export type NoteDetailsResultObject = 34 | | ResultObject<{ 35 | filepath: string; 36 | content: string; 37 | body: string; 38 | frontMatter: string; 39 | properties: NoteProperties; 40 | uriPath: string; 41 | uriUID?: string; 42 | uid?: string | string[]; 43 | }> 44 | | ErrorObject; 45 | -------------------------------------------------------------------------------- /src/utils/callbacks.ts: -------------------------------------------------------------------------------- 1 | import { ObsidianProtocolData, requestUrl, TAbstractFile } from "obsidian"; 2 | import { excludeKeys } from "filter-obj"; 3 | import { XCALLBACK_RESULT_PREFIX } from "src/constants"; 4 | import { PLUGIN_INFO } from "src/plugin-info"; 5 | import { AnyParams } from "src/routes"; 6 | import { 7 | AnyHandlerResult, 8 | AnyHandlerSuccess, 9 | HandlerFailure, 10 | StringResultObject, 11 | } from "src/types"; 12 | import { success } from "src/utils/results-handling"; 13 | import { toKebabCase } from "src/utils/string-handling"; 14 | 15 | /** 16 | * @param baseURL - The base `x-callback-url` of the receiver, e.g. 17 | * "another-app://", "another-app://x-callback-url/success" or 18 | * "another-app://success" 19 | * @param handlerRes - Any route handler result object 20 | * 21 | * @returns A `StringResultObject` with the `result` property set to the called 22 | * URL 23 | * 24 | * @see {@link AnyHandlerResult} 25 | */ 26 | export function sendUrlCallback( 27 | baseURL: string, 28 | handlerRes: AnyHandlerResult, 29 | params: AnyParams | ObsidianProtocolData, 30 | ): StringResultObject { 31 | const url = new URL(baseURL); 32 | 33 | if (handlerRes.isSuccess) { 34 | addObjectToUrlSearchParams(( handlerRes).result, url); 35 | } else { 36 | const { errorCode, errorMessage } = handlerRes; 37 | url.searchParams.set("errorCode", errorCode.toString()); 38 | url.searchParams.set("errorMessage", errorMessage); 39 | } 40 | 41 | if (params["x-source"] && /actions for obsidian/i.test(params["x-source"])) { 42 | url.searchParams.set("pv", PLUGIN_INFO.pluginVersion); 43 | } 44 | 45 | const returnParams: Record = params["debug-mode"] 46 | ? excludeKeys( params, [ 47 | "debug-mode", 48 | "x-success", 49 | "x-error", 50 | "_computed", 51 | ]) 52 | : {}; 53 | addObjectToUrlSearchParams(returnParams, url, "input"); 54 | 55 | const callbackURL = url.toString().replace(/\+/g, "%20"); 56 | sendCallbackResult(callbackURL); 57 | 58 | return success(callbackURL); 59 | } 60 | 61 | /** 62 | * Adds properties of an object as search params to a `URL` instance. The keys 63 | * of the object will be normalized to kebab case. 64 | * 65 | * @param obj - An object whose properties are to be added an `URL` object as 66 | * search parameters 67 | * @param url - The `URL` target object 68 | * @param prefix - An optional prefix to be added to the parameter names, 69 | * defaults to `XCALLBACK_RESULT_PREFIX` 70 | */ 71 | function addObjectToUrlSearchParams( 72 | obj: Record, 73 | url: URL, 74 | prefix: string = XCALLBACK_RESULT_PREFIX, 75 | ) { 76 | const sortedKeys = Object.keys(obj).sort(); 77 | for (const key of sortedKeys) { 78 | if (!Object.prototype.hasOwnProperty.call(obj, key)) continue; 79 | 80 | let val: string | undefined; 81 | if (typeof obj[key] === "string") { 82 | val = obj[key]; 83 | } else if (obj[key] instanceof TAbstractFile) { 84 | val = ( obj[key]).path; 85 | } else if (typeof obj[key] !== "undefined") { 86 | val = JSON.stringify(obj[key]); 87 | } 88 | 89 | if (val === undefined) continue; 90 | 91 | url.searchParams.set(toKebabCase(`${prefix}-${key}`), val); 92 | } 93 | } 94 | 95 | /** 96 | * Sends a XCU callback, i.e. makes a request to the given URI. 97 | * 98 | * If the URL is a HTTP/HTTPS one, it is assumed the callback is slated for the 99 | * HTTP server of the testing setup, and Obsidian's own `requestUrl()` is used. 100 | * 101 | * In production mode (outside testing) the URI is passed to the OS using 102 | * `window.open()`, which passes them to the registered apps. (In testing, we 103 | * use a HTTP server, and `window.open()` would have the OS pass the URI to the 104 | * default browser, which is not what we want.) 105 | * 106 | * @param uri - The URI to call 107 | */ 108 | function sendCallbackResult(uri: string) { 109 | if (/^https?:\/\//.test(uri)) { 110 | requestUrl(uri); 111 | } else { 112 | window.open(uri); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/utils/parameters.ts: -------------------------------------------------------------------------------- 1 | import { parseFrontMatterEntry, TAbstractFile } from "obsidian"; 2 | import { z } from "zod"; 3 | import { STRINGS } from "src/constants"; 4 | import { sanitizeFilePathAndGetAbstractFile } from "src/utils/file-handling"; 5 | import { self } from "src/utils/self"; 6 | import { 7 | checkForEnabledPeriodicNoteFeature, 8 | getCurrentPeriodicNotePath, 9 | getMostRecentPeriodicNotePath, 10 | PeriodicNoteType, 11 | PeriodicNoteTypeWithRecents, 12 | } from "src/utils/periodic-notes-handling"; 13 | import { ErrorCode, failure, success } from "src/utils/results-handling"; 14 | import { NoteTargetingParameterKey } from "src/routes"; 15 | import { NoteTargetingParams } from "src/schemata"; 16 | import { StringResultObject } from "src/types.d"; 17 | 18 | // TYPES ---------------------------------------- 19 | 20 | type ResolvedData = { 21 | _resolved: Record; 22 | }; 23 | 24 | export type ResolvedNoteTargetingValues = Readonly<{ 25 | _resolved: { 26 | inputKey: NoteTargetingParameterKey; 27 | inputPath: string; 28 | inputFile: TAbstractFile | undefined; 29 | }; 30 | }>; 31 | 32 | // RESOLVERS ---------------------------------------- 33 | 34 | /** 35 | * Validates the note targeting parameters and adds computed values to the 36 | * input object (under the `_resolved` key). 37 | * 38 | * This function ensures that exactly one of the specified targeting parameters 39 | * (`file`, `uid`, or `periodic-note`) is provided. If the validation passes, 40 | * it gets the requested note path based on the input and appends it to the 41 | * returned object. 42 | * 43 | * @param data - The input data containing targeting parameters. 44 | * @param ctx - The Zod refinement context used for adding validation issues. 45 | * @param throwOnMissingNote - Whether to throw a Zod validation error if the 46 | * requested note path does not exist. Defaults to `false`. 47 | * @returns The input object augmented with computed values if validation 48 | * succeeds; otherwise, it triggers a Zod validation error. 49 | * @throws {ZodError} When more than one or none of the targeting parameters are provided. 50 | * 51 | * @template T - The type of the input data. 52 | */ 53 | export function resolveNoteTargeting( 54 | data: T, 55 | ctx: z.RefinementCtx, 56 | throwOnMissingNote: boolean = false, 57 | ): T & ResolvedNoteTargetingValues { 58 | const input = data as NoteTargetingParams; 59 | 60 | // Validate that only one of the three keys is present 61 | const keysCount = [ 62 | NoteTargetingParameterKey.File, 63 | NoteTargetingParameterKey.UID, 64 | NoteTargetingParameterKey.PeriodicNote, 65 | ] 66 | .filter((key) => key in input) 67 | .length; 68 | 69 | if (keysCount !== 1) { 70 | ctx.addIssue({ 71 | code: z.ZodIssueCode.custom, 72 | message: STRINGS.faulty_note_targeting, 73 | }); 74 | return z.NEVER; 75 | } 76 | 77 | // Get the requested file path 78 | let inputKey: NoteTargetingParameterKey; 79 | let inputPath = ""; 80 | if (NoteTargetingParameterKey.File in input) { 81 | const val = input[NoteTargetingParameterKey.File]!; 82 | inputKey = NoteTargetingParameterKey.File; 83 | inputPath = val; 84 | } // 85 | else if (NoteTargetingParameterKey.UID in input) { 86 | const val = input[NoteTargetingParameterKey.UID]!; 87 | inputKey = NoteTargetingParameterKey.UID; 88 | 89 | const res = filepathForUID(val); 90 | inputPath = res.isSuccess ? res.result : ""; 91 | } // 92 | else if (input[NoteTargetingParameterKey.PeriodicNote]) { 93 | const val = input[ 94 | NoteTargetingParameterKey.PeriodicNote 95 | ]! as unknown as PeriodicNoteTypeWithRecents; 96 | inputKey = NoteTargetingParameterKey.PeriodicNote; 97 | 98 | const periodicNoteType = val.replace(/^recent-/, "") as PeriodicNoteType; 99 | const shouldFindMostRecent = val.startsWith("recent-"); 100 | 101 | // Normalize "recent-daily" into "daily" etc. then check feature availability 102 | const isPluginAvailable = checkForEnabledPeriodicNoteFeature( 103 | periodicNoteType, 104 | ); 105 | if (!isPluginAvailable) { 106 | ctx.addIssue({ 107 | code: z.ZodIssueCode.custom, 108 | message: STRINGS[`${periodicNoteType}_note`].feature_not_available, 109 | }); 110 | return z.NEVER; 111 | } 112 | 113 | if (shouldFindMostRecent) { 114 | // Get the most recent note path 115 | const resRPN = getMostRecentPeriodicNotePath(periodicNoteType); 116 | inputPath = resRPN.isSuccess ? resRPN.result : ""; 117 | } else { 118 | // Get the current note path 119 | inputPath = getCurrentPeriodicNotePath(periodicNoteType); 120 | } 121 | } 122 | 123 | // Validate that the requested note path exists 124 | let inputFile: TAbstractFile | undefined; 125 | if (inputPath != "") { 126 | const resFileTest = sanitizeFilePathAndGetAbstractFile(inputPath); 127 | if (resFileTest) { 128 | inputFile = resFileTest; 129 | inputPath = resFileTest.path; 130 | } 131 | } 132 | 133 | if (!inputFile && throwOnMissingNote) { 134 | ctx.addIssue({ 135 | code: z.ZodIssueCode.custom, 136 | message: STRINGS.note_not_found, 137 | path: [inputPath], 138 | }); 139 | return z.NEVER; 140 | } 141 | 142 | // Return original object plus resolved values 143 | return mergeResolvedData(data, { 144 | inputKey: inputKey!, 145 | inputPath, 146 | inputFile, 147 | }); 148 | } 149 | 150 | /** 151 | * Validates the note targeting parameters and adds computed values. Triggers a 152 | * Zod validation error if the requested note path does not exist. 153 | * 154 | * This function ensures that exactly one of the specified targeting parameters 155 | * (`file`, `uid`, or `periodic-note`) is provided. If the validation passes, 156 | * it gets the requested note path based on the input and appends it to the 157 | * returned object. 158 | * 159 | * @param data - The input data containing targeting parameters. 160 | * @param ctx - The Zod refinement context used for adding validation issues. 161 | * @returns The input object augmented with computed values if validation 162 | * succeeds; otherwise, it triggers a Zod validation error. Also triggers a 163 | * Zod validation error if the note path does not exist. 164 | * @throws {ZodError} If more than one or none of the targeting parameters are 165 | * provided. 166 | * 167 | * @template T - The type of the input data. 168 | */ 169 | export function resolveNoteTargetingStrict( 170 | data: T, 171 | ctx: z.RefinementCtx, 172 | ): T & ResolvedNoteTargetingValues { 173 | return resolveNoteTargeting(data, ctx, true); 174 | } 175 | 176 | // HELPERS ---------------------------------------- 177 | 178 | /** 179 | * Merges the resolved values into the input object. If the input object already 180 | * contains a `_resolved` key, the new values are merged into it. 181 | */ 182 | export function mergeResolvedData( 183 | data: T, 184 | resolved: U, 185 | ): T & { _resolved: U } { 186 | return { 187 | ...data, 188 | _resolved: { 189 | ...(data as T & ResolvedData)._resolved || {}, 190 | ...resolved, 191 | }, 192 | }; 193 | } 194 | 195 | function filepathForUID(uid: string): StringResultObject { 196 | const path = self().app.vault 197 | .getMarkdownFiles() 198 | .find((note) => { 199 | let uidValues = parseFrontMatterEntry( 200 | self().app.metadataCache.getFileCache(note)?.frontmatter, 201 | self().settings.frontmatterKey, 202 | ); 203 | return [uidValues].flat().map((u) => `${u}`).includes(uid); 204 | }) 205 | ?.path; 206 | 207 | return path 208 | ? success(path) 209 | : failure(ErrorCode.notFound, STRINGS.note_not_found); 210 | } 211 | -------------------------------------------------------------------------------- /src/utils/periodic-notes-handling.ts: -------------------------------------------------------------------------------- 1 | import { moment, TFile } from "obsidian"; 2 | import { 3 | appHasDailyNotesPluginLoaded, 4 | appHasMonthlyNotesPluginLoaded, 5 | appHasQuarterlyNotesPluginLoaded, 6 | appHasWeeklyNotesPluginLoaded, 7 | appHasYearlyNotesPluginLoaded, 8 | createDailyNote, 9 | createMonthlyNote, 10 | createQuarterlyNote, 11 | createWeeklyNote, 12 | createYearlyNote, 13 | getAllDailyNotes, 14 | getAllMonthlyNotes, 15 | getAllQuarterlyNotes, 16 | getAllWeeklyNotes, 17 | getAllYearlyNotes, 18 | getDailyNote, 19 | getDailyNoteSettings, 20 | getMonthlyNote, 21 | getMonthlyNoteSettings, 22 | getQuarterlyNote, 23 | getQuarterlyNoteSettings, 24 | getWeeklyNote, 25 | getWeeklyNoteSettings, 26 | getYearlyNote, 27 | getYearlyNoteSettings, 28 | } from "obsidian-daily-notes-interface"; 29 | import { STRINGS } from "src/constants"; 30 | import { StringResultObject } from "src/types"; 31 | import { sanitizeFilePath } from "src/utils/file-handling"; 32 | import { 33 | isCommunityPluginEnabled, 34 | isCorePluginEnabled, 35 | } from "src/utils/plugins"; 36 | import { ErrorCode, failure, success } from "src/utils/results-handling"; 37 | import { pause } from "src/utils/time"; 38 | 39 | // TYPES ---------------------------------------- 40 | export enum PeriodicNoteType { 41 | DailyNote = "daily", 42 | WeeklyNote = "weekly", 43 | MonthlyNote = "monthly", 44 | QuarterlyNote = "quarterly", 45 | YearlyNote = "yearly", 46 | } 47 | 48 | export enum PeriodicNoteTypeWithRecents { 49 | DailyNote = "daily", 50 | WeeklyNote = "weekly", 51 | MonthlyNote = "monthly", 52 | QuarterlyNote = "quarterly", 53 | YearlyNote = "yearly", 54 | RecentDailyNote = "recent-daily", 55 | RecentWeeklyNote = "recent-weekly", 56 | RecentMonthlyNote = "recent-monthly", 57 | RecentQuarterlyNote = "recent-quarterly", 58 | RecentYearlyNote = "recent-yearly", 59 | } 60 | 61 | // FUNCTIONS ---------------------------------------- 62 | 63 | export function getCurrentPeriodicNotePath( 64 | periodicNoteType: PeriodicNoteType, 65 | ): string { 66 | let getSettingsFn: Function; 67 | switch (periodicNoteType) { 68 | case PeriodicNoteType.DailyNote: 69 | getSettingsFn = getDailyNoteSettings; 70 | break; 71 | 72 | case PeriodicNoteType.WeeklyNote: 73 | getSettingsFn = getWeeklyNoteSettings; 74 | break; 75 | 76 | case PeriodicNoteType.MonthlyNote: 77 | getSettingsFn = getMonthlyNoteSettings; 78 | break; 79 | 80 | case PeriodicNoteType.QuarterlyNote: 81 | getSettingsFn = getQuarterlyNoteSettings; 82 | break; 83 | 84 | case PeriodicNoteType.YearlyNote: 85 | getSettingsFn = getYearlyNoteSettings; 86 | break; 87 | } 88 | 89 | const { format, folder } = getSettingsFn(); 90 | const title = moment().format(format); 91 | return sanitizeFilePath(`${folder}/${title}.md`); 92 | } 93 | 94 | export function getMostRecentPeriodicNotePath( 95 | periodicNoteType: PeriodicNoteType, 96 | ): StringResultObject { 97 | const notes = getAllPeriodicNotes(periodicNoteType); 98 | const mostRecentKey = Object.keys(notes).sort().last(); 99 | return mostRecentKey 100 | ? success(notes[mostRecentKey].path) 101 | : failure(ErrorCode.notFound, STRINGS.note_not_found); 102 | } 103 | 104 | export function getCurrentPeriodicNote( 105 | periodicNoteType: PeriodicNoteType, 106 | ): TFile | undefined { 107 | const now = moment(); 108 | switch (periodicNoteType) { 109 | case PeriodicNoteType.DailyNote: 110 | return getDailyNote(now, getAllDailyNotes()); 111 | 112 | case PeriodicNoteType.WeeklyNote: 113 | return getWeeklyNote(now, getAllWeeklyNotes()); 114 | 115 | case PeriodicNoteType.MonthlyNote: 116 | return getMonthlyNote(now, getAllMonthlyNotes()); 117 | 118 | case PeriodicNoteType.QuarterlyNote: 119 | return getQuarterlyNote(now, getAllQuarterlyNotes()); 120 | 121 | case PeriodicNoteType.YearlyNote: 122 | return getYearlyNote(now, getAllYearlyNotes()); 123 | } 124 | } 125 | 126 | export function getAllPeriodicNotes( 127 | periodicNoteType: PeriodicNoteType, 128 | ): Record { 129 | switch (periodicNoteType) { 130 | case PeriodicNoteType.DailyNote: 131 | return getAllDailyNotes(); 132 | 133 | case PeriodicNoteType.WeeklyNote: 134 | return getAllWeeklyNotes(); 135 | 136 | case PeriodicNoteType.MonthlyNote: 137 | return getAllMonthlyNotes(); 138 | 139 | case PeriodicNoteType.QuarterlyNote: 140 | return getAllQuarterlyNotes(); 141 | 142 | case PeriodicNoteType.YearlyNote: 143 | return getAllYearlyNotes(); 144 | } 145 | } 146 | 147 | export function checkForEnabledPeriodicNoteFeature( 148 | periodicNoteType: PeriodicNoteType, 149 | ): boolean { 150 | switch (periodicNoteType) { 151 | case PeriodicNoteType.DailyNote: 152 | return appHasDailyNotesPluginLoaded(); 153 | 154 | case PeriodicNoteType.WeeklyNote: 155 | return appHasWeeklyNotesPluginLoaded(); 156 | 157 | case PeriodicNoteType.MonthlyNote: 158 | return appHasMonthlyNotesPluginLoaded(); 159 | 160 | case PeriodicNoteType.QuarterlyNote: 161 | return appHasQuarterlyNotesPluginLoaded(); 162 | 163 | case PeriodicNoteType.YearlyNote: 164 | return appHasYearlyNotesPluginLoaded(); 165 | } 166 | } 167 | 168 | export async function createPeriodicNote( 169 | periodicNoteType: PeriodicNoteType, 170 | ): Promise { 171 | const now = moment(); 172 | let newFile: Promise; 173 | 174 | switch (periodicNoteType) { 175 | case PeriodicNoteType.DailyNote: 176 | newFile = createDailyNote(now); 177 | break; 178 | 179 | case PeriodicNoteType.WeeklyNote: 180 | newFile = createWeeklyNote(now); 181 | break; 182 | 183 | case PeriodicNoteType.MonthlyNote: 184 | newFile = createMonthlyNote(now); 185 | break; 186 | 187 | case PeriodicNoteType.QuarterlyNote: 188 | newFile = createQuarterlyNote(now); 189 | break; 190 | 191 | case PeriodicNoteType.YearlyNote: 192 | newFile = createYearlyNote(now); 193 | break; 194 | } 195 | 196 | if ( 197 | isCorePluginEnabled("templates") || 198 | isCommunityPluginEnabled("templater-obsidian") 199 | ) { 200 | await pause(500); 201 | } 202 | 203 | return newFile; 204 | } 205 | 206 | /** 207 | * Checks if the daily/weekly/monthly/etc periodic note feature is available, 208 | * and gets the path to the current related note. 209 | * 210 | * @returns Successful `StringResultObject` containing the path if the PN 211 | * functionality is available and there is a current daily note. Unsuccessful 212 | * `StringResultObject` if it isn't. 213 | */ 214 | export function getExistingPeriodicNotePathIfPluginIsAvailable( 215 | periodicNoteType: PeriodicNoteType, 216 | ): StringResultObject { 217 | if (!checkForEnabledPeriodicNoteFeature(periodicNoteType)) { 218 | return failure( 219 | ErrorCode.featureUnavailable, 220 | STRINGS[`${periodicNoteType}_note`].feature_not_available, 221 | ); 222 | } 223 | 224 | const pNote = getCurrentPeriodicNote(periodicNoteType); 225 | return pNote 226 | ? success(pNote.path) 227 | : failure(ErrorCode.notFound, STRINGS.note_not_found); 228 | } 229 | -------------------------------------------------------------------------------- /src/utils/plugins.ts: -------------------------------------------------------------------------------- 1 | import { PluginResultObject } from "src/types"; 2 | import { self } from "src/utils/self"; 3 | import { ErrorCode, failure, success } from "src/utils/results-handling"; 4 | 5 | /** 6 | * Returns sorted list of the string IDs of the enabled community plugins. 7 | * 8 | * @returns {string[]} - A sorted list of enabled community plugins. 9 | */ 10 | function enabledCommunityPlugins(): string[] { 11 | const list: string[] = Array.from( 12 | self().app.plugins?.enabledPlugins || [], 13 | ); 14 | return list.sort(); 15 | } 16 | 17 | /** 18 | * Checks if a specific community plugin is enabled. 19 | * 20 | * @param {string} pluginID - The ID of the plugin to check. 21 | * 22 | * @returns {boolean} - True if the plugin is enabled, false otherwise. 23 | */ 24 | export function isCommunityPluginEnabled(pluginID: string): boolean { 25 | return enabledCommunityPlugins().contains(pluginID); 26 | } 27 | 28 | /** 29 | * Gets the enabled community plugin with the specified ID. 30 | * 31 | * @param {string} pluginID The ID of the community plugin to retrieve. 32 | * 33 | * @returns {PluginResultObject} A result object containing the plugin if available. 34 | */ 35 | export function getEnabledCommunityPlugin( 36 | pluginID: string, 37 | ): PluginResultObject { 38 | return isCommunityPluginEnabled(pluginID) 39 | ? success(self().app.plugins.getPlugin(pluginID)) 40 | : failure( 41 | ErrorCode.featureUnavailable, 42 | `Community plugin ${pluginID} is not enabled.`, 43 | ); 44 | } 45 | 46 | /** 47 | * Checks if a specific core plugin is enabled. 48 | * 49 | * @param {string} pluginID - The ID of the plugin to check. 50 | * 51 | * @returns {boolean} - True if the plugin is enabled, false otherwise. 52 | */ 53 | export function isCorePluginEnabled(pluginID: string): boolean { 54 | return !!self().app.internalPlugins?.getEnabledPluginById(pluginID); 55 | } 56 | 57 | /** 58 | * Gets the enabled core plugin with the specified ID. 59 | * 60 | * @param {string} pluginID The ID of the core plugin to retrieve. 61 | * 62 | * @returns {PluginResultObject} A result object containing the plugin if available. 63 | */ 64 | export function getEnabledCorePlugin(pluginID: string): PluginResultObject { 65 | const plugin = self().app.internalPlugins?.getEnabledPluginById(pluginID); 66 | 67 | return plugin ? success(plugin) : failure( 68 | ErrorCode.featureUnavailable, 69 | `Core plugin ${pluginID} is not enabled.`, 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/results-handling.ts: -------------------------------------------------------------------------------- 1 | import { ErrorObject, ResultObject } from "src/types"; 2 | 3 | /** 4 | * Returns a `ResultObject` based on the passed-in parameters. 5 | * 6 | * @param result The `ResultObject`'s `result` key 7 | * @param processedFilepath Optional, the `ResultObject`'s `processedFilepath` key 8 | * @returns A `ResultObject` with the `isSuccess` key set to `true` 9 | */ 10 | export function success( 11 | result: T, 12 | processedFilepath?: string, 13 | ): ResultObject { 14 | return { isSuccess: true, result, processedFilepath }; 15 | } 16 | 17 | /** 18 | * Returns an `ErrorObject` based on the passed-in parameters. 19 | * 20 | * @param errorCode The `ErrorObject`'s `errorCode` key 21 | * @param errorMessage The `ErrorObject`'s `errorMessage` key 22 | * @returns An `ErrorObject` with the `isSuccess` key set to `false` 23 | */ 24 | export function failure( 25 | errorCode: ErrorCode, 26 | errorMessage: string, 27 | ): ErrorObject { 28 | return { isSuccess: false, errorCode, errorMessage }; 29 | } 30 | 31 | export enum ErrorCode { 32 | notFound = 404, 33 | pluginUnavailable = 424, 34 | featureUnavailable = 424, 35 | unableToCreateNote = 400, 36 | unableToWrite = 400, 37 | invalidInput = 406, 38 | noteAlreadyExists = 409, 39 | handlerError = 500, 40 | unknownError = 500, 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/routing.ts: -------------------------------------------------------------------------------- 1 | import { success } from "src/utils/results-handling"; 2 | import { AnyParams, RouteSubpath } from "src/routes"; 3 | import { incomingBaseParams } from "src/schemata"; 4 | import { HandlerTextSuccess } from "src/types"; 5 | import { showBrandedNotice } from "src/utils/ui"; 6 | 7 | export function helloRoute(path: string = "/"): RouteSubpath { 8 | return { path, schema: incomingBaseParams.extend({}), handler: handleHello }; 9 | } 10 | 11 | async function handleHello(data: AnyParams): Promise { 12 | showBrandedNotice("… is ready for action 🚀"); 13 | return success({ message: "Hello!" }); 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/search.ts: -------------------------------------------------------------------------------- 1 | import { TFile } from "obsidian"; 2 | import { 3 | getEnabledCommunityPlugin, 4 | getEnabledCorePlugin, 5 | } from "src/utils/plugins"; 6 | import { pause } from "src/utils/time"; 7 | import { STRINGS } from "src/constants"; 8 | import { OmnisearchAPI, SearchResultObject } from "src/types"; 9 | import { self } from "src/utils/self"; 10 | import { ErrorCode, failure, success } from "src/utils/results-handling"; 11 | 12 | /** 13 | * Executes a global search for the specified query and returns the search 14 | * results (= file paths) as a `SearchResultObject`. 15 | * 16 | * @param {string} query The search query string. 17 | */ 18 | export async function doSearch(query: string): Promise { 19 | // Get the global search plugin instance 20 | const res = getEnabledCorePlugin("global-search"); 21 | 22 | // If the plugin instance is not available, return an error response 23 | if (!res.isSuccess) { 24 | return failure( 25 | ErrorCode.featureUnavailable, 26 | STRINGS.global_search_feature_not_available, 27 | ); 28 | } 29 | 30 | // Open the global search panel and wait for it to load 31 | const pluginInstance = res.result; 32 | pluginInstance.openGlobalSearch(query); 33 | const searchLeaf = self().app.workspace.getLeavesOfType("search")[0]; 34 | const searchView = await searchLeaf.open(searchLeaf.view); 35 | await pause(2000); 36 | 37 | // Extract the search result hits 38 | const rawSearchResult: Map = 39 | ( searchView).dom.resultDomLookup; 40 | const hits = Array.from(rawSearchResult.keys()).map((tfile) => tfile.path); 41 | 42 | // Return the search result as a `SearchResultObject` 43 | return success({ hits }); 44 | } 45 | 46 | /** 47 | * Executes an Omnisearch …search for the specified query and returns the 48 | * results (= file paths) as a `SearchResultObject`. 49 | * 50 | * @param {string} query The search query string. 51 | */ 52 | export async function doOmnisearch(query: string): Promise { 53 | // Get the Omnisearch plugin instance or back off 54 | const res = getEnabledCommunityPlugin("omnisearch"); 55 | if (!res.isSuccess) { 56 | return failure( 57 | ErrorCode.featureUnavailable, 58 | STRINGS.omnisearch_plugin_not_available, 59 | ); 60 | } 61 | 62 | // Execute the Omnisearch query 63 | const plugin = res.result.api; 64 | const results = await plugin.search(query); 65 | const hits = results.map((result) => result.path); 66 | 67 | // Return the search result as a `SearchResultObject` 68 | return success({ hits }); 69 | } 70 | -------------------------------------------------------------------------------- /src/utils/self.ts: -------------------------------------------------------------------------------- 1 | import type ActionsURI from "src/main"; 2 | import { RealLifePlugin } from "src/types"; 3 | 4 | let _self: RealLifePlugin; 5 | 6 | export function self(): RealLifePlugin; 7 | export function self(pluginInstance: ActionsURI): RealLifePlugin; 8 | export function self(pluginInstance?: ActionsURI): RealLifePlugin { 9 | if (pluginInstance) _self = pluginInstance as unknown as RealLifePlugin; 10 | if (!_self) throw new Error("Plugin instance not set"); 11 | return _self; 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/string-handling.ts: -------------------------------------------------------------------------------- 1 | import { getFrontMatterInfo } from "obsidian"; 2 | import { STRINGS } from "src/constants"; 3 | import { RegexResultObject } from "src/types"; 4 | import { ErrorCode, failure, success } from "src/utils/results-handling"; 5 | 6 | /** 7 | * Makes sure the passed-in string ends in a newline. 8 | * 9 | * @param str - The string that should end in a newline 10 | * @returns String ending in a newline 11 | */ 12 | export function endStringWithNewline(str: string = ""): string { 13 | return str.endsWith("\n") ? str : `${str}\n`; 14 | } 15 | 16 | /** 17 | * Tries to parse a regular expression stored as a string into an actual, real 18 | * `RegExp` object. 19 | * 20 | * @param search - A string containing a full regular expression, e.g. "/^herp/i" 21 | * 22 | * @returns A `RegexResultObject` object containing either an `error` string or 23 | * a `RegExp` object 24 | */ 25 | export function parseStringIntoRegex(search: string): RegexResultObject { 26 | if (!search.startsWith("/")) { 27 | return failure(ErrorCode.invalidInput, STRINGS.search_pattern_invalid); 28 | } 29 | 30 | // Starts to look like a regex, let's try to parse it. 31 | let re = search.slice(1); 32 | const lastSlashIdx = re.lastIndexOf("/"); 33 | 34 | if (lastSlashIdx === 0) { 35 | return failure(ErrorCode.invalidInput, STRINGS.search_pattern_empty); 36 | } 37 | 38 | let searchPattern: RegExp; 39 | let flags = re.slice(lastSlashIdx + 1); 40 | re = re.slice(0, lastSlashIdx); 41 | 42 | try { 43 | searchPattern = new RegExp(re, flags); 44 | } catch (e) { 45 | return failure(ErrorCode.invalidInput, STRINGS.search_pattern_unparseable); 46 | } 47 | 48 | return success(searchPattern); 49 | } 50 | 51 | /** 52 | * Escapes special characters in a string that are used in regular expressions. 53 | * This function is useful when a string is to be treated as a literal pattern 54 | * inside a regular expression, rather than as part of the regular expression 55 | * syntax. 56 | * 57 | * @param string - The string to be escaped. 58 | * @returns The escaped string, with special regular expression characters prefixed 59 | * with a backslash. This makes the string safe to use within a RegExp constructor 60 | * or function. 61 | */ 62 | export function escapeRegExpChars(string: string) { 63 | return string.replace(/([.*+?^${}()|[\]\\])/g, "\\$1"); 64 | } 65 | 66 | /** 67 | * Extracts front matter and body from a passed-in string. 68 | * 69 | * @param noteContent - The content of the note to be searched 70 | * 71 | * @returns An object containing both `frontMatter` and `body` of the note as 72 | * keys. When no front matter was found, `frontMatter` will be an empty string 73 | * while `note` will contain the input string 74 | * 75 | * @see {@link https://help.obsidian.md/Advanced+topics/YAML+front+matter | Obsidian's YAML front matter documentation} 76 | */ 77 | export function extractNoteContentParts( 78 | noteContent: string, 79 | ): { frontMatter: string; body: string } { 80 | const info = getFrontMatterInfo(noteContent); 81 | 82 | return { 83 | frontMatter: info.frontmatter, 84 | body: noteContent.slice(info.contentStart), 85 | }; 86 | } 87 | 88 | /** 89 | * Returns the kebab-cased version of a passed-in string. 90 | * 91 | * @param text - The text to be turned kebab-case 92 | * 93 | * @returns Text in kebab-case 94 | * 95 | * @example "hello you veryNice" -> "hello-you-very-nice" 96 | */ 97 | export function toKebabCase(text: string): string { 98 | return text 99 | .replace(/([a-z])([A-Z])/g, "$1-$2") 100 | .replace(/\s+/g, "-") 101 | .toLowerCase(); 102 | } 103 | -------------------------------------------------------------------------------- /src/utils/time.ts: -------------------------------------------------------------------------------- 1 | export async function pause(milliseconds: number): Promise { 2 | return new Promise((resolve) => { 3 | setTimeout( 4 | () => resolve(), 5 | milliseconds, 6 | ); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/ui.ts: -------------------------------------------------------------------------------- 1 | import { FileView, Notice, requireApiVersion } from "obsidian"; 2 | import { STRINGS } from "src/constants"; 3 | import { StringResultObject } from "src/types"; 4 | import { getFile } from "src/utils/file-handling"; 5 | import { self } from "src/utils/self"; 6 | import { ErrorCode, failure, success } from "src/utils/results-handling"; 7 | 8 | /** 9 | * Displays a `Notice` inside Obsidian. The notice is prefixed with 10 | * "[Actions URI]" so the sender is clear to the receiving user. 11 | * 12 | * @param msg - The message to be shown in the notice 13 | */ 14 | export function showBrandedNotice(msg: string) { 15 | new Notice(`[Actions URI] ${msg}`); 16 | } 17 | 18 | /** 19 | * Logs anything to the console, prefixed with "[Actions URI]" so the sender is 20 | * clear. Standard log level. 21 | * 22 | * @param data - Anything that can be logged, really 23 | */ 24 | export function logToConsole(...data: any[]) { 25 | console.log("[Actions URI]", ...data); 26 | } 27 | 28 | /** 29 | * Logs anything to the console, prefixed with "[Actions URI]" so the sender is 30 | * clear. Error log level. 31 | * 32 | * @param data - Anything that can be logged, really 33 | */ 34 | export function logErrorToConsole(...data: any[]) { 35 | console.error("[Actions URI]", ...data); 36 | } 37 | 38 | /** 39 | * Given a file path, the function will check whether the note file is already 40 | * open and then focus it, or it'll open the note. 41 | * 42 | * @param filepath - The path to the file to be focussed or opened 43 | * 44 | * @returns A positive string result object specifying the action taken 45 | */ 46 | export async function focusOrOpenNote( 47 | filepath: string, 48 | ): Promise { 49 | // Is this file open already? If so, can we just focus it? 50 | const res = await revealLeafWithFilePath(filepath); 51 | if (res.isSuccess) { 52 | return res; 53 | } 54 | 55 | const res1 = await getFile(filepath); 56 | if (res1.isSuccess) { 57 | self().app.workspace.getLeaf(true).openFile(res1.result); 58 | return success(STRINGS.note_opened); 59 | } 60 | 61 | return failure(ErrorCode.notFound, STRINGS.note_not_found); 62 | } 63 | 64 | /** 65 | * Finds an open note with the passed-in filepath. If it's found, it'll be 66 | * revealed, otherwise nothing happens. 67 | * 68 | * @param filepath - The path to the file to be focussed 69 | * 70 | * @returns Success when note could be found and focussed, error otherwise 71 | */ 72 | async function revealLeafWithFilePath( 73 | filepath: string, 74 | ): Promise { 75 | for (let leaf of self().app.workspace.getLeavesOfType("markdown")) { 76 | // See https://publish.obsidian.md/dev-docs-test/Plugins/Guides/Understanding+deferred+views 77 | if (requireApiVersion("1.7.2")) { 78 | // @ts-ignore 79 | await leaf.loadIfDeferred(); 80 | } 81 | 82 | if (leaf.view instanceof FileView && leaf.view.file?.path === filepath) { 83 | await self().app.workspace.revealLeaf(leaf); 84 | return success("Open file found and focussed"); 85 | } 86 | } 87 | 88 | return failure(ErrorCode.notFound, "File currently not open"); 89 | } 90 | -------------------------------------------------------------------------------- /src/utils/zod.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { TAbstractFile, TFile, TFolder } from "obsidian"; 3 | import { self } from "src/utils/self"; 4 | import { 5 | sanitizeFilePath, 6 | sanitizeFilePathAndGetAbstractFile, 7 | } from "src/utils/file-handling"; 8 | import { 9 | getEnabledCommunityPlugin, 10 | getEnabledCorePlugin, 11 | } from "src/utils/plugins"; 12 | 13 | // The absence of a parameter `blah`, a `blah=false` and a value-less `blah=` 14 | // should all be treated as `false`. My reign shall be merciful. 15 | export const zodOptionalBoolean = z.preprocess( 16 | (param: unknown): boolean => 17 | typeof param === "string" && param !== "false" && param !== "", 18 | z.boolean().optional(), 19 | ); 20 | 21 | export const zodSanitizedNotePath = z.string() 22 | .min(1, { message: "can't be empty" }) 23 | .transform((file) => sanitizeFilePath(file)); 24 | 25 | export const zodSanitizedFilePath = z.string() 26 | .min(1, { message: "can't be empty" }) 27 | .transform((file) => sanitizeFilePath(file, false)); 28 | 29 | export const zodSanitizedFolderPath = z.string() 30 | .min(1, { message: "can't be empty" }) 31 | .transform((file) => sanitizeFilePath(file, false)); 32 | 33 | /** 34 | * A schema which expects a string containing a JSON-encoded array of strings, 35 | * and which will return the parsed array of strings. 36 | */ 37 | export const zodJsonStringArray = z.string() 38 | .refine((str) => { 39 | try { 40 | const value = JSON.parse(str); 41 | return Array.isArray(value) && 42 | value.every((item) => typeof item === "string"); 43 | } catch (error) { 44 | return false; 45 | } 46 | }, { 47 | message: "Input must be a JSON-encoded string array.", 48 | }) 49 | .transform((str) => JSON.parse(str)); 50 | 51 | /** 52 | * A schema which expects a string containing a JSON-encoded object containing 53 | * only values of type `string`, `string[]`, `number`, `boolean` or `null`. 54 | * Return the object if valid. 55 | */ 56 | export const zodJsonPropertiesObject = z.string() 57 | .refine((str) => { 58 | try { 59 | const value = JSON.parse(str); 60 | 61 | if (typeof value !== "object") { 62 | return false; 63 | } 64 | 65 | const isValid = Object.values(value) 66 | .every((item) => { 67 | const type = typeof item; 68 | if (["string", "number", "boolean"].includes(type) || item === null) { 69 | return true; 70 | } 71 | 72 | if (Array.isArray(item)) { 73 | return item.every((subItem) => typeof subItem === "string"); 74 | } 75 | 76 | return false; 77 | }); 78 | 79 | return isValid; 80 | } catch (error) { 81 | return false; 82 | } 83 | }, { 84 | message: 85 | "Input must be a JSON-encoded object containing only values of type string, string array, number, boolean or null.", 86 | }) 87 | .transform((str) => JSON.parse(str)); 88 | 89 | /** 90 | * A schema which expects a comma-separated list of strings, and which will 91 | * return the parsed array of strings. 92 | */ 93 | export const zodCommaSeparatedStrings = z.string() 94 | .min(1, { message: "can't be empty" }) 95 | .transform((str) => str.split(",").map((item) => item.trim())); 96 | 97 | /** 98 | * A schema which tests the passed-in string to see if it's a valid path to an 99 | * existing template. If it is, returns a `TFile` instance. 100 | */ 101 | export const zodExistingTemplaterPath = z.preprocess( 102 | lookupAbstractFileForTemplaterPath, 103 | z.instanceof(TFile, { 104 | message: "Template doesn't exist or Templater isn't enabled", 105 | }), 106 | ); 107 | 108 | /** 109 | * A schema which tests the passed-in string to see if it's a valid path to an 110 | * existing template. If it is, returns a `TFile` instance. 111 | */ 112 | export const zodExistingTemplatesPath = z.preprocess( 113 | lookupAbstractFileForTemplatesPath, 114 | z.instanceof(TFile, { 115 | message: "Template doesn't exist or Templates isn't enabled", 116 | }), 117 | ); 118 | 119 | /** 120 | * A schema which tests the passed-in string to see if it's a valid path to an 121 | * existing file. If it is, returns a `TFile` instance. 122 | */ 123 | export const zodExistingFilePath = z.preprocess( 124 | lookupAbstractFileForFilePath, 125 | z.instanceof(TFile, { message: "File doesn't exist" }), 126 | ); 127 | 128 | /** 129 | * A schema which tests the passed-in string to see if it's a valid path to an 130 | * existing folder. If it is, returns a `TFolder` instance. 131 | */ 132 | export const zodExistingFolderPath = z.preprocess( 133 | lookupAbstractFolderForPath, 134 | z.instanceof(TFolder, { message: "Folder doesn't exist" }), 135 | ); 136 | 137 | /** 138 | * A schema which expects an undefined value (i.e. no parameter passed in), and 139 | * returns a default value instead. 140 | * 141 | * @param defaultValue The default value to return if the parameter is undefined 142 | */ 143 | export const zodUndefinedChangedToDefaultValue = (defaultValue: any) => 144 | z.undefined() 145 | .refine((val) => val === undefined) 146 | .transform(() => defaultValue); 147 | 148 | /** 149 | * A schema which expects an empty string, and overwrites it with a given value. 150 | * 151 | * @param defaultString The default value to return if the parameter is undefined 152 | */ 153 | export const zodEmptyStringChangedToDefaultString = (defaultString: string) => 154 | z.literal("") 155 | .refine((val) => val === "") 156 | .transform(() => defaultString); 157 | 158 | // HELPERS ---------------------------------------- 159 | 160 | /** 161 | * Takes an incoming parameter and returns the corresponding `TAbstractFile` if 162 | * the parameter is a string and the string corresponds to an existing file or 163 | * folder. Otherwise returns `null`. 164 | * 165 | * @param path Any incoming zod parameter 166 | */ 167 | function lookupAbstractFileForFilePath(path: any): TAbstractFile | null { 168 | return (typeof path === "string" && path.length > 0) 169 | ? sanitizeFilePathAndGetAbstractFile(path, false) 170 | : null; 171 | } 172 | 173 | /** 174 | * Takes an incoming parameter and returns the corresponding `TAbstractFile` if 175 | * the parameter is a string and the string corresponds to an existing file or 176 | * folder. Otherwise returns `null`. 177 | * 178 | * @param path Any incoming zod parameter 179 | */ 180 | function lookupAbstractFolderForPath(path: any): TAbstractFile | null { 181 | return (typeof path === "string" && path.length > 0) 182 | ? self().app.vault.getFolderByPath(path) 183 | : null; 184 | } 185 | 186 | /** 187 | * Takes an incoming parameter and returns the corresponding `TAbstractFile` if 188 | * the parameter is a string and the string corresponds to an existing template 189 | * file. If the passed in path can't be found, the function will also check 190 | * Templater's template folder path for the file. Returns `null` when the search 191 | * came up empty. 192 | * 193 | * @param path Any incoming zod parameter 194 | * @returns 195 | */ 196 | function lookupAbstractFileForTemplaterPath(path: any): TAbstractFile | null { 197 | if (typeof path !== "string" || !path) { 198 | return null; 199 | } 200 | 201 | const abstractFile = sanitizeFilePathAndGetAbstractFile(path, true); 202 | if (abstractFile) return abstractFile; 203 | 204 | const res = getEnabledCommunityPlugin("templater-obsidian"); 205 | if (res.isSuccess) { 206 | const folder = res.result.settings?.templates_folder; 207 | return sanitizeFilePathAndGetAbstractFile(`${folder}/${path}`, true) || 208 | sanitizeFilePathAndGetAbstractFile(`${folder}/${path}.md`, true); 209 | } 210 | 211 | return null; 212 | } 213 | 214 | /** 215 | * Takes an incoming parameter and returns the corresponding `TAbstractFile` if 216 | * the parameter is a string and the string corresponds to an existing template 217 | * file. If the passed in path can't be found, the function will also check 218 | * Templates' template folder path for the file. Returns `null` when the search 219 | * came up empty. 220 | * 221 | * @param path Any incoming zod parameter 222 | * @returns 223 | */ 224 | function lookupAbstractFileForTemplatesPath(path: any): TAbstractFile | null { 225 | if (typeof path !== "string" || !path) { 226 | return null; 227 | } 228 | 229 | const abstractFile = sanitizeFilePathAndGetAbstractFile(path, true); 230 | if (abstractFile) return abstractFile; 231 | 232 | const res = getEnabledCorePlugin("templates"); 233 | if (res.isSuccess) { 234 | const folder = res.result.options?.folder; 235 | return sanitizeFilePathAndGetAbstractFile(`${folder}/${path}`, true) || 236 | sanitizeFilePathAndGetAbstractFile(`${folder}/${path}.md`, true); 237 | } 238 | 239 | return null; 240 | } 241 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Test Setup Documentation 2 | 3 | This document explains the setup and structure of the test environment for the Obsidian Actions URI plugin. 4 | 5 | ## Prerequisites / Assumptions for Testing 6 | 7 | The test suite assumes that Obsidian is installed and configured to know about a test vault named "plugin-test-vault" located at `~/tmp/plugin-test-vault`. This vault should be configured as needed for testing various plugin features. 8 | 9 | A blueprint of this test vault is stored in this repository at `tests/plugin-test-vault.original`. 10 | 11 | ## Running Tests 12 | 13 | [Jest](https://jestjs.io) is used as the test runner. The test suite utilizes global setup and teardown scripts to manage the test environment. 14 | 15 | A test run performs the following steps: 16 | 17 | ### 1. Global Setup (`tests/global-setup.ts`) 18 | 19 | - Ensures the `~/tmp` directory exists. 20 | - Removes any existing test vault at `~/tmp/plugin-test-vault`. 21 | - Copies the blueprint vault from `tests/plugin-test-vault.original` to `~/tmp/plugin-test-vault`. 22 | - Ensures the Actions URI plugin is enabled in the copied vault's `community-plugins.json`. 23 | - Copies the compiled plugin files (`main.js`, `manifest.json`) into the test vault's plugin directory. 24 | - Opens the copied vault in Obsidian using a `obsidian://open` URI. 25 | - Starts a local HTTP callback server on port 3000 (`tests/callback-server.ts`). 26 | 27 | ### 2. Test Execution (`*.test.ts` files) 28 | 29 | - Jest runs the test files. 30 | - Tests interact with the Obsidian plugin by sending `obsidian://actions-uri/…` URIs. 31 | - Tests use helper functions (`tests/helpers.ts`) to send URIs and wait for callbacks. 32 | 33 | ### 3. Global Teardown (`tests/global-teardown.ts`) 34 | 35 | - Signals Obsidian to close the test vault using a `obsidian://actions-uri/vault/close` URI. 36 | - Removes the temporary test vault directory at `~/tmp/plugin-test-vault`. 37 | - Stops the local HTTP callback server. 38 | 39 | **Important:** The original vault at `tests/plugin-test-vault.original` is **not** modified by the tests. 40 | 41 | ## XCU Call Flow in Tests 42 | 43 | The test suite simulates user interaction by sending Obsidian Actions URIs. The process involves the following steps: 44 | 45 | 1. **Initiating the Call:** A test case calls the `callObsidian()` helper function (`tests/helpers.ts`), providing the desired route path and any necessary payload parameters. 46 | 2. **URI Construction:** The `callObsidian()` function constructs a full `obsidian://actions-uri/…` URI. This URI includes the test vault name and sets the `x-success` and `x-error` callback parameters to point to the `/success` and `/failure` endpoints of the local callback server running on port 3000. 47 | 3. **Sending the URI:** The constructed URI is opened using the `sendUri()` helper function, which uses OS-specific commands (`open`, `start`, `xdg-open`) to trigger Obsidian to handle the URI. 48 | 4. **Obsidian Processing:** Obsidian receives the URI and the Actions URI plugin processes the requested route. 49 | 5. **Sending Callback:** Based on the outcome of processing the route, the Actions URI plugin sends an HTTP GET request to either the `x-success` or `x-error` URL specified in the original URI. Depending on the environment, that request is sent either using `window.open()` (live) or `fetch()` (testing). (See `sendCallbackResult()` in `src/utils/callbacks.ts`.) 50 | 6. **Receiving Callback:** The local callback server (`tests/callback-server.ts`) receives the HTTP request on either the `/success` or `/failure` endpoint. 51 | 7. **Resolving Promise:** The `waitForCallback()` method in the `CallbackServer` instance, which `callObsidian()` is awaiting, receives the data from the incoming request and resolves the promise. 52 | 8. **Processing Result:** The `callObsidian()` function receives the data from the resolved promise, determines if it was a success or failure callback based on the received data structure, and returns a `Result` object (`tests/types.d.ts`) containing the outcome. 53 | 9. **Assertions:** The test case then uses the returned `Result` object to make assertions about the success or failure of the call and the received data. 54 | 55 | ## Structure of the Test Vault and Test Files 56 | 57 | Test files (`*.test.ts`) and their related Markdown notes (`*.md`) are organized within the `tests/plugin-test-vault.original/` directory. The folder structure within `plugin-test-vault.original` mirrors the Actions URI routes being tested. 58 | 59 | For example, files related to testing the `/note/get` route are located in `tests/plugin-test-vault.original/note/get/`. This directory contains the test file (`noteGet.test.ts`) and any Markdown notes (`.md` files) required for those specific tests. 60 | 61 | ``` 62 | tests/ 63 | plugin-test-vault.original/ 64 | note/ 65 | get/ 66 | noteGet.test.ts // Test cases for the `/note/get` route 67 | first-note.md // Markdown note used in noteGet.test.ts 68 | second-note.md // Another Markdown note used in noteGet.test.ts 69 | dataview/ 70 | list/ 71 | dataviewList.test.ts // Test cases for the `/dataview/list` route 72 | // … any necessary files for `dataview/list` tests 73 | // … other routes 74 | ``` 75 | 76 | The test files are typically named after the route they are testing (e.g., `noteGet.test.ts` for the `/note/get` route). 77 | 78 | ### Plugins 79 | 80 | The vault is preconfigured with the following community plugins: 81 | 82 | - Actions URI: The plugin being tested, built and copied into the test vault during setup. 83 | - [Dataview](https://github.com/blacksmithgu/obsidian-dataview): A data index and query language over Markdown files. 84 | - [Logstravaganza](https://github.com/czottmann/obsidian-logstravaganza): Captures developer tool console logs into a `.ndjson` file in the vault's root directory. This is useful for debugging and understanding the flow of the plugin during tests. 85 | - [Periodic Notes](https://github.com/liamcain/obsidian-periodic-notes): For managing periodic notes. 86 | - [Templater](https://github.com/SilentVoid13/Templater): For creating and managing templates. 87 | 88 | ## Key Components 89 | 90 | - **`tests/plugin-test-vault.original/`**: The blueprint of the Obsidian vault used for testing. Copied to a temporary location before each test run. 91 | - **`tests/global-setup.ts`**: Jest global setup script. Handles creating and configuring the temporary test vault and starting the callback server. 92 | - **`tests/global-teardown.ts`**: Jest global teardown script. Handles cleaning up the temporary test vault and stopping the callback server. 93 | - **`tests/callback-server.ts`**: A simple HTTP server that listens for `x-success` (`/success`) and `x-error` (`/failure`) callbacks from the Obsidian plugin. 94 | - **`tests/helpers.ts`**: Contains helper functions for the tests, including `sendUri` (to open Obsidian URIs) and `callObsidian` (to send an Actions URI and wait for a callback, returning a `Result` type). 95 | - **`tests/types.d.ts`**: Defines custom types used in the tests, such as the `Result` type for handling success and error outcomes. 96 | 97 | ## TODO 98 | 99 | - [ ] add function for looking up files in the vault folder which correlates to the route being tested 100 | -------------------------------------------------------------------------------- /tests/callback-server.ts: -------------------------------------------------------------------------------- 1 | import * as http from "http"; 2 | import { URL } from "url"; 3 | import { CallbackData } from "./types"; 4 | 5 | const TEST_PORT = 3000; 6 | 7 | export class CallbackServer { 8 | private server: http.Server; 9 | private callbackData: CallbackData | null = null; 10 | private resolve: ((data: CallbackData) => void) | null = null; 11 | private reject: ((error: Error) => void) | null = null; 12 | 13 | public baseURL: string = `http://localhost:${TEST_PORT}`; 14 | 15 | constructor() { 16 | this.server = http.createServer(async (req, res) => { 17 | const url = new URL(req.url || "/", this.baseURL); 18 | const params = Object.fromEntries(url.searchParams.entries()); 19 | 20 | if (url.pathname.startsWith("/success")) { 21 | this.callbackData = { success: params }; 22 | if (this.resolve) { 23 | this.resolve(this.callbackData); 24 | this.reset(); 25 | } 26 | res.writeHead(200, { "Content-Type": "text/plain" }); 27 | res.end("Success callback received"); 28 | } else if (url.pathname.startsWith("/failure")) { 29 | this.callbackData = { error: params }; 30 | if (this.resolve) { 31 | this.resolve(this.callbackData); 32 | this.reset(); 33 | } 34 | res.writeHead(200, { "Content-Type": "text/plain" }); 35 | res.end("Failure callback received"); 36 | } else { 37 | res.writeHead(404, { "Content-Type": "text/plain" }); 38 | res.end("Not Found"); 39 | } 40 | }); 41 | } 42 | 43 | start(): Promise { 44 | return new Promise((resolve, reject) => { 45 | this.server.listen(TEST_PORT, () => { 46 | console.log(`- Callback server listening on port ${TEST_PORT}`); 47 | resolve(); 48 | }); 49 | this.server.on("error", reject); 50 | }); 51 | } 52 | 53 | stop(): Promise { 54 | return new Promise((resolve, reject) => { 55 | this.server.close((err) => { 56 | if (err) { 57 | reject(err); 58 | } else { 59 | console.log("- Callback server stopped"); 60 | resolve(); 61 | } 62 | }); 63 | }); 64 | } 65 | 66 | waitForCallback(timeout = 3000): Promise { 67 | return new Promise((resolve, reject) => { 68 | this.resolve = resolve; 69 | this.reject = reject; 70 | 71 | const timer = setTimeout(() => { 72 | this.reset(); 73 | reject(new Error("Callback timeout")); 74 | }, timeout); 75 | 76 | // Override resolve and reject to clear the timer 77 | const originalResolve = resolve; 78 | const originalReject = reject; 79 | 80 | this.resolve = (data) => { 81 | clearTimeout(timer); 82 | originalResolve(data); 83 | this.reset(); // Reset after resolving 84 | }; 85 | 86 | this.reject = (error) => { 87 | clearTimeout(timer); 88 | originalReject(error); 89 | this.reset(); // Reset after rejecting 90 | }; 91 | }); 92 | } 93 | 94 | private reset() { 95 | this.callbackData = null; 96 | this.resolve = null; 97 | this.reject = null; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/global-setup.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs/promises"; 2 | import * as path from "path"; 3 | import * as os from "os"; 4 | import chokidar from "chokidar"; 5 | import { CallbackServer } from "./callback-server"; 6 | import { asyncExec, pause } from "./helpers"; 7 | import { id as pluginID } from "../manifest.json"; 8 | 9 | /** 10 | * The name of the vault used for testing. The value of the constant is the same 11 | * as the "blueprint" test vault stored in the `__tests__/` folder, sans the 12 | * extension, i.e. `plugin-test-vault` (value) instead of 13 | * `plugin-test-vault.original` (the folder). 14 | * 15 | * This constant is used in setting up the actual test vault (see the 16 | * `setup-vault.ts` script), and for deciding how XCU callbacks are made (see 17 | * `src/utils/callbacks.ts`). 18 | */ 19 | const testVaultName = "plugin-test-vault"; 20 | 21 | /** 22 | * Sets up a temporary Obsidian vault for testing purposes. 23 | * 24 | * This function performs the following steps: 25 | * 1. Ensures the parent directory for the test vault exists. 26 | * 2. Removes any existing test vault at the target location. 27 | * 3. Copies a blueprint vault to the test location. 28 | * 4. Ensures the plugin is enabled in the vault's `community-plugins.json`. 29 | * 5. Copies the compiled plugin files into the vault's plugin directory. 30 | * 6. Opens the copied vault in Obsidian. 31 | * 7. Starts the global callback server. 32 | */ 33 | export default async function globalSetup() { 34 | console.log("\nSetting up test vault…"); 35 | 36 | const blueprintVaultPath = path.join(__dirname, `${testVaultName}.original`); 37 | const testVaultDir = path.join(os.homedir(), "tmp"); 38 | const testVaultPath = path.join(testVaultDir, testVaultName); 39 | const obsidianDir = path.join(testVaultPath, ".obsidian"); 40 | const pluginDir = path.join(obsidianDir, "plugins", pluginID); 41 | 42 | // Ensure the parent directory for the test vault exists 43 | console.log("- Creating temp vault…"); 44 | await createTestVault(testVaultDir, testVaultPath, blueprintVaultPath); 45 | 46 | // Ensure the plugin is in the community-plugins.json and compiled files are copied 47 | console.log(`- Ensuring ${pluginID} plugin is enabled…`); 48 | await ensureTestPluginIsEnabled(pluginDir, obsidianDir); 49 | 50 | console.log("- Copying new plugin build into test vault…"); 51 | copyNewPluginBuildIntoTestVault(pluginDir); 52 | 53 | console.log(`- Opening test vault in Obsidian…`); 54 | await openTestVaultInObsidian(); 55 | 56 | console.log(`- Starting the global callback server…`); 57 | const httpServer = await startHTTPServer(); 58 | 59 | console.log("- Finding NDJSON console log file and setting up watcher…"); 60 | const consoleLogFile = await locateLogstravaganzaLogFile(testVaultPath); 61 | 62 | console.log(`- Watching ${consoleLogFile} for new log entries…`); 63 | const { logPath, logWatcher } = await startLogFileWatcher( 64 | testVaultPath, 65 | consoleLogFile, 66 | ); 67 | 68 | global.httpServer = httpServer; 69 | global.testVault = { 70 | logPath, 71 | logRows: [], 72 | logWatcher, 73 | name: testVaultName, 74 | path: testVaultPath, 75 | }; 76 | 77 | console.log("Test vault set up!\n"); 78 | } 79 | 80 | async function startLogFileWatcher( 81 | testVaultPath: string, 82 | consoleLogFile: string, 83 | ) { 84 | const logPath = path.join(testVaultPath, consoleLogFile); 85 | 86 | // Use polling as the file might be written to by another process 87 | const logWatcher = chokidar.watch(logPath, { 88 | persistent: true, 89 | usePolling: true, 90 | interval: 50, 91 | }); 92 | 93 | // `lastSize` keeps track of the last read position, so we can read only new 94 | // lines, starting from the moment the vault setup is complete. 95 | let lastSize = (await fs.stat(logPath)).size; 96 | 97 | logWatcher.on("change", async () => { 98 | try { 99 | const stats = await fs.stat(logPath); 100 | const currentSize = stats.size; 101 | 102 | if (currentSize > lastSize) { 103 | const fileHandle = await fs.open(logPath, "r"); 104 | const buffer = Buffer.alloc(currentSize - lastSize); 105 | await fileHandle.read(buffer, 0, currentSize - lastSize, lastSize); 106 | await fileHandle.close(); 107 | 108 | const newContent = buffer.toString(); 109 | const lines = newContent.split("\n").filter((line) => line.length); 110 | global.testVault.logRows.push(...lines); 111 | lastSize = currentSize; 112 | } 113 | } catch (e) { 114 | console.error("Error reading new lines from console log file:", e); 115 | } 116 | }); 117 | 118 | return { logPath, logWatcher }; 119 | } 120 | 121 | async function locateLogstravaganzaLogFile( 122 | testVaultPath: string, 123 | ): Promise { 124 | const vaultFiles = await fs.readdir(testVaultPath); 125 | const consoleLogFile = vaultFiles.find((file) => file.endsWith(".ndjson")); 126 | 127 | if (!consoleLogFile) { 128 | throw new Error(`No NDJSON file found in test vault at ${testVaultPath}`); 129 | } 130 | 131 | return consoleLogFile; 132 | } 133 | 134 | async function startHTTPServer() { 135 | const httpServer = new CallbackServer(); 136 | await httpServer.start(); 137 | return httpServer; 138 | } 139 | 140 | /** 141 | * Open the vault in Obsidian and give it a moment to load. 142 | */ 143 | async function openTestVaultInObsidian() { 144 | await asyncExec(`open "obsidian://open?vault=${testVaultName}"`); 145 | await pause(2000); 146 | } 147 | 148 | /** 149 | * Copy the compiled plugin files from the project root to the vault's plugin 150 | * directory. 151 | */ 152 | function copyNewPluginBuildIntoTestVault(pluginDir: string) { 153 | ["main.js", "manifest.json"] 154 | .forEach(async (file) => { 155 | try { 156 | await fs.copyFile(file, path.join(pluginDir, file)); 157 | } catch (e) { 158 | throw new Error(`Failed to copy ${file}: ${e}`); 159 | } 160 | }); 161 | } 162 | 163 | async function ensureTestPluginIsEnabled( 164 | pluginDir: string, 165 | obsidianDir: string, 166 | ) { 167 | await fs.mkdir(pluginDir, { recursive: true }); 168 | 169 | // Update community-plugins.json to ensure the plugin is enabled 170 | const communityPluginsPath = path.join(obsidianDir, "community-plugins.json"); 171 | let communityPlugins: string[] = []; 172 | try { 173 | const content = await fs.readFile(communityPluginsPath, "utf-8"); 174 | communityPlugins = JSON.parse(content); 175 | } catch (e) { 176 | // File might not exist, start with empty array 177 | } 178 | 179 | if (!communityPlugins.includes(pluginID)) { 180 | communityPlugins.push(pluginID); 181 | await fs.writeFile(communityPluginsPath, JSON.stringify(communityPlugins)); 182 | } 183 | } 184 | 185 | async function createTestVault( 186 | testVaultDir: string, 187 | testVaultPath: string, 188 | blueprintVaultPath: string, 189 | ) { 190 | await fs.mkdir(testVaultDir, { recursive: true }); 191 | 192 | // Remove existing test vault if it exists 193 | try { 194 | await fs.rm(testVaultPath, { recursive: true, force: true }); 195 | } catch (e) { 196 | // Ignore if it doesn't exist 197 | } 198 | 199 | // Copy the blueprint vault 200 | await fs.cp(blueprintVaultPath, testVaultPath, { recursive: true }); 201 | } 202 | -------------------------------------------------------------------------------- /tests/global-teardown.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs/promises"; 2 | import { asyncExec, pause } from "./helpers"; 3 | 4 | /** 5 | * Tears down (removes) the specified test vault directory. 6 | */ 7 | export default async function globalTeardown() { 8 | console.log("\nTearing down test vault…"); 9 | 10 | await asyncExec( 11 | `open "obsidian://actions-uri/vault/close?vault=${global.testVault.name}"`, 12 | ); 13 | console.log("- Signalled Obsidian to close the vault…"); 14 | // Wait for a moment to ensure the vault is closed, including all open files 15 | await pause(500); 16 | 17 | // Close the console log watcher 18 | if (global.testVault.logWatcher) { 19 | await global.testVault.logWatcher.close(); 20 | console.log("- Quit the console log watcher"); 21 | } 22 | 23 | if (global.testVault.path) { 24 | // We don't remove the parent directory anymore, only the vault itself 25 | await fs.rm(global.testVault.path, { recursive: true, force: true }); 26 | console.log(`- Removed temp vault at ${global.testVault.path}`); 27 | } else { 28 | console.warn("- No vault path found in `global.testVault.path`!"); 29 | } 30 | 31 | if (global.httpServer) { 32 | await global.httpServer.stop(); 33 | console.log("- Stopped HTTP callback server"); 34 | } else { 35 | console.warn("- No HTTP server instance found!"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import { randomUUID } from "crypto"; 3 | import { platform } from "os"; 4 | import { promisify } from "util"; 5 | import { LogEntry, Result } from "./types"; 6 | 7 | export const asyncExec = promisify(exec); 8 | 9 | export function sendUri(uri: string): Promise { 10 | let command: string; 11 | const osType = platform(); 12 | 13 | switch (osType) { 14 | // macOS 15 | case "darwin": 16 | command = `open "${uri}"`; 17 | break; 18 | 19 | // Windows 20 | case "win32": 21 | command = `start "" "${uri}"`; 22 | break; 23 | 24 | // Linux 25 | case "linux": 26 | command = `xdg-open "${uri}"`; 27 | break; 28 | 29 | default: 30 | return Promise.reject(new Error(`Unsupported OS: ${osType}`)); 31 | } 32 | 33 | return new Promise((resolve, reject) => { 34 | exec( 35 | command, 36 | (error) => error ? reject(error) : resolve(), 37 | ); 38 | }); 39 | } 40 | 41 | /** A simple wait-for-n-ms function. */ 42 | export async function pause(milliseconds: number): Promise { 43 | return new Promise((resolve) => { 44 | setTimeout( 45 | () => resolve(), 46 | milliseconds, 47 | ); 48 | }); 49 | } 50 | 51 | /** 52 | * Calls an Obsidian Actions URI endpoint and waits for a callback from the test callback server. 53 | * 54 | * This function constructs an Obsidian URI with the specified route path and payload parameters. 55 | * It automatically includes the required 'vault', 'x-success', and 'x-error' parameters, 56 | * setting 'x-success' to the '/success' endpoint and 'x-error' to the '/failure' endpoint 57 | * of the local test callback server (http://localhost:3000). 58 | * 59 | * After sending the URI to Obsidian, the function waits for a response from the callback server. 60 | * The response is returned as a `Result` object, containing either the success value (`ok: true`) 61 | * or an error object (`ok: false`), based on which callback endpoint was invoked by the Obsidian plugin. 62 | * 63 | * @template T - The expected type of the success value. 64 | * @template E - The expected type of the error object. 65 | * 66 | * @param path - The route path of the Actions URI endpoint to call (e.g., "note/get", "file/create"). 67 | * @param payload - An optional object containing key-value pairs for the endpoint's URL parameters. 68 | * @param timeout - The maximum time to wait for a callback from the server (default: 3000 ms). 69 | * @returns A Promise that resolves with a `Result` object. 70 | * - If the '/success' callback is received, `result.ok` is true and `result.value` contains the received data. 71 | * - If the '/failure' callback is received, `result.ok` is false and `result.error` contains the received error data. 72 | * - If a timeout or unknown error occurs, `result.ok` is false and `result.error` contains the error. 73 | */ 74 | export async function callObsidian( 75 | path: string, 76 | payload: Record = {}, 77 | timeout: number = 3000, 78 | ): Promise> { 79 | const cbServer = global.httpServer!; 80 | 81 | const uri = constructObsidianURI(path, payload); 82 | const cbPromise = cbServer.waitForCallback(timeout); 83 | await sendUri(uri); 84 | let callbackRes; 85 | 86 | try { 87 | callbackRes = await cbPromise; 88 | } catch (error) { 89 | // Handle timeout or other errors from `waitForCallback()` 90 | return { 91 | ok: false, 92 | error: error as E, 93 | log: await collectRecentLogEntries(), 94 | }; 95 | } 96 | 97 | const logEntries = await collectRecentLogEntries(); 98 | 99 | if (callbackRes.success) { 100 | try { 101 | // Attempt to parse success data if it's a JSON string 102 | const parsedValue = JSON.parse(callbackRes.success); 103 | return { ok: true, value: parsedValue as T, log: logEntries }; 104 | } catch (e) { 105 | // If parsing fails, return the raw string 106 | return { ok: true, value: callbackRes.success as T, log: logEntries }; 107 | } 108 | } else if (callbackRes.error) { 109 | // Assuming error data is always an object with errorCode and errorMessage 110 | return { ok: false, error: callbackRes.error as E, log: logEntries }; 111 | } 112 | 113 | // Should not happen if `waitForCallback()` works correctly 114 | return { 115 | ok: false, 116 | error: new Error("Unknown callback data received") as E, 117 | log: logEntries, 118 | }; 119 | } 120 | 121 | /** 122 | * Collects recent log entries from the test vault and clears the log rows. 123 | * This function is used to gather log entries after a callback is received. 124 | * 125 | * @returns A Promise that resolves with an array of log entries. 126 | */ 127 | async function collectRecentLogEntries(): Promise { 128 | await pause(100); 129 | const logEntries = global.testVault.logRows.map((l) => JSON.parse(l)); 130 | global.testVault.logRows = []; 131 | return logEntries; 132 | } 133 | 134 | /** 135 | * Constructs an Obsidian URI with the specified route path and payload parameters. 136 | * Automatically includes the required 'vault', 'x-success', and 'x-error' parameters. 137 | * 138 | * @param path - The route path of the Actions URI endpoint to call (e.g., "note/get", "file/create"). 139 | * @param payload - An object containing key-value pairs for the endpoint's URL parameters. 140 | * @returns A string representing the constructed Obsidian URI. 141 | */ 142 | function constructObsidianURI( 143 | path: string, 144 | payload: Record, 145 | ): string { 146 | // Generate a unique identifier for the callback so it's easier to track 147 | const uuid = randomUUID(); 148 | const cbServer = global.httpServer; 149 | const url = new URL(`obsidian://actions-uri/${path}`); 150 | 151 | // Set required parameters 152 | url.searchParams.set("vault", global.testVault.name); 153 | 154 | // Allow for custom x-success parameter, even if rarely used 155 | if ( 156 | Object.hasOwn(payload, "x-success") && 157 | typeof payload["x-success"] !== "undefined" 158 | ) { 159 | url.searchParams.set("x-success", payload["x-success"]); 160 | } else { 161 | url.searchParams.set("x-success", `${cbServer.baseURL}/success/${uuid}`); 162 | } 163 | 164 | // Allow for custom x-error parameter, even if rarely used 165 | if ( 166 | Object.hasOwn(payload, "x-error") && 167 | typeof payload["x-error"] !== "undefined" 168 | ) { 169 | url.searchParams.set("x-error", payload["x-error"]); 170 | } else { 171 | url.searchParams.set("x-error", `${cbServer.baseURL}/failure/${uuid}`); 172 | } 173 | 174 | // Add payload parameters 175 | for (const key in payload) { 176 | if (Object.prototype.hasOwnProperty.call(payload, key)) { 177 | url.searchParams.set(key, String(payload[key])); 178 | } 179 | } 180 | 181 | return url.toString(); 182 | } 183 | -------------------------------------------------------------------------------- /tests/periodic-notes.ts: -------------------------------------------------------------------------------- 1 | import * as moment from "moment"; 2 | import { PeriodicNoteSet, RecentPeriodicNoteSet } from "#tests/types.d"; 3 | 4 | export const periodicNotes: PeriodicNoteSet[] = [ 5 | { key: "daily", dateString: moment().format("YYYY-MM-DD") }, 6 | { key: "weekly", dateString: moment().format("gggg-[W]ww") }, 7 | { key: "monthly", dateString: moment().format("YYYY-MM") }, 8 | { key: "quarterly", dateString: moment().format("YYYY-[Q]Q") }, 9 | { key: "yearly", dateString: moment().format("YYYY") }, 10 | ]; 11 | 12 | export const recentPeriodicNotes: RecentPeriodicNoteSet[] = [ 13 | { key: "recent-daily", dateString: "2025-05-18" }, 14 | { key: "recent-weekly", dateString: "2025-W20" }, 15 | { key: "recent-monthly", dateString: "2025-04" }, 16 | { key: "recent-quarterly", dateString: "2025-Q1" }, 17 | { key: "recent-yearly", dateString: "2024" }, 18 | ]; 19 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/app.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/appearance.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/community-plugins.json: -------------------------------------------------------------------------------- 1 | [ 2 | "logstravaganza", 3 | "periodic-notes", 4 | "auto-periodic-notes", 5 | "templater-obsidian", 6 | "actions-uri" 7 | ] -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/core-plugins.json: -------------------------------------------------------------------------------- 1 | { 2 | "file-explorer": true, 3 | "global-search": false, 4 | "switcher": false, 5 | "graph": false, 6 | "backlink": false, 7 | "canvas": false, 8 | "outgoing-link": false, 9 | "tag-pane": false, 10 | "properties": false, 11 | "page-preview": false, 12 | "daily-notes": true, 13 | "templates": true, 14 | "note-composer": false, 15 | "command-palette": false, 16 | "slash-command": false, 17 | "editor-status": true, 18 | "bookmarks": false, 19 | "markdown-importer": false, 20 | "zk-prefixer": false, 21 | "random-note": false, 22 | "outline": false, 23 | "word-count": false, 24 | "slides": false, 25 | "audio-recorder": false, 26 | "workspaces": false, 27 | "file-recovery": false, 28 | "publish": false, 29 | "sync": false, 30 | "webviewer": false 31 | } -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/graph.json: -------------------------------------------------------------------------------- 1 | { 2 | "collapse-filter": true, 3 | "search": "", 4 | "showTags": false, 5 | "showAttachments": false, 6 | "hideUnresolved": false, 7 | "showOrphans": true, 8 | "collapse-color-groups": true, 9 | "colorGroups": [], 10 | "collapse-display": true, 11 | "showArrow": false, 12 | "textFadeMultiplier": 0, 13 | "nodeSizeMultiplier": 1, 14 | "lineSizeMultiplier": 1, 15 | "collapse-forces": true, 16 | "centerStrength": 0.518713248970312, 17 | "repelStrength": 10, 18 | "linkStrength": 1, 19 | "linkDistance": 250, 20 | "scale": 1, 21 | "close": true 22 | } -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/plugins/actions-uri/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "actions-uri", 3 | "name": "Actions URI", 4 | "version": "1.7.3", 5 | "minAppVersion": "1.5.11", 6 | "description": "Adds additional `x-callback-url` endpoints to the app for common actions — it's a clean, super-charged addition to Obsidian URI.", 7 | "author": "Carlo Zottmann", 8 | "authorUrl": "https://github.com/czottmann", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/plugins/auto-periodic-notes/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "alwaysOpen": false, 3 | "daily": { 4 | "available": true, 5 | "enabled": true, 6 | "closeExisting": false, 7 | "openAndPin": false, 8 | "excludeWeekends": false 9 | }, 10 | "weekly": { 11 | "available": true, 12 | "enabled": true, 13 | "closeExisting": false, 14 | "openAndPin": false 15 | }, 16 | "monthly": { 17 | "available": true, 18 | "enabled": true, 19 | "closeExisting": false, 20 | "openAndPin": false 21 | }, 22 | "quarterly": { 23 | "available": true, 24 | "enabled": true, 25 | "closeExisting": false, 26 | "openAndPin": false 27 | }, 28 | "yearly": { 29 | "available": true, 30 | "enabled": true, 31 | "closeExisting": false, 32 | "openAndPin": false 33 | } 34 | } -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/plugins/auto-periodic-notes/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "auto-periodic-notes", 3 | "name": "Auto Periodic Notes", 4 | "version": "0.2.3", 5 | "minAppVersion": "1.6.6", 6 | "description": "Creates new periodic notes automatically in the background and allows these to be pinned in your open tabs, requires the Periodic Notes plugin.", 7 | "author": "Jamie Hurst", 8 | "authorUrl": "https://jamiehurst.co.uk", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/plugins/logstravaganza/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "fileNameContainsDate": false, 3 | "formatterID": "ndjson", 4 | "outputFolder": "/", 5 | "logLevel": "debug", 6 | "debounceWrites": false 7 | } -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/plugins/logstravaganza/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "logstravaganza", 3 | "name": "Logstravaganza", 4 | "version": "2.2.0", 5 | "minAppVersion": "1.8.0", 6 | "description": "A simple proxy for `console.*()` calls which copies log messages and uncaught exceptions to a file.", 7 | "author": "Carlo Zottmann", 8 | "authorUrl": "https://zottmann.dev", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/plugins/periodic-notes/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "showGettingStartedBanner": true, 3 | "hasMigratedDailyNoteSettings": false, 4 | "hasMigratedWeeklyNoteSettings": false, 5 | "daily": { 6 | "format": "", 7 | "template": "_templates/Daily Note.md", 8 | "folder": "", 9 | "enabled": true 10 | }, 11 | "weekly": { 12 | "format": "", 13 | "template": "_templates/Weekly Note.md", 14 | "folder": "", 15 | "enabled": true 16 | }, 17 | "monthly": { 18 | "format": "", 19 | "template": "_templates/Monthly Note.md", 20 | "folder": "", 21 | "enabled": true 22 | }, 23 | "quarterly": { 24 | "format": "", 25 | "template": "_templates/Quarterly Note.md", 26 | "folder": "", 27 | "enabled": true 28 | }, 29 | "yearly": { 30 | "format": "", 31 | "template": "_templates/Yearly Note.md", 32 | "folder": "", 33 | "enabled": true 34 | } 35 | } -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/plugins/periodic-notes/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "periodic-notes", 3 | "name": "Periodic Notes", 4 | "description": "Create/manage your daily, weekly, and monthly notes", 5 | "version": "0.0.17", 6 | "author": "Liam Cain", 7 | "authorUrl": "https://github.com/liamcain/", 8 | "isDesktopOnly": false, 9 | "minAppVersion": "0.10.11" 10 | } 11 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/plugins/periodic-notes/styles.css: -------------------------------------------------------------------------------- 1 | .periodic-modal { 2 | min-width: 40vw; 3 | } 4 | 5 | .settings-banner { 6 | background-color: var(--background-primary-alt); 7 | border-radius: 8px; 8 | border: 1px solid var(--background-modifier-border); 9 | margin-bottom: 1em; 10 | margin-top: 1em; 11 | padding: 1.5em; 12 | text-align: left; 13 | } 14 | 15 | .settings-banner h3 { 16 | margin-top: 0; 17 | } 18 | 19 | .settings-banner h4 { 20 | margin-bottom: 0.25em; 21 | } 22 | 23 | .has-error { 24 | color: var(--text-error); 25 | } 26 | 27 | input.has-error { 28 | color: var(--text-error); 29 | border-color: var(--text-error); 30 | } 31 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/plugins/templater-obsidian/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "templater-obsidian", 3 | "name": "Templater", 4 | "version": "2.11.1", 5 | "description": "Create and use templates", 6 | "minAppVersion": "1.5.0", 7 | "author": "SilentVoid", 8 | "authorUrl": "https://github.com/SilentVoid13", 9 | "helpUrl": "https://silentvoid13.github.io/Templater/", 10 | "isDesktopOnly": false 11 | } 12 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/plugins/templater-obsidian/styles.css: -------------------------------------------------------------------------------- 1 | .templater_search { 2 | width: calc(100% - 20px); 3 | } 4 | 5 | .templater_div { 6 | border-top: 1px solid var(--background-modifier-border); 7 | } 8 | 9 | .templater_div > .setting-item { 10 | border-top: none !important; 11 | align-self: center; 12 | } 13 | 14 | .templater_div > .setting-item > .setting-item-control { 15 | justify-content: space-around; 16 | padding: 0; 17 | width: 100%; 18 | } 19 | 20 | .templater_div 21 | > .setting-item 22 | > .setting-item-control 23 | > .setting-editor-extra-setting-button { 24 | align-self: center; 25 | } 26 | 27 | .templater_donating { 28 | margin: 10px; 29 | } 30 | 31 | .templater_title { 32 | margin: 0; 33 | padding: 0; 34 | margin-top: 5px; 35 | text-align: center; 36 | } 37 | 38 | .templater_template { 39 | align-self: center; 40 | margin-left: 5px; 41 | margin-right: 5px; 42 | width: 70%; 43 | } 44 | 45 | .templater_cmd { 46 | margin-left: 5px; 47 | margin-right: 5px; 48 | font-size: 14px; 49 | width: 100%; 50 | } 51 | 52 | .templater_div2 > .setting-item { 53 | align-content: center; 54 | justify-content: center; 55 | } 56 | 57 | .templater-prompt-div { 58 | display: flex; 59 | } 60 | 61 | .templater-prompt-form { 62 | display: flex; 63 | flex-grow: 1; 64 | } 65 | 66 | .templater-prompt-input { 67 | flex-grow: 1; 68 | } 69 | 70 | .templater-button-div { 71 | display: flex; 72 | flex-direction: column; 73 | align-items: center; 74 | margin-top: 1rem; 75 | } 76 | 77 | textarea.templater-prompt-input { 78 | height: 10rem; 79 | } 80 | 81 | textarea.templater-prompt-input:focus { 82 | border-color: var(--interactive-accent); 83 | } 84 | 85 | .cm-s-obsidian .templater-command-bg { 86 | left: 0px; 87 | right: 0px; 88 | background-color: var(--background-primary-alt); 89 | } 90 | 91 | .cm-s-obsidian .cm-templater-command { 92 | font-size: 0.85em; 93 | font-family: var(--font-monospace); 94 | line-height: 1.3; 95 | } 96 | 97 | .cm-s-obsidian .templater-inline .cm-templater-command { 98 | background-color: var(--background-primary-alt); 99 | } 100 | 101 | .cm-s-obsidian .cm-templater-command.cm-templater-opening-tag { 102 | font-weight: bold; 103 | } 104 | 105 | .cm-s-obsidian .cm-templater-command.cm-templater-closing-tag { 106 | font-weight: bold; 107 | } 108 | 109 | .cm-s-obsidian .cm-templater-command.cm-templater-interpolation-tag { 110 | color: var(--code-property, #008bff); 111 | } 112 | 113 | .cm-s-obsidian .cm-templater-command.cm-templater-execution-tag { 114 | color: var(--code-function, #c0d700); 115 | } 116 | 117 | .cm-s-obsidian .cm-templater-command.cm-keyword { 118 | color: var(--code-keyword, #00a7aa); 119 | font-weight: normal; 120 | } 121 | 122 | .cm-s-obsidian .cm-templater-command.cm-atom { 123 | color: var(--code-normal, #f39b35); 124 | } 125 | 126 | .cm-s-obsidian .cm-templater-command.cm-value, 127 | .cm-s-obsidian .cm-templater-command.cm-number, 128 | .cm-s-obsidian .cm-templater-command.cm-type { 129 | color: var(--code-value, #a06fca); 130 | } 131 | 132 | .cm-s-obsidian .cm-templater-command.cm-def, 133 | .cm-s-obsidian .cm-templater-command.cm-type.cm-def { 134 | color: var(--code-normal, var(--text-normal)); 135 | } 136 | 137 | .cm-s-obsidian .cm-templater-command.cm-property, 138 | .cm-s-obsidian .cm-templater-command.cm-property.cm-def, 139 | .cm-s-obsidian .cm-templater-command.cm-attribute { 140 | color: var(--code-function, #98e342); 141 | } 142 | 143 | .cm-s-obsidian .cm-templater-command.cm-variable, 144 | .cm-s-obsidian .cm-templater-command.cm-variable-2, 145 | .cm-s-obsidian .cm-templater-command.cm-variable-3, 146 | .cm-s-obsidian .cm-templater-command.cm-meta { 147 | color: var(--code-property, #d4d4d4); 148 | } 149 | 150 | .cm-s-obsidian .cm-templater-command.cm-callee, 151 | .cm-s-obsidian .cm-templater-command.cm-operator, 152 | .cm-s-obsidian .cm-templater-command.cm-qualifier, 153 | .cm-s-obsidian .cm-templater-command.cm-builtin { 154 | color: var(--code-operator, #fc4384); 155 | } 156 | 157 | .cm-s-obsidian .cm-templater-command.cm-tag { 158 | color: var(--code-tag, #fc4384); 159 | } 160 | 161 | .cm-s-obsidian .cm-templater-command.cm-comment, 162 | .cm-s-obsidian .cm-templater-command.cm-comment.cm-tag, 163 | .cm-s-obsidian .cm-templater-command.cm-comment.cm-attribute { 164 | color: var(--code-comment, #696d70); 165 | } 166 | 167 | .cm-s-obsidian .cm-templater-command.cm-string, 168 | .cm-s-obsidian .cm-templater-command.cm-string-2 { 169 | color: var(--code-string, #e6db74); 170 | } 171 | 172 | .cm-s-obsidian .cm-templater-command.cm-header, 173 | .cm-s-obsidian .cm-templater-command.cm-hr { 174 | color: var(--code-keyword, #da7dae); 175 | } 176 | 177 | .cm-s-obsidian .cm-templater-command.cm-link { 178 | color: var(--code-normal, #696d70); 179 | } 180 | 181 | .cm-s-obsidian .cm-templater-command.cm-error { 182 | border-bottom: 1px solid #c42412; 183 | } 184 | 185 | .CodeMirror-hints { 186 | position: absolute; 187 | z-index: 10; 188 | overflow: hidden; 189 | list-style: none; 190 | 191 | margin: 0; 192 | padding: 2px; 193 | 194 | -webkit-box-shadow: 2px 3px 5px rgba(0, 0, 0, 0.2); 195 | -moz-box-shadow: 2px 3px 5px rgba(0, 0, 0, 0.2); 196 | box-shadow: 2px 3px 5px rgba(0, 0, 0, 0.2); 197 | border-radius: 3px; 198 | border: 1px solid silver; 199 | 200 | background: white; 201 | font-size: 90%; 202 | font-family: monospace; 203 | 204 | max-height: 20em; 205 | overflow-y: auto; 206 | } 207 | 208 | .CodeMirror-hint { 209 | margin: 0; 210 | padding: 0 4px; 211 | border-radius: 2px; 212 | white-space: pre; 213 | color: black; 214 | cursor: pointer; 215 | } 216 | 217 | li.CodeMirror-hint-active { 218 | background: #08f; 219 | color: white; 220 | } 221 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/.obsidian/workspace.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": { 3 | "id": "90ac7104a2c14afa", 4 | "type": "split", 5 | "children": [ 6 | { 7 | "id": "26e8f8e844ba644b", 8 | "type": "tabs", 9 | "children": [ 10 | { 11 | "id": "a9a8380bedb17107", 12 | "type": "leaf", 13 | "state": { 14 | "type": "markdown", 15 | "state": { 16 | "file": "Welcome.md", 17 | "mode": "source", 18 | "source": false, 19 | "backlinks": false 20 | }, 21 | "icon": "lucide-file", 22 | "title": "Welcome" 23 | } 24 | } 25 | ] 26 | } 27 | ], 28 | "direction": "vertical" 29 | }, 30 | "left": { 31 | "id": "916340f1f9ab9dc8", 32 | "type": "split", 33 | "children": [ 34 | { 35 | "id": "925ec3c27c6026ca", 36 | "type": "tabs", 37 | "children": [ 38 | { 39 | "id": "657f9126fd67266c", 40 | "type": "leaf", 41 | "state": { 42 | "type": "file-explorer", 43 | "state": { 44 | "sortOrder": "alphabetical", 45 | "autoReveal": false 46 | }, 47 | "icon": "lucide-folder-closed", 48 | "title": "File explorer" 49 | } 50 | } 51 | ] 52 | } 53 | ], 54 | "direction": "horizontal", 55 | "width": 300 56 | }, 57 | "right": { 58 | "id": "8278f287aacfb41b", 59 | "type": "split", 60 | "children": [], 61 | "direction": "horizontal", 62 | "width": 300, 63 | "collapsed": true 64 | }, 65 | "left-ribbon": { 66 | "hiddenItems": { 67 | "templater-obsidian:Templater": false, 68 | "daily-notes:Open today's note": false, 69 | "templates:Insert template": false, 70 | "periodic-notes:Open today": false 71 | } 72 | }, 73 | "active": "a9a8380bedb17107", 74 | "lastOpenFiles": [ 75 | "Welcome.md" 76 | ] 77 | } -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/2024.md: -------------------------------------------------------------------------------- 1 | ## Yearly Note: 2024 2 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/2025-04.md: -------------------------------------------------------------------------------- 1 | ## Monthly Note: 2025-04 2 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/2025-05-18.md: -------------------------------------------------------------------------------- 1 | ## Daily Note: 2025-05-18 2 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/2025-Q1.md: -------------------------------------------------------------------------------- 1 | ## Quarterly Note: 2025-Q1 2 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/2025-W20.md: -------------------------------------------------------------------------------- 1 | ## Weekly Note: 2025-W20 2 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/Welcome.md: -------------------------------------------------------------------------------- 1 | This is a test vault. 2 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/_templates/Daily Note.md: -------------------------------------------------------------------------------- 1 | ## Daily Note: {{title}} 2 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/_templates/Monthly Note.md: -------------------------------------------------------------------------------- 1 | ## Monthly Note: {{title}} 2 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/_templates/Quarterly Note.md: -------------------------------------------------------------------------------- 1 | ## Quarterly Note: {{title}} 2 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/_templates/Weekly Note.md: -------------------------------------------------------------------------------- 1 | ## Weekly Note: {{title}} 2 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/_templates/Yearly Note.md: -------------------------------------------------------------------------------- 1 | ## Yearly Note: {{title}} 2 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/any/standard-parameters.test.ts: -------------------------------------------------------------------------------- 1 | import { callObsidian } from "#tests/helpers"; 2 | 3 | describe("any route", () => { 4 | it( 5 | "should throw an error on missing/invalid `x-success` parameter", 6 | async () => { 7 | const res = await callObsidian("/", { "x-success": undefined }, 1000); 8 | expect(res.ok).toBe(false); 9 | expect(res.log).toBeDefined(); 10 | expect( 11 | res.log!.find((l) => 12 | l.level === "error" && 13 | l.sender.startsWith("plugin:actions-uri:") && 14 | l.args.join(" ").includes("x-success") 15 | ), 16 | ).toBeDefined(); 17 | }, 18 | 10000, 19 | ); 20 | 21 | it( 22 | "should throw an error on missing/invalid `x-error` parameter", 23 | async () => { 24 | const res = await callObsidian("/", { "x-error": undefined }, 1000); 25 | expect(res.ok).toBe(false); 26 | if (!res.ok) { 27 | expect(res.error.message).toBe("Callback timeout"); 28 | } 29 | 30 | expect(res.log).toBeDefined(); 31 | expect( 32 | res.log!.find((l) => 33 | l.level === "error" && 34 | l.sender.startsWith("plugin:actions-uri:") && 35 | l.args.join(" ").includes("x-error") 36 | ), 37 | ).toBeDefined(); 38 | }, 39 | 10000, 40 | ); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/append/noteAppend.test.ts: -------------------------------------------------------------------------------- 1 | import { callObsidian } from "#tests/helpers"; 2 | 3 | describe("note/append", () => { 4 | it.todo("needs testing"); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/create/noteCreate.test.ts: -------------------------------------------------------------------------------- 1 | import { callObsidian } from "#tests/helpers"; 2 | 3 | describe("note/create", () => { 4 | it.todo("needs testing"); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/delete/noteDelete.test.ts: -------------------------------------------------------------------------------- 1 | import { callObsidian } from "#tests/helpers"; 2 | 3 | describe("note/delete", () => { 4 | it.todo("needs testing"); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/get-active/noteGetActive.test.ts: -------------------------------------------------------------------------------- 1 | import { callObsidian } from "#tests/helpers"; 2 | 3 | describe("note/get-active", () => { 4 | it.todo("needs testing"); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/get-first-named/noteGetFirstNamed.test.ts: -------------------------------------------------------------------------------- 1 | import { callObsidian } from "#tests/helpers"; 2 | 3 | describe("note/get-first-named", () => { 4 | it.todo("needs testing"); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/get/note-1.md: -------------------------------------------------------------------------------- 1 | --- 2 | tags: 3 | - one 4 | - two 5 | id: 01JV9K2XGJA4HH5XVWKC8EPQ4W 6 | --- 7 | 8 | # Hello 9 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/get/noteGet.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs/promises"; 2 | import * as path from "path"; 3 | import { callObsidian, pause } from "#tests/helpers"; 4 | import { periodicNotes, recentPeriodicNotes } from "#tests/periodic-notes"; 5 | 6 | describe("/note/get", () => { 7 | it("should return note content on success", async () => { 8 | // console.log(__dirname); 9 | 10 | const res = await callObsidian("note/get", { file: "note/get/note-1.md" }); 11 | expect(res.ok).toBe(true); 12 | 13 | if (res.ok) { 14 | expect(res.value["result-filepath"]).toBe("note/get/note-1.md"); 15 | expect(res.value["result-body"]).not.toEqual(""); 16 | expect(res.value["result-content"]).toContain("# Hello"); 17 | expect(res.value["result-front-matter"]) 18 | .toBe("tags:\n - one\n - two\nid: 01JV9K2XGJA4HH5XVWKC8EPQ4W\n"); 19 | expect(JSON.parse(res.value["result-properties"])) 20 | .toEqual({ tags: ["one", "two"], id: "01JV9K2XGJA4HH5XVWKC8EPQ4W" }); 21 | expect(res.value["result-uri-path"]) 22 | .toContain("file=note%2Fget%2Fnote-1.md"); 23 | } 24 | }, 10000); 25 | 26 | it("should return error on failure", async () => { 27 | const res = await callObsidian("note/get", { file: "note/get/invalid.md" }); 28 | expect(res.ok).toBe(false); 29 | if (!res.ok) { 30 | expect(res.error.errorCode).toBe("404"); 31 | } 32 | }, 10000); 33 | 34 | it("should return note content for periodic notes", async () => { 35 | for (const p of periodicNotes) { 36 | const res = await callObsidian( 37 | "note/get", 38 | { "periodic-note": p.key, silent: true }, 39 | ); 40 | expect(res.ok).toBe(true); 41 | if (res.ok) { 42 | expect(res.value["result-filepath"]).toContain(p.dateString); 43 | } 44 | } 45 | }); 46 | 47 | it("should return note content for recent periodic notes", async () => { 48 | expect(global.testVault.path).toBeDefined(); 49 | const vaultPath = global.testVault.path!; 50 | 51 | // Gather the list of current periodic notes (these are created during vault 52 | // launch), so we can move them out of the way in order to test the lookup 53 | // of recent periodic notes. 54 | const renames = periodicNotes.map((p) => { 55 | const oldName = path.join(vaultPath, `${p.dateString}.md`); 56 | return [oldName, oldName + ".bak"]; 57 | }); 58 | 59 | // Rename the current periodic notes for this test. 60 | for (const [oldName, newName] of renames) { 61 | try { 62 | await fs.rename(oldName, newName); 63 | } catch (e) {} 64 | } 65 | 66 | // Give Obsidian a moment to index the changes 67 | await pause(1000); 68 | 69 | try { 70 | for (const p of recentPeriodicNotes) { 71 | const res = await callObsidian( 72 | "note/get", 73 | { "periodic-note": p.key, silent: true }, 74 | ); 75 | expect(res.ok).toBe(true); 76 | if (res.ok) { 77 | expect(res.value["result-filepath"]).toContain(p.dateString); 78 | } 79 | } 80 | } finally { 81 | // Change the current periodic notes back to their original names. 82 | for (const [oldName, newName] of renames) { 83 | try { 84 | await fs.rename(newName, oldName); 85 | } catch (e) {} 86 | } 87 | } 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/list/noteList.test.ts: -------------------------------------------------------------------------------- 1 | import { callObsidian } from "#tests/helpers"; 2 | 3 | describe("note/list", () => { 4 | it.todo("needs testing"); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/open/note-1.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/czottmann/obsidian-actions-uri/5b6d22ddc3b16f51ac50017653a8660f9560dffc/tests/plugin-test-vault.original/note/open/note-1.md -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/open/note-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | uid: 01JVM672TZYJ134Z74M2HY8GNC 3 | --- 4 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/open/noteOpen.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs/promises"; 2 | import * as path from "path"; 3 | import { callObsidian, pause } from "#tests/helpers"; 4 | import { periodicNotes, recentPeriodicNotes } from "#tests/periodic-notes"; 5 | 6 | describe("note/open", () => { 7 | it("should open a note by its file name", async () => { 8 | const res = await callObsidian( 9 | "note/open", 10 | { file: "note/open/note-1.md" }, 11 | ); 12 | expect(res.ok).toBe(true); 13 | }); 14 | 15 | it("should open a note by its UID", async () => { 16 | const res = await callObsidian( 17 | "note/open", 18 | { uid: "01JVM672TZYJ134Z74M2HY8GNC" }, 19 | ); 20 | expect(res.ok).toBe(true); 21 | }); 22 | 23 | it("should open periodic notes", async () => { 24 | for (const p of periodicNotes) { 25 | const res = await callObsidian("note/open", { "periodic-note": p.key }); 26 | expect(res.ok).toBe(true); 27 | } 28 | }); 29 | 30 | it("should open recent periodic notes", async () => { 31 | expect(global.testVault.path).toBeDefined(); 32 | const vaultPath = global.testVault.path!; 33 | 34 | // Gather the list of current periodic notes (these are created during vault 35 | // launch), so we can move them out of the way in order to test the lookup 36 | // of recent periodic notes. 37 | const renames = periodicNotes.map((p) => { 38 | const oldName = path.join(vaultPath, `${p.dateString}.md`); 39 | return [oldName, oldName + ".bak"]; 40 | }); 41 | 42 | // Rename the current periodic notes for this test. 43 | for (const [oldName, newName] of renames) { 44 | try { 45 | await fs.rename(oldName, newName); 46 | } catch (e) {} 47 | } 48 | 49 | // Give Obsidian a moment to index the changes 50 | await pause(1000); 51 | 52 | try { 53 | for (const p of recentPeriodicNotes) { 54 | const res = await callObsidian("note/open", { "periodic-note": p.key }); 55 | expect(res.ok).toBe(true); 56 | } 57 | } finally { 58 | // Change the current periodic notes back to their original names. 59 | for (const [oldName, newName] of renames) { 60 | try { 61 | await fs.rename(newName, oldName); 62 | } catch (e) {} 63 | } 64 | } 65 | }); 66 | 67 | it("should return an error when the requested note doesn't exist", async () => { 68 | const res1 = await callObsidian("note/open", { uid: "unknown-id" }); 69 | expect(res1.ok).toBe(false); 70 | if (!res1.ok) { 71 | expect(res1.error.errorCode).toBe("404"); 72 | } 73 | 74 | const res2 = await callObsidian("note/open", { file: "missing-note.md" }); 75 | expect(res2.ok).toBe(false); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/prepend/notePrepend.test.ts: -------------------------------------------------------------------------------- 1 | import { callObsidian } from "#tests/helpers"; 2 | 3 | describe("note/prepend", () => { 4 | it.todo("needs testing"); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/rename/noteRename.test.ts: -------------------------------------------------------------------------------- 1 | import { callObsidian } from "#tests/helpers"; 2 | 3 | describe("note/rename", () => { 4 | it.todo("needs testing"); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/search-regex-and-replace/noteSearchRegexAndReplace.test.ts: -------------------------------------------------------------------------------- 1 | import { callObsidian } from "#tests/helpers"; 2 | 3 | describe("note/search-regex-and-replace", () => { 4 | it.todo("needs testing"); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/search-string-and-replace/noteSearchStringAndReplace.test.ts: -------------------------------------------------------------------------------- 1 | import { callObsidian } from "#tests/helpers"; 2 | 3 | describe("note/search-string-and-replace", () => { 4 | it.todo("needs testing"); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/touch/noteTouch.test.ts: -------------------------------------------------------------------------------- 1 | import { callObsidian } from "#tests/helpers"; 2 | 3 | describe("note/touch", () => { 4 | it.todo("needs testing"); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/plugin-test-vault.original/note/trash/noteTrash.test.ts: -------------------------------------------------------------------------------- 1 | import { callObsidian } from "#tests/helpers"; 2 | 3 | describe("note/trash", () => { 4 | it.todo("needs testing"); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/types.d.ts: -------------------------------------------------------------------------------- 1 | import { CallbackServer } from "./callback-server"; 2 | import { FSWatcher } from "chokidar"; 3 | 4 | type TestVaultConfig = { 5 | logPath: string; 6 | logRows: string[]; 7 | logWatcher: FSWatcher; 8 | name: string; 9 | path: string; 10 | }; 11 | 12 | // Declare global variable 13 | declare global { 14 | var httpServer: CallbackServer; 15 | var testVault: TestVaultConfig; 16 | } 17 | 18 | export type CallbackData = { 19 | success?: any; 20 | error?: any; 21 | }; 22 | 23 | export type LogEntry = Record; 24 | 25 | export type Result = 26 | | { ok: true; value: T; log?: LogEntry[] } 27 | | { ok: false; error: E; log?: LogEntry[] }; 28 | 29 | export type PeriodicNoteSet = { 30 | /** 31 | * The key used to identify the periodic note, e.g. "daily", "weekly", etc. 32 | */ 33 | key: 34 | | "daily" 35 | | "weekly" 36 | | "monthly" 37 | | "quarterly" 38 | | "yearly"; 39 | 40 | /** 41 | * The date format used in the periodic note, e.g. "YYYY-MM-DD" or "gggg-[W]ww". 42 | */ 43 | dateString: string; 44 | }; 45 | 46 | export type RecentPeriodicNoteSet = { 47 | /** 48 | * The key used to identify the periodic note, e.g. "daily", "weekly", etc. 49 | */ 50 | key: 51 | | "recent-daily" 52 | | "recent-weekly" 53 | | "recent-monthly" 54 | | "recent-quarterly" 55 | | "recent-yearly"; 56 | 57 | /** 58 | * A fixed date string used in the periodic note, e.g. "2025-05-19" or "2025-W20". 59 | */ 60 | dateString: string; 61 | }; 62 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowUnreachableCode": false, 5 | "allowUnusedLabels": false, 6 | "baseUrl": ".", 7 | "forceConsistentCasingInFileNames": true, 8 | "importHelpers": true, 9 | "isolatedModules": true, 10 | "lib": [ 11 | "DOM", 12 | "ES2022", 13 | ], 14 | "module": "ESNext", 15 | "moduleResolution": "node", 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitAny": true, 18 | "noImplicitReturns": true, 19 | "noUnusedLocals": true, 20 | "strict": true, 21 | "target": "ES2022" 22 | }, 23 | "include": [ 24 | "src/**/*.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.9.0": "0.15.0", 3 | "0.10.0": "0.15.0", 4 | "0.10.1": "0.15.0", 5 | "0.10.2": "0.15.0", 6 | "0.10.3": "0.15.0", 7 | "0.10.4": "0.15.0", 8 | "0.10.5": "0.15.0", 9 | "0.10.6": "0.15.0", 10 | "0.11.0": "1.0.0", 11 | "0.12.0": "1.0.0", 12 | "0.12.1": "1.0.0", 13 | "0.13.0": "1.0.0", 14 | "0.14.0": "1.0.0", 15 | "0.14.1": "1.0.0", 16 | "0.14.2": "1.0.0", 17 | "0.15.0": "1.0.0", 18 | "0.16.0": "1.1.0", 19 | "0.16.1": "1.1.0", 20 | "0.16.2": "1.1.0", 21 | "0.16.3": "1.1.0", 22 | "0.16.4": "1.1.0", 23 | "0.17.0": "1.1.0", 24 | "0.18.0": "1.1.0", 25 | "1.1.0": "1.1.0", 26 | "1.1.1": "1.2.0", 27 | "1.1.2": "1.2.0", 28 | "1.2.0": "1.2.0", 29 | "1.2.1": "1.2.0", 30 | "1.2.2": "1.2.0", 31 | "1.2.3": "1.2.0", 32 | "1.2.4": "1.2.0", 33 | "1.2.5": "1.3.0", 34 | "1.3.0": "1.3.0", 35 | "1.3.1": "1.3.0", 36 | "1.4.0": "1.4.0", 37 | "1.4.1": "1.4.0", 38 | "1.4.2": "1.4.0", 39 | "1.5.0": "1.4.0", 40 | "1.5.1": "1.4.0", 41 | "1.5.2": "1.5.0", 42 | "1.6.x": "1.5.0", 43 | "1.6.0": "1.5.0", 44 | "1.6.1": "1.5.0", 45 | "1.6.2": "1.5.0", 46 | "1.6.3": "1.5.0", 47 | "1.6.4": "1.5.0", 48 | "1.6.5": "1.5.11", 49 | "1.7.0": "1.5.11", 50 | "1.7.1": "1.5.11", 51 | "post-create-pause": "1.5.11", 52 | "post-create-pause.1": "1.5.11", 53 | "1.7.2": "1.5.11", 54 | "1.7.3": "1.5.11", 55 | "1.8.0": "1.8.0", 56 | "1.8.1": "1.8.0" 57 | } 58 | --------------------------------------------------------------------------------