├── .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