├── .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 |
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 | 
17 | 
18 | 
19 | 
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 |
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 | 
39 | 
40 | 
41 | 
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 |
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 |
--------------------------------------------------------------------------------