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