├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── cspell.json ├── eslint.config.mts ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── AttachmentCollector.ts ├── AttachmentPath.ts ├── Plugin.ts ├── PluginSettings.ts ├── PluginSettingsManager.ts ├── PluginSettingsTab.ts ├── PluginTypes.ts ├── PrismComponent.ts ├── Substitutions.ts ├── main.ts └── styles │ └── main.scss ├── tsconfig.json └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | tab_width = 2 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | buy_me_a_coffee: mnaoumov 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report a bug and help improve the plugin 3 | title: "[BUG] Short description of the bug" 4 | labels: bug 5 | assignees: mnaoumov 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | ## Bug report 11 | - type: textarea 12 | attributes: 13 | label: Description 14 | description: A clear and concise description of the bug. Include any relevant details. 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Steps to Reproduce 20 | description: Provide a step-by-step description. 21 | value: | 22 | 1. Go to '...' 23 | 2. Click on '...' 24 | 3. Notice that '...' 25 | ... 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Expected Behavior 31 | description: What did you expect to happen? 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: Actual Behavior 37 | description: What actually happened? Include error messages if available. 38 | validations: 39 | required: true 40 | - type: textarea 41 | attributes: 42 | label: Environment Information 43 | description: Environment Information 44 | value: | 45 | - **Plugin Version**: [e.g., 1.0.0] 46 | - **Obsidian Version**: [e.g., v1.3.2] 47 | - **Operating System**: [e.g., Windows 10] 48 | validations: 49 | required: true 50 | - type: textarea 51 | attributes: 52 | label: Attachments 53 | description: Required for bug reproduction 54 | value: | 55 | - Please attach a video showing the bug. It is not mandatory, but might be very helpful to speed up the bug fix 56 | - Please attach a sample vault where the bug can be reproduced. It is not mandatory, but might be very helpful to speed up the bug fix 57 | validations: 58 | required: true 59 | - type: checkboxes 60 | attributes: 61 | label: Confirmations 62 | description: Ensure the following conditions are met 63 | options: 64 | - label: I attached a video showing the bug, or it is not necessary 65 | required: true 66 | - label: I attached a sample vault where the bug can be reproduced, or it is not necessary 67 | required: true 68 | - label: I have tested the bug with the latest version of the plugin 69 | required: true 70 | - label: I have checked GitHub for existing bugs 71 | required: true 72 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Request a feature and help improve the plugin 3 | title: "[FR] Short description of the feature" 4 | labels: enhancement 5 | assignees: mnaoumov 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | ## Feature Request 11 | - type: textarea 12 | attributes: 13 | label: Description 14 | description: A clear and concise description of the feature request. Include any relevant details. 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Details 20 | description: Provide a step-by-step description. 21 | value: | 22 | 1. Go to '...' 23 | 2. Click on '...' 24 | 3. Notice that '...' 25 | ... 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Desired Behavior 31 | description: What do you want to happen? 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: Current Behavior 37 | description: What actually happens? 38 | validations: 39 | required: true 40 | - type: textarea 41 | attributes: 42 | label: Attachments 43 | description: Required for feature investigation 44 | value: | 45 | - Please attach a video showing the current behavior. It is not mandatory, but might be very helpful to speed up the feature implementation 46 | - Please attach a sample vault where the desired Feature Request could be applied. It is not mandatory, but might be very helpful to speed up the feature implementation 47 | validations: 48 | required: true 49 | - type: checkboxes 50 | attributes: 51 | label: Confirmations 52 | description: Ensure the following conditions are met 53 | options: 54 | - label: I attached a video showing the current behavior, or it is not necessary 55 | required: true 56 | - label: I attached a sample vault where the desired Feature Request could be applied, or it is not necessary 57 | required: true 58 | - label: I have tested the absence of the requested feature with the latest version of the plugin 59 | required: true 60 | - label: I have checked GitHub for existing Feature Requests 61 | required: true 62 | -------------------------------------------------------------------------------- /.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 | 24 | dist 25 | 26 | .env 27 | /tsconfig.tsbuildinfo 28 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 7.8.1 4 | 5 | - Fix build 6 | - Update libs 7 | 8 | ## 7.8.0 9 | 10 | - Update attachmentFolderPath on opening file 11 | 12 | ## 7.7.6 13 | 14 | - Update libs 15 | 16 | ## 7.7.5 17 | 18 | - Update libs 19 | 20 | ## 7.7.4 21 | 22 | - Update libs 23 | 24 | ## 7.7.3 25 | 26 | - Properly handle sequential special characters 27 | - Update libs 28 | 29 | ## 7.7.2 30 | 31 | - Update libs 32 | 33 | ## 7.7.1 34 | 35 | - Reset default url format 36 | 37 | ## 7.7.0 38 | 39 | - Modify url generation not faking the file instances 40 | - Add markdown URL format customization #152 (thanks to @Kamesuta) 41 | - Update libs 42 | 43 | ## 7.6.1 44 | 45 | - Update libs 46 | 47 | ## 7.6.0 48 | 49 | - Fix size 50 | - Add placeholder 51 | - Fix compilation 52 | - Update libs 53 | 54 | ## 7.5.0 55 | 56 | - Switch to EmptyAttachmentFolderBehavior 57 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/26.22.0 58 | 59 | ## 7.4.3 60 | 61 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/26.16.0 62 | 63 | ## 7.4.2 64 | 65 | - Improve performance 66 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/26.15.2 67 | 68 | ## 7.4.1 69 | 70 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/26.11.0 71 | 72 | ## 7.4.0 73 | 74 | - Add Treat as attachment extensions. 75 | - [FR #147 Support .md Attachments](https://github.com/RainCat1998/obsidian-custom-attachment-location/issues/147). 76 | 77 | ## 7.3.0 78 | 79 | - Add settings code highlighting 80 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/26.7.0 81 | 82 | ## 7.2.6 83 | 84 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/26.1.2 85 | 86 | ## 7.2.5 87 | 88 | - Pass original file name with extension 89 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/26.1.0 90 | 91 | ## 7.2.4 92 | 93 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.1.1 94 | 95 | ## 7.2.3 96 | 97 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.1.0 98 | 99 | ## 7.2.2 100 | 101 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.0.1 102 | 103 | ## 7.2.1 104 | 105 | - New template 106 | 107 | ## 7.2.0 108 | 109 | - Show progress bar 110 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/21.1.0 111 | 112 | ## 7.1.0 113 | 114 | - Replace special characters 115 | 116 | ## 7.0.5 117 | 118 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.17.5 119 | 120 | ## 7.0.4 121 | 122 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.17.2 123 | 124 | ## 7.0.3 125 | 126 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.8.2 127 | 128 | ## 7.0.2 129 | 130 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.6.0 131 | 132 | ## 7.0.1 133 | 134 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.3.0 135 | 136 | ## 7.0.0 137 | 138 | - Allow call fillTemplate() from custom token 139 | - Add include/exclude settings 140 | 141 | ## 6.0.2 142 | 143 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.2.1 144 | 145 | ## 6.0.1 146 | 147 | - Update template 148 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/18.4.2 149 | 150 | ## 6.0.0 151 | 152 | - Refactor to support insert attachment 153 | - Rename settings 154 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/17.8.0 155 | 156 | ## 5.1.7 157 | 158 | - Paste in input/textarea 159 | 160 | ## 5.1.6 161 | 162 | - Lint 163 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/17.2.2 164 | 165 | ## 5.1.5 166 | 167 | - Format 168 | 169 | ## 5.1.4 170 | 171 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/16.1.0 172 | 173 | ## 5.1.3 174 | 175 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/16.0.3 176 | 177 | ## 5.1.2 178 | 179 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/15.0.0 180 | 181 | ## 5.1.1 182 | 183 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/13.9.0 184 | 185 | ## 5.1.0 186 | 187 | - Show visible whitespace 188 | 189 | ## 5.0.2 190 | 191 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/12.2.1 192 | - Validate separator 193 | 194 | ## 5.0.1 195 | 196 | - Pass attachment filename 197 | 198 | ## 5.0.0 199 | 200 | - Add custom tokens 201 | - Add frontmatter formatter 202 | - Validate path after applying tokens 203 | - Add fileCreationDate/fileModificationDate 204 | - Handle ../ paths 205 | - Add randoms and uuid 206 | - Add originalCopiedFileExtension 207 | - Don't allow tokens in prompt 208 | - Allow root path 209 | - Allow leading and trailing / 210 | - Allow . and .. 211 | 212 | ## 4.31.1 213 | 214 | - Respect renameOnlyImages when collecting 215 | 216 | ## 4.31.0 217 | 218 | - Enable custom whitespace replacement 219 | - Handle raw link 220 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/11.2.0 221 | 222 | ## 4.30.6 223 | 224 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/11.0.0 225 | 226 | ## 4.30.5 227 | 228 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/9.0.2 229 | 230 | ## 4.30.4 231 | 232 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/9.0.0 233 | 234 | ## 4.30.3 235 | 236 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/6.0.0 237 | 238 | ## 4.30.2 239 | 240 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/5.3.1 241 | 242 | ## 4.30.1 243 | 244 | - Refactor loop 245 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/5.3.0 246 | 247 | ## 4.30.0 248 | 249 | - Remove date selector 250 | - Refactor templating 251 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/4.19.0 252 | 253 | ## 4.29.1 254 | 255 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/4.14.0 256 | 257 | ## 4.29.0 258 | 259 | - Use image-override to be compatible with `Paste Mode` plugin 260 | - Fix check for pasted image 261 | 262 | ## 4.28.5 263 | 264 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/4.13.3 265 | 266 | ## 4.28.4 267 | 268 | - Update libs - fixes mobile build 269 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/4.13.1 270 | 271 | ## 4.28.3 272 | 273 | - Avoid default exports 274 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/4.13.0 275 | 276 | ## 4.28.2 277 | 278 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/4.11.0 279 | 280 | ## 4.28.1 281 | 282 | - Check for missing webUtils (Electron < 29) 283 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/4.8.2 284 | 285 | ## 4.28.0 286 | 287 | - Fix passing path in new Electron 288 | 289 | ## 4.27.6 290 | 291 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/4.8.2 292 | 293 | ## 4.27.5 294 | 295 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.44.0 296 | 297 | ## 4.27.4 298 | 299 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.43.2 300 | 301 | ## 4.27.3 302 | 303 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.43.1 304 | 305 | ## 4.27.2 306 | 307 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.42.1 308 | 309 | ## 4.27.1 310 | 311 | - Refactor 312 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.42.0 313 | 314 | ## 4.27.0 315 | 316 | - Allow paste in link editing textbox 317 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.41.0 318 | 319 | ## 4.26.0 320 | 321 | - Don't fail on broken canvas 322 | 323 | ## 4.25.0 324 | 325 | - Add support for frontmatter links 326 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.40.0 327 | 328 | ## 4.24.0 329 | 330 | - Support multi-window 331 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.39.0 332 | 333 | ## 4.23.2 334 | 335 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.35.0 336 | 337 | ## 4.23.1 338 | 339 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.34.0 340 | 341 | ## 4.23.0 342 | 343 | - Refactor 344 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.33.0 345 | 346 | ## 4.22.1 347 | 348 | - Refactor 349 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.28.2 350 | 351 | ## 4.22.0 352 | 353 | - Replace whitespace on drop 354 | - Fix relative path resolution 355 | - Handle duplicates 356 | - Fix stat for mobile 357 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.28.1 358 | 359 | ## 4.21.0 360 | 361 | - Fix race condition 362 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.26.1 363 | 364 | ## 4.20.0 365 | 366 | - Init all settings 367 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.20.0 368 | 369 | ## 4.19.0 370 | 371 | - Don't remove folders with hidden files 372 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.16.0 373 | 374 | ## 4.18.0 375 | 376 | - Add `Delete orphan attachments` setting 377 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.14.1 378 | 379 | ## 4.17.0 380 | 381 | - Remove to trash 382 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.13.0 383 | 384 | ## 4.16.0 385 | 386 | - Preserve angle brackets and leading dot 387 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.12.0 388 | 389 | ## 4.15.0 390 | 391 | - Reuse `RenameDeleteHandler` 392 | - Add optional `skipFolderCreation` to `getAvailablePathForAttachments` 393 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.10.0 394 | 395 | ## 4.14.0 396 | 397 | - Proper integration with Better Markdown Links 398 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.7.0 399 | 400 | ## 4.13.0 401 | 402 | - Handle special renames 403 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.4.1 404 | 405 | ## 4.12.2 406 | 407 | - Fix jpegQuality dropdown binding 408 | 409 | ## 4.12.1 410 | 411 | - Add extension 412 | 413 | ## 4.12.0 414 | 415 | - Add `Rename attachments on collecting` setting 416 | 417 | ## 4.11.0 418 | 419 | - Show warning 420 | - Fix build 421 | 422 | ## 4.10.0 423 | 424 | - Fix settings saving 425 | - Allow dot-folders 426 | - Fix mobile loading 427 | - Fix backlinks race condition 428 | - Process attachments before note 429 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/3.2.0 430 | 431 | ## 4.9.4 432 | 433 | - Handle removed parent folder case 434 | - Rename attachments before changing links 435 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/2.26.2 436 | 437 | ## 4.9.3 438 | 439 | - Fix backlink check 440 | - Check for race conditions 441 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/2.25.2 442 | 443 | ## 4.9.2 444 | 445 | - Fix options merging 446 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/2.23.3 447 | 448 | ## 4.9.1 449 | 450 | - Fix related attachments notice 451 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/2.23.2 452 | 453 | ## 4.9.0 454 | 455 | - Don't create fake file. 456 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/2.23.1 457 | 458 | ## 4.8.0 459 | 460 | - Don't create duplicates when dragging vault files 461 | 462 | ## 4.7.0 463 | 464 | - Skip paste handler in metadata editor 465 | 466 | ## 4.6.0 467 | 468 | - Fix race condition 469 | 470 | ## 4.5.0 471 | 472 | - Ensure `getAvailablePathForAttachments` creates missing folder 473 | 474 | ## 4.4.0 475 | 476 | - Fix race conditions 477 | 478 | ## 4.3.3 479 | 480 | - Bugfixes 481 | 482 | ## 4.3.2 483 | 484 | - Fix double paste 485 | 486 | ## 4.3.1 487 | 488 | - Create attachment folders on paste/drop 489 | 490 | ## 4.3.0 491 | 492 | - Create attachment folder only when it is needed 493 | 494 | ## 4.2.1 495 | 496 | - Fix build 497 | 498 | ## 4.2.0 499 | 500 | - Add `Rename only images` setting 501 | 502 | ## 4.1.0 503 | 504 | - Generate links exactly as Obsidian does 505 | 506 | ## 4.0.0 507 | 508 | - Disable Obsidian's built-in way to update links 509 | - Add commands and buttons to collect attachments 510 | 511 | ## 3.8.0 512 | 513 | - Improve checks for target type 514 | 515 | ## 3.7.0 516 | 517 | - Add `Rename pasted files with known names` setting 518 | 519 | ## 3.6.0 520 | 521 | - Handle move, not only rename 522 | - Add `Keep empty attachment folders` setting 523 | 524 | ## 3.5.0 525 | 526 | - Preserve draggable on redrop 527 | 528 | ## 3.4.0 529 | 530 | - Handle rename/delete for canvas 531 | 532 | ## 3.3.0 533 | 534 | - Add `${foldername}` and `${folderPath}` 535 | 536 | ## 3.2.0 537 | 538 | - Configure `Duplicate name separator` 539 | 540 | ## 3.1.0 541 | 542 | - Add canvas support 543 | 544 | ## 3.0.0 545 | 546 | - Don't modify `attachmentFolderPath` setting 547 | 548 | ## 2.1.0 549 | 550 | - Configure drag&drop as paste behavior 551 | - Remove extra dot before jpg 552 | - Add support for `${prompt}` 553 | 554 | ## 2.0.0 555 | 556 | - Make universal paste/drop 557 | 558 | ## 1.3.1 559 | 560 | - Bugfixes 561 | 562 | ## 1.3.0 563 | 564 | - Substitute `${originalCopiedFilename}` 565 | 566 | ## 1.2.0 567 | 568 | - Bugfixes 569 | 570 | ## 1.1.0 571 | 572 | - Bugfixes 573 | 574 | ## 1.0.3 575 | 576 | - Remove unused attachment folder 577 | 578 | ## 1.0.2 579 | 580 | - Forbid backslashes 581 | 582 | ## 1.0.1 583 | 584 | - Add settings validation 585 | 586 | ## 1.0.0 587 | 588 | - Fix README.md template example to prevent inappropriate latex rendering by @kaiiiz in https://github.com/RainCat1998/obsidian-custom-attachment-location/pull/28 589 | - Handle pasting multiple images by @mnaoumov in https://github.com/RainCat1998/obsidian-custom-attachment-location/pull/58 590 | - Support date var template(moment.js) in folder path & image name by @Harrd in https://github.com/RainCat1998/obsidian-custom-attachment-location/pull/56 591 | - Add mobile support by @mengbo in https://github.com/RainCat1998/obsidian-custom-attachment-location/pull/44 592 | - Add name sanitization when creating folder. by @EricWiener in https://github.com/RainCat1998/obsidian-custom-attachment-location/pull/35 593 | - Feature: Compress images from png to jpeg while pasting from the clipboard by @kaiiiz in https://github.com/RainCat1998/obsidian-custom-attachment-location/pull/29 594 | 595 | ## 0.0.9 596 | 597 | - Update attachment folder config when note renamed by @mnaoumov in https://github.com/RainCat1998/obsidian-custom-attachment-location/pull/26 598 | 599 | ## 0.0.8 600 | 601 | - Move attachments when note is moved by @mnaoumov in https://github.com/RainCat1998/obsidian-custom-attachment-location/pull/21 602 | - Make attachment folder setting modified every time file opens by @mnaoumov in https://github.com/RainCat1998/obsidian-custom-attachment-location/pull/23 603 | 604 | ## 0.0.7 605 | 606 | - Fixed minor typo in the settings by @astrodad in https://github.com/RainCat1998/obsidian-custom-attachment-location/pull/10 607 | - Temporarily fix Drag-n-Drop file from explorer doesn't copy file to obsidian vault. 608 | 609 | ## 0.0.6 610 | 611 | - Add support for absolute path and relative path. 612 | - Add options for auto renaming. 613 | 614 | ## 0.0.5 615 | 616 | - Add support for drop event 617 | - Fix typos & grammar by @TypicalHog in https://github.com/RainCat1998/obsidian-custom-attachment-location/pull/2 618 | 619 | ## 0.0.4 620 | 621 | - Optimize code 622 | 623 | ## 0.0.3 624 | 625 | - Add setting tabs and fix bugs. 626 | 627 | ## 0.0.2 628 | 629 | - Add support for custom pasted image filename. 630 | 631 | ## 0.0.1 632 | 633 | - Initial release 634 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 RainCat1998 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 | # Obsidian Custom Attachment location 2 | 3 | Customize attachment location with tokens (`${fileName}`, `${date:format}`, etc) like typora. 4 | 5 | ## Features 6 | 7 | - Modify location for attachment folder. 8 | - Modify filename for **Pasted Files**. 9 | 10 | ## Settings 11 | 12 | ### Location for new attachments 13 | 14 | - Same to "Files & Links -> Default location for new attachments". 15 | - **Put "./" at the beginning of the path if you want to use relative path.** 16 | - See available [tokens](#tokens). 17 | - example: `assets/${filename}`, `./assets/${filename}`, `./assets/${filename}/${date:YYYY}` 18 | 19 | ### Generated attachment filename 20 | 21 | - See available [tokens](#tokens). 22 | - example: `${originalCopiedFileName}-${date:YYYYMMDDHHmmssSSS}`, `${filename}-img-${date:YYYYMMDD}` 23 | - Obsidian default: `Pasted image ${date:YYYYMMDDHHmmss}`. 24 | 25 | ### Should rename attachment folder 26 | 27 | Automatically update attachment folder name if [Location for New Attachments](#location-for-new-attachments) contains `${filename}`. 28 | 29 | ### Should rename attachment files 30 | 31 | Automatically update attachment files in target md file if [Generated attachment filename](#generated-attachment-filename) contains `${filename}`. 32 | 33 | ### Special characters replacement 34 | 35 | Automatically replace special characters in attachment folder and file name with the specified string. 36 | 37 | ### Should rename attachments to lowercase 38 | 39 | Automatically set all characters in folder name and pasted image name to be lowercase. 40 | 41 | ### Should convert pasted images to JPEG 42 | 43 | Paste images from clipboard converting them to JPEG. 44 | 45 | ### JPEG Quality 46 | 47 | The smaller the quality, the greater the compression ratio. 48 | 49 | ### Convert images on drag&drop 50 | 51 | If enabled and `Convert pasted images to JPEG` setting is enabled, images drag&dropped into the editor will be converted to JPEG. 52 | 53 | ### Rename only images 54 | 55 | If enabled, only image files will be renamed. 56 | 57 | If disabled, all attachment files will be renamed. 58 | 59 | ### Rename pasted files with known names 60 | 61 | If enabled, pasted copied files with known names will be renamed. 62 | 63 | If disabled, only clipboard image objects (e.g., screenshots) will be renamed. 64 | 65 | ### Rename attachments on drag&drop 66 | 67 | If enabled, attachments dragged and dropped into the editor will be renamed according to the [Generated attachment filename](#generated-attachment-filename) setting. 68 | 69 | ### Should rename collected attachments 70 | 71 | If enabled, attachments processed via `Collect attachments` commands will be renamed according to the [Generated attachment filename](#generated-attachment-filename) setting. 72 | 73 | ### Duplicate name separator 74 | 75 | When you are pasting/dragging a file with the same name as an existing file, this separator will be added to the file name. 76 | 77 | E.g., when you are dragging file `existingFile.pdf`, it will be renamed to `existingFile 1.pdf`, `existingFile 2.pdf`, etc, getting the first name available. 78 | 79 | Default value is `␣` (`space`). 80 | 81 | ### Should keep empty attachment folders 82 | 83 | If enabled, empty attachment folders will be preserved, useful for source control purposes. 84 | 85 | ### Should delete orphan attachments 86 | 87 | If enabled, when the note is deleted, its orphan attachments are deleted as well. 88 | 89 | ## Tokens 90 | 91 | The following tokens can be used in the [Location for New Attachments](#location-for-new-attachments) and [Generated attachment filename](#generated-attachment-filename) settings. 92 | 93 | The tokens are case-insensitive. The formats are case-sensitive. 94 | 95 | - `${date:format}`: Current date/time using [Moment.js formatting][Moment.js formatting]. 96 | - `${fileCreationDate:format}`: File creation date/time using [Moment.js formatting][Moment.js formatting]. 97 | - `${fileModificationDate:format}`: File modification date/time using [Moment.js formatting][Moment.js formatting]. 98 | - `${fileName}`: Current note filename. 99 | - `${filePath}`: Full path to current note. 100 | - `${folderName}`: Current note's folder name. 101 | - `${folderPath}`: Full path to current note's folder. 102 | - `${frontmatter:key}`: Frontmatter value of the current note. Nested keys are supported, e.g., `key1.key2.3.key4`. 103 | - `${originalCopiedFileExtension}`: Extension of the original copied to clipboard or dragged file. 104 | - `${}`: File name of the original copied to clipboard or dragged file. 105 | - `${prompt}`: The value asked from the user prompt. 106 | - `${randomDigit}`: A random digit. 107 | - `${randomDigitOrLetter}`: A random digit or letter. 108 | - `${randomLetter}`: A random letter. 109 | - `${uuid}`: A random UUID. 110 | 111 | ## Custom tokens 112 | 113 | You can define custom tokens in the `Custom tokens` setting. 114 | 115 | The custom tokens are defined as a functions, both sync and async are supported. 116 | 117 | Example: 118 | 119 | ```javascript 120 | exports.myCustomToken1 = (substitutions, format) => { 121 | return substitutions.fileName + substitutions.app.appId + format; 122 | }; 123 | 124 | exports.myCustomToken2 = async (substitutions, format) => { 125 | return await Promise.resolve( 126 | substitutions.fileName + substitutions.app.appId + format 127 | ); 128 | }; 129 | ``` 130 | 131 | Then you can use the defined `${myCustomToken1}`, `${myCustomToken2:format}` tokens in the [Location for New Attachments](#location-for-new-attachments) and [Generated attachment filename](#generated-attachment-filename) settings. 132 | 133 | - `substitutions`: is an object with the following properties: 134 | - `app`: Obsidian app object. 135 | - `fileName`: The filename of the current note. 136 | - `filePath`: The full path to the current note. 137 | - `folderName`: The name of the folder containing the current note. 138 | - `folderPath`: The full path to the folder containing the current note. 139 | - `originalCopiedFileExtension`: Extension of the original copied to clipboard or dragged file. 140 | - ``: File name of the original copied to clipboard or dragged file. 141 | - `fillTemplate(template)`: Function to fill the template with the given format. E.g., `substitutions.fillTemplate('${date:YYYY-MM-DD}')`. 142 | - `format`: optional format string. 143 | 144 | ## Changelog 145 | 146 | All notable changes to this project will be documented in the [CHANGELOG](./CHANGELOG.md). 147 | 148 | ## Installation 149 | 150 | The plugin is available in [the official Community Plugins repository](https://obsidian.md/plugins?id=obsidian-custom-attachment-location). 151 | 152 | ### Beta versions 153 | 154 | To install the latest beta release of this plugin (regardless if it is available in [the official Community Plugins repository](https://obsidian.md/plugins) or not), follow these steps: 155 | 156 | 1. Ensure you have the [BRAT plugin](https://obsidian.md/plugins?id=obsidian42-brat) installed and enabled. 157 | 2. Click [Install via BRAT](https://intradeus.github.io/http-protocol-redirector?r=obsidian://brat?plugin=https://github.com/RainCat1998/obsidian-custom-attachment-location). 158 | 3. An Obsidian pop-up window should appear. In the window, click the `Add plugin` button once and wait a few seconds for the plugin to install. 159 | 160 | ## Debugging 161 | 162 | By default, debug messages for this plugin are hidden. 163 | 164 | To show them, run the following command: 165 | 166 | ```js 167 | window.DEBUG.enable('obsidian-custom-attachment-location'); 168 | ``` 169 | 170 | For more details, refer to the [documentation](https://github.com/mnaoumov/obsidian-dev-utils/blob/main/docs/debugging.md). 171 | 172 | ## Support 173 | 174 | Buy Me A Coffee 175 | 176 | ## License 177 | 178 | © [RainCat1998](https://github.com/RainCat1998/) 179 | 180 | Maintainer: [Michael Naumov](https://github.com/mnaoumov/) 181 | 182 | [Moment.js formatting]: https://momentjs.com/docs/#/displaying/format/ 183 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "ignorePaths": [ 4 | "dist", 5 | "node_modules", 6 | "tsconfig.tsbuildinfo" 7 | ], 8 | "dictionaryDefinitions": [], 9 | "dictionaries": [], 10 | "words": [ 11 | "astrodad", 12 | "backlink", 13 | "Backlinks", 14 | "collab", 15 | "creatordate", 16 | "excalidraw", 17 | "frontmatter", 18 | "Harrd", 19 | "hotreload", 20 | "jinder", 21 | "kaiiiz", 22 | "Kamesuta", 23 | "lezer", 24 | "Linkpath", 25 | "Linktext", 26 | "lucide", 27 | "mengbo", 28 | "mnaoumov", 29 | "Naumov", 30 | "outfile", 31 | "postversion", 32 | "preversion", 33 | "Promisable", 34 | "redrop", 35 | "tsbuildinfo", 36 | "typora", 37 | "Wikilink", 38 | "Wikilinks" 39 | ], 40 | "ignoreWords": [], 41 | "import": [], 42 | "enabled": true 43 | } 44 | -------------------------------------------------------------------------------- /eslint.config.mts: -------------------------------------------------------------------------------- 1 | import type { Linter } from 'eslint'; 2 | 3 | import { obsidianDevUtilsConfigs } from 'obsidian-dev-utils/ScriptUtils/ESLint/eslint.config'; 4 | 5 | const configs: Linter.Config[] = [ 6 | ...obsidianDevUtilsConfigs 7 | ]; 8 | 9 | // eslint-disable-next-line import-x/no-default-export 10 | export default configs; 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-custom-attachment-location", 3 | "name": "Custom Attachment Location", 4 | "version": "7.8.1", 5 | "minAppVersion": "1.8.10", 6 | "description": "Customize attachment location with variables(${fileName}, ${date:format}, etc) like typora.", 7 | "author": "RainCat1998", 8 | "authorUrl": "https://github.com/RainCat1998/", 9 | "isDesktopOnly": false, 10 | "fundingUrl": "https://www.buymeacoffee.com/mnaoumov" 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-custom-attachment-location", 3 | "version": "7.8.1", 4 | "description": "Customize attachment location with variables($filename, $data, etc) like typora.", 5 | "main": "main.js", 6 | "scripts": { 7 | "build": "obsidian-dev-utils build", 8 | "build:clean": "obsidian-dev-utils build:clean", 9 | "build:compile": "obsidian-dev-utils build:compile", 10 | "build:compile:svelte": "obsidian-dev-utils build:compile:svelte", 11 | "build:compile:typescript": "obsidian-dev-utils build:compile:typescript", 12 | "dev": "obsidian-dev-utils dev", 13 | "format": "obsidian-dev-utils format", 14 | "format:check": "obsidian-dev-utils format:check", 15 | "lint": "obsidian-dev-utils lint", 16 | "lint:fix": "obsidian-dev-utils lint:fix", 17 | "spellcheck": "obsidian-dev-utils spellcheck", 18 | "version": "obsidian-dev-utils version" 19 | }, 20 | "keywords": [], 21 | "author": "RainCat1998", 22 | "license": "MIT", 23 | "devDependencies": { 24 | "@antfu/utils": "^9.2.0", 25 | "@tsconfig/strictest": "^2.0.5", 26 | "@types/node": "^22.15.30", 27 | "@types/semver": "^7.7.0", 28 | "electron": "^36.4.0", 29 | "jiti": "^2.4.2", 30 | "moment": "^2.30.1", 31 | "obsidian": "^1.8.7", 32 | "obsidian-dev-utils": "^27.0.1", 33 | "obsidian-typings": "^3.9.6", 34 | "semver": "^7.7.2", 35 | "tsx": "^4.19.4", 36 | "type-fest": "^4.41.0", 37 | "typescript": "^5.8.3" 38 | }, 39 | "overrides": { 40 | "@antfu/utils": "$@antfu/utils" 41 | }, 42 | "type": "module" 43 | } 44 | -------------------------------------------------------------------------------- /src/AttachmentCollector.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Reference, 3 | ReferenceCache, 4 | TFile, 5 | TFolder 6 | } from 'obsidian'; 7 | import type { FileChange } from 'obsidian-dev-utils/obsidian/FileChange'; 8 | import type { PathOrAbstractFile } from 'obsidian-dev-utils/obsidian/FileSystem'; 9 | import type { CanvasData } from 'obsidian/canvas.d.ts'; 10 | 11 | import { 12 | App, 13 | Notice, 14 | setIcon, 15 | Vault 16 | } from 'obsidian'; 17 | import { throwExpression } from 'obsidian-dev-utils/Error'; 18 | import { appendCodeBlock } from 'obsidian-dev-utils/HTMLElement'; 19 | import { toJson } from 'obsidian-dev-utils/Object'; 20 | import { applyFileChanges } from 'obsidian-dev-utils/obsidian/FileChange'; 21 | import { 22 | getPath, 23 | isCanvasFile, 24 | isNote 25 | } from 'obsidian-dev-utils/obsidian/FileSystem'; 26 | import { 27 | extractLinkFile, 28 | updateLink 29 | } from 'obsidian-dev-utils/obsidian/Link'; 30 | import { loop } from 'obsidian-dev-utils/obsidian/Loop'; 31 | import { 32 | getAllLinks, 33 | getBacklinksForFileSafe, 34 | getCacheSafe 35 | } from 'obsidian-dev-utils/obsidian/MetadataCache'; 36 | import { confirm } from 'obsidian-dev-utils/obsidian/Modals/Confirm'; 37 | import { addToQueue } from 'obsidian-dev-utils/obsidian/Queue'; 38 | import { referenceToFileChange } from 'obsidian-dev-utils/obsidian/Reference'; 39 | import { 40 | copySafe, 41 | process, 42 | renameSafe 43 | } from 'obsidian-dev-utils/obsidian/Vault'; 44 | import { deleteEmptyFolderHierarchy } from 'obsidian-dev-utils/obsidian/VaultEx'; 45 | import { 46 | basename, 47 | dirname, 48 | extname, 49 | join, 50 | makeFileName 51 | } from 'obsidian-dev-utils/Path'; 52 | 53 | import type { Plugin } from './Plugin.ts'; 54 | 55 | import { 56 | getAttachmentFolderFullPathForPath, 57 | getPastedFileName 58 | } from './AttachmentPath.ts'; 59 | import { Substitutions } from './Substitutions.ts'; 60 | 61 | interface AttachmentMoveResult { 62 | newAttachmentPath: string; 63 | oldAttachmentPath: string; 64 | } 65 | 66 | export async function collectAttachments( 67 | plugin: Plugin, 68 | note: TFile, 69 | oldPath?: string, 70 | attachmentFilter?: (path: string) => boolean 71 | ): Promise { 72 | const app = plugin.app; 73 | oldPath ??= note.path; 74 | attachmentFilter ??= (): boolean => true; 75 | 76 | const notice = new Notice(`Collecting attachments for ${note.path}`); 77 | 78 | const attachmentsMap = new Map(); 79 | const isCanvas = isCanvasFile(app, note); 80 | 81 | await applyFileChanges(app, note, async () => { 82 | const cache = await getCacheSafe(app, note); 83 | 84 | if (!cache) { 85 | return []; 86 | } 87 | 88 | const links = isCanvas ? await getCanvasLinks(app, note) : getAllLinks(cache); 89 | const changes: FileChange[] = []; 90 | 91 | for (const link of links) { 92 | const attachmentMoveResult = await prepareAttachmentToMove(plugin, link, note.path, oldPath); 93 | if (!attachmentMoveResult) { 94 | continue; 95 | } 96 | 97 | if (!attachmentFilter(attachmentMoveResult.oldAttachmentPath)) { 98 | continue; 99 | } 100 | 101 | const backlinks = await getBacklinksForFileSafe(app, attachmentMoveResult.oldAttachmentPath); 102 | if (backlinks.count() > 1) { 103 | attachmentMoveResult.newAttachmentPath = await copySafe(app, attachmentMoveResult.oldAttachmentPath, attachmentMoveResult.newAttachmentPath); 104 | } else { 105 | attachmentMoveResult.newAttachmentPath = await renameSafe(app, attachmentMoveResult.oldAttachmentPath, attachmentMoveResult.newAttachmentPath); 106 | await deleteEmptyFolderHierarchy(app, dirname(attachmentMoveResult.oldAttachmentPath)); 107 | } 108 | 109 | attachmentsMap.set(attachmentMoveResult.oldAttachmentPath, attachmentMoveResult.newAttachmentPath); 110 | 111 | if (!isCanvas) { 112 | const newContent = updateLink({ 113 | app, 114 | link, 115 | newSourcePathOrFile: note, 116 | newTargetPathOrFile: attachmentMoveResult.newAttachmentPath, 117 | oldTargetPathOrFile: attachmentMoveResult.oldAttachmentPath 118 | }); 119 | 120 | changes.push(referenceToFileChange(link, newContent)); 121 | } 122 | } 123 | 124 | return changes; 125 | }); 126 | 127 | if (isCanvas) { 128 | await process(app, note, (content) => { 129 | const canvasData = JSON.parse(content) as CanvasData; 130 | for (const node of canvasData.nodes) { 131 | if (node.type !== 'file') { 132 | continue; 133 | } 134 | const newPath = attachmentsMap.get(node.file); 135 | if (!newPath) { 136 | continue; 137 | } 138 | node.file = newPath; 139 | } 140 | return toJson(canvasData); 141 | }); 142 | } 143 | 144 | notice.hide(); 145 | } 146 | 147 | export function collectAttachmentsCurrentFolder(plugin: Plugin, checking: boolean): boolean { 148 | const note = plugin.app.workspace.getActiveFile(); 149 | if (!isNoteEx(plugin, note)) { 150 | return false; 151 | } 152 | 153 | if (!checking) { 154 | addToQueue(plugin.app, () => collectAttachmentsInFolder(plugin, note?.parent ?? throwExpression(new Error('Parent folder not found')))); 155 | } 156 | 157 | return true; 158 | } 159 | 160 | export function collectAttachmentsCurrentNote(plugin: Plugin, checking: boolean): boolean { 161 | const note = plugin.app.workspace.getActiveFile(); 162 | if (!note || !isNoteEx(plugin, note)) { 163 | return false; 164 | } 165 | 166 | if (!checking) { 167 | if (plugin.settings.isPathIgnored(note.path)) { 168 | new Notice('Note path is ignored'); 169 | return true; 170 | } 171 | 172 | addToQueue(plugin.app, () => collectAttachments(plugin, note)); 173 | } 174 | 175 | return true; 176 | } 177 | 178 | export function collectAttachmentsEntireVault(plugin: Plugin): void { 179 | addToQueue(plugin.app, () => collectAttachmentsInFolder(plugin, plugin.app.vault.getRoot())); 180 | } 181 | 182 | export async function collectAttachmentsInFolder(plugin: Plugin, folder: TFolder): Promise { 183 | if ( 184 | !await confirm({ 185 | app: plugin.app, 186 | message: createFragment((f) => { 187 | f.appendText('Do you want to collect attachments for all notes in folder: '); 188 | appendCodeBlock(f, folder.path); 189 | f.appendText(' and all its subfolders?'); 190 | f.createEl('br'); 191 | f.appendText('This operation cannot be undone.'); 192 | }), 193 | title: createFragment((f) => { 194 | setIcon(f.createSpan(), 'lucide-alert-triangle'); 195 | f.appendText(' Collect attachments in folder'); 196 | }) 197 | }) 198 | ) { 199 | return; 200 | } 201 | plugin.consoleDebug(`Collect attachments in folder: ${folder.path}`); 202 | const files: TFile[] = []; 203 | Vault.recurseChildren(folder, (child) => { 204 | if (isNoteEx(plugin, child)) { 205 | files.push(child as TFile); 206 | } 207 | }); 208 | 209 | files.sort((a, b) => a.path.localeCompare(b.path)); 210 | 211 | await loop({ 212 | abortSignal: plugin.abortSignal, 213 | buildNoticeMessage: (file, iterationStr) => `Collecting attachments ${iterationStr} - ${file.path}`, 214 | items: files, 215 | processItem: async (file) => { 216 | if (plugin.settings.isPathIgnored(file.path)) { 217 | return; 218 | } 219 | await collectAttachments(plugin, file); 220 | }, 221 | progressBarTitle: 'Custom Attachment Location: Collecting attachments...', 222 | shouldContinueOnError: true, 223 | shouldShowProgressBar: true 224 | }); 225 | } 226 | 227 | export function isNoteEx(plugin: Plugin, pathOrFile: null | PathOrAbstractFile): boolean { 228 | if (!pathOrFile || !isNote(plugin.app, pathOrFile)) { 229 | return false; 230 | } 231 | 232 | const path = getPath(plugin.app, pathOrFile); 233 | return plugin.settings.treatAsAttachmentExtensions.every((extension) => !path.endsWith(extension)); 234 | } 235 | 236 | async function getCanvasLinks(app: App, canvasFile: TFile): Promise { 237 | const canvasData = await app.vault.readJson(canvasFile.path) as CanvasData; 238 | const paths = canvasData.nodes.filter((node) => node.type === 'file').map((node) => node.file); 239 | return paths.map((path) => ({ 240 | link: path, 241 | original: path, 242 | position: { 243 | end: { col: 0, line: 0, loc: 0, offset: 0 }, 244 | start: { col: 0, line: 0, loc: 0, offset: 0 } 245 | } 246 | })); 247 | } 248 | 249 | async function prepareAttachmentToMove( 250 | plugin: Plugin, 251 | link: Reference, 252 | newNotePath: string, 253 | oldNotePath: string 254 | ): Promise { 255 | const app = plugin.app; 256 | 257 | const oldAttachmentFile = extractLinkFile(app, link, oldNotePath); 258 | if (!oldAttachmentFile) { 259 | return null; 260 | } 261 | 262 | if (isNoteEx(plugin, oldAttachmentFile)) { 263 | return null; 264 | } 265 | 266 | const oldAttachmentPath = oldAttachmentFile.path; 267 | const oldAttachmentName = oldAttachmentFile.name; 268 | 269 | const oldNoteBaseName = basename(oldNotePath, extname(oldNotePath)); 270 | const newNoteBaseName = basename(newNotePath, extname(newNotePath)); 271 | 272 | let newAttachmentName: string; 273 | 274 | if (plugin.settings.shouldRenameCollectedAttachments) { 275 | newAttachmentName = makeFileName( 276 | await getPastedFileName(plugin, new Substitutions(plugin.app, newNotePath, oldAttachmentFile.name)), 277 | oldAttachmentFile.extension 278 | ); 279 | } else if (plugin.settings.shouldRenameAttachmentFiles) { 280 | newAttachmentName = oldAttachmentName.replaceAll(oldNoteBaseName, newNoteBaseName); 281 | } else { 282 | newAttachmentName = oldAttachmentName; 283 | } 284 | 285 | const newAttachmentFolderPath = await getAttachmentFolderFullPathForPath(plugin, newNotePath, newAttachmentName); 286 | const newAttachmentPath = join(newAttachmentFolderPath, newAttachmentName); 287 | 288 | if (oldAttachmentPath === newAttachmentPath) { 289 | return null; 290 | } 291 | 292 | return { 293 | newAttachmentPath, 294 | oldAttachmentPath 295 | }; 296 | } 297 | -------------------------------------------------------------------------------- /src/AttachmentPath.ts: -------------------------------------------------------------------------------- 1 | import { normalizePath } from 'obsidian'; 2 | import { join } from 'obsidian-dev-utils/Path'; 3 | 4 | import type { Plugin } from './Plugin.ts'; 5 | 6 | import { 7 | Substitutions, 8 | validatePath 9 | } from './Substitutions.ts'; 10 | 11 | export async function getAttachmentFolderFullPathForPath( 12 | plugin: Plugin, 13 | notePath: string, 14 | attachmentFilename: string 15 | ): Promise { 16 | return await getAttachmentFolderPath(plugin, new Substitutions(plugin.app, notePath, attachmentFilename)); 17 | } 18 | 19 | export async function getPastedFileName(plugin: Plugin, substitutions: Substitutions): Promise { 20 | return await resolvePathTemplate(plugin, plugin.settings.generatedAttachmentFilename, substitutions); 21 | } 22 | 23 | export function replaceSpecialCharacters(plugin: Plugin, str: string): string { 24 | if (!plugin.settings.specialCharacters) { 25 | return str; 26 | } 27 | 28 | str = str.replace(plugin.settings.specialCharactersRegExp, plugin.settings.specialCharactersReplacement); 29 | return str; 30 | } 31 | 32 | async function getAttachmentFolderPath(plugin: Plugin, substitutions: Substitutions): Promise { 33 | return await resolvePathTemplate(plugin, plugin.settings.attachmentFolderPath, substitutions); 34 | } 35 | 36 | async function resolvePathTemplate(plugin: Plugin, template: string, substitutions: Substitutions): Promise { 37 | let resolvedPath = await substitutions.fillTemplate(template); 38 | const validationError = validatePath(resolvedPath, false); 39 | if (validationError) { 40 | throw new Error(`Resolved path ${resolvedPath} is invalid: ${validationError}`); 41 | } 42 | 43 | if (plugin.settings.shouldRenameAttachmentsToLowerCase) { 44 | resolvedPath = resolvedPath.toLowerCase(); 45 | } 46 | 47 | resolvedPath = replaceSpecialCharacters(plugin, resolvedPath); 48 | if (resolvedPath.startsWith('./') || resolvedPath.startsWith('../')) { 49 | resolvedPath = join(substitutions.folderPath, resolvedPath); 50 | } 51 | 52 | resolvedPath = normalizePath(resolvedPath); 53 | return resolvedPath; 54 | } 55 | -------------------------------------------------------------------------------- /src/Plugin.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | App, 3 | FileManager 4 | } from 'obsidian'; 5 | import type { 6 | ExtendedWrapper, 7 | GetAvailablePathForAttachmentsExtendedFn 8 | } from 'obsidian-dev-utils/obsidian/AttachmentPath'; 9 | import type { RenameDeleteHandlerSettings } from 'obsidian-dev-utils/obsidian/RenameDeleteHandler'; 10 | import type { ConfigItem } from 'obsidian-typings/implementations'; 11 | 12 | import { webUtils } from 'electron'; 13 | import moment from 'moment'; 14 | import { 15 | Menu, 16 | TAbstractFile, 17 | TFile, 18 | TFolder, 19 | Vault 20 | } from 'obsidian'; 21 | import { convertAsyncToSync } from 'obsidian-dev-utils/Async'; 22 | import { blobToJpegArrayBuffer } from 'obsidian-dev-utils/Blob'; 23 | import { getAvailablePathForAttachments } from 'obsidian-dev-utils/obsidian/AttachmentPath'; 24 | import { getAbstractFileOrNull } from 'obsidian-dev-utils/obsidian/FileSystem'; 25 | import { 26 | encodeUrl, 27 | generateMarkdownLink, 28 | testAngleBrackets, 29 | testWikilink 30 | } from 'obsidian-dev-utils/obsidian/Link'; 31 | import { alert } from 'obsidian-dev-utils/obsidian/Modals/Alert'; 32 | import { registerPatch } from 'obsidian-dev-utils/obsidian/MonkeyAround'; 33 | import { PluginBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginBase'; 34 | import { 35 | EmptyAttachmentFolderBehavior, 36 | registerRenameDeleteHandlers 37 | } from 'obsidian-dev-utils/obsidian/RenameDeleteHandler'; 38 | import { createFolderSafe } from 'obsidian-dev-utils/obsidian/Vault'; 39 | import { 40 | join, 41 | makeFileName 42 | } from 'obsidian-dev-utils/Path'; 43 | import { parentFolderPath } from 'obsidian-typings/implementations'; 44 | import { compare } from 'semver'; 45 | 46 | import type { PluginTypes } from './PluginTypes.ts'; 47 | 48 | import { 49 | collectAttachmentsCurrentFolder, 50 | collectAttachmentsCurrentNote, 51 | collectAttachmentsEntireVault, 52 | collectAttachmentsInFolder, 53 | isNoteEx 54 | } from './AttachmentCollector.ts'; 55 | import { 56 | getAttachmentFolderFullPathForPath, 57 | getPastedFileName 58 | } from './AttachmentPath.ts'; 59 | import { AttachmentRenameMode } from './PluginSettings.ts'; 60 | import { PluginSettingsManager } from './PluginSettingsManager.ts'; 61 | import { PluginSettingsTab } from './PluginSettingsTab.ts'; 62 | import { PrismComponent } from './PrismComponent.ts'; 63 | import { Substitutions } from './Substitutions.ts'; 64 | 65 | type GenerateMarkdownLinkFn = FileManager['generateMarkdownLink']; 66 | type GetAvailablePathFn = Vault['getAvailablePath']; 67 | type GetConfigFn = Vault['getConfig']; 68 | type GetPathForFileFn = typeof webUtils['getPathForFile']; 69 | type SaveAttachmentFn = App['saveAttachment']; 70 | 71 | const PASTED_IMAGE_NAME_REG_EXP = /Pasted image (?\d{14})/; 72 | const PASTED_IMAGE_DATE_FORMAT = 'YYYYMMDDHHmmss'; 73 | const THRESHOLD_IN_SECONDS = 10; 74 | 75 | interface FileEx { 76 | path: string; 77 | } 78 | 79 | export class Plugin extends PluginBase { 80 | private currentAttachmentFolderPath: null | string = null; 81 | private readonly pathMarkdownUrlMap = new Map(); 82 | 83 | protected override createSettingsManager(): PluginSettingsManager { 84 | return new PluginSettingsManager(this); 85 | } 86 | 87 | protected override createSettingsTab(): null | PluginSettingsTab { 88 | return new PluginSettingsTab(this); 89 | } 90 | 91 | protected override async onLayoutReady(): Promise { 92 | await super.onLayoutReady(); 93 | registerPatch(this, this.app.vault, { 94 | getAvailablePath: (): GetAvailablePathFn => this.getAvailablePath.bind(this), 95 | getAvailablePathForAttachments: (): ExtendedWrapper & GetAvailablePathForAttachmentsExtendedFn => { 96 | const extendedWrapper: ExtendedWrapper = { 97 | isExtended: true as const 98 | }; 99 | return Object.assign(this.getAvailablePathForAttachments.bind(this), extendedWrapper) as ExtendedWrapper & GetAvailablePathForAttachmentsExtendedFn; 100 | }, 101 | getConfig: (next: GetConfigFn): GetConfigFn => { 102 | return (name: ConfigItem): unknown => { 103 | return this.getConfig(next, name); 104 | }; 105 | } 106 | }); 107 | 108 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 109 | if (webUtils) { 110 | registerPatch(this, webUtils, { 111 | getPathForFile: (next: GetPathForFileFn): GetPathForFileFn => { 112 | return (file: File): string => { 113 | return this.getPathForFile(file, next); 114 | }; 115 | } 116 | }); 117 | } 118 | 119 | registerPatch(this, this.app.fileManager, { 120 | generateMarkdownLink: (next: GenerateMarkdownLinkFn): GenerateMarkdownLinkFn => { 121 | return (file: TFile, sourcePath: string, subpath?: string, alias?: string): string => { 122 | return this.generateMarkdownLink(next, file, sourcePath, subpath, alias); 123 | }; 124 | } 125 | }); 126 | 127 | if (compare(this.settings.warningVersion, '7.0.0') < 0) { 128 | if (this.settings.customTokensStr) { 129 | await alert({ 130 | app: this.app, 131 | message: createFragment((f) => { 132 | f.appendText('In plugin version 7.0.0, the format for custom tokens has changed. Please update your custom tokens accordingly. Refer to the '); 133 | f.createEl('a', { 134 | href: 'https://github.com/RainCat1998/obsidian-custom-attachment-location?tab=readme-ov-file#custom-tokens', 135 | text: 'documentation' 136 | }); 137 | f.appendText(' for more information.'); 138 | }) 139 | }); 140 | } 141 | 142 | await this.settingsManager.editAndSave((settings) => { 143 | settings.warningVersion = this.manifest.version; 144 | }); 145 | } 146 | } 147 | 148 | protected override async onloadImpl(): Promise { 149 | await super.onloadImpl(); 150 | registerRenameDeleteHandlers(this, () => { 151 | const settings: Partial = { 152 | emptyAttachmentFolderBehavior: this.settings.emptyAttachmentFolderBehavior, 153 | isNote: (path) => isNoteEx(this, path), 154 | isPathIgnored: (path) => this.settings.isPathIgnored(path), 155 | shouldHandleDeletions: this.settings.shouldDeleteOrphanAttachments, 156 | shouldHandleRenames: true, 157 | shouldRenameAttachmentFiles: this.settings.shouldRenameAttachmentFiles, 158 | shouldRenameAttachmentFolder: this.settings.shouldRenameAttachmentFolder, 159 | shouldUpdateFilenameAliases: true 160 | }; 161 | return settings; 162 | }); 163 | 164 | this.addCommand({ 165 | checkCallback: (checking) => collectAttachmentsCurrentNote(this, checking), 166 | id: 'collect-attachments-current-note', 167 | name: 'Collect attachments in current note' 168 | }); 169 | 170 | this.addCommand({ 171 | checkCallback: (checking) => collectAttachmentsCurrentFolder(this, checking), 172 | id: 'collect-attachments-current-folder', 173 | name: 'Collect attachments in current folder' 174 | }); 175 | 176 | this.addCommand({ 177 | callback: () => { 178 | collectAttachmentsEntireVault(this); 179 | }, 180 | id: 'collect-attachments-entire-vault', 181 | name: 'Collect attachments in entire vault' 182 | }); 183 | 184 | this.registerEvent(this.app.workspace.on('file-menu', this.handleFileMenu.bind(this))); 185 | 186 | registerPatch(this, this.app, { 187 | saveAttachment: (next: SaveAttachmentFn): SaveAttachmentFn => { 188 | return (name, extension, data): Promise => { 189 | return this.saveAttachment(next, name, extension, data); 190 | }; 191 | } 192 | }); 193 | this.addChild(new PrismComponent()); 194 | 195 | this.registerEvent(this.app.workspace.on('file-open', convertAsyncToSync(this.handleFileOpen.bind(this)))); 196 | } 197 | 198 | private generateMarkdownLink(next: GenerateMarkdownLinkFn, file: TFile, sourcePath: string, subpath?: string, alias?: string): string { 199 | let defaultLink = next.call(this.app.fileManager, file, sourcePath, subpath, alias); 200 | 201 | if (!this.settings.markdownUrlFormat) { 202 | return defaultLink; 203 | } 204 | 205 | const markdownUrl = this.pathMarkdownUrlMap.get(file.path); 206 | 207 | if (!markdownUrl) { 208 | return defaultLink; 209 | } 210 | 211 | if (testWikilink(defaultLink)) { 212 | defaultLink = generateMarkdownLink({ 213 | app: this.app, 214 | isWikilink: false, 215 | originalLink: defaultLink, 216 | sourcePathOrFile: sourcePath, 217 | targetPathOrFile: file 218 | }); 219 | } 220 | 221 | if (testAngleBrackets(defaultLink)) { 222 | return defaultLink.replace(/\]\(<.+?>\)/, `](<${markdownUrl}>)`); 223 | } 224 | 225 | return defaultLink.replace(/\]\(.+?\)/, `](${encodeUrl(markdownUrl)})`); 226 | } 227 | 228 | private getAvailablePath(filename: string, extension: string): string { 229 | let suffixNum = 0; 230 | 231 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 232 | while (true) { 233 | const path = makeFileName(suffixNum === 0 ? filename : `${filename}${this.settings.duplicateNameSeparator}${suffixNum.toString()}`, extension); 234 | 235 | if (!getAbstractFileOrNull(this.app, path, true)) { 236 | return path; 237 | } 238 | 239 | suffixNum++; 240 | } 241 | } 242 | 243 | private async getAvailablePathForAttachments( 244 | filename: string, 245 | extension: string, 246 | file: null | TFile, 247 | skipFolderCreation: boolean | undefined 248 | ): Promise { 249 | let attachmentPath: string; 250 | if (!file || !isNoteEx(this, file)) { 251 | attachmentPath = await getAvailablePathForAttachments(this.app, filename, extension, file, true); 252 | } else { 253 | const attachmentFolderFullPath = await getAttachmentFolderFullPathForPath(this, file.path, makeFileName(filename, extension)); 254 | attachmentPath = this.app.vault.getAvailablePath(join(attachmentFolderFullPath, filename), extension); 255 | } 256 | 257 | if (!skipFolderCreation) { 258 | const folderPath = parentFolderPath(attachmentPath); 259 | if (!await this.app.vault.exists(folderPath)) { 260 | await createFolderSafe(this.app, folderPath); 261 | if (this.settings.emptyAttachmentFolderBehavior === EmptyAttachmentFolderBehavior.Keep) { 262 | await this.app.vault.create(join(folderPath, '.gitkeep'), ''); 263 | } 264 | } 265 | } 266 | 267 | return attachmentPath; 268 | } 269 | 270 | private getConfig(next: GetConfigFn, name: ConfigItem): unknown { 271 | if (name !== 'attachmentFolderPath' || this.currentAttachmentFolderPath === null) { 272 | return next.call(this.app.vault, name); 273 | } 274 | 275 | return this.currentAttachmentFolderPath; 276 | } 277 | 278 | private getPathForFile(file: File, next: GetPathForFileFn): string { 279 | const fileEx = file as Partial; 280 | if (fileEx.path) { 281 | return fileEx.path; 282 | } 283 | return next(file); 284 | } 285 | 286 | private handleFileMenu(menu: Menu, file: TAbstractFile): void { 287 | if (!(file instanceof TFolder)) { 288 | return; 289 | } 290 | 291 | menu.addItem((item) => { 292 | item.setTitle('Collect attachments in folder') 293 | .setIcon('download') 294 | .onClick(() => collectAttachmentsInFolder(this, file)); 295 | }); 296 | } 297 | 298 | private async handleFileOpen(file: null | TFile): Promise { 299 | if (file === null) { 300 | this.currentAttachmentFolderPath = null; 301 | return; 302 | } 303 | 304 | this.currentAttachmentFolderPath = await getAttachmentFolderFullPathForPath(this, file.path, 'dummy.pdf'); 305 | } 306 | 307 | private async saveAttachment(next: SaveAttachmentFn, name: string, extension: string, data: ArrayBuffer): Promise { 308 | const activeFile = this.app.workspace.getActiveFile(); 309 | if (!activeFile || this.settings.isPathIgnored(activeFile.path)) { 310 | return next.call(this.app, name, extension, data); 311 | } 312 | 313 | let isPastedImage = false; 314 | const match = PASTED_IMAGE_NAME_REG_EXP.exec(name); 315 | if (match) { 316 | const timestampString = match.groups?.['Timestamp']; 317 | if (timestampString) { 318 | const parsedDate = moment(timestampString, PASTED_IMAGE_DATE_FORMAT); 319 | if (parsedDate.isValid()) { 320 | if (moment().diff(parsedDate, 'seconds') < THRESHOLD_IN_SECONDS) { 321 | isPastedImage = true; 322 | } 323 | } 324 | } 325 | } 326 | 327 | if (isPastedImage && extension === 'png' && this.settings.shouldConvertPastedImagesToJpeg) { 328 | extension = 'jpg'; 329 | data = await blobToJpegArrayBuffer(new Blob([data], { type: 'image/png' }), this.settings.jpegQuality); 330 | } 331 | 332 | let shouldRename = false; 333 | 334 | switch (this.settings.attachmentRenameMode) { 335 | case AttachmentRenameMode.All: 336 | shouldRename = true; 337 | break; 338 | case AttachmentRenameMode.None: 339 | break; 340 | case AttachmentRenameMode.OnlyPastedImages: 341 | shouldRename = isPastedImage; 342 | break; 343 | default: 344 | throw new Error('Invalid attachment rename mode'); 345 | } 346 | 347 | if (shouldRename) { 348 | name = await getPastedFileName(this, new Substitutions(this.app, activeFile.path, makeFileName(name, extension))); 349 | } 350 | 351 | const file = await next.call(this.app, name, extension, data); 352 | if (this.settings.markdownUrlFormat) { 353 | const markdownUrl = await new Substitutions(this.app, file.path, file.name).fillTemplate(this.settings.markdownUrlFormat); 354 | this.pathMarkdownUrlMap.set(file.path, markdownUrl); 355 | } else { 356 | this.pathMarkdownUrlMap.delete(file.path); 357 | } 358 | return file; 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /src/PluginSettings.ts: -------------------------------------------------------------------------------- 1 | import { EmptyAttachmentFolderBehavior } from 'obsidian-dev-utils/obsidian/RenameDeleteHandler'; 2 | import { escapeRegExp } from 'obsidian-dev-utils/RegExp'; 3 | 4 | import { Substitutions } from './Substitutions.ts'; 5 | 6 | const ALWAYS_MATCH_REG_EXP = /(?:)/; 7 | const NEVER_MATCH_REG_EXP = /$./; 8 | 9 | export enum AttachmentRenameMode { 10 | None = 'None', 11 | 12 | OnlyPastedImages = 'Only pasted images', 13 | // eslint-disable-next-line perfectionist/sort-enums 14 | All = 'All' 15 | } 16 | 17 | export class PluginSettings { 18 | // eslint-disable-next-line no-template-curly-in-string 19 | public attachmentFolderPath = './assets/${filename}'; 20 | public attachmentRenameMode: AttachmentRenameMode = AttachmentRenameMode.OnlyPastedImages; 21 | public duplicateNameSeparator = ' '; 22 | public emptyAttachmentFolderBehavior: EmptyAttachmentFolderBehavior = EmptyAttachmentFolderBehavior.DeleteWithEmptyParents; 23 | // eslint-disable-next-line no-template-curly-in-string 24 | public generatedAttachmentFilename = 'file-${date:YYYYMMDDHHmmssSSS}'; 25 | // eslint-disable-next-line no-magic-numbers 26 | public jpegQuality = 0.8; 27 | public markdownUrlFormat = ''; 28 | public shouldConvertPastedImagesToJpeg = false; 29 | public shouldDeleteOrphanAttachments = false; 30 | public shouldRenameAttachmentFiles = false; 31 | public shouldRenameAttachmentFolder = true; 32 | public shouldRenameAttachmentsToLowerCase = false; 33 | public shouldRenameCollectedAttachments = false; 34 | public specialCharacters = '#^[]|*\\<>:?'; 35 | public specialCharactersReplacement = '-'; 36 | public treatAsAttachmentExtensions: readonly string[] = ['.excalidraw.md']; 37 | public warningVersion = '0.0.0'; 38 | public get customTokensStr(): string { 39 | return this._customTokensStr; 40 | } 41 | 42 | public set customTokensStr(value: string) { 43 | this._customTokensStr = value; 44 | Substitutions.registerCustomFormatters(this._customTokensStr); 45 | } 46 | 47 | public get excludePaths(): string[] { 48 | return this._excludePaths; 49 | } 50 | 51 | public set excludePaths(value: string[]) { 52 | this._excludePaths = value.filter(Boolean); 53 | this._excludePathsRegExp = makeRegExp(this._excludePaths, NEVER_MATCH_REG_EXP); 54 | } 55 | 56 | public get includePaths(): string[] { 57 | return this._includePaths; 58 | } 59 | 60 | public set includePaths(value: string[]) { 61 | this._includePaths = value.filter(Boolean); 62 | this._includePathsRegExp = makeRegExp(this._includePaths, ALWAYS_MATCH_REG_EXP); 63 | } 64 | 65 | public get specialCharactersRegExp(): RegExp { 66 | return new RegExp(`[${escapeRegExp(this.specialCharacters)}]+`, 'g'); 67 | } 68 | 69 | private _customTokensStr = ''; 70 | private _excludePaths: string[] = []; 71 | private _excludePathsRegExp = NEVER_MATCH_REG_EXP; 72 | private _includePaths: string[] = []; 73 | 74 | private _includePathsRegExp = ALWAYS_MATCH_REG_EXP; 75 | 76 | public isPathIgnored(path: string): boolean { 77 | return !this._includePathsRegExp.test(path) || this._excludePathsRegExp.test(path); 78 | } 79 | } 80 | 81 | function makeRegExp(paths: string[], defaultRegExp: RegExp): RegExp { 82 | if (paths.length === 0) { 83 | return defaultRegExp; 84 | } 85 | 86 | const regExpStrCombined = paths.map((path) => { 87 | if (path.startsWith('/') && path.endsWith('/')) { 88 | return path.slice(1, -1); 89 | } 90 | return `^${escapeRegExp(path)}`; 91 | }) 92 | .map((regExpStr) => `(${regExpStr})`) 93 | .join('|'); 94 | return new RegExp(regExpStrCombined); 95 | } 96 | -------------------------------------------------------------------------------- /src/PluginSettingsManager.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeReturn } from 'obsidian-dev-utils/Type'; 2 | 3 | import { PluginSettingsManagerBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginSettingsManagerBase'; 4 | import { EmptyAttachmentFolderBehavior } from 'obsidian-dev-utils/obsidian/RenameDeleteHandler'; 5 | import { isValidRegExp } from 'obsidian-dev-utils/RegExp'; 6 | 7 | import type { PluginTypes } from './PluginTypes.ts'; 8 | 9 | import { PluginSettings } from './PluginSettings.ts'; 10 | import { 11 | getCustomTokenFormatters, 12 | INVALID_FILENAME_PATH_CHARS_REG_EXP, 13 | validateFilename, 14 | validatePath 15 | } from './Substitutions.ts'; 16 | 17 | class LegacySettings extends PluginSettings { 18 | public autoRenameFiles = false; 19 | public autoRenameFolder = true; 20 | public convertImagesOnDragAndDrop = false; 21 | public convertImagesToJpeg = false; 22 | public dateTimeFormat = ''; 23 | public deleteOrphanAttachments = false; 24 | public keepEmptyAttachmentFolders = false; 25 | // eslint-disable-next-line no-template-curly-in-string 26 | public pastedFileName = 'file-${date:YYYYMMDDHHmmssSSS}'; 27 | public pastedImageFileName = ''; 28 | public renameAttachmentsOnDragAndDrop = false; 29 | public renameCollectedFiles = false; 30 | public renameOnlyImages = false; 31 | public renamePastedFilesWithKnownNames = false; 32 | public replaceWhitespace = false; 33 | public shouldKeepEmptyAttachmentFolders = false; 34 | public toLowerCase = false; 35 | public whitespaceReplacement = ''; 36 | } 37 | 38 | export class PluginSettingsManager extends PluginSettingsManagerBase { 39 | protected override createDefaultSettings(): PluginSettings { 40 | return new PluginSettings(); 41 | } 42 | 43 | protected override async onLoadRecord(record: Record): Promise { 44 | await super.onLoadRecord(record); 45 | const legacySettings = record as Partial; 46 | const dateTimeFormat = legacySettings.dateTimeFormat ?? 'YYYYMMDDHHmmssSSS'; 47 | legacySettings.attachmentFolderPath = addDateTimeFormat(legacySettings.attachmentFolderPath ?? '', dateTimeFormat); 48 | 49 | legacySettings.generatedAttachmentFilename = addDateTimeFormat( 50 | // eslint-disable-next-line no-template-curly-in-string 51 | legacySettings.generatedAttachmentFilename ?? legacySettings.pastedFileName ?? legacySettings.pastedImageFileName ?? 'file-${date}', 52 | dateTimeFormat 53 | ); 54 | if (legacySettings.replaceWhitespace !== undefined) { 55 | legacySettings.whitespaceReplacement = legacySettings.replaceWhitespace ? '-' : ''; 56 | } 57 | 58 | if (legacySettings.autoRenameFiles !== undefined) { 59 | legacySettings.shouldRenameAttachmentFiles = legacySettings.autoRenameFiles; 60 | } 61 | 62 | if (legacySettings.autoRenameFolder !== undefined) { 63 | legacySettings.shouldRenameAttachmentFolder = legacySettings.autoRenameFolder; 64 | } 65 | 66 | if (legacySettings.deleteOrphanAttachments !== undefined) { 67 | legacySettings.shouldDeleteOrphanAttachments = legacySettings.deleteOrphanAttachments; 68 | } 69 | 70 | if (legacySettings.keepEmptyAttachmentFolders !== undefined) { 71 | legacySettings.shouldKeepEmptyAttachmentFolders = legacySettings.keepEmptyAttachmentFolders; 72 | } 73 | 74 | if (legacySettings.renameCollectedFiles !== undefined) { 75 | legacySettings.shouldRenameCollectedAttachments = legacySettings.renameCollectedFiles; 76 | } 77 | 78 | if (legacySettings.toLowerCase !== undefined) { 79 | legacySettings.shouldRenameAttachmentsToLowerCase = legacySettings.toLowerCase; 80 | } 81 | 82 | if (legacySettings.convertImagesToJpeg !== undefined) { 83 | legacySettings.shouldConvertPastedImagesToJpeg = legacySettings.convertImagesToJpeg; 84 | } 85 | 86 | if (legacySettings.whitespaceReplacement) { 87 | legacySettings.specialCharacters = `${legacySettings.specialCharacters ?? ''} `; 88 | legacySettings.specialCharactersReplacement = legacySettings.whitespaceReplacement; 89 | } 90 | 91 | if (legacySettings.shouldKeepEmptyAttachmentFolders !== undefined) { 92 | legacySettings.emptyAttachmentFolderBehavior = legacySettings.shouldKeepEmptyAttachmentFolders 93 | ? EmptyAttachmentFolderBehavior.Keep 94 | : EmptyAttachmentFolderBehavior.DeleteWithEmptyParents; 95 | } 96 | } 97 | 98 | protected override registerValidators(): void { 99 | this.registerValidator('attachmentFolderPath', (value) => validatePath(value)); 100 | this.registerValidator('generatedAttachmentFilename', (value) => validatePath(value)); 101 | this.registerValidator('specialCharacters', (value): MaybeReturn => { 102 | if (value.includes('/')) { 103 | return 'Special characters must not contain /'; 104 | } 105 | }); 106 | 107 | this.registerValidator('specialCharactersReplacement', (value): MaybeReturn => { 108 | if (INVALID_FILENAME_PATH_CHARS_REG_EXP.exec(value)) { 109 | return 'Special character replacement must not contain invalid filename path characters.'; 110 | } 111 | }); 112 | 113 | this.registerValidator('duplicateNameSeparator', (value): MaybeReturn => { 114 | return validateFilename(`filename${value}1`, false); 115 | }); 116 | 117 | this.registerValidator('includePaths', (value): MaybeReturn => { 118 | return pathsValidator(value); 119 | }); 120 | 121 | this.registerValidator('excludePaths', (value): MaybeReturn => { 122 | return pathsValidator(value); 123 | }); 124 | 125 | this.registerValidator('customTokensStr', (value): MaybeReturn => { 126 | customTokensValidator(value); 127 | }); 128 | } 129 | } 130 | 131 | function addDateTimeFormat(str: string, dateTimeFormat: string): string { 132 | // eslint-disable-next-line no-template-curly-in-string 133 | return str.replaceAll('${date}', `\${date:${dateTimeFormat}}`); 134 | } 135 | 136 | function customTokensValidator(value: string): MaybeReturn { 137 | const formatters = getCustomTokenFormatters(value); 138 | if (formatters === null) { 139 | return 'Invalid custom tokens code'; 140 | } 141 | } 142 | 143 | function pathsValidator(paths: string[]): MaybeReturn { 144 | for (const path of paths) { 145 | if (path.startsWith('/') && path.endsWith('/')) { 146 | const regExp = path.slice(1, -1); 147 | if (!isValidRegExp(regExp)) { 148 | return `Invalid regular expression ${path}`; 149 | } 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/PluginSettingsTab.ts: -------------------------------------------------------------------------------- 1 | import { normalizePath } from 'obsidian'; 2 | import { 3 | getEnumKey, 4 | getEnumValue 5 | } from 'obsidian-dev-utils/Enum'; 6 | import { appendCodeBlock } from 'obsidian-dev-utils/HTMLElement'; 7 | import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginSettingsTabBase'; 8 | import { EmptyAttachmentFolderBehavior } from 'obsidian-dev-utils/obsidian/RenameDeleteHandler'; 9 | import { SettingEx } from 'obsidian-dev-utils/obsidian/SettingEx'; 10 | 11 | import type { PluginTypes } from './PluginTypes.ts'; 12 | 13 | import { AttachmentRenameMode } from './PluginSettings.ts'; 14 | import { TOKENIZED_STRING_LANGUAGE } from './PrismComponent.ts'; 15 | 16 | const VISIBLE_WHITESPACE_CHARACTER = '␣'; 17 | 18 | export class PluginSettingsTab extends PluginSettingsTabBase { 19 | public override display(): void { 20 | super.display(); 21 | this.containerEl.empty(); 22 | 23 | new SettingEx(this.containerEl) 24 | .setName('Location for new attachments') 25 | .setDesc(createFragment((f) => { 26 | f.appendText('Start with '); 27 | appendCodeBlock(f, '.'); 28 | f.appendText(' to use relative path.'); 29 | f.createEl('br'); 30 | f.appendText('See available '); 31 | f.createEl('a', { href: 'https://github.com/RainCat1998/obsidian-custom-attachment-location?tab=readme-ov-file#tokens', text: 'tokens' }); 32 | f.createEl('br'); 33 | f.appendText('Dot-folders like '); 34 | appendCodeBlock(f, '.attachments'); 35 | f.appendText(' are not recommended, because Obsidian doesn\'t track them. You might need to use '); 36 | f.createEl('a', { href: 'https://github.com/polyipseity/obsidian-show-hidden-files/', text: 'Show Hidden Files' }); 37 | f.appendText(' Plugin to manage them.'); 38 | })) 39 | .addCodeHighlighter((codeHighlighter) => { 40 | codeHighlighter.setLanguage(TOKENIZED_STRING_LANGUAGE); 41 | codeHighlighter.inputEl.addClass('tokenized-string-setting-control'); 42 | this.bind(codeHighlighter, 'attachmentFolderPath', { 43 | componentToPluginSettingsValueConverter(uiValue: string): string { 44 | return normalizePath(uiValue); 45 | }, 46 | pluginSettingsToComponentValueConverter(pluginSettingsValue: string): string { 47 | return pluginSettingsValue; 48 | } 49 | }); 50 | }); 51 | 52 | new SettingEx(this.containerEl) 53 | .setName('Generated attachment filename') 54 | .setDesc(createFragment((f) => { 55 | f.appendText('See available '); 56 | f.createEl('a', { href: 'https://github.com/RainCat1998/obsidian-custom-attachment-location?tab=readme-ov-file#tokens', text: 'tokens' }); 57 | })) 58 | .addCodeHighlighter((codeHighlighter) => { 59 | codeHighlighter.setLanguage(TOKENIZED_STRING_LANGUAGE); 60 | codeHighlighter.inputEl.addClass('tokenized-string-setting-control'); 61 | this.bind(codeHighlighter, 'generatedAttachmentFilename'); 62 | }); 63 | 64 | new SettingEx(this.containerEl) 65 | .setName('Markdown URL format') 66 | .setDesc(createFragment((f) => { 67 | f.appendText('Format for the URL that will be inserted into Markdown.'); 68 | f.createEl('br'); 69 | f.appendText('See available '); 70 | f.createEl('a', { href: 'https://github.com/RainCat1998/obsidian-custom-attachment-location?tab=readme-ov-file#tokens', text: 'tokens' }); 71 | f.createEl('br'); 72 | f.appendText('Leave blank to use the default format.'); 73 | })) 74 | .addCodeHighlighter((codeHighlighter) => { 75 | codeHighlighter.setLanguage(TOKENIZED_STRING_LANGUAGE); 76 | codeHighlighter.inputEl.addClass('tokenized-string-setting-control'); 77 | this.bind(codeHighlighter, 'markdownUrlFormat'); 78 | }); 79 | 80 | new SettingEx(this.containerEl) 81 | .setName('Attachment rename mode') 82 | .setDesc(createFragment((f) => { 83 | f.appendText('When attaching files, '); 84 | f.createEl('br'); 85 | appendCodeBlock(f, 'None'); 86 | f.appendText(' - their names are preserved, '); 87 | f.createEl('br'); 88 | appendCodeBlock(f, 'Only pasted images'); 89 | f.appendText(' - only pasted images are renamed.'); 90 | f.createEl('br'); 91 | appendCodeBlock(f, 'All'); 92 | f.appendText(' - all files are renamed.'); 93 | })) 94 | .addDropdown((dropdown) => { 95 | dropdown.addOptions(AttachmentRenameMode); 96 | this.bind(dropdown, 'attachmentRenameMode', { 97 | componentToPluginSettingsValueConverter: (value) => getEnumValue(AttachmentRenameMode, value), 98 | pluginSettingsToComponentValueConverter: (value) => getEnumKey(AttachmentRenameMode, value) 99 | }); 100 | }); 101 | 102 | new SettingEx(this.containerEl) 103 | .setName('Should rename attachment folder') 104 | .setDesc(createFragment((f) => { 105 | f.appendText('When renaming md files, automatically rename attachment folder if folder name contains '); 106 | // eslint-disable-next-line no-template-curly-in-string 107 | appendCodeBlock(f, '${filename}'); 108 | f.appendText('.'); 109 | })) 110 | .addToggle((toggle) => { 111 | this.bind(toggle, 'shouldRenameAttachmentFolder'); 112 | }); 113 | 114 | new SettingEx(this.containerEl) 115 | .setName('Should rename attachment files') 116 | .setDesc(createFragment((f) => { 117 | f.appendText('When renaming md files, automatically rename attachment files if file name contains '); 118 | // eslint-disable-next-line no-template-curly-in-string 119 | appendCodeBlock(f, '${filename}'); 120 | f.appendText('.'); 121 | })) 122 | .addToggle((toggle) => { 123 | this.bind(toggle, 'shouldRenameAttachmentFiles'); 124 | }); 125 | 126 | new SettingEx(this.containerEl) 127 | .setName('Special characters') 128 | .setDesc(createFragment((f) => { 129 | f.appendText('Special characters in attachment folder and file name to be replaced or removed.'); 130 | f.createEl('br'); 131 | f.appendText('Leave blank to preserve special characters.'); 132 | })) 133 | .addText((text) => { 134 | this.bind(text, 'specialCharacters', { 135 | componentToPluginSettingsValueConverter: (value: string): string => value.replaceAll(VISIBLE_WHITESPACE_CHARACTER, ''), 136 | pluginSettingsToComponentValueConverter: (value: string): string => value.replaceAll(' ', VISIBLE_WHITESPACE_CHARACTER), 137 | shouldResetSettingWhenComponentIsEmpty: false 138 | }); 139 | text.inputEl.addEventListener('input', () => { 140 | text.inputEl.value = showWhitespaceCharacter(text.inputEl.value); 141 | }); 142 | }); 143 | 144 | new SettingEx(this.containerEl) 145 | .setName('Special characters replacement') 146 | .setDesc(createFragment((f) => { 147 | f.appendText('Replacement string for special characters in attachment folder and file name.'); 148 | f.createEl('br'); 149 | f.appendText('Leave blank to remove special characters.'); 150 | })) 151 | .addText((text) => { 152 | this.bind(text, 'specialCharactersReplacement', { 153 | shouldResetSettingWhenComponentIsEmpty: false 154 | }); 155 | }); 156 | 157 | new SettingEx(this.containerEl) 158 | .setName('Should rename attachments to lowercase') 159 | .setDesc('Automatically set all characters in folder name and pasted image name to be lowercase.') 160 | .addToggle((toggle) => { 161 | this.bind(toggle, 'shouldRenameAttachmentsToLowerCase'); 162 | }); 163 | 164 | new SettingEx(this.containerEl) 165 | .setName('Should convert pasted images to JPEG') 166 | .setDesc('Paste images from clipboard converting them to JPEG.') 167 | .addToggle((toggle) => { 168 | this.bind(toggle, 'shouldConvertPastedImagesToJpeg'); 169 | }); 170 | 171 | new SettingEx(this.containerEl) 172 | .setName('JPEG Quality') 173 | .setDesc('The smaller the quality, the greater the compression ratio.') 174 | .addDropdown((dropDown) => { 175 | dropDown.addOptions(generateJpegQualityOptions()); 176 | this.bind(dropDown, 'jpegQuality', { 177 | componentToPluginSettingsValueConverter: (value) => Number(value), 178 | pluginSettingsToComponentValueConverter: (value) => value.toString() 179 | }); 180 | }); 181 | 182 | new SettingEx(this.containerEl) 183 | .setName('Should rename collected attachments') 184 | .setDesc(createFragment((f) => { 185 | f.appendText('If enabled, attachments processed via '); 186 | appendCodeBlock(f, 'Collect attachments'); 187 | f.appendText(' commands will be renamed according to the '); 188 | appendCodeBlock(f, 'Pasted File Name'); 189 | f.appendText(' setting.'); 190 | })) 191 | .addToggle((toggle) => { 192 | this.bind(toggle, 'shouldRenameCollectedAttachments'); 193 | }); 194 | 195 | new SettingEx(this.containerEl) 196 | .setName('Duplicate name separator') 197 | .setDesc(createFragment((f) => { 198 | f.appendText('When you are pasting/dragging a file with the same name as an existing file, this separator will be added to the file name.'); 199 | f.createEl('br'); 200 | f.appendText('E.g., when you are dragging file '); 201 | appendCodeBlock(f, 'existingFile.pdf'); 202 | f.appendText(', it will be renamed to '); 203 | appendCodeBlock(f, 'existingFile 1.pdf'); 204 | f.appendText(', '); 205 | appendCodeBlock(f, 'existingFile 2.pdf'); 206 | f.appendText(', etc, getting the first name available.'); 207 | })) 208 | .addText((text) => { 209 | this.bind(text, 'duplicateNameSeparator', { 210 | componentToPluginSettingsValueConverter: (value: string) => value.replaceAll(VISIBLE_WHITESPACE_CHARACTER, ' '), 211 | pluginSettingsToComponentValueConverter: showWhitespaceCharacter 212 | }); 213 | text.inputEl.addEventListener('input', () => { 214 | text.inputEl.value = showWhitespaceCharacter(text.inputEl.value); 215 | }); 216 | }); 217 | 218 | new SettingEx(this.containerEl) 219 | .setName('Empty attachment folder behavior') 220 | .setDesc(createFragment((f) => { 221 | f.appendText('When the attachment folder becomes empty, '); 222 | f.createEl('br'); 223 | appendCodeBlock(f, 'Keep'); 224 | f.appendText(' - will keep the empty attachment folder, '); 225 | f.createEl('br'); 226 | appendCodeBlock(f, 'Delete'); 227 | f.appendText(' - will delete the empty attachment folder, '); 228 | f.createEl('br'); 229 | appendCodeBlock(f, 'Delete with empty parents'); 230 | f.appendText(' - will delete the empty attachment folder and its empty parent folders.'); 231 | })) 232 | .addDropdown((dropdown) => { 233 | dropdown.addOptions({ 234 | /* eslint-disable perfectionist/sort-objects */ 235 | [EmptyAttachmentFolderBehavior.Keep]: 'Keep', 236 | [EmptyAttachmentFolderBehavior.Delete]: 'Delete', 237 | [EmptyAttachmentFolderBehavior.DeleteWithEmptyParents]: 'Delete with empty parents' 238 | /* eslint-enable perfectionist/sort-objects */ 239 | }); 240 | this.bind(dropdown, 'emptyAttachmentFolderBehavior', { 241 | componentToPluginSettingsValueConverter: (value) => getEnumValue(EmptyAttachmentFolderBehavior, value), 242 | pluginSettingsToComponentValueConverter: (value) => getEnumKey(EmptyAttachmentFolderBehavior, value) 243 | }); 244 | }); 245 | 246 | new SettingEx(this.containerEl) 247 | .setName('Should delete orphan attachments') 248 | .setDesc('If enabled, when the note is deleted, its orphan attachments are deleted as well.') 249 | .addToggle((toggle) => { 250 | this.bind(toggle, 'shouldDeleteOrphanAttachments'); 251 | }); 252 | 253 | new SettingEx(this.containerEl) 254 | .setName('Include paths') 255 | .setDesc(createFragment((f) => { 256 | f.appendText('Include notes from the following paths'); 257 | f.createEl('br'); 258 | f.appendText('Insert each path on a new line'); 259 | f.createEl('br'); 260 | f.appendText('You can use path string or '); 261 | appendCodeBlock(f, '/regular expression/'); 262 | f.createEl('br'); 263 | f.appendText('If the setting is empty, all notes are included'); 264 | })) 265 | .addMultipleText((multipleText) => { 266 | this.bind(multipleText, 'includePaths'); 267 | }); 268 | 269 | new SettingEx(this.containerEl) 270 | .setName('Exclude paths') 271 | .setDesc(createFragment((f) => { 272 | f.appendText('Exclude notes from the following paths'); 273 | f.createEl('br'); 274 | f.appendText('Insert each path on a new line'); 275 | f.createEl('br'); 276 | f.appendText('You can use path string or '); 277 | appendCodeBlock(f, '/regular expression/'); 278 | f.createEl('br'); 279 | f.appendText('If the setting is empty, no notes are excluded'); 280 | })) 281 | .addMultipleText((multipleText) => { 282 | this.bind(multipleText, 'excludePaths'); 283 | }); 284 | 285 | new SettingEx(this.containerEl) 286 | .setName('Custom tokens') 287 | .setDesc(createFragment((f) => { 288 | f.appendText('Custom tokens to be used in the attachment folder path and pasted file name.'); 289 | f.createEl('br'); 290 | f.appendText('See '); 291 | f.createEl('a', { href: 'https://github.com/RainCat1998/obsidian-custom-attachment-location?tab=readme-ov-file#custom-tokens', text: 'documentation' }); 292 | f.appendText(' for more information.'); 293 | })) 294 | .addCodeHighlighter((codeHighlighter) => { 295 | codeHighlighter.setLanguage('javascript'); 296 | codeHighlighter.inputEl.addClass('custom-tokens-setting-control'); 297 | this.bind(codeHighlighter, 'customTokensStr'); 298 | codeHighlighter.setPlaceholder(`exports.myCustomToken1 = (substitutions, format) => { 299 | return substitutions.fileName + substitutions.app.appId + format; 300 | }; 301 | 302 | exports.myCustomToken2 = async (substitutions, format) => { 303 | return await Promise.resolve( 304 | substitutions.fileName + substitutions.app.appId + format 305 | ); 306 | };`); 307 | }); 308 | 309 | new SettingEx(this.containerEl) 310 | .setName('Treat as attachment extensions') 311 | .setDesc(createFragment((f) => { 312 | f.appendText('Treat files with these extensions as attachments.'); 313 | f.createEl('br'); 314 | f.appendText('By default, '); 315 | appendCodeBlock(f, '.md'); 316 | f.appendText(' and '); 317 | appendCodeBlock(f, '.canvas'); 318 | f.appendText(' linked files are not treated as attachments and are not moved with the note.'); 319 | f.createEl('br'); 320 | f.appendText('You can add custom extensions, e.g. '); 321 | appendCodeBlock(f, '.foo.md'); 322 | f.appendText(', '); 323 | appendCodeBlock(f, '.bar.canvas'); 324 | f.appendText(', to override this behavior.'); 325 | })) 326 | .addMultipleText((multipleText) => { 327 | this.bind(multipleText, 'treatAsAttachmentExtensions'); 328 | }); 329 | } 330 | } 331 | 332 | function generateJpegQualityOptions(): Record { 333 | const MAX_QUALITY = 10; 334 | const ans: Record = {}; 335 | for (let i = 1; i <= MAX_QUALITY; i++) { 336 | const valueStr = (i / MAX_QUALITY).toFixed(1); 337 | ans[valueStr] = valueStr; 338 | } 339 | 340 | return ans; 341 | } 342 | 343 | function showWhitespaceCharacter(value: string): string { 344 | return value.replaceAll(' ', VISIBLE_WHITESPACE_CHARACTER); 345 | } 346 | -------------------------------------------------------------------------------- /src/PluginTypes.ts: -------------------------------------------------------------------------------- 1 | import type { PluginTypesBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginTypesBase'; 2 | 3 | import type { Plugin } from './Plugin.ts'; 4 | import type { PluginSettings } from './PluginSettings.ts'; 5 | import type { PluginSettingsManager } from './PluginSettingsManager.ts'; 6 | import type { PluginSettingsTab } from './PluginSettingsTab.ts'; 7 | 8 | export interface PluginTypes extends PluginTypesBase { 9 | plugin: Plugin; 10 | pluginSettings: PluginSettings; 11 | pluginSettingsManager: PluginSettingsManager; 12 | pluginSettingsTab: PluginSettingsTab; 13 | } 14 | -------------------------------------------------------------------------------- /src/PrismComponent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | loadPrism 4 | } from 'obsidian'; 5 | import { invokeAsyncSafely } from 'obsidian-dev-utils/Async'; 6 | 7 | export const TOKENIZED_STRING_LANGUAGE = 'custom-attachment-location-tokenized-string'; 8 | 9 | export class PrismComponent extends Component { 10 | public override onload(): void { 11 | super.onload(); 12 | invokeAsyncSafely(this.initPrism.bind(this)); 13 | } 14 | 15 | private async initPrism(): Promise { 16 | const prism = await loadPrism(); 17 | prism.languages[TOKENIZED_STRING_LANGUAGE] = { 18 | expression: { 19 | greedy: true, 20 | inside: { 21 | format: { 22 | alias: 'number', 23 | pattern: /[a-zA-Z0-9_]+/ 24 | }, 25 | formatDelimiter: { 26 | alias: 'regex', 27 | pattern: /:/ 28 | }, 29 | prefix: { 30 | alias: 'regex', 31 | pattern: /[${}]/ 32 | }, 33 | token: { 34 | alias: 'string', 35 | pattern: /^[a-zA-Z0-9_]+/ 36 | } 37 | }, 38 | pattern: /\${[a-zA-Z0-9_]+(?::[a-zA-Z0-9_]+)?}/ 39 | }, 40 | important: { 41 | pattern: /^\./ 42 | }, 43 | operator: { 44 | alias: 'entity', 45 | pattern: /\// 46 | } 47 | }; 48 | 49 | this.register(() => { 50 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 51 | delete prism.languages[TOKENIZED_STRING_LANGUAGE]; 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Substitutions.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | App, 3 | TFile 4 | } from 'obsidian'; 5 | import type { Promisable } from 'type-fest'; 6 | 7 | import moment from 'moment'; 8 | import { getNestedPropertyValue } from 'obsidian-dev-utils/Object'; 9 | import { getFileOrNull } from 'obsidian-dev-utils/obsidian/FileSystem'; 10 | import { prompt } from 'obsidian-dev-utils/obsidian/Modals/Prompt'; 11 | import { 12 | basename, 13 | dirname, 14 | extname 15 | } from 'obsidian-dev-utils/Path'; 16 | import { 17 | replaceAll, 18 | replaceAllAsync, 19 | trimEnd, 20 | trimStart 21 | } from 'obsidian-dev-utils/String'; 22 | 23 | type Formatter = (substitutions: Substitutions, format: string) => Promisable; 24 | 25 | const MORE_THAN_TWO_DOTS_REG_EXP = /^\.{3,}$/; 26 | const TRAILING_DOTS_AND_SPACES_REG_EXP = /[. ]+$/; 27 | export const INVALID_FILENAME_PATH_CHARS_REG_EXP = /[\\/:*?"<>|]/; 28 | export const SUBSTITUTION_TOKEN_REG_EXP = /\${(?.+?)(?::(?.+?))?}/g; 29 | 30 | export function getCustomTokenFormatters(customTokensStr: string): Map | null { 31 | const formatters = new Map(); 32 | try { 33 | // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func 34 | const customTokenInitFn = new Function('exports', customTokensStr) as (exports: object) => void; 35 | const exports = {}; 36 | customTokenInitFn(exports); 37 | for (const [token, formatter] of Object.entries(exports)) { 38 | formatters.set(token, formatter as Formatter); 39 | } 40 | return formatters; 41 | } catch (e) { 42 | throw new Error('Error initializing custom token formatters', { cause: e }); 43 | } 44 | } 45 | 46 | function formatDate(format: string): string { 47 | return moment().format(format); 48 | } 49 | 50 | function formatFileDate(app: App, filePath: string, format: string, getTimestamp: (file: TFile) => number): string { 51 | const file = getFileOrNull(app, filePath); 52 | if (!file) { 53 | return ''; 54 | } 55 | return moment(getTimestamp(file)).format(format); 56 | } 57 | 58 | function generateRandomDigit(): string { 59 | return generateRandomSymbol('0123456789'); 60 | } 61 | 62 | function generateRandomDigitOrLetter(): string { 63 | return generateRandomSymbol('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'); 64 | } 65 | 66 | function generateRandomLetter(): string { 67 | return generateRandomSymbol('ABCDEFGHIJKLMNOPQRSTUVWXYZ'); 68 | } 69 | 70 | function generateUuid(): string { 71 | return crypto.randomUUID(); 72 | } 73 | 74 | function getFrontmatterValue(app: App, filePath: string, key: string): string { 75 | const file = getFileOrNull(app, filePath); 76 | if (!file) { 77 | return ''; 78 | } 79 | 80 | const cache = app.metadataCache.getFileCache(file); 81 | 82 | if (!cache?.frontmatter) { 83 | return ''; 84 | } 85 | 86 | const value = getNestedPropertyValue(cache.frontmatter, key) ?? ''; 87 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 88 | return String(value); 89 | } 90 | 91 | export class Substitutions { 92 | private static readonly formatters = new Map(); 93 | 94 | static { 95 | this.registerCustomFormatters(''); 96 | } 97 | 98 | public readonly fileName: string; 99 | 100 | public readonly folderName: string; 101 | public readonly folderPath: string; 102 | public readonly originalCopiedFileExtension: string; 103 | public constructor(private readonly app: App, private readonly filePath: string, private readonly originalCopiedFileName = '') { 104 | this.fileName = basename(filePath, extname(filePath)); 105 | this.folderName = basename(dirname(filePath)); 106 | this.folderPath = dirname(filePath); 107 | 108 | const originalCopiedFileExtension = extname(originalCopiedFileName); 109 | this.originalCopiedFileName = basename(originalCopiedFileName, originalCopiedFileExtension); 110 | this.originalCopiedFileExtension = originalCopiedFileExtension.slice(1); 111 | } 112 | 113 | public static isRegisteredToken(token: string): boolean { 114 | return Substitutions.formatters.has(token.toLowerCase()); 115 | } 116 | 117 | public static registerCustomFormatters(customTokensStr: string): void { 118 | this.formatters.clear(); 119 | this.registerFormatter('date', (_substitutions, format) => formatDate(format)); 120 | this.registerFormatter( 121 | 'fileCreationDate', 122 | (substitutions, format) => formatFileDate(substitutions.app, substitutions.filePath, format, (file) => file.stat.ctime) 123 | ); 124 | this.registerFormatter( 125 | 'fileModificationDate', 126 | (substitutions, format) => formatFileDate(substitutions.app, substitutions.filePath, format, (file) => file.stat.mtime) 127 | ); 128 | this.registerFormatter('fileName', (substitutions) => substitutions.fileName); 129 | this.registerFormatter('filePath', (substitutions) => substitutions.filePath); 130 | this.registerFormatter('folderName', (substitutions) => substitutions.folderName); 131 | this.registerFormatter('folderPath', (substitutions) => substitutions.folderPath); 132 | this.registerFormatter('frontmatter', (substitutions, key) => getFrontmatterValue(substitutions.app, substitutions.filePath, key)); 133 | this.registerFormatter('originalCopiedFileExtension', (substitutions) => substitutions.originalCopiedFileExtension); 134 | this.registerFormatter('originalCopiedFileName', (substitutions) => substitutions.originalCopiedFileName); 135 | this.registerFormatter('prompt', (substitutions) => substitutions.prompt()); 136 | this.registerFormatter('randomDigit', () => generateRandomDigit()); 137 | this.registerFormatter('randomDigitOrLetter', () => generateRandomDigitOrLetter()); 138 | this.registerFormatter('randomLetter', () => generateRandomLetter()); 139 | this.registerFormatter('uuid', () => generateUuid()); 140 | 141 | const customFormatters = getCustomTokenFormatters(customTokensStr) ?? new Map(); 142 | for (const [token, formatter] of customFormatters.entries()) { 143 | this.registerFormatter(token, formatter); 144 | } 145 | } 146 | 147 | private static registerFormatter(token: string, formatter: Formatter): void { 148 | this.formatters.set(token.toLowerCase(), formatter); 149 | } 150 | 151 | public async fillTemplate(template: string): Promise { 152 | return await replaceAllAsync(template, SUBSTITUTION_TOKEN_REG_EXP, async (_, token, format) => { 153 | const formatter = Substitutions.formatters.get(token.toLowerCase()); 154 | if (!formatter) { 155 | throw new Error(`Invalid token: ${token}`); 156 | } 157 | 158 | try { 159 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 160 | return String(await formatter(this, format) ?? ''); 161 | } catch (e) { 162 | throw new Error(`Error formatting token \${${token}}`, { cause: e }); 163 | } 164 | }); 165 | } 166 | 167 | private async prompt(): Promise { 168 | const promptResult = await prompt({ 169 | app: this.app, 170 | defaultValue: this.originalCopiedFileName, 171 | // eslint-disable-next-line no-template-curly-in-string 172 | title: 'Provide a value for ${prompt} template', 173 | valueValidator: (value) => validateFilename(value, false) 174 | }); 175 | if (promptResult === null) { 176 | throw new Error('Prompt cancelled'); 177 | } 178 | return promptResult; 179 | } 180 | } 181 | 182 | export function validateFilename(filename: string, areTokensAllowed = true): string { 183 | if (areTokensAllowed) { 184 | filename = removeTokenFormatting(filename); 185 | const unknownToken = validateTokens(filename); 186 | if (unknownToken) { 187 | return `Unknown token: ${unknownToken}`; 188 | } 189 | } else { 190 | const match = filename.match(SUBSTITUTION_TOKEN_REG_EXP); 191 | if (match) { 192 | return 'Tokens are not allowed in file name'; 193 | } 194 | } 195 | 196 | if (filename === '.' || filename === '..') { 197 | return ''; 198 | } 199 | 200 | if (!filename) { 201 | return 'File name is empty'; 202 | } 203 | 204 | if (INVALID_FILENAME_PATH_CHARS_REG_EXP.test(filename)) { 205 | return `File name "${filename}" contains invalid symbols`; 206 | } 207 | 208 | if (MORE_THAN_TWO_DOTS_REG_EXP.test(filename)) { 209 | return `File name "${filename}" contains more than two dots`; 210 | } 211 | 212 | if (TRAILING_DOTS_AND_SPACES_REG_EXP.test(filename)) { 213 | return `File name "${filename}" contains trailing dots or spaces`; 214 | } 215 | 216 | return ''; 217 | } 218 | 219 | export function validatePath(path: string, areTokensAllowed = true): string { 220 | if (areTokensAllowed) { 221 | path = removeTokenFormatting(path); 222 | const unknownToken = validateTokens(path); 223 | if (unknownToken) { 224 | return `Unknown token: ${unknownToken}`; 225 | } 226 | } else { 227 | const match = path.match(SUBSTITUTION_TOKEN_REG_EXP); 228 | if (match) { 229 | return 'Tokens are not allowed in path'; 230 | } 231 | } 232 | 233 | path = trimStart(path, '/'); 234 | path = trimEnd(path, '/'); 235 | 236 | if (path === '') { 237 | return ''; 238 | } 239 | 240 | const parts = path.split('/'); 241 | for (const part of parts) { 242 | const partValidationError = validateFilename(part); 243 | 244 | if (partValidationError) { 245 | return partValidationError; 246 | } 247 | } 248 | 249 | return ''; 250 | } 251 | 252 | function generateRandomSymbol(symbols: string): string { 253 | return symbols[Math.floor(Math.random() * symbols.length)] ?? ''; 254 | } 255 | 256 | function removeTokenFormatting(str: string): string { 257 | return replaceAll(str, SUBSTITUTION_TOKEN_REG_EXP, (_, token) => `\${${token}}`); 258 | } 259 | 260 | function validateTokens(str: string): null | string { 261 | const matches = str.matchAll(SUBSTITUTION_TOKEN_REG_EXP); 262 | for (const match of matches) { 263 | const token = match[1] ?? ''; 264 | if (!Substitutions.isRegisteredToken(token)) { 265 | return token; 266 | } 267 | } 268 | return null; 269 | } 270 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import './styles/main.scss'; 2 | import { Plugin } from './Plugin.ts'; 3 | 4 | // eslint-disable-next-line import-x/no-default-export 5 | export default Plugin; 6 | -------------------------------------------------------------------------------- /src/styles/main.scss: -------------------------------------------------------------------------------- 1 | .obsidian-custom-attachment-location { 2 | &.obsidian-dev-utils.code-highlighter-component textarea.tokenized-string-setting-control { 3 | height: 6em; 4 | } 5 | 6 | &.obsidian-dev-utils.multiple-text-component textarea { 7 | height: 6em; 8 | width: 20em; 9 | } 10 | 11 | &.obsidian-dev-utils.code-highlighter-component textarea.custom-tokens-setting-control { 12 | height: 18em; 13 | width: 36em; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/strictest/tsconfig.json", 3 | "compilerOptions": { 4 | "allowImportingTsExtensions": true, 5 | "allowJs": true, 6 | "allowSyntheticDefaultImports": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "importHelpers": true, 9 | "inlineSourceMap": true, 10 | "inlineSources": true, 11 | "isolatedModules": true, 12 | "lib": [ 13 | "DOM", 14 | "ESNext" 15 | ], 16 | "module": "NodeNext", 17 | "moduleResolution": "NodeNext", 18 | "noEmit": true, 19 | "noImplicitAny": true, 20 | "skipLibCheck": false, 21 | "strictNullChecks": true, 22 | "target": "ESNext", 23 | "types": [ 24 | "node", 25 | "obsidian-typings" 26 | ], 27 | "verbatimModuleSyntax": true 28 | }, 29 | "include": [ 30 | "./eslint.config.*ts", 31 | "./src/**/*.svelte", 32 | "./src/**/*.ts", 33 | "./src/**/*.tsx", 34 | "./scripts/**/*.ts" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.9": "0.12.17", 3 | "0.0.8": "0.12.17", 4 | "0.0.7": "0.12.17", 5 | "0.0.6": "0.12.17", 6 | "0.0.5": "0.12.17", 7 | "0.0.4": "0.12.17", 8 | "0.0.3": "0.12.17", 9 | "0.0.2": "0.12.17", 10 | "0.0.1": "0.12.17", 11 | "1.0.0": "1.6.7", 12 | "1.0.1": "1.6.7", 13 | "1.0.2": "1.6.7", 14 | "1.0.3": "1.6.7", 15 | "1.1.0": "1.6.7", 16 | "1.2.0": "1.6.7", 17 | "1.3.0": "1.6.7", 18 | "1.3.1": "1.6.7", 19 | "2.0.0": "1.6.7", 20 | "2.1.0": "1.6.7", 21 | "3.0.0": "1.6.7", 22 | "3.1.0": "1.6.7", 23 | "3.2.0": "1.6.7", 24 | "3.3.0": "1.6.7", 25 | "3.4.0": "1.6.7", 26 | "3.5.0": "1.6.7", 27 | "3.6.0": "1.6.7", 28 | "3.7.0": "1.6.7", 29 | "3.8.0": "1.6.7", 30 | "4.0.0": "1.6.7", 31 | "4.1.0": "1.6.7", 32 | "4.2.0": "1.6.7", 33 | "4.2.1": "1.6.7", 34 | "4.3.0": "1.6.7", 35 | "4.3.1": "1.6.7", 36 | "4.3.2": "1.6.7", 37 | "4.3.3": "1.6.7", 38 | "4.4.0": "1.6.7", 39 | "4.5.0": "1.6.7", 40 | "4.6.0": "1.6.7", 41 | "4.7.0": "1.6.7", 42 | "4.8.0": "1.6.7", 43 | "4.9.0": "1.6.7", 44 | "4.9.1": "1.6.7", 45 | "4.9.2": "1.6.7", 46 | "4.9.3": "1.6.7", 47 | "4.9.4": "1.6.7", 48 | "4.10.0": "1.6.7", 49 | "4.11.0": "1.6.7", 50 | "4.12.0": "1.6.7", 51 | "4.12.1": "1.6.7", 52 | "4.12.2": "1.6.7", 53 | "4.13.0": "1.6.7", 54 | "4.14.0": "1.6.7", 55 | "4.15.0": "1.6.7", 56 | "4.16.0": "1.6.7", 57 | "4.17.0": "1.6.7", 58 | "4.18.0": "1.6.7", 59 | "4.19.0": "1.6.7", 60 | "4.20.0": "1.6.7", 61 | "4.21.0": "1.6.7", 62 | "4.22.0": "1.6.7", 63 | "4.22.1": "1.6.7", 64 | "4.23.0": "1.6.7", 65 | "4.23.1": "1.6.7", 66 | "4.23.2": "1.6.7", 67 | "4.24.0": "1.7.4", 68 | "4.25.0": "1.7.4", 69 | "4.26.0": "1.7.4", 70 | "4.27.0": "1.7.4", 71 | "4.27.1": "1.7.4", 72 | "4.27.2": "1.7.4", 73 | "4.27.3": "1.7.4", 74 | "4.27.4": "1.7.4", 75 | "4.27.5": "1.7.4", 76 | "4.27.6": "1.7.6", 77 | "4.28.0": "1.7.6", 78 | "4.28.1": "1.7.6", 79 | "4.28.2": "1.7.7", 80 | "4.28.3": "1.7.7", 81 | "4.28.4": "1.7.7", 82 | "4.28.5": "1.7.7", 83 | "4.29.0": "1.7.7", 84 | "4.29.1": "1.7.7", 85 | "4.30.0": "1.7.7", 86 | "4.30.1": "1.7.7", 87 | "4.30.2": "1.7.7", 88 | "4.30.3": "1.7.7", 89 | "4.30.4": "1.7.7", 90 | "4.30.5": "1.7.7", 91 | "4.30.6": "1.7.7", 92 | "4.31.0": "1.7.7", 93 | "4.31.1": "1.7.7", 94 | "5.0.0": "1.7.7", 95 | "5.0.1": "1.7.7", 96 | "5.0.2": "1.7.7", 97 | "5.1.0": "1.7.7", 98 | "5.1.1": "1.7.7", 99 | "5.1.2": "1.7.7", 100 | "5.1.3": "1.7.7", 101 | "5.1.4": "1.7.7", 102 | "5.1.5": "1.7.7", 103 | "5.1.6": "1.8.3", 104 | "5.1.7": "1.8.4", 105 | "6.0.0": "1.8.4", 106 | "6.0.1": "1.8.4", 107 | "6.0.2": "1.8.4", 108 | "7.0.0": "1.8.7", 109 | "7.0.1": "1.8.7", 110 | "7.0.2": "1.8.7", 111 | "7.0.3": "1.8.9", 112 | "7.0.4": "1.8.9", 113 | "7.0.5": "1.8.9", 114 | "7.1.0": "1.8.9", 115 | "7.2.0": "1.8.9", 116 | "7.2.1": "1.8.9", 117 | "7.2.2": "1.8.9", 118 | "7.2.3": "1.8.9", 119 | "7.2.4": "1.8.9", 120 | "7.2.5": "1.8.9", 121 | "7.2.6": "1.8.9", 122 | "7.3.0": "1.8.9", 123 | "7.4.0": "1.8.9", 124 | "7.4.1": "1.8.9", 125 | "7.4.2": "1.8.10", 126 | "7.4.3": "1.8.10", 127 | "7.5.0": "1.8.10", 128 | "7.6.0": "1.8.10", 129 | "7.6.1": "1.8.10", 130 | "7.7.0": "1.8.10", 131 | "7.7.1": "1.8.10", 132 | "7.7.2": "1.8.10", 133 | "7.7.3": "1.8.10", 134 | "7.7.4": "1.8.10", 135 | "7.7.5": "1.8.10", 136 | "7.7.6": "1.8.10", 137 | "7.8.0": "1.8.10", 138 | "7.8.1": "1.8.10" 139 | } 140 | --------------------------------------------------------------------------------