├── .editorconfig
├── .eslintignore
├── .eslintrc
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ └── release.yml
├── .gitignore
├── .npmrc
├── LICENSE
├── README.md
├── assets
├── main.gif
├── new-note-properties.gif
├── simple.gif
└── templates.gif
├── esbuild.config.mjs
├── intentsSchema.yaml
├── manifest.json
├── package-lock.json
├── package.json
├── src
├── intents
│ ├── frontmatter.ts
│ ├── index.ts
│ └── intents.ts
├── main.ts
├── settings
│ ├── config.ts
│ └── index.ts
├── templates
│ ├── index.ts
│ └── templates.ts
└── variables
│ ├── index.ts
│ ├── providers
│ ├── folder.ts
│ ├── index.ts
│ ├── natural_date.ts
│ ├── note.ts
│ ├── number.ts
│ └── text.ts
│ ├── suggest.ts
│ └── templateVariables.ts
├── styles.css
├── tsconfig.json
├── version-bump.mjs
└── versions.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | # top-most EditorConfig file
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | end_of_line = lf
7 | insert_final_newline = true
8 | indent_style = tab
9 | indent_size = 4
10 | tab_width = 4
11 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 |
3 | main.js
4 |
--------------------------------------------------------------------------------
/.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/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: Feature X has problem Y
5 | labels: bug, New
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Platform (please complete the following information):**
27 | - [ ] Desktop
28 | - [ ] Android
29 | - [ ] IOS
30 |
31 | **Additional context**
32 | Add any other context about the problem here.
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: New
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release Obsidian plugin
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*"
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v3
14 |
15 | - name: Use Node.js
16 | uses: actions/setup-node@v3
17 | with:
18 | node-version: "18.x"
19 |
20 | - name: Build plugin
21 | run: |
22 | npm install
23 | npm run build
24 |
25 | - name: Create release
26 | env:
27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28 | run: |
29 | tag="${GITHUB_REF#refs/tags/}"
30 |
31 | gh release create "$tag" \
32 | --title="$tag" \
33 | --draft \
34 | main.js manifest.json styles.css
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # vscode
2 | .vscode
3 |
4 | # Intellij
5 | *.iml
6 | .idea
7 |
8 | # npm
9 | node_modules
10 |
11 | # Don't include the compiled main.js file in the repo.
12 | # They should be uploaded to GitHub releases instead.
13 | main.js
14 |
15 | # Exclude sourcemaps
16 | *.map
17 |
18 | # obsidian
19 | data.json
20 |
21 | # Exclude macOS Finder (System Explorer) View States
22 | .DS_Store
23 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | tag-version-prefix=""
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Roman Kubiv
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 | Configure note templating locally in frontmatter and insert values using prompts.
2 |
3 | Is this [Obsidian](https://obsidian.md) plugin for you? [You should use this plugin if](#you-should-use-this-plugin-if)
4 |
5 |
6 |
7 | Note: configuration in GIFs uses old YAML property names. All other YAML in this readme is up to date.
8 |
9 |
10 | Notes are created by running intents.
11 | # Intents
12 | Intents group together templates and are the core unit of configuration in this plugin.
13 | They represent the different intents for creating a note.
14 | Eg: "I intend to create a meeting note".
15 |
16 | The frontmatter of any note can hold a list of intents.
17 | The easiest way to edit frontmatter is to enable the [source property display mode](https://help.obsidian.md/Editing+and+formatting/Properties#Display+modes) in the Obsidian settings.
18 |
19 | Example: simple intents
20 | ```yaml
21 | ---
22 | intents_to:
23 | - make_a: task ✅
24 | - make_a: meeting 🤝
25 | - make_a: person 🙋♂️
26 | ---
27 | ```
28 |
29 | ## Running intents
30 | ### Note intents
31 | The `Run note intent` command is the simplest way to run an intent.
32 |
33 |
34 |
35 | To run a note intent, you have to select the note that contains that intent.
36 |
37 | ### Selecting a note
38 | Another plugin called "Filtered Opener" is required to select a note from your vault.
39 | To select a note, Filtered Opener displays a list of every note in your vault.
40 | Filtered Opener can also be configured to display a subset of your notes using filters.
41 | Please install the [Filtered Opener plugin](https://github.com/Balibaloo/obsidian-filtered-opener) as it is required to use this plugin.
42 |
43 | After selecting a note, you will be shown its list of intents to chose from.
44 | If a note only has one intent, that intent will be selected automatically.
45 |
46 | ### Global intents
47 | Global intents are intents that are in the global intents note.
48 |
49 | Global intents can be ran in the context of any other note.
50 | A context note is used to resolve relative paths and before running the global intent, it [imports and merges](#importing-intents) the global intents before the resulting merged intent is ran.
51 |
52 | The `Run global intent` command runs a global intent in the global context.
53 | The global context is the note containing global intents.
54 | Paths relative to the global context are resolved relative to the root folder of your vault.
55 |
56 | When global intents are configured, a command is created for each global intent called `Create ${intent.name} for note`.
57 | This command lets you run a global intent in the context of a note.
58 |
59 | #### Configuring global intents
60 | The global intents note is configured by setting the Global Intents Note Path in the plugin settings.
61 | This note must be reloaded by using the `Reload global intents` command for changes to apply.
62 |
63 | # Templates
64 | Intents can have many note templates.
65 |
66 | A note template contains a path to a note.
67 | The new note will contain the contents of this template note.
68 |
69 |
70 |
71 |
72 | The properties of templates are:
73 | | property name | required | Default | description |
74 | | ---- | ---- | ---- | ---- |
75 | | `called` | Yes | | The display name of the template |
76 | | `at_path` | Yes | | An alternative for `outputs_to_pathname` that allows [using variable values](#using-variable-values). The example below uses the [new_note_name](#new_note_name) variable to format the name of the new note |
77 | | `is_disabled` | No | false | see [disabling intents, templates and variables](#disabling-intents-templates-and-variables) |
78 | | | | | Additional properties covered in [New Note Properties](#new-note-properties) |
79 |
80 |
81 |
82 | Example: intents with templates
83 | ```yaml
84 | ---
85 | intents_to:
86 | - make_a: "task ✅"
87 | with_templates:
88 | - called: "default"
89 | at_path: "Templates/task template.md"
90 | - called: "graded"
91 | at_path: "Templates/graded task template.md"
92 | - called: "worksheet"
93 | at_path: "Templates/worksheet task template.md"
94 | - make_a: "meeting 🤝"
95 | with_templates:
96 | - called: "default"
97 | at_path: "Templates/meeting template.md"
98 | - called: "project review"
99 | at_path: "Templates/project review meeting template.md"
100 | - called: "standup"
101 | at_path: "Templates/standup meeting template.md"
102 | - make_a: "person 🙋♂️"
103 | with_templates:
104 | - called: "default"
105 | at_path: "Templates/person template.md"
106 | - called: "work colleague"
107 | at_path: "Templates/work colleague person template.md"
108 | ---
109 | ```
110 |
111 | # New note properties
112 | Both intents and templates can have a new note pathname and a list of variables.
113 |
114 | Template new note properties overwrite intent new note properties.
115 |
116 | Example: Task intent has 3 different templates with different output folders and note names.
117 |
118 |
119 | The properties of new notes are:
120 | | property name | required | Default | description |
121 | | ---- | ---- | ---- | ---- |
122 | | `outputs_to_pathname` | No | A note called `new_note_name` in the same folder as the context note | The output location and name of the new note |
123 | | `outputs_to_templated_pathname` | No | `./{{new_note_name}}` is the template representation of the above | An alternative for `outputs_to_pathname` that allows [using variable values](#using-variable-values). The example below uses the [new_note_name](#new_note_name) variable to format the name of the new note |
124 | | `with_variables` | No | | A list of [variables](#variables) |
125 | | `is_disabled` | No | false | see [disabling intents, templates and variables](#disabling-intents-templates-and-variables) |
126 |
127 |
128 |
129 | Example:
130 | - A task intent that `outputs_to_templated_pathname`.
131 | - A "graded task" template with an additional `date_released` variable and a custom output pathname.
132 | - A "worksheet task" template with an additional `worksheet_number` variable and a custom output pathname.
133 | ```yaml
134 | ---
135 | intents_to:
136 | - make_a: task ✅
137 | outputs_to_templated_pathname: ./✔ tasks/✅ {{new_note_name}}
138 | with_variables:
139 | - called: deadline
140 | of_type: natural_date
141 | with_templates:
142 | - called: default ✅
143 | at_path: Templates/task template.md
144 | - called: graded 🎓
145 | at_path: Templates/graded task template.md
146 | outputs_to_templated_pathname: ./✔ tasks/🎓 {{new_note_name}}
147 | with_variables:
148 | - called: date_released
149 | of_type: natural_date
150 | - called: percent
151 | - called: worksheet 📃
152 | at_path: Templates/worksheet task template.md
153 | outputs_to_templated_pathname: "./📃 worksheets/📃 Worksheet #{{worksheet_number}} - {{new_note_name}}"
154 | with_variables:
155 | - called: worksheet_number
156 | of_type: number
157 | is_over: 1
158 | ---
159 | ```
160 |
161 | # Variables
162 | There are multiple types of variables but all variables contain a common set of properties:
163 |
164 | | property name | required | Default | description |
165 | | ---- | ---- | ---- | ---- |
166 | | `called` | Yes | | The name of the variable, used when inserting values into templates. see [using variable values](#using-variable-values).
For the purpose of demonstration this property uses lowercase and underscores instead of spaces but it can contain any characters eg emojis. |
167 | | `of_type` | No | [text](#text) | The type of the variable. See [variable types](#variable-types). |
168 | | `is_required` | No | false | If `true`, when you enter an invalid value the note creation process will stop and an error message will be shown. |
169 | | `that_prompts` | No | | The text that is displayed when prompting. |
170 | | `described_as` | No | | Text that will be shown bellow the prompt. |
171 | | `is_initially` | No | | The value that will be in the prompt initially. |
172 | | `uses_selection` | No | false | See [prepopulating prompts using selection](#prepopulating-prompts-using-selection). |
173 | | `replaces_selection_with_templated` | No | \[\[{{[new_note_name](#new_note_name)}}\]\] | Template text that replaces the selection if `uses_selection` is enabled |
174 | | `hinted_as` | No | | The value displayed inside the prompt when it is empty. |
175 | | `is_disabled` | No | false | See [disabling intents, templates and variables](#disabling-intents-templates-and-variables) |
176 |
177 | There are multiple ways to use variable values. See [using variable values](#using-variable-values).
178 |
179 | ## Variable types
180 | Each type of variable has its own parameters and validation.
181 |
182 | ### Text
183 | A simple text prompt.
184 | Text is the default variable type.
185 |
186 | | property name | required | Default | description |
187 | | ---- | ---- | ---- | ---- |
188 | | `matches_regex`| No| |A regular expression used to validate the text
189 |
190 | Example:
191 | ```yaml
192 | ---
193 | with_variables:
194 | - called: word_starting_with_auto
195 | of_type: text
196 | matches_regex: ^auto
197 | ---
198 | ```
199 |
200 | ### Number
201 | A simple number prompt.
202 | Any number including integers and floats.
203 |
204 | | property name | required | Default | description |
205 | | ---- | ---- | ---- | ---- |
206 | |`is_over`| No|| the minimum allowed value|
207 | |`is_under`| No|| the maximum allowed value|
208 |
209 | Example:
210 | ```yaml
211 | ---
212 | with_variables:
213 | - called: a_number
214 | of_type: number
215 | is_over: -10.8
216 | is_under: 11.22
217 | ---
218 | ```
219 |
220 | ### Natural date
221 | A natural date provided the [natural language dates](https://github.com/argenos/nldates-obsidian) plugin.
222 |
223 |
224 | | property name | required | Default | description |
225 | | ---- | ---- | ---- | ---- |
226 | | `format` | No | Defaults to natural date setting | The output format of the natural date |
227 | |`is_after` | No|| The date must be after this date. A natural language date |
228 | |`is_before` | No|| The date must be before this date. A natural language date |
229 |
230 |
231 | Example:
232 | ```yaml
233 | ---
234 | with_variables:
235 | - called: some_date
236 | of_type: natural_date
237 | format: "YYYY-MM-dd"
238 | is_after: yesterday # today or later
239 | is_before: next year
240 | ---
241 | ```
242 |
243 | ### Note
244 | A path to a note chosen from a list of notes. Uses same Filtered Opener plugin as when [selecting a note](#selecting-a-note).
245 | The Filtered Opener plugin takes the name of the filter set (`note_filter_set_name`) to display a list of notes to chose from.
246 |
247 | | property name | required | Default | description |
248 | | ---- | ---- | ---- | ---- |
249 | |`note_filter_set_name`| No| Allows all notes| The name of the note filter set.|
250 |
251 | Example:
252 | ```yaml
253 | ---
254 | with_variables:
255 | - called: some_note
256 | of_type: note
257 | note_filter_set_name: maps of content
258 | ---
259 | ```
260 |
261 |
262 |
263 | ### Folder
264 | A path to a folder chosen from a list of folders. Uses same Filtered Opener plugin as when [selecting a note](#selecting-a-note).
265 | The Filtered Opener plugin takes the name of the filter set (`folder_filter_set_name`) to display a list of folders to chose from.
266 |
267 | | property name | required | Default | description |
268 | | ---- | ---- | ---- | ---- |
269 | |`in_folder`| No| Vault root folder |A folder to start searching from, defaults to the vault folder.|
270 | |`at_depth`| No| Depth configured in Filtered Opener |The depth of folders to include, for a folder structure of `root/inner/leaf`, a depth of 2 will show notes down to the `leaf` level.|
271 | |`includes_roots`|No| `false` | When `false` notes only at the specified depth are shown. When `true` notes at all levels down to the specified depth are shown.|
272 | |`folder_filter_set_name`| No |Allows all folders | The name of the folder filter set.|
273 |
274 | Example:
275 | ```yaml
276 | ---
277 | with_variables:
278 | - called: a_project_folder
279 | of_type: folder
280 | in_folder: "/🏗 projects"
281 | at_depth: 1
282 | includes_roots: false
283 | folder_filter_set_name: default
284 | ---
285 | ```
286 |
287 |
288 |
289 | ## Using variable values
290 | When using variables, text in the format of `{{variable_name}}` is replaced with the value of the variable.
291 | If the variable called `variable_name` is not in the current intent, the `{{variable_name}}` text will not be changed.
292 |
293 | When creating a new note, variables in the [template](#templates) are also replaced before the new file is created.
294 |
295 | If you are already familiar with the [Templater](https://github.com/SilentVoid13/Templater) plugin, it will run its templating after the variables of this plugin are replaced.
296 |
297 | ## Advanced variable use
298 | ### new_note_name
299 | This is a [text](#text) variable that is added to every intent automatically.
300 |
301 | It holds the name of the new note and can be used in `outputs_to_templated_pathname` to add other text, including other variables, to the new note name.
302 |
303 | See [new note properties](#new-note-properties)
304 |
305 | It can also be used with a [folder](#folder) variable to chose the output folder of the new note eg [to create a project](#project)
306 |
307 | If an intent [disables](#disabling-intents-templates-and-variables) `new_note_name` and doesn't set `outputs_to_pathname`, by default the name of the new note will be the name of the intent and it will be created in the same folder as the context note.
308 |
309 | ### Disabling intents, templates and variables
310 | Intents, templates and variables can be disabled by setting `is_disabled` to `true`.
311 | - Disabled intents and templates are ignored and not shown when one must be selected.
312 | - Disabled variables are ignored, their prompts are not shown and they wont be replaced when [using variable values](#using-variable-values).
313 |
314 | Disabled items are still [imported](#importing-intents) and can be un-hidden by setting their `is_disabled` property to `false`.
315 |
316 | ### Prepopulating prompts using selection
317 | When running an intent, selected text can be used to pre-populate the prompts for variables.
318 |
319 | The selection will be split using the delimiters configured in the plugin settings and then assigned to variables by the order that they appear in the variable list.
320 |
321 | To enable this for a variable, set `uses_selection` to `true`.
322 |
323 | If a variable is assigned a valid value from the selection, the value will be accepted and the variable prompt will be skipped.
324 | If the value is not valid, the prompt will be shown prepopulated with the selected value.
325 |
326 | # Configuration Schema
327 | The full schema used by this plugin is shown in the [Intents Schema File](./intentsSchema.yaml).
328 |
329 | When debugging intents, check the [developer console](https://forum.obsidian.md/t/how-to-access-the-console/16703).
330 | If an intent has properties that aren't in this schema, an error will be shown.
331 |
332 | # Importing intents
333 | Notes can import intents from other notes using the `intents_imported_from` property.
334 |
335 | The `intents_imported_from` property accepts any number of paths to configuration notes.
336 | Example: Import single note
337 | ```yaml
338 | ---
339 | intents_imported_from: "some/configuration note.md"
340 | ---
341 | ```
342 |
343 | Example: Import a list of notes
344 | ```yaml
345 | ---
346 | intents_imported_from: ["some/configuration note.md", "other/configuration note.md" ]
347 | ---
348 | ```
349 |
350 | The intents of the imported notes are loaded first and are then merged with note intents.
351 |
352 | **If a note with an intent imports an intent with the same name, the current note intent properties will overwrite the properties of the imported intent.**
353 |
354 | Overwriting properties of imported intents is useful to:
355 | - add and change templates
356 | - eg: use a template in the same folder by overwriting the template path with a relative one
357 | - add variables
358 | - [enabling and disabling intents, templates and variables](#disabling-intents-templates-and-variables)
359 |
360 |
361 | # You should use this plugin if
362 | - you use templates to create notes
363 | - you want to insert variables into your templates and use prompts to capture their values
364 | - you want to group your templates by intent
365 | - eg: a task, a meeting, …
366 | - some of your intents have multiple templates
367 | - tasks: a normal task, a graded task, a research task
368 | - meetings: daily stand-up meeting, project catch-up meeting, catch-up with a colleague
369 | - you want to extend/override your prompts and templates on a note by note basis
370 | - use a template note next to your note (relative path)
371 | - add prompts to an existing intent/template
372 | - add more intents/templates
373 | - …
374 |
375 | ## Additional features:
376 | - auto-populate variable prompts with selected text
377 | - use multiple cursors to create multiple notes
378 | - replace the selected text with a link to the new note
379 | - use a template to change the replacement text
380 | - supports many variable types eg text, number
381 | - and other types of variable providers eg: natural date, note, folder
382 | - import other config notes
383 |
384 | ## Other plugins with overlapping functionality
385 | Contextual Note Templating (CNT) functionality compared to:
386 | - [Note from template](https://github.com/mo-seph/obsidian-note-from-template): Both plugins show prompts and use the selection to pre-populate fields that can be inserted into many note properties eg output folder, name, note title and body.
387 | - The major difference is that CNT shows one field at a time and extends the functionality of a single field.
388 | - CNT extends fields into [Variables](#variables).
389 | - Each variable has its own configurable prompt and its type adds validation and post processing.
390 | - [Hotkeys for templates](https://github.com/Vinzent03/obsidian-hotkeys-for-templates):
391 | - Instead of creating hotkeys (commands) for each template, this plugin creates commands for each [Intent](#intents).
392 |
393 |
394 |
395 | # Examples
396 | ## Simplest runnable intent
397 | ```yaml
398 | ---
399 | intents_to:
400 | - make_a: task
401 | ---
402 | ```
403 |
404 | This intent will create an empty note named the value of [new_note_name](#new_note_name) in the same folder as the context note.
405 |
406 | ## Creating in a folder
407 | Create an empty note in a folder called `tasks` next to the context note.
408 | ```yaml
409 | ---
410 | intents_to:
411 | - make_a: task
412 | outputs_to_templated_pathname: "./tasks/{{new_note_name}}"
413 | ---
414 | ```
415 |
416 |
417 | Create an empty note in a folder in the root of the vault called `vault tasks`.
418 | ```yaml
419 | ---
420 | intents_to:
421 | - make_a: task
422 | outputs_to_templated_pathname: "/vault tasks/{{new_note_name}}"
423 | ---
424 | ```
425 |
426 | Chose a folder and place a task in its own folder in that folder.
427 | ```yaml
428 | ---
429 | intents_to:
430 | - make_a: task
431 | with_variables:
432 | - called: output_folder
433 | is_required: true
434 | of_type: folder
435 | in_folder: ✅ tasks
436 | at_depth: 1
437 | includes_roots: false
438 | folder_filter_set_name: default
439 | outputs_to_templated_pathname: "{{output_folder}}/{{new_note_name}}/{{new_note_name}}"
440 | ---
441 | ```
442 |
443 |
444 | ## Adding templates
445 | Adding a simple template.
446 | ```yaml
447 | ---
448 | intents_to:
449 | - make_a: task
450 | with_templates:
451 | - called: simple task
452 | at_path: "/path /to /templates folder /simple task template.md"
453 | ---
454 | ```
455 |
456 | Using a template next to a context note.
457 | ```yaml
458 | ---
459 | intents_to:
460 | - make_a: task
461 | with_templates:
462 | - called: simple task
463 | at_path: "./simple task template.md"
464 | ---
465 | ```
466 |
467 | ## Create a note with its own intents
468 | This intent creates a project note with an emoji in the project note name.
469 | This intent disables `new_note_name` and replaces it with a `new_project_name` variable so that it doesn't replace `new_note_name` in the project note.
470 | ```yaml
471 | ---
472 | intents_to:
473 | - make_a: project
474 | with_variables:
475 | - called: new_note_name
476 | is_disabled: true
477 | - called: new_project_name
478 | outputs_to_templated_pathname: "./{{new_project_name}}/🏗 {{new_project_name}}"
479 | with_templates:
480 | - called: default
481 | at_path: "/path /to /templates /project template.md"
482 | ---
483 | ```
484 |
485 | This creates a project note with an intent that contains the project name in the task name template.
486 | Because `new_note_name` is disabled, it wont be replaced but `new_project_name` will.
487 | Project note template:
488 | ```yaml
489 | ---
490 | intents_to:
491 | - make_a: task
492 | outputs_to_templated_pathname: "./tasks/{{new_project_name}}-{{new_note_name}}"
493 | with_templates:
494 | - called: simple task
495 | at_path: "./simple task template.md"
496 | ---
497 |
498 | # Note for {{new_note_name}} project!
499 | Contents of the project note template!
500 | ```
501 |
502 |
503 | ## Project
504 | This intent creates a project note with an intent that has the project name in the task name, created in its own folder in a category folder.
505 |
506 | In this case the `🏗 projects` folder contains subfolders that categorize projects.
507 | This intent disables `new_note_name` and replaces it with a `new_project_name` variable so that it doesn't replace `new_note_name` in the project note.
508 | ```yaml
509 | ---
510 | intents_to:
511 | - make_a: project
512 | with_variables:
513 | - called: new_note_name
514 | is_disabled: true
515 | - called: new_project_name
516 | - called: output_folder
517 | is_required: true
518 | of_type: folder
519 | in_folder: 🏗 projects
520 | at_depth: 1
521 | includes_roots: false
522 | outputs_to_templated_pathname: "{{output_folder}}/{{new_project_name}}/🏗 {{new_project_name}}"
523 | with_templates:
524 | - called: default
525 | at_path: "/path /to /templates /project template.md"
526 | ---
527 | ```
528 |
529 | Because `new_note_name` is disabled, it wont be replaced but `new_project_name` will.
530 | Project note template:
531 | ```yaml
532 | ---
533 | intents_to:
534 | - make_a: task
535 | outputs_to_templated_pathname: "./tasks/{{new_project_name}}-{{new_note_name}}"
536 | with_templates:
537 | - called: simple task
538 | at_path: "./simple task template.md"
539 | ---
540 |
541 | # Note for {{new_project_name}} project!
542 | ```
543 |
544 | # Attributions
545 | This repository uses code from the following projects:
546 | - https://github.com/chhoumann/quickadd
547 |
548 | Code credits are also placed in comments above code.
549 |
--------------------------------------------------------------------------------
/assets/main.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Balibaloo/obsidian-local-template-configuration/99b0dd33d7ca3c92c15ada5694583ec93ae8725b/assets/main.gif
--------------------------------------------------------------------------------
/assets/new-note-properties.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Balibaloo/obsidian-local-template-configuration/99b0dd33d7ca3c92c15ada5694583ec93ae8725b/assets/new-note-properties.gif
--------------------------------------------------------------------------------
/assets/simple.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Balibaloo/obsidian-local-template-configuration/99b0dd33d7ca3c92c15ada5694583ec93ae8725b/assets/simple.gif
--------------------------------------------------------------------------------
/assets/templates.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Balibaloo/obsidian-local-template-configuration/99b0dd33d7ca3c92c15ada5694583ec93ae8725b/assets/templates.gif
--------------------------------------------------------------------------------
/esbuild.config.mjs:
--------------------------------------------------------------------------------
1 | import esbuild from "esbuild";
2 | import process from "process";
3 | import builtins from "builtin-modules";
4 | import { YAMLPlugin } from "esbuild-yaml";
5 |
6 | const banner =
7 | `/*
8 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
9 | if you want to view the source, please visit the github repository of this plugin
10 | */
11 | `;
12 |
13 | const prod = (process.argv[2] === "production");
14 |
15 | const context = await esbuild.context({
16 | banner: {
17 | js: banner,
18 | },
19 | entryPoints: ["src/main.ts"],
20 | bundle: true,
21 | external: [
22 | "obsidian",
23 | "electron",
24 | "@codemirror/autocomplete",
25 | "@codemirror/collab",
26 | "@codemirror/commands",
27 | "@codemirror/language",
28 | "@codemirror/lint",
29 | "@codemirror/search",
30 | "@codemirror/state",
31 | "@codemirror/view",
32 | "@lezer/common",
33 | "@lezer/highlight",
34 | "@lezer/lr",
35 | ...builtins],
36 | format: "cjs",
37 | target: "es2018",
38 | logLevel: "info",
39 | sourcemap: prod ? false : "inline",
40 | treeShaking: true,
41 | outfile: "main.js",
42 | plugins: [YAMLPlugin()]
43 | });
44 |
45 | if (prod) {
46 | await context.rebuild();
47 | process.exit(0);
48 | } else {
49 | await context.watch();
50 | }
--------------------------------------------------------------------------------
/intentsSchema.yaml:
--------------------------------------------------------------------------------
1 | # Schema of intents frontmatter
2 | ## ? means the property is optional
3 | ## (someValue) shows the default value if no value is specified
4 |
5 | intents_imported_from: "'text'|['text']"
6 | intents_to:
7 | - make_a: "text"
8 | is_disabled: "?true/false (false)"
9 | outputs_to_pathname: "?text (./new_note_name)"
10 | outputs_to_templated_pathname: "?text (./{{new_note_name}})"
11 | with_templates:
12 | - called: "text"
13 | is_disabled: "?true/false (false)"
14 | at_path: "text"
15 | outputs_to_pathname: "?text (./new_note_name)"
16 | outputs_to_templated_pathname: "?text (./{{new_note_name}})"
17 | with_variables:
18 | # See below
19 | with_variables:
20 | - called: "text"
21 | of_type: "?text (text)"
22 | is_required: "?true/false (false)"
23 | that_prompts: "?text"
24 | described_as: "?text"
25 | is_initially: "?text"
26 | uses_selection: "?true/false (false)"
27 | replaces_selection_with_templated: "?text ([[{{new_note_name}}]])"
28 | hinted_as: "?text"
29 | is_disabled: "?true/false (false)"
30 |
31 | ## Variable types
32 | # Text
33 | matches_regex: "?text"
34 |
35 | # Number
36 | is_over: "?number,"
37 | is_under: "?number,"
38 |
39 | # Natural date
40 | is_after: "?text"
41 | is_before: "?text"
42 | format: "?text"
43 |
44 | # Note
45 | note_filter_set_name: "?text"
46 |
47 | # Folder
48 | in_folder: "?text (/)"
49 | at_depth: "?number (Depth configured in Filtered Opener)"
50 | includes_roots: "?true/false (false)"
51 | folder_filter_set_name: "?text"
52 |
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "contextual-note-templating",
3 | "name": "Contextual note templating",
4 | "version": "2.2.2",
5 | "minAppVersion": "0.15.0",
6 | "description": "Prompts for values and templates to create notes.",
7 | "author": "Roman Kubiv",
8 | "authorUrl": "https://roman.kubiv.com",
9 | "fundingUrl": "buymeacoffee.com/romankubiv",
10 | "isDesktopOnly": false
11 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "obsidian-contextual-note-templating",
3 | "version": "2.2.2",
4 | "description": "Contextual note templating with prompts for values and templates",
5 | "main": "main.js",
6 | "scripts": {
7 | "dev": "node esbuild.config.mjs",
8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
9 | "version": "node version-bump.mjs && git add manifest.json versions.json"
10 | },
11 | "keywords": [],
12 | "author": "",
13 | "license": "MIT",
14 | "devDependencies": {
15 | "@types/node": "^16.11.6",
16 | "@typescript-eslint/eslint-plugin": "5.29.0",
17 | "@typescript-eslint/parser": "5.29.0",
18 | "builtin-modules": "3.3.0",
19 | "esbuild": "^0.21.2",
20 | "esbuild-yaml": "^1.1.1",
21 | "obsidian": "latest",
22 | "tslib": "2.4.0",
23 | "typescript": "4.7.4"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/intents/frontmatter.ts:
--------------------------------------------------------------------------------
1 | import { App, FrontMatterCache, TAbstractFile, TFile, normalizePath } from "obsidian";
2 | import { join as joinPath } from "path";
3 | import { Intent, NewNoteProperties, } from ".";
4 | import {
5 | Template,
6 | } from "../templates";
7 | import {
8 | TemplateVariable,
9 | TemplateVariableType,
10 | variableProviderVariableParsers,
11 | } from "../variables";
12 |
13 | // @ts-ignore yaml bundled by esbuild-yaml
14 | import intentSchema from "../../intentsSchema.yaml";
15 |
16 | export async function getIntentsFromTFile( app: App, file:TFile): Promise {
17 | return getIntentsFromFM( app, await getFrontmatter( app, file ), file);
18 | }
19 |
20 | function getIntentsFromFM(app: App, fm: FrontMatterCache, sourceFile: TFile): Intent[] {
21 | const newIntents: Intent[] = (fm?.intents_to || []).map((iFm: any): Intent => {
22 | fmValidateIntent( iFm );
23 | return {
24 | name: iFm.make_a,
25 | disable: typeof iFm?.is_disabled === "undefined" ? undefined :
26 | typeof iFm?.is_disabled === "boolean" ? iFm?.is_disabled :
27 | Boolean(iFm?.is_disabled?.[0]?.toUpperCase() === "T"),
28 | templates: getTemplatesFromFM(app, iFm),
29 | newNoteProperties: getNewNotePropertiesFromFM(app, iFm),
30 | sourceNotePath: sourceFile.path,
31 | }
32 | });
33 |
34 | return newIntents;
35 | }
36 |
37 | function getTemplatesFromFM(app: App, fm: FrontMatterCache): Template[] {
38 | return (fm?.with_templates || []).map((tFm: any): Template =>
39 | (fmValidateTemplate( tFm ), {
40 | name: tFm.called,
41 | disable: typeof tFm?.is_disabled === "undefined" ? undefined :
42 | typeof tFm?.is_disabled === "boolean" ? tFm?.is_disabled :
43 | Boolean(tFm?.is_disabled?.[0]?.toUpperCase() === "T"),
44 | path: tFm.at_path,
45 | newNoteProperties: getNewNotePropertiesFromFM(app, tFm),
46 | })
47 | );
48 | }
49 |
50 | function getNewNotePropertiesFromFM(app: App, fm: FrontMatterCache): NewNoteProperties {
51 | return {
52 | output_pathname: fm.outputs_to_pathname,
53 | output_pathname_template: fm.outputs_to_templated_pathname,
54 | variables: getVariablesFromFM(app,fm),
55 | selection_replace_template: fm.replaces_selection_with_templated,
56 | }
57 | }
58 |
59 |
60 | function getVariablesFromFM(app: App, fm: FrontMatterCache) {
61 | return (fm?.with_variables || []).map((v: any): TemplateVariable => {
62 | fmValidateVariable( v );
63 | const type: TemplateVariableType = TemplateVariableType[v.of_type as keyof typeof TemplateVariableType]
64 | || TemplateVariableType.text;
65 |
66 | const baseVariables: TemplateVariable = {
67 | name: v.called,
68 | type: type,
69 | disable: typeof v?.is_disabled === "undefined" ? undefined :
70 | typeof v?.is_disabled === "boolean" ? v?.is_disabled :
71 | Boolean(v?.is_disabled?.[0]?.toUpperCase() === "T"),
72 | required: typeof v?.is_required === "undefined" ? undefined :
73 | typeof v?.is_required === "boolean" ? v?.is_required :
74 | Boolean(v?.is_required?.[0]?.toUpperCase() === "T"),
75 | use_selection: typeof v?.uses_selection === "undefined" ? undefined :
76 | typeof v?.uses_selection === "boolean" ? v?.uses_selection :
77 | Boolean(v?.uses_selection?.[0]?.toUpperCase() === "T"),
78 | initial: v.is_initially,
79 | placeholder: v.hinted_as,
80 | prompt: v.that_prompts,
81 | description: v.described_as,
82 | }
83 |
84 | return Object.assign(baseVariables, variableProviderVariableParsers[type](app,v))
85 | })
86 | }
87 |
88 |
89 |
90 | async function getFrontmatter(app: App, note: TFile, visited: string[]| null = null): Promise {
91 | return new Promise(async (resolve, reject) => {
92 |
93 | const fm = app.metadataCache.getFileCache(note)?.frontmatter || {};
94 |
95 | visited = visited || new Array();
96 |
97 | // Resolve note import contents
98 | const importPathsFM: string[] | string[][] = [fm.intents_imported_from || []];
99 | const importsPaths: string[] = importPathsFM.flat();
100 |
101 | let fmImports = {}
102 | for (let path of importsPaths) {
103 | const resolvedPath = resolvePathRelativeToAbstractFile(path, note) + ".md";
104 | const importFile = app.vault.getAbstractFileByPath(resolvedPath);
105 |
106 | // Check for circular imports
107 | if (visited.contains(resolvedPath)){
108 | console.error("Error: Circular dependency of",resolvedPath,"in", visited);
109 | return reject(`Error getting frontmatter: \nCircular import of ${path} in ${note.name}`);
110 | }
111 |
112 | if (!(importFile instanceof TFile)) {
113 | console.error("Error: importing non-note",resolvedPath);
114 | continue;
115 | }
116 |
117 | try {
118 | const fmI = await getFrontmatter(app, importFile, [...visited, resolvedPath])
119 | fmImports = namedObjectDeepMerge(fmImports, fmI);
120 | } catch (e){
121 | reject(e)
122 | }
123 | }
124 |
125 | resolve(namedObjectDeepMerge(fmImports, fm));
126 | })
127 | }
128 |
129 |
130 | export function resolvePathRelativeToAbstractFile(path: string | void, projectFile: TAbstractFile): string | void {
131 | if (!path)
132 | return;
133 |
134 | const parentFolder = projectFile instanceof TFile ? projectFile.parent : projectFile;
135 | const newNoteFolderPath: string | void = normalizePath(
136 | path[0] === "."
137 | ? joinPath(parentFolder?.path || "", path)
138 | : path)
139 | if (!newNoteFolderPath)
140 | return;
141 |
142 | return newNoteFolderPath.replace(new RegExp("\.md$",), "");
143 | }
144 |
145 | // https://stackoverflow.com/questions/27936772/how-to-deep-merge-instead-of-shallow-merge
146 | export function namedObjectDeepMerge(obj1: any, obj2: any) {
147 | const clone1 = structuredClone(obj1);
148 | const clone2 = structuredClone(obj2);
149 |
150 | if (clone2 instanceof Array && clone1 instanceof Array) {
151 | // merge same name items, push new items
152 | clone2.forEach((item: any) => {
153 | const sharedIndex = clone1.findIndex((v: any) => v.name === item.name);
154 | if (sharedIndex === -1)
155 | return clone1.push(item);
156 |
157 | clone1[sharedIndex] = namedObjectDeepMerge(clone1[sharedIndex], item);
158 | })
159 | } else if (clone2 instanceof Object && clone1 instanceof Object) {
160 | // Merge items by key
161 | for (let key in clone2) {
162 | clone1[key] = namedObjectDeepMerge(clone1[key], clone2[key]);
163 | }
164 | } else {
165 | // Else primitive value
166 | return ![null, "", undefined].includes(clone2) ? clone2 : clone1;
167 | }
168 |
169 | return clone1;
170 | };
171 |
172 | function fmValidateIntent( fm:FrontMatterCache ){
173 | const exampleIntent = intentSchema["intents_to"]?.[0];
174 | if (!exampleIntent){
175 | throw new Error("Failed to get schema for intent")
176 | }
177 |
178 | validateFmSchema( fm, exampleIntent, "intent");
179 | }
180 |
181 | function fmValidateTemplate( fm:FrontMatterCache ){
182 | const exampleTemplate = intentSchema["intents_to"]?.[0]?.["with_templates"]?.[0];
183 | if (!exampleTemplate){
184 | throw new Error("Failed to get schema for template")
185 | }
186 |
187 | validateFmSchema( fm, exampleTemplate, "template");
188 | }
189 |
190 | function fmValidateVariable( fm:FrontMatterCache ){
191 | const exampleVariable = intentSchema["intents_to"]?.[0]?.["with_variables"]?.[0];
192 | if (!exampleVariable){
193 | throw new Error("Failed to get schema for variable");
194 | }
195 |
196 | validateFmSchema( fm, exampleVariable, "variable");
197 | }
198 |
199 | function validateFmSchema( fm:FrontMatterCache, schema:FrontMatterCache, name:string){
200 | const exampleKeys = Object.keys(schema);
201 | const unknownKeys = Object.keys( fm ).filter( k => ! exampleKeys.contains(k))
202 |
203 | if ( unknownKeys.length === 0 ) return;
204 |
205 | const examplePropertyStrings = exampleKeys.map( k => `${k}: "${schema[k]}"`)
206 |
207 | console.warn(`Unrecognized ${name} properties: [ ${unknownKeys.join(", ")} ]
208 | Valid ${name} properties:
209 | - ${examplePropertyStrings.join("\n- ")}`)
210 | }
--------------------------------------------------------------------------------
/src/intents/index.ts:
--------------------------------------------------------------------------------
1 | import { TAbstractFile } from "obsidian";
2 | import { Template } from "../templates";
3 | import { TemplateVariable } from "../variables";
4 | import { getIntentsFromTFile, resolvePathRelativeToAbstractFile, namedObjectDeepMerge } from "./frontmatter";
5 | import { choseIntent, runIntent } from "./intents";
6 |
7 | export type Intent = hasNewNoteProperties & {
8 | name: string;
9 | disable: boolean;
10 | templates: Template[];
11 | sourceNotePath: string;
12 | }
13 |
14 | export type hasNewNoteProperties = {
15 | newNoteProperties: NewNoteProperties;
16 | }
17 |
18 | export type NewNoteProperties = {
19 | output_pathname: string,
20 | output_pathname_template: string,
21 | variables: TemplateVariable[],
22 | selection_replace_template?: string,
23 | }
24 |
25 | export {
26 | getIntentsFromTFile,
27 | resolvePathRelativeToAbstractFile,
28 | namedObjectDeepMerge,
29 | choseIntent,
30 | runIntent,
31 | };
--------------------------------------------------------------------------------
/src/intents/intents.ts:
--------------------------------------------------------------------------------
1 | import { App, EditorPosition, EditorSelection, FuzzySuggestModal, Notice, TFile, TFolder } from "obsidian";
2 | import PTPlugin from "../main";
3 | import { Intent, namedObjectDeepMerge, resolvePathRelativeToAbstractFile } from ".";
4 | import { ReservedVariableName, TemplateVariable, getVariableValues } from "../variables";
5 | import { getIntentTemplate } from "../templates";
6 |
7 |
8 | class IntentSuggestModal extends FuzzySuggestModal {
9 | constructor(app: App, items: Intent[], callback: (item: Intent) => void) {
10 | super(app);
11 | this.items = items;
12 | this.callback=callback;
13 | }
14 |
15 | items: Intent[];
16 | callback: (item: Intent) => void;
17 |
18 | getItems(): Intent[] {
19 | return this.items;
20 | }
21 |
22 | getItemText(item: Intent): string {
23 | return `${item.name}`;
24 | }
25 | onChooseItem(item: Intent, evt: MouseEvent | KeyboardEvent): void {
26 | this.callback(item);
27 | }
28 | }
29 |
30 | export async function choseIntent(intents:Intent[]):Promise {
31 | return new Promise((resolve,rejects) => {
32 | if (intents.length === 0) {
33 | new Notice(`Error: No intents found`);
34 | return rejects("No intents found");
35 | }
36 |
37 | const shownIntents = intents.filter(i => !i.disable);
38 | if (shownIntents.length === 0) {
39 | new Notice(`Error: All intents are hidden`);
40 | return rejects("All intents are hidden");
41 | }
42 |
43 | if (shownIntents.length === 1) {
44 | return resolve(shownIntents[0]);
45 | }
46 | new IntentSuggestModal(this.app, shownIntents, resolve).open();
47 | })
48 | }
49 |
50 |
51 | export async function runIntent(plugin:PTPlugin, intent: Intent) {
52 | // console.log("Running intent:", intent);
53 |
54 | let variablesToGather = intent.newNoteProperties.variables;
55 |
56 | const abstractIntentSource = plugin.app.vault.getAbstractFileByPath( intent.sourceNotePath );
57 | if ( ! abstractIntentSource ){
58 | new Notice("Error: Intent source doesn't exist anymore. Please reload this intent.");
59 | return;
60 | }
61 |
62 | // If templates configured
63 | let templateContents = "";
64 | if (intent.templates.length !== 0) {
65 | const chosenTemplate = await getIntentTemplate(intent);
66 | // console.log("Chosen template:", chosenTemplate);
67 | if (!chosenTemplate) {
68 | new Notice("Error: No template selected");
69 | return;
70 | }
71 |
72 | // get template
73 | const templatePath: string | void = resolvePathRelativeToAbstractFile(chosenTemplate.path, abstractIntentSource);
74 | if (!templatePath) {
75 | new Notice(`Error: Invalid path for the ${chosenTemplate.name} template of the ${intent.name} intent`);
76 | return;
77 | }
78 |
79 | const templateNote = this.app.vault.getAbstractFileByPath(templatePath+".md");
80 | if (!(templateNote instanceof TFile)) {
81 | new Notice("Error: Template does not exist: " + templatePath);
82 | return;
83 | }
84 |
85 | templateContents = await this.app.vault.cachedRead(templateNote);
86 | variablesToGather = namedObjectDeepMerge(variablesToGather, chosenTemplate.newNoteProperties.variables);
87 | intent.newNoteProperties = namedObjectDeepMerge(intent.newNoteProperties, chosenTemplate.newNoteProperties);
88 | }
89 |
90 | variablesToGather = variablesToGather.filter(v => !v.disable);
91 |
92 | const selections:(EditorSelection|null)[] = plugin.app.workspace.activeEditor?.editor?.listSelections() ?? [ null ];
93 | const creatingMultipleNotes = selections.length > 1;
94 |
95 | for (let selection of selections){
96 | await runIntentWithSelection( plugin, intent, variablesToGather, templateContents, selection, creatingMultipleNotes )
97 | }
98 |
99 | }
100 |
101 | async function runIntentWithSelection(plugin:PTPlugin, intent: Intent, variablesToGather:TemplateVariable[], templateContents:string, selection:EditorSelection|null, creatingMultipleNotes:boolean){
102 | const abstractIntentSource = plugin.app.vault.getAbstractFileByPath( intent.sourceNotePath );
103 | if ( ! abstractIntentSource ){
104 | new Notice("Error: Intent source doesn't exist anymore. Please reload this intent.");
105 | return;
106 | }
107 |
108 | const variablesToSelect = variablesToGather.filter(v => v.use_selection);
109 |
110 | let selectionVariables = {};
111 | if ( selection ){
112 | const [selectionStart, selectionEnd] = getOrderedSelectionBounds(selection);
113 | const selectionText = plugin.app.workspace.activeEditor?.editor?.getRange( selectionStart, selectionEnd ) || "";
114 | const selectionSplit = selectionText.split(new RegExp(`[${plugin.settings.selectionDelimiters}]`,"g"))
115 | .map(v=>v.trim());
116 |
117 | selectionVariables = variablesToSelect.reduce((acc:any, variable:TemplateVariable, index) => {
118 | acc[variable.name] = selectionSplit[index] ?? "";
119 | return acc;
120 | }, {});
121 | // console.log("Found selection variables:", selectionVariables);
122 | }
123 |
124 | let gatheredValues;
125 | try {
126 | gatheredValues = await getVariableValues(plugin.app, variablesToGather, selectionVariables);
127 | } catch (e) {
128 | new Notice(e);
129 | return console.error("Error: failed to gather all variables");
130 | }
131 |
132 |
133 | const newNoteContents = getReplacedVariablesText(templateContents, gatheredValues);
134 |
135 | const newNotePathName = getNewNotePathName(intent, gatheredValues);
136 | const newNotePathNameResolved = resolvePathRelativeToAbstractFile(newNotePathName, abstractIntentSource);
137 | if (!newNotePathNameResolved){
138 | new Notice(`Error: Failed to determine ${intent.name} output path`);
139 | return;
140 | }
141 |
142 | // create folder if not exists
143 | const newNoteResolvedDir = newNotePathNameResolved.split("/").slice(0,-1).join("/") || "/";
144 | if (!(this.app.vault.getAbstractFileByPath(newNoteResolvedDir) instanceof TFolder)) {
145 | await this.app.vault.createFolder(newNoteResolvedDir);
146 | }
147 |
148 | let newNote;
149 | try {
150 | newNote = await plugin.app.vault.create(
151 | newNotePathNameResolved+".md",
152 | newNoteContents
153 | );
154 | } catch (e){
155 | new Notice(`Error: Could not create ${newNotePathNameResolved}, ${e.message}`, 6_000)
156 | return;
157 | }
158 |
159 | if ( selection && ! selectionIsEmpty( selection )){
160 | const newNoteNameResolved = newNotePathNameResolved.split("/").at(-1);
161 | const selectionTemplate = intent.newNoteProperties.selection_replace_template || `[[${newNoteNameResolved}]]`;
162 | const selectionReplacement = getReplacedVariablesText( selectionTemplate, gatheredValues );
163 |
164 | // TODO fix selection replacement when focus changes, when creating multiple files
165 | const [selectionStart, selectionEnd] = getOrderedSelectionBounds(selection);
166 | plugin.app.workspace.activeEditor?.editor?.replaceRange( selectionReplacement, selectionStart, selectionEnd );
167 | }
168 |
169 | if ( plugin.settings.showNewNotes && ( ! creatingMultipleNotes || plugin.settings.showNewMultiNotes) ){
170 | const newLeaf = plugin.app.workspace.getLeaf( plugin.settings.showNewNotesStyle );
171 | await newLeaf.openFile(newNote, { active: !creatingMultipleNotes });
172 | }
173 | // console.log("New note created:", newNotePathNameResolved);
174 |
175 | }
176 |
177 |
178 | function getReplacedVariablesText(text: string, values:{[key: string]: string}): string{
179 | function escapeRegExp(str:string) {
180 | return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
181 | }
182 | return Object.keys(values).reduce((text, varName)=>
183 | text.replaceAll(new RegExp(`\\{\\{\\s*${escapeRegExp(varName)}\\s*\\}\\}`, "g"), values[varName])
184 | , text);
185 | }
186 |
187 | function getNewNotePathName(intent:Intent, values:{[key: string]: string}):string{
188 | const newNoteProps = intent.newNoteProperties;
189 |
190 | if (newNoteProps.output_pathname_template &&
191 | newNoteProps.output_pathname_template?.trim())
192 | return getReplacedVariablesText(newNoteProps.output_pathname_template, values);
193 |
194 | if (newNoteProps.output_pathname &&
195 | newNoteProps.output_pathname?.trim())
196 | return newNoteProps.output_pathname;
197 |
198 | if (values[ReservedVariableName.new_note_name] &&
199 | values[ReservedVariableName.new_note_name]?.trim())
200 | return "./"+values[ReservedVariableName.new_note_name];
201 |
202 | return intent.name;
203 | }
204 |
205 | function getOrderedSelectionBounds( selection:EditorSelection ):[ head:EditorPosition, tail:EditorPosition ]{
206 | const {anchor, head} = selection;
207 |
208 | if ( anchor.line > head.line )
209 | return [head, anchor];
210 |
211 | if ( anchor.line < head.line )
212 | return [anchor, head];
213 |
214 | if ( anchor.ch > head.ch )
215 | return [head, anchor];
216 |
217 | return [anchor, head];
218 | }
219 |
220 | function selectionIsEmpty( selection:EditorSelection ): boolean {
221 | if (selection.anchor.line !== selection.head.line)
222 | return false;
223 |
224 | if (selection.anchor.ch !== selection.head.ch)
225 | return false;
226 |
227 | return true;
228 | }
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { Notice, Plugin, TFile } from 'obsidian';
2 | import { DEFAULT_SETTINGS, PTSettings, PTSettingTab } from './settings';
3 | import { ReservedVariableName, TemplateVariable, TemplateVariableType } from './variables';
4 | import {
5 | Intent,
6 | getIntentsFromTFile,
7 | namedObjectDeepMerge,
8 | choseIntent,
9 | runIntent
10 | } from './intents';
11 |
12 |
13 | const DEFAULT_VARIABLES: TemplateVariable[] = [{
14 | name: ReservedVariableName.new_note_name,
15 | type: TemplateVariableType.text,
16 | required: true,
17 | use_selection: true,
18 | disable: false,
19 | prompt: "New note name",
20 | }]
21 |
22 | const NOTICE_TIMEOUT = 10_000;
23 |
24 | export default class PTPlugin extends Plugin {
25 | settings: PTSettings;
26 |
27 | async onload() {
28 | await this.loadSettings();
29 | this.settings.intents.forEach(i => this.createCommandForIntent(i));
30 |
31 | this.addSettingTab(new PTSettingTab(this.app, this));
32 |
33 | this.addCommand({
34 | id: 'run-active-note-intent',
35 | name: 'Run intent from active note',
36 | callback: async () => {
37 |
38 | const intentNote = this.app.workspace.getActiveFile()
39 | if ( ! intentNote) {
40 | new Notice("Error: No active note");
41 | return;
42 | }
43 |
44 | try {
45 | const noteIntents = await getIntentsFromTFile(this.app, intentNote);
46 | noteIntents.forEach(i =>
47 | i.newNoteProperties.variables = namedObjectDeepMerge(
48 | DEFAULT_VARIABLES,
49 | i.newNoteProperties.variables
50 | ))
51 |
52 | const chosenIntent = await choseIntent(noteIntents);
53 | if (!choseIntent)
54 | return;
55 |
56 | runIntent(this, chosenIntent);
57 | } catch (e) {
58 | return new Notice(e, NOTICE_TIMEOUT);
59 | }
60 | }
61 | });
62 |
63 | this.addCommand({
64 | id: 'reload-global-intents',
65 | name: 'Reload global intents',
66 | callback: async () => {
67 | const globalIntentsNote = this.app.vault.getAbstractFileByPath(this.settings.globalIntentsNotePath);
68 |
69 | if (!(globalIntentsNote instanceof TFile)) {
70 | new Notice(`Error: Please configure the note containing global intents for the ${this.manifest.name} plugin`);
71 | this.settings.pluginConfigured = false;
72 | return this.saveSettings();
73 | }
74 |
75 | try {
76 | // console.log("Loading global intents from", globalIntentsNote);
77 | this.settings.intents = await getIntentsFromTFile(this.app, globalIntentsNote);
78 | this.settings.intents.forEach(i => {
79 | i.newNoteProperties.variables = namedObjectDeepMerge(
80 | DEFAULT_VARIABLES,
81 | i.newNoteProperties.variables
82 | )
83 | i.sourceNotePath = this.app.vault.getRoot().path
84 | })
85 | this.settings.intents.forEach((intent) => {
86 | this.createCommandForIntent(intent);
87 | });
88 | } catch (e) {
89 | return new Notice(e, NOTICE_TIMEOUT);
90 | }
91 |
92 |
93 | // console.log("Loaded intents:", this.settings.intents);
94 | new Notice(`Success: Loaded ${this.settings.intents.length} intents`);
95 | this.settings.pluginConfigured = true;
96 | return this.saveSettings();
97 | }
98 | });
99 |
100 | this.addCommand({
101 | id: 'run-global-intent',
102 | name: 'Run global intent',
103 | callback: async () => {
104 | const chosenIntent = await choseIntent(this.settings.intents);
105 | if (!choseIntent)
106 | return;
107 |
108 | runIntent(this, chosenIntent);
109 | }
110 | });
111 |
112 | this.addCommand({
113 | id: 'run-local-intent',
114 | name: 'Run note intent',
115 | callback: async () => {
116 | const filteredOpener = (this.app as any).plugins.plugins["filtered-opener"];
117 | if (!filteredOpener) {
118 | new Notice("Error: Filtered Opener plugin not found. Please install it from the community plugins tab.");
119 | console.error("Error running note intent, Filtered Opener plugin not found");
120 | return;
121 | }
122 |
123 | const intentNote = await filteredOpener.api_getNote(this.settings.intentNotesFilterSetName);
124 | if (!(intentNote instanceof TFile)) {
125 | new Notice("Error: Note does not exist");
126 | console.error("Error running note intent, note does not exist:", intentNote);
127 | return;
128 | }
129 |
130 | try {
131 | const noteIntents = await getIntentsFromTFile(this.app, intentNote);
132 | noteIntents.forEach(i =>
133 | i.newNoteProperties.variables = namedObjectDeepMerge(
134 | DEFAULT_VARIABLES,
135 | i.newNoteProperties.variables
136 | ))
137 |
138 | const chosenIntent = await choseIntent(noteIntents);
139 | if (!choseIntent)
140 | return;
141 |
142 | runIntent(this, chosenIntent);
143 | } catch (e) {
144 | return new Notice(e, NOTICE_TIMEOUT);
145 | }
146 | }
147 | });
148 |
149 | }
150 |
151 | createCommandForIntent(intent: Intent) {
152 | const normalizedIntentName = intent.name.toLowerCase()
153 | .replaceAll(/[^\w\s]/g,"").replace(/\s+/g,' ').replace(/\s/g,'-');
154 |
155 | this.addCommand({
156 | id: `create-${normalizedIntentName}`,
157 | name: `Create ${intent.name} for note`,
158 | callback: async () => {
159 | const filteredOpener = (this.app as any).plugins.plugins["filtered-opener"];
160 | if (!filteredOpener) {
161 | new Notice("Error: Filtered Opener plugin not found. Please install it from the community plugins tab.");
162 | console.error("Error running note intent, Filtered Opener plugin not found");
163 | return;
164 | }
165 |
166 | const intentNote = await filteredOpener.api_getNote(this.settings.intentNotesFilterSetName);
167 | if (!(intentNote instanceof TFile)) {
168 | new Notice("Error: Note does not exist");
169 | console.error("Error running", intent.name ,"intent, note does not exist:", intentNote);
170 | return;
171 | }
172 |
173 | try {
174 | const noteIntents = await getIntentsFromTFile(this.app, intentNote);
175 | const noteIntentsWithGlobalIntents = namedObjectDeepMerge(this.settings.intents, noteIntents) as Intent[];
176 | const chosenIntent = noteIntentsWithGlobalIntents.find(i => i.name === intent.name);
177 |
178 | if (!chosenIntent) {
179 | new Notice(`Error: Failed to get ${intent.name} intent`);
180 | return;
181 | }
182 |
183 | chosenIntent.sourceNotePath = intentNote.path;
184 |
185 | runIntent(this, chosenIntent);
186 | } catch (e) {
187 | return new Notice(e, NOTICE_TIMEOUT);
188 | }
189 | }
190 | })
191 | }
192 |
193 |
194 | onunload() {
195 |
196 | }
197 |
198 | async loadSettings() {
199 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
200 | }
201 |
202 | async saveSettings() {
203 | await this.saveData(this.settings);
204 | }
205 | }
--------------------------------------------------------------------------------
/src/settings/config.ts:
--------------------------------------------------------------------------------
1 | import { App, PaneType, PluginSettingTab, Setting, normalizePath } from "obsidian";
2 | import PTPlugin from "../main";
3 | import { PTSettings } from ".";
4 |
5 |
6 | export const DEFAULT_SETTINGS: PTSettings = {
7 | globalIntentsNotePath: '',
8 | pluginConfigured: false,
9 | intents: [],
10 | intentNotesFilterSetName: "default",
11 | selectionDelimiters: ",|",
12 | showNewNotes: true,
13 | showNewNotesStyle: "tab",
14 | showNewMultiNotes: true,
15 | }
16 |
17 |
18 | export class PTSettingTab extends PluginSettingTab {
19 | plugin: PTPlugin;
20 |
21 | constructor(app: App, plugin: PTPlugin) {
22 | super(app, plugin);
23 | this.plugin = plugin;
24 | }
25 |
26 | display(): void {
27 | const { containerEl } = this;
28 | containerEl.empty();
29 |
30 | new Setting(containerEl)
31 | .setName('Global intents note path')
32 | .setDesc('Path to note containing global intents')
33 | .addText(text =>
34 |
35 | text.setPlaceholder('Path')
36 | .setValue(this.plugin.settings.globalIntentsNotePath)
37 | .onChange(async (value) => {
38 | value = normalizePath(value);
39 | this.plugin.settings.globalIntentsNotePath = value + (!value.endsWith(".md") ? ".md" : "");
40 | await this.plugin.saveSettings();
41 | })
42 |
43 | );
44 |
45 |
46 |
47 | new Setting(containerEl)
48 | .setName("Intent note filter set name")
49 | .setDesc("The name of the Filtered Opener File Filter Set used to display a list of notes with intents.")
50 | .addText(text => {
51 | text.setValue(this.plugin.settings.intentNotesFilterSetName)
52 | text.onChange(async v => {
53 | this.plugin.settings.intentNotesFilterSetName = v;
54 | await this.plugin.saveSettings();
55 | })
56 | })
57 |
58 | new Setting(containerEl)
59 | .setName("Selection delimiters")
60 | .setDesc(`The set of characters that will be used to split the selection into separate values. Used with the "use_selection" variable property.`)
61 | .addText(text => {
62 | text.setValue(this.plugin.settings.selectionDelimiters)
63 | text.onChange(async v => {
64 | if (v === "") return;
65 | this.plugin.settings.selectionDelimiters = v;
66 | await this.plugin.saveSettings();
67 | })
68 | })
69 |
70 | new Setting(containerEl)
71 | .setName("Open newly created notes")
72 | .addToggle( tg => {
73 | tg.setTooltip("Yes/No")
74 | .setValue(this.plugin.settings.showNewNotes)
75 | .onChange( async v => {
76 | this.plugin.settings.showNewNotes = v;
77 | await this.plugin.saveSettings();
78 | this.hide();
79 | this.display();
80 | })
81 | }).addDropdown( dd => {
82 | dd.setDisabled( ! this.plugin.settings.showNewNotes)
83 | .addOptions({
84 | "false": "in active window",
85 | "tab": "in new tab",
86 | "split": "in new split",
87 | "window": "in new window",
88 | })
89 | .setValue( String(this.plugin.settings.showNewNotesStyle) )
90 | .onChange(async v => {
91 | let nv: PaneType|false;
92 | if (v === "false") {
93 | nv = false;
94 | } else if (v === "tab" || v === "split" || v === "window") {
95 | nv = v;
96 | } else {
97 | throw Error("Error: Unknown open new note style");
98 | }
99 |
100 | this.plugin.settings.showNewNotesStyle = nv;
101 | await this.plugin.saveSettings();
102 | })
103 |
104 | })
105 |
106 | new Setting(containerEl)
107 | .setName("Open new notes when creating multiple notes")
108 | .setDesc(fragWithHTML("You can create multiple notes by selecting text with multiple cursors"))
109 | .addToggle( tg => {
110 | tg.setValue( this.plugin.settings.showNewMultiNotes && this.plugin.settings.showNewNotes)
111 | .setDisabled( ! this.plugin.settings.showNewNotes )
112 | .onChange( async v => {
113 | this.plugin.settings.showNewMultiNotes = v;
114 | await this.plugin.saveSettings();
115 | })
116 | })
117 |
118 | }
119 | }
120 |
121 | function fragWithHTML(html: string){
122 | return createFragment((frag) => (frag.createDiv().innerHTML = html));
123 | }
--------------------------------------------------------------------------------
/src/settings/index.ts:
--------------------------------------------------------------------------------
1 | import { PaneType } from "obsidian";
2 | import { Intent } from "../intents";
3 | import { DEFAULT_SETTINGS, PTSettingTab } from "./config";
4 |
5 | export interface PTSettings {
6 | globalIntentsNotePath: string;
7 | pluginConfigured: boolean;
8 | intents: Intent[];
9 | intentNotesFilterSetName: string;
10 | selectionDelimiters: string;
11 | showNewNotes: boolean;
12 | showNewNotesStyle: PaneType|false;
13 | showNewMultiNotes: boolean;
14 | }
15 |
16 | export {
17 | DEFAULT_SETTINGS,
18 | PTSettingTab,
19 | }
--------------------------------------------------------------------------------
/src/templates/index.ts:
--------------------------------------------------------------------------------
1 | import { hasNewNoteProperties } from "../intents";
2 | import { getIntentTemplate } from "./templates";
3 |
4 | export type Template = hasNewNoteProperties & {
5 | name: string;
6 | path: string;
7 | disable: boolean;
8 | }
9 |
10 | export {
11 | getIntentTemplate
12 | };
--------------------------------------------------------------------------------
/src/templates/templates.ts:
--------------------------------------------------------------------------------
1 | import { App, FuzzySuggestModal, Notice } from "obsidian";
2 | import { Template } from ".";
3 | import { Intent, namedObjectDeepMerge } from "../intents";
4 |
5 | export async function getIntentTemplate(intent: Intent): Promise {
6 | if (intent.templates.length == 0) {
7 | new Notice(`Error: ${intent.name} has no templates`);
8 | return null;
9 | }
10 |
11 | const shownTemplates = intent.templates.filter(t => !t.disable);
12 | if (shownTemplates.length == 0) {
13 | new Notice(`Error: All ${intent.name} templates are hidden`);
14 | return null;
15 | }
16 |
17 | if (shownTemplates.length == 1) {
18 | return shownTemplates[0];
19 | }
20 |
21 | const selectedTemplate = await runTemplateSelectModal(this.app, shownTemplates);
22 | if (!selectedTemplate){
23 | return null;
24 | }
25 |
26 | // merge template and intent newNoteProperties
27 | selectedTemplate.newNoteProperties = namedObjectDeepMerge(intent.newNoteProperties, selectedTemplate.newNoteProperties);
28 | return selectedTemplate;
29 | }
30 |
31 | export function runTemplateSelectModal(app: App, items: Template[]): Promise {
32 | return new Promise((resolve, reject) => {
33 | new TemplateSelectModal(app, items, resolve).open()
34 | });
35 | }
36 |
37 | class TemplateSelectModal extends FuzzySuggestModal {
38 | constructor(app: App, items: Template[], callback: (item: Template) => void) {
39 | super(app);
40 | this.items = items;
41 | this.callback = callback;
42 | }
43 |
44 | items: Template[];
45 | callback: (item: Template) => void;
46 |
47 | getItems(): Template[] {
48 | return this.items;
49 | }
50 |
51 | getItemText(item: Template): string {
52 | return item.name;
53 | }
54 | onChooseItem(item: Template, evt: MouseEvent | KeyboardEvent): void {
55 | this.callback(item);
56 | }
57 | }
--------------------------------------------------------------------------------
/src/variables/index.ts:
--------------------------------------------------------------------------------
1 |
2 | import { getVariableValues } from "./templateVariables";
3 | import { TemplateVariableType, TemplateVariableVariables, variableProviderVariableParsers } from "./providers";
4 |
5 | export enum ReservedVariableName {
6 | new_note_name = "new_note_name",
7 | }
8 |
9 | export type TemplateVariable = {
10 | name: string,
11 | type: TemplateVariableType,
12 | disable: boolean,
13 | required?: boolean,
14 | use_selection?: boolean,
15 | initial?: string,
16 | placeholder?: string,
17 | prompt?: string,
18 | description?: string,
19 | } & TemplateVariableVariables
20 |
21 | export {
22 | getVariableValues,
23 | TemplateVariableType,
24 | variableProviderVariableParsers,
25 | };
--------------------------------------------------------------------------------
/src/variables/providers/folder.ts:
--------------------------------------------------------------------------------
1 | import { App, TFolder, normalizePath } from "obsidian";
2 | import { TemplateVariable } from "..";
3 |
4 | export type TemplateVariableVariables_Folder = {
5 | root_folder: string,
6 | depth: number,
7 | include_roots: boolean,
8 | folder_filter_set_name: string,
9 | };
10 |
11 | export const parseFolderVariableFrontmatter = (app: App, fm:any) => ({
12 | root_folder: fm.in_folder,
13 | depth: fm.at_depth,
14 | include_roots: typeof fm?.includes_roots === "undefined" ? undefined :
15 | typeof fm?.includes_roots === "boolean" ? fm?.includes_roots :
16 | Boolean(fm?.includes_roots?.[0]?.toUpperCase() === "T"),
17 | folder_filter_set_name: fm.folder_filter_set_name,
18 | })
19 |
20 | export async function getFolderVariableValue(app: App,variable: TemplateVariable&TemplateVariableVariables_Folder, existingValue:string):Promise{
21 | if (!validateFolder(app, variable, existingValue, false)) {
22 |
23 | try {
24 | const filteredOpener = (app as any).plugins.plugins["filtered-opener"];
25 | if (!filteredOpener) {
26 | throw new Error("Error: Filtered Opener plugin not found. Please install it from the community plugins tab.");
27 | }
28 |
29 | const newProjectFolder = await filteredOpener.api_getFolder(variable.root_folder, variable.depth, variable.include_roots, variable.folder_filter_set_name);
30 | if (!(newProjectFolder instanceof TFolder))
31 | throw new Error(`Error: Filtered Opener plugin did not return a folder for variable ${variable.name}`);
32 |
33 | existingValue = newProjectFolder.path;
34 | } catch (e){
35 | console.log(e);
36 | }
37 | validateFolder(app, variable, existingValue, true);
38 | }
39 |
40 | return existingValue;
41 | }
42 |
43 |
44 | function validateFolder(app: App, variable: TemplateVariable & TemplateVariableVariables_Folder, value: string, throwErrors: boolean): boolean {
45 | if (!value || !(app.vault.getAbstractFileByPath(normalizePath(value)) instanceof TFolder)) {
46 | if (variable.required && throwErrors)
47 | throw new Error(`Error: missing required folder variable ${variable.name}`);
48 | return false;
49 | }
50 |
51 | return true;
52 | }
--------------------------------------------------------------------------------
/src/variables/providers/index.ts:
--------------------------------------------------------------------------------
1 | import { App } from "obsidian";
2 | import { TemplateVariable } from "..";
3 | import {
4 | TemplateVariableVariables_Text,
5 | getTextVariableValue,
6 | parseTextVariableFrontmatter
7 | } from "./text";
8 | import { TemplateVariableVariables_Number,
9 | getNumberVariableValue,
10 | parseNumberVariableFrontmatter
11 | } from "./number";
12 | import {
13 | TemplateVariableVariables_NaturalDate,
14 | getNaturalDateVariableValue,
15 | parseNaturalDateVariableFrontmatter
16 | } from "./natural_date";
17 | import {
18 | TemplateVariableVariables_Folder,
19 | getFolderVariableValue,
20 | parseFolderVariableFrontmatter
21 | } from "./folder";
22 | import {
23 | TemplateVariableVariables_Note,
24 | getNoteVariableValue,
25 | parseNoteVariableFrontmatter
26 | } from "./note";
27 |
28 | export enum TemplateVariableType {
29 | text = "text",
30 | number = "number",
31 | natural_date = "natural_date",
32 | note = "note",
33 | folder = "folder",
34 | }
35 |
36 | export type TemplateVariableVariables =
37 | | TemplateVariableVariables_Text
38 | | TemplateVariableVariables_Number
39 | | TemplateVariableVariables_NaturalDate
40 | | TemplateVariableVariables_Folder;
41 |
42 |
43 | export type TemplateVariableVariablesLut = {
44 | [T in TemplateVariableType]: {
45 | [TemplateVariableType.text]: TemplateVariableVariables_Text,
46 | [TemplateVariableType.number]: TemplateVariableVariables_Number,
47 | [TemplateVariableType.natural_date]: TemplateVariableVariables_NaturalDate,
48 | [TemplateVariableType.note]: TemplateVariableVariables_Note,
49 | [TemplateVariableType.folder]: TemplateVariableVariables_Folder,
50 | }[T]
51 | };
52 |
53 |
54 | export const variableProviderVariableParsers: {
55 | [K in keyof TemplateVariableVariablesLut]: (app: App, fm: any) => TemplateVariableVariablesLut[K];
56 | } = {
57 | [TemplateVariableType.text]: parseTextVariableFrontmatter,
58 | [TemplateVariableType.number]: parseNumberVariableFrontmatter,
59 | [TemplateVariableType.natural_date]: parseNaturalDateVariableFrontmatter,
60 | [TemplateVariableType.note]: parseNoteVariableFrontmatter,
61 | [TemplateVariableType.folder]: parseFolderVariableFrontmatter,
62 | };
63 |
64 | export const variableProviderVariableGetters: {
65 | [K in keyof TemplateVariableVariablesLut]: (app: App, variable: TemplateVariable & TemplateVariableVariablesLut[K], value: string) => Promise;
66 | } = {
67 | [TemplateVariableType.text]: getTextVariableValue,
68 | [TemplateVariableType.number]: getNumberVariableValue,
69 | [TemplateVariableType.natural_date]: getNaturalDateVariableValue,
70 | [TemplateVariableType.note]: getNoteVariableValue,
71 | [TemplateVariableType.folder]: getFolderVariableValue,
72 | };
73 |
74 |
--------------------------------------------------------------------------------
/src/variables/providers/natural_date.ts:
--------------------------------------------------------------------------------
1 | import { App } from "obsidian";
2 | import { TemplateVariable } from "..";
3 | import { GenericInputPrompt } from "../suggest";
4 |
5 | export type TemplateVariableVariables_NaturalDate = {
6 | after?: string,
7 | before?: string,
8 | format?: string,
9 | };
10 |
11 | export const parseNaturalDateVariableFrontmatter = (app: App, fm: any) => {
12 | const NLDates = (app as any).plugins.getPlugin("nldates-obsidian");
13 |
14 | const dateVariable:TemplateVariableVariables_NaturalDate = {
15 | after: fm.is_after,
16 | before: fm.is_before,
17 | format: fm.format,
18 | }
19 |
20 | if (dateVariable.after && !NLDates.parseDate(dateVariable.after).moment.isValid()){
21 | throw new Error(`Error: Intent variable ${fm.name}, date after property, ${dateVariable.after} is not a valid natural language date.`);
22 | };
23 |
24 | if (dateVariable.before && !NLDates.parseDate(dateVariable.before).moment.isValid()){
25 | throw new Error(`Error: Intent variable ${fm.name}, date before property, ${dateVariable.before} is not a valid natural language date.`);
26 | };
27 |
28 | return dateVariable;
29 | };
30 |
31 | export async function getNaturalDateVariableValue(app: App, variable: TemplateVariable&TemplateVariableVariables_NaturalDate, existingValue:string): Promise{
32 | if (!validateNaturalDate(app, variable, existingValue, false)) {
33 | try {
34 | existingValue = await GenericInputPrompt.Prompt(app, variable,
35 | text => validateNaturalDate(app, variable, text, false),
36 | "Error: Please enter a valid natural language date"
37 | + (variable.after ? ` after ${variable.after}` : "")
38 | + (variable.before && variable.after ? " and" : "")
39 | + (variable.before ? ` before ${variable.before}` : ""));
40 | } catch (e){
41 | console.log(e);
42 | }
43 |
44 | validateNaturalDate(app, variable, existingValue, true);
45 | }
46 |
47 | const NLDates = (app as any).plugins.getPlugin("nldates-obsidian");
48 | if ( variable.format ){
49 | existingValue = NLDates.parseDate(existingValue).moment.format( variable.format )
50 | } else {
51 | existingValue = NLDates.parseDate(existingValue).formattedString;
52 | }
53 |
54 | return existingValue;
55 | }
56 |
57 | function validateNaturalDate(app: App, variable: TemplateVariable & TemplateVariableVariables_NaturalDate, val: string, throwErrors: boolean): boolean {
58 | const NLDates = (app as any).plugins.getPlugin("nldates-obsidian");
59 | if (!NLDates) {
60 | throw new Error("Natural Language dates is required for natural date parsing. Please install it from the community plugin settings");
61 | }
62 | const parsedDate = NLDates.parseDate(val);
63 | if (!parsedDate.moment.isValid()) {
64 | if (variable.required && throwErrors)
65 | throw new Error(`Error: The date entered for ${variable.name} is not valid`);
66 | return false;
67 | }
68 |
69 | if (variable.after && !parsedDate.moment.isAfter(NLDates.parseDate(variable.after).moment)) {
70 | if (variable.required && throwErrors)
71 | throw new Error(`Error: The date entered for ${variable.name}, (${parsedDate.formattedString}) "${val}" must be before "${NLDates.parseDate(variable.after).moment }" (${variable.after})`);
72 | return false;
73 | }
74 |
75 | if (variable.before && !parsedDate.moment.isBefore(NLDates.parseDate(variable.before).moment)) {
76 | if (variable.required && throwErrors)
77 | throw new Error(`Error: The date entered for ${variable.name}, (${parsedDate.formattedString}) "${val}" must be after "${NLDates.parseDate(variable.before).moment }" (${variable.before})`);
78 | return false;
79 | }
80 |
81 | return true;
82 | }
--------------------------------------------------------------------------------
/src/variables/providers/note.ts:
--------------------------------------------------------------------------------
1 | import { App, TFile, normalizePath } from "obsidian";
2 | import { TemplateVariable } from "..";
3 |
4 | export type TemplateVariableVariables_Note = {
5 | note_filter_set_name: string,
6 | };
7 |
8 | export const parseNoteVariableFrontmatter = (app: App, fm:any) : TemplateVariableVariables_Note => ({
9 | note_filter_set_name: fm.note_filter_set_name,
10 | })
11 |
12 | export async function getNoteVariableValue(app: App, variable: TemplateVariable&TemplateVariableVariables_Note, existingValue:string):Promise{
13 | if (!validateNote(app, variable, existingValue, false)) {
14 |
15 | try {
16 | const filteredOpener = (app as any).plugins.plugins["filtered-opener"];
17 | if (!filteredOpener) {
18 | throw new Error("Error: Filtered Opener plugin not found. Please install it from the community plugins tab.");
19 | }
20 |
21 | const selectedNote = await filteredOpener.api_getNote(variable.note_filter_set_name);
22 | if (!(selectedNote instanceof TFile))
23 | throw new Error(`Error: Filtered Opener plugin did not return a note for variable ${variable.name}`);
24 |
25 | existingValue = selectedNote.path;
26 | } catch (e){
27 | console.log(e);
28 | }
29 | validateNote(app, variable, existingValue, true);
30 | }
31 |
32 | return existingValue;
33 | }
34 |
35 |
36 | function validateNote(app: App, variable: TemplateVariable & TemplateVariableVariables_Note, value: string, throwErrors: boolean): boolean {
37 | if (!value || !(app.vault.getAbstractFileByPath(normalizePath(value)) instanceof TFile)) {
38 | if (variable.required && throwErrors)
39 | throw new Error(`Error: missing required note variable ${variable.name}`);
40 | return false;
41 | }
42 |
43 | return true;
44 | }
--------------------------------------------------------------------------------
/src/variables/providers/number.ts:
--------------------------------------------------------------------------------
1 | import { App } from "obsidian";
2 | import { TemplateVariable } from "..";
3 | import { GenericInputPrompt } from "../suggest";
4 |
5 |
6 | export type TemplateVariableVariables_Number = {
7 | min?: number,
8 | max?: number,
9 | };
10 |
11 | export const parseNumberVariableFrontmatter = (app: App, fm: any) => ({
12 | min: parseFloat(fm.is_over),
13 | max: parseFloat(fm.is_under),
14 | })
15 |
16 | export async function getNumberVariableValue(app: App, variable: TemplateVariable&TemplateVariableVariables_Number, existingValue:string):Promise{
17 | if (!validateNumber(app, variable, existingValue, false)) {
18 | const minString = variable.min ? `${variable.min} <= ` : "";
19 | const maxString = variable.max ? ` <= ${variable.max}` : "";
20 | const placeholderString = variable.placeholder || minString + variable.name + maxString;
21 |
22 | try {
23 | existingValue = await GenericInputPrompt.Prompt(app, variable,
24 | text => validateNumber(app, variable, text, false)
25 | , `Error: Please enter a number` + ((variable.min || variable.max) ? `in the range ${minString} x ${maxString}` : "")
26 | );
27 | } catch (e){
28 | console.log(e);
29 | }
30 | validateNumber(app, variable, existingValue, true);
31 | }
32 |
33 | return existingValue;
34 | }
35 |
36 |
37 | function validateNumber(app: App, variable: TemplateVariable & TemplateVariableVariables_Number, value: string, throwErrors: boolean): boolean {
38 | const parsedNum = parseFloat(value);
39 | const isValidNum: boolean = Boolean(parsedNum);
40 | if (!isValidNum) {
41 | if (variable.required && throwErrors)
42 | throw new Error(`Error: missing required number variable ${variable.name}`);
43 | return false;
44 | }
45 |
46 | if (variable.min && parsedNum < variable.min) {
47 | value = "";
48 | if (variable.required && throwErrors)
49 | throw new Error(`Error: The value entered for ${variable.name} (${parsedNum}) is below the minimum (${variable.min})`);
50 | return false;
51 | }
52 | if (variable.max && parsedNum > variable.max) {
53 | value = "";
54 | if (variable.required && throwErrors)
55 | throw new Error(`Error: The value entered for ${variable.name} (${parsedNum}) is above the maximum (${variable.max})`);
56 | return false;
57 | }
58 | return true;
59 | }
--------------------------------------------------------------------------------
/src/variables/providers/text.ts:
--------------------------------------------------------------------------------
1 | import { App } from "obsidian";
2 | import { TemplateVariable } from "..";
3 | import { GenericInputPrompt } from "../suggest";
4 |
5 | export type TemplateVariableVariables_Text = {
6 | regex ?: string,
7 | };
8 |
9 | export const parseTextVariableFrontmatter = (app: App, fm:any) => ({
10 | regex: fm.matches_regex,
11 | })
12 |
13 | export async function getTextVariableValue(app: App, variable: TemplateVariable&TemplateVariableVariables_Text, existingValue:string):Promise{
14 | if (!validateText(app, variable, existingValue, false)) {
15 | try {
16 | existingValue = await GenericInputPrompt.Prompt(app, variable,
17 | text => validateText(app, variable, text, false),
18 | "Error: Please enter text" + (variable.regex ? ` that matches the following regex "${variable.regex}"` : "")
19 | );
20 | } catch (e){
21 | console.log(e);
22 | }
23 | validateText(app, variable, existingValue, true);
24 | };
25 |
26 | return existingValue
27 | }
28 |
29 | function validateText(app: App, variable: TemplateVariable & TemplateVariableVariables_Text , value: string, throwErrors: boolean): boolean {
30 | if (variable.regex && !Boolean(value.match(variable.regex))) {
31 | if (variable.required && throwErrors)
32 | throw new Error(`Error: value for ${variable.name} doesn't match the regular expression "${variable.regex}"`)
33 | return false;
34 |
35 | } else if (value === "" || !value) {
36 | if (variable.required && throwErrors)
37 | throw new Error(`Error: missing required text variable ${variable.name}`);
38 | return false;
39 | }
40 |
41 |
42 | return true;
43 | }
44 |
--------------------------------------------------------------------------------
/src/variables/suggest.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/chhoumann/quickadd/blob/master/src/gui/GenericInputPrompt/GenericInputPrompt.ts#L24
2 | import { App, ButtonComponent, Modal, Notice, TextComponent } from "obsidian";
3 | import { TemplateVariable } from ".";
4 |
5 |
6 | export class GenericInputPrompt extends Modal {
7 | public waitForClose: Promise;
8 |
9 | private resolvePromise: (input: string) => void;
10 | private rejectPromise: (reason?: unknown) => void;
11 | private didSubmit = false;
12 | private inputComponent: TextComponent;
13 | private input: string;
14 | private readonly placeholder: string;
15 | private readonly required: boolean;
16 | private validator: ((text:string) => boolean) | undefined;
17 | private errorMessage: string;
18 |
19 | public static Prompt(
20 | app: App,
21 | variable: TemplateVariable,
22 | validator?: (text:string) => boolean,
23 | validationErrorMessage?: string,
24 | ): Promise {
25 | const newPromptModal = new GenericInputPrompt(
26 | app,
27 | variable.prompt || variable.name,
28 | variable.description,
29 | variable.placeholder,
30 | variable.initial,
31 | variable.required,
32 | validator,
33 | validationErrorMessage,
34 | );
35 | return newPromptModal.waitForClose;
36 | }
37 |
38 | protected constructor(
39 | app: App,
40 | private header: string,
41 | private description?: string,
42 | placeholder?: string,
43 | value?: string,
44 | required?: boolean,
45 | validator?: (text:string) => boolean,
46 | validationErrorMessage?: string,
47 | ) {
48 | super(app);
49 | this.placeholder = placeholder ?? "";
50 | this.input = value ?? "";
51 | this.required = required ?? false;
52 | this.validator = validator;
53 | this.errorMessage = validationErrorMessage ?? "Error: Input invalid";
54 |
55 | this.waitForClose = new Promise((resolve, reject) => {
56 | this.resolvePromise = resolve;
57 | this.rejectPromise = reject;
58 | });
59 |
60 | this.display();
61 | this.open();
62 |
63 | }
64 |
65 | private display() {
66 | this.contentEl.empty();
67 | this.titleEl.textContent = this.header;
68 |
69 | if (this.required){
70 | this.titleEl.addClass("requiredInputHeader");
71 | }
72 |
73 |
74 | const mainContentContainer: HTMLDivElement = this.contentEl.createDiv();
75 | if (this.description){
76 | mainContentContainer.createDiv({text:this.description, cls:"modal-description"});
77 | }
78 | this.inputComponent = this.createInputField(
79 | mainContentContainer,
80 | this.input,
81 | this.placeholder,
82 | );
83 | this.createButtonBar(mainContentContainer);
84 | }
85 |
86 | protected createInputField(
87 | container: HTMLElement,
88 | value: string,
89 | placeholder?: string,
90 | ) {
91 | const textComponent = new TextComponent(container);
92 | textComponent.inputEl.classList.add("text-input");
93 | textComponent
94 | .setPlaceholder(placeholder ?? "")
95 | .setValue( value.toString() )
96 | .onChange((value) => {
97 | this.input = value
98 | this.updateInputValidation(textComponent, value);
99 | })
100 | .inputEl.addEventListener("keydown", this.submitEnterCallback);
101 |
102 | this.updateInputValidation(textComponent, value);
103 |
104 | return textComponent;
105 | }
106 |
107 | protected updateInputValidation(textComponent:TextComponent, value:string){
108 | if (this.validator){
109 | if (this.validator(value)){
110 | textComponent.inputEl.removeClass("requiredInput");
111 | } else {
112 | textComponent.inputEl.addClass("requiredInput");
113 | }
114 | }
115 | }
116 |
117 | private createButton(
118 | container: HTMLElement,
119 | text: string,
120 | callback: (evt: MouseEvent) => unknown
121 | ) {
122 | const btn = new ButtonComponent(container);
123 | btn.setButtonText(text).onClick(callback);
124 |
125 | return btn;
126 | }
127 |
128 | private createButtonBar(mainContentContainer: HTMLDivElement) {
129 | const buttonBarContainer: HTMLDivElement =
130 | mainContentContainer.createDiv();
131 | this.createButton(
132 | buttonBarContainer,
133 | "Ok",
134 | this.submitClickCallback
135 | ).setCta().buttonEl.classList.add("ok-button");
136 | this.createButton(
137 | buttonBarContainer,
138 | "Cancel",
139 | this.cancelClickCallback
140 | );
141 |
142 | buttonBarContainer.classList.add("button-bar");
143 | }
144 |
145 | private submitClickCallback = (evt: MouseEvent) => this.submit();
146 | private cancelClickCallback = (evt: MouseEvent) => this.cancel();
147 |
148 | private submitEnterCallback = (evt: KeyboardEvent) => {
149 | if (!evt.isComposing && evt.key === "Enter") {
150 | evt.preventDefault();
151 | this.submit();
152 | }
153 | };
154 |
155 | private submit() {
156 | if (this.validator && !this.validator(this.input))
157 | return new Notice(this.errorMessage);
158 |
159 | this.didSubmit = true;
160 |
161 | this.close();
162 | }
163 |
164 | private cancel() {
165 | this.close();
166 | }
167 |
168 | private resolveInput() {
169 | if (!this.didSubmit) this.rejectPromise("No input given.");
170 | else this.resolvePromise(this.input);
171 | }
172 |
173 | private removeInputListener() {
174 | this.inputComponent.inputEl.removeEventListener(
175 | "keydown",
176 | this.submitEnterCallback
177 | );
178 | }
179 |
180 | onOpen() {
181 | super.onOpen();
182 |
183 | this.inputComponent.inputEl.focus();
184 | this.inputComponent.inputEl.select();
185 | }
186 |
187 | onClose() {
188 | super.onClose();
189 | this.resolveInput();
190 | this.removeInputListener();
191 | }
192 | }
--------------------------------------------------------------------------------
/src/variables/templateVariables.ts:
--------------------------------------------------------------------------------
1 | import { App } from "obsidian";
2 | import { TemplateVariable } from ".";
3 | import { variableProviderVariableGetters } from "./providers";
4 |
5 |
6 | export async function getVariableValues(app: App, variables: TemplateVariable[], existingValues: { [key: string]: string }) {
7 | // gather variable values
8 | const gatheredValues: any = {};
9 | for (let variable of variables) {
10 | const val = existingValues[variable.name] ?? "";
11 | //@ts-ignore variable is a correct type but TS expects it to be the union of all correct types
12 | gatheredValues[variable.name] = await variableProviderVariableGetters[variable.type](app, variable, val);
13 | }
14 |
15 | // console.log("Gathered variable values:", gatheredValues);
16 | return gatheredValues;
17 | }
18 |
--------------------------------------------------------------------------------
/styles.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --required: #ff6500;
3 | }
4 |
5 | .requiredInputHeader:after {
6 | content: ' *';
7 | color: var(--required);
8 | }
9 |
10 | .requiredInput{
11 | border-color: var(--required) !important;
12 | border-width: revert !important;
13 | }
14 |
15 | .modal-description {
16 | padding-bottom: 40px;
17 | }
18 |
19 | .text-input {
20 | width: 100%;
21 | }
22 |
23 | .button-bar {
24 | display: flex;
25 | flex-direction: row-reverse;
26 | justify-content: flex-start;
27 | margin-top: 1rem;
28 | gap: 0.5rem;
29 | }
30 |
31 | .ok-button {
32 | margin-right: 0;
33 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "inlineSourceMap": true,
5 | "inlineSources": true,
6 | "module": "ESNext",
7 | "target": "ES6",
8 | "allowJs": true,
9 | "noImplicitAny": true,
10 | "moduleResolution": "node",
11 | "importHelpers": true,
12 | "isolatedModules": true,
13 | "skipLibCheck": false,
14 | "strictNullChecks": true,
15 | "lib": [
16 | "DOM",
17 | "ES5",
18 | "ES6",
19 | "ES7",
20 | "ES2021"
21 | ]
22 | },
23 | "include": [
24 | "**/*.ts"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/version-bump.mjs:
--------------------------------------------------------------------------------
1 | import { readFileSync, writeFileSync } from "fs";
2 |
3 | const targetVersion = process.env.npm_package_version;
4 |
5 | // read minAppVersion from manifest.json and bump version to target version
6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
7 | const { minAppVersion } = manifest;
8 | manifest.version = targetVersion;
9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
10 |
11 | // update versions.json with target version and minAppVersion from manifest.json
12 | let versions = JSON.parse(readFileSync("versions.json", "utf8"));
13 | versions[targetVersion] = minAppVersion;
14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
15 |
--------------------------------------------------------------------------------
/versions.json:
--------------------------------------------------------------------------------
1 | {
2 | "1.0.0": "0.15.0",
3 | "2.0.2": "0.15.0",
4 | "2.0.3": "0.15.0",
5 | "2.0.4": "0.15.0",
6 | "2.0.5": "0.15.0",
7 | "2.0.6": "0.15.0",
8 | "2.0.7": "0.15.0",
9 | "2.0.8": "0.15.0",
10 | "2.0.9": "0.15.0",
11 | "2.0.10": "0.15.0",
12 | "2.0.11": "0.15.0",
13 | "2.0.12": "0.15.0",
14 | "2.0.13": "0.15.0",
15 | "2.0.14": "0.15.0",
16 | "2.1.0": "0.15.0",
17 | "2.1.1": "0.15.0",
18 | "2.1.2": "0.15.0",
19 | "2.1.3": "0.15.0",
20 | "2.2.0": "0.15.0",
21 | "2.2.1": "0.15.0",
22 | "2.2.2": "0.15.0"
23 | }
--------------------------------------------------------------------------------