├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── OnDemandRender.svelte ├── README.md ├── ScrollView.ts ├── ScrollViewComponent.svelte ├── ScrollViewMarkdownComponent.svelte ├── TagFolderList.ts ├── TagFolderView.ts ├── TagFolderViewBase.ts ├── TagFolderViewComponent.svelte ├── V2TreeFolderComponent.svelte ├── V2TreeItemComponent.svelte ├── dialog.ts ├── esbuild.config.mjs ├── eslint.config.mjs ├── images ├── respect-nestedtag-1.png ├── respect-nestedtag-2.png ├── screenshot.png └── simplecase.png ├── main.ts ├── manifest.json ├── package-lock.json ├── package.json ├── store.ts ├── styles.css ├── svelte.config.js ├── tsconfig.json ├── types.ts ├── updates.md ├── util.ts ├── v2codebehind.ts └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | insert_final_newline = true 7 | indent_style = tab 8 | indent_size = 4 9 | tab_width = 4 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: vrtmrz 4 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian Plugin 2 | on: 3 | push: 4 | # Sequence of patterns matched against refs/tags 5 | tags: 6 | - '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 # otherwise, you will failed to push refs to dest repo 16 | submodules: recursive 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: '18.x' # You might need to adjust this value to your own version 21 | # Get the version number and put it in a variable 22 | - name: Get Version 23 | id: version 24 | run: | 25 | echo "::set-output name=tag::$(git describe --abbrev=0 --tags)" 26 | # Build the plugin 27 | - name: Build 28 | id: build 29 | run: | 30 | npm ci 31 | npm run build --if-present 32 | # Package the required files into a zip 33 | - name: Package 34 | run: | 35 | mkdir ${{ github.event.repository.name }} 36 | cp main.js manifest.json styles.css README.md ${{ github.event.repository.name }} 37 | zip -r ${{ github.event.repository.name }}.zip ${{ github.event.repository.name }} 38 | # Create the release on github 39 | - name: Create Release 40 | id: create_release 41 | uses: actions/create-release@v1 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | VERSION: ${{ github.ref }} 45 | with: 46 | tag_name: ${{ github.ref }} 47 | release_name: ${{ github.ref }} 48 | draft: true 49 | prerelease: false 50 | # Upload the packaged release file 51 | - name: Upload zip file 52 | id: upload-zip 53 | uses: actions/upload-release-asset@v1 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | with: 57 | upload_url: ${{ steps.create_release.outputs.upload_url }} 58 | asset_path: ./${{ github.event.repository.name }}.zip 59 | asset_name: ${{ github.event.repository.name }}-${{ steps.version.outputs.tag }}.zip 60 | asset_content_type: application/zip 61 | # Upload the main.js 62 | - name: Upload main.js 63 | id: upload-main 64 | uses: actions/upload-release-asset@v1 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | with: 68 | upload_url: ${{ steps.create_release.outputs.upload_url }} 69 | asset_path: ./main.js 70 | asset_name: main.js 71 | asset_content_type: text/javascript 72 | # Upload the manifest.json 73 | - name: Upload manifest.json 74 | id: upload-manifest 75 | uses: actions/upload-release-asset@v1 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | with: 79 | upload_url: ${{ steps.create_release.outputs.upload_url }} 80 | asset_path: ./manifest.json 81 | asset_name: manifest.json 82 | asset_content_type: application/json 83 | # Upload the style.css 84 | - name: Upload styles.css 85 | id: upload-css 86 | uses: actions/upload-release-asset@v1 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | with: 90 | upload_url: ${{ steps.create_release.outputs.upload_url }} 91 | asset_path: ./styles.css 92 | asset_name: styles.css 93 | asset_content_type: text/css 94 | # TODO: release notes??? 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | # package-lock.json 11 | 12 | # Don't include the compiled main.js file in the repo. 13 | # They should be uploaded to GitHub releases instead. 14 | main.js 15 | main_org.js 16 | 17 | # Exclude sourcemaps 18 | *.map 19 | 20 | # obsidian 21 | data.json 22 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.js 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "printWidth": 120, 5 | "semi": true 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 vorotamoroz 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. -------------------------------------------------------------------------------- /OnDemandRender.svelte: -------------------------------------------------------------------------------- 1 | 55 | 56 |
57 | {@render children?.({ isVisible, })} 58 |
59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## TagFolder 2 | 3 | This is the plugin that shows your tags like folders. 4 | 5 | ![screenshot](images/screenshot.png) 6 | 7 | ### How to use 8 | 9 | Install this plugin, press `Ctrl+p`, and choose "Show Tag Folder". 10 | 11 | ### Behavior 12 | 13 | This plugin creates a tree by tags permutation. 14 | 15 | Like this, 16 | ### Simple case 17 | 18 | If you have docs, 19 | ``` 20 | Apple : #food #red #sweet 21 | Pear : #food #green #sweet 22 | Tuna : #food #red 23 | ``` 24 | ![](./images/simplecase.png) 25 | 26 | ...and more are shown. 27 | 28 | ### Case of respecting nested tags 29 | 30 | The nested tag works well for Tag Folder. 31 | 32 | Tag Folder respects nested tags and makes the dedicated hierarchy. The nested child doesn't leak out over the parent. 33 | 34 | ``` 35 | TagFolder Readme: #dev #readme #2021/12/10 #status/draft 36 | Technical information: #dev #note #2021/12/09 #status/draft 37 | SelfHosted LiveSync Readme : #dev #readme #2021/12/06 #status/proofread 38 | Old Note: #dev #readme #2021/12/10 #status/abandoned 39 | ``` 40 | #### Tag hierarchy of status 41 | 42 | ![](./images/respect-nestedtag-1.png) 43 | 44 | #### Tag hierarchy of date 45 | 46 | ![](./images/respect-nestedtag-2.png) 47 | 48 | 49 | #### Search tags 50 | You can search tags. like this: 51 | 52 | ``` 53 | sweet -red | food -sweet 54 | ``` 55 | When using this filter, this plugin shows only "Pear" (Sweet but not red) and "Tuna" (food but not sweet). 56 | 57 | ### Settings 58 | 59 | #### Behavior 60 | 61 | ##### Always Open 62 | 63 | Place TagFolder on the left pane and activate it at every Obsidian launch. 64 | 65 | ##### Use pinning 66 | We can pin the tag if we enable this option. 67 | When this feature is enabled, the pin information is saved in the file set in the next configuration. 68 | Pinned tags are sorted according to `key` in the frontmatter of `taginfo.md`. 69 | 70 | ##### Pin information file 71 | We can change the name of the file in which pin information is saved. 72 | This can be configured also from the context-menu. 73 | 74 | | Item | Meaning of the value | 75 | | -------- | ------------------------------------------------------------------------------------------------- | 76 | | key | If exists, the tag is pinned. | 77 | | mark | The label which is shown instead of `📌`. | 78 | | alt | The tag will be shown as this. But they will not be merged into the same one. No `#` is required. | 79 | | redirect | The tag will be redirected to the configured one and will be merged. No `#` is required. | 80 | 81 | ##### Disable narrowing down 82 | TagFolder creates the folder structure by collecting combinations of tags that are used in the same note, to make it easier for us to find notes. 83 | When this feature is enabled, collected combinations are no longer structured and show as we have organized them in a manner. 84 | 85 | 86 | #### Files 87 | 88 | ##### Display Method 89 | 90 | You can configure how the entry shows. 91 | 92 | ##### Order method 93 | 94 | You can order items by: 95 | - Displaying name 96 | - Filename 97 | - Modified time 98 | - Fullpath of the file 99 | 100 | ##### Use title 101 | 102 | When you enable this option, the value in the frontmatter or first level one heading will be shown instead of `NAME`. 103 | 104 | ##### Frontmatter path 105 | Dotted path to retrieve title from frontmatter. 106 | 107 | #### Tags 108 | 109 | ##### Order method 110 | 111 | You can order tags by: 112 | - Filename 113 | - Count of items 114 | 115 | ##### Use virtual tags 116 | 117 | When we enable this feature, our notes will be tagged as their freshness automatically. 118 | | Icon | Edited ... | 119 | | ---- | --------------------- | 120 | | 🕐 | Within an hour | 121 | | 📖 | Within 6 hours | 122 | | 📗 | Within 3 days | 123 | | 📚 | Within 7 days | 124 | | 🗄 | Older than 7 days ago | 125 | 126 | ##### Display folder as tag 127 | 128 | When we enable this feature, the folder will be shown as a tag. 129 | 130 | ##### Store tags in frontmatter for new notes 131 | 132 | This setting changes how tags are stored in new notes created by TagFolder. When disabled, tags are stored as #hashtags at the top of new notes. When enabled, tags are stored in the frontmatter and displayed in the note's Properties. 133 | 134 | #### Actions 135 | 136 | ##### Search tags inside TagFolder when clicking tags 137 | We can search tags inside TagFolder when clicking tags instead of opening the default search pane. 138 | With control and shift keys, we can remove the tag from the search condition or add an exclusion of it to that. 139 | 140 | ##### List files in a separated pane 141 | When enabled, files will be shown in a separated pane. 142 | 143 | #### Arrangements 144 | 145 | ##### Hide Items 146 | 147 | Configure hiding items. 148 | - Hide nothing 149 | - Only intermediates of nested tags 150 | - All intermediates 151 | 152 | If you have these items: 153 | ``` 154 | 2021-11-01 : #daily/2021/11 #status/summarized 155 | 2021-11-02 : #daily/2021/11 #status/summarized 156 | 2021-11-03 : #daily/2021/11 #status/jot 157 | 2021-12-01 : #daily/2021/12 #status/jot 158 | ``` 159 | 160 | This setting affects as like below. 161 | - Hide nothing 162 | 163 | ``` 164 | daily 165 | → 2021 166 | → 11 167 | status 168 | → jot 169 | 2021-11-03 170 | → summarized 171 | 2021-11-01 172 | 2021-11-02 173 | 2021-11-01 174 | 2021-11-02 175 | 2021-11-03 176 | 2021-11-01 177 | 2021-11-02 178 | 2021-11-03 179 | 2021-11-01 180 | 2021-11-02 181 | 2021-11-03 182 | 2021-12-01 183 | → 12 184 | : 185 | 2021-11-01 186 | 2021-11-02 187 | 2021-11-03 188 | 2021-12-01 189 | ``` 190 | 191 | - Only intermediates of nested tags 192 | Hide only intermediates of nested tags, so show items only on the last or break of the nested tags. 193 | ``` 194 | daily 195 | → 2021 196 | → 11 197 | status 198 | → jot 199 | 2021-11-03 200 | → summarized 201 | 2021-11-01 202 | 2021-11-02 203 | 2021-11-01 204 | 2021-11-02 205 | 2021-11-03 206 | → 12 207 | : 208 | ``` 209 | - All intermediates 210 | Hide all intermediates, so show items only deepest. 211 | ``` 212 | daily 213 | → 2021 214 | → 11 215 | status 216 | → jot 217 | 2021-11-03 218 | → summarized 219 | 2021-11-01 220 | 2021-11-02 221 | → 12 222 | : 223 | ``` 224 | 225 | ##### Merge redundant combinations 226 | When this feature is enabled, a/b and b/a are merged into a/b if there are no intermediates. 227 | 228 | ##### Do not simplify empty folders 229 | Keep empty folders, even if they can be simplified. 230 | 231 | ##### Do not treat nested tags as dedicated levels 232 | 233 | If you enable this option, every nested tag is split into normal tags. 234 | 235 | `#dev/TagFolder` will be treated like `#dev` and `#TagFolder`. 236 | 237 | ##### Reduce duplicated parents in nested tags 238 | 239 | If we have the doc (e.g., `example note`) with nested tags which have the same parents, like `#topic/calculus`, `#topic/electromagnetics`: 240 | 241 | - Disabled 242 | ``` 243 | topic 244 | - > calculus 245 | topic 246 | - > electromagnetics 247 | example note 248 | example note 249 | ``` 250 | - Enabled 251 | ``` 252 | topic 253 | - > calculus 254 | - > electromagnetics 255 | example note 256 | example note 257 | ``` 258 | 259 | #### Filters 260 | 261 | 262 | ##### Target Folders 263 | If we set this, the plugin will only target files in it. 264 | 265 | 266 | ##### Ignore Folders 267 | 268 | Ignore documents in specific folders. 269 | 270 | 271 | ##### Ignore note Tag 272 | 273 | If the note has the tag that is set in here, the note would be treated as there was not. 274 | 275 | ##### Ignore Tag 276 | 277 | Tags that were set here would be treated as there were not. 278 | 279 | ##### Archive tags 280 | -------------------------------------------------------------------------------- /ScrollView.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TFile, 3 | ItemView, 4 | WorkspaceLeaf, type ViewStateResult 5 | } from "obsidian"; 6 | import ScrollViewComponent from "./ScrollViewComponent.svelte"; 7 | import { 8 | type ScrollViewState, 9 | type ScrollViewFile, 10 | VIEW_TYPE_SCROLL 11 | } from "types"; 12 | import { writable, type Writable } from "svelte/store"; 13 | import TagFolderPlugin from "./main"; 14 | import { doEvents } from "./util"; 15 | import { mount, unmount } from "svelte"; 16 | 17 | // Show notes as like scroll. 18 | export class ScrollView extends ItemView { 19 | 20 | component?: ReturnType; 21 | plugin: TagFolderPlugin; 22 | icon = "sheets-in-box"; 23 | store: Writable; 24 | state: ScrollViewState = { files: [], title: "", tagPath: "" }; 25 | title: string = ""; 26 | navigation = true; 27 | 28 | getIcon(): string { 29 | return "sheets-in-box"; 30 | } 31 | 32 | constructor(leaf: WorkspaceLeaf, plugin: TagFolderPlugin) { 33 | super(leaf); 34 | this.plugin = plugin; 35 | this.store = writable({ files: [], title: "", tagPath: "" }); 36 | } 37 | 38 | 39 | getViewType() { 40 | return VIEW_TYPE_SCROLL; 41 | } 42 | 43 | getDisplayText() { 44 | return this.state.tagPath || "Tags scroll"; 45 | } 46 | 47 | async setFile(filenames: ScrollViewFile[]) { 48 | this.state = { ...this.state, files: filenames }; 49 | await this.updateView(); 50 | } 51 | 52 | async setState(state: ScrollViewState, result: ViewStateResult): Promise { 53 | this.state = { ...state }; 54 | this.title = state.title; 55 | await this.updateView(); 56 | result = { 57 | history: false 58 | }; 59 | return; 60 | } 61 | 62 | getState() { 63 | return this.state; 64 | } 65 | 66 | isFileOpened(path: string) { 67 | return this.state.files.some(e => e.path == path); 68 | } 69 | 70 | getScrollViewState(): ScrollViewState { 71 | return this.state; 72 | } 73 | 74 | async updateView() { 75 | //Load file content 76 | const items = [] as ScrollViewFile[]; 77 | for (const item of this.state.files) { 78 | if (item.content) { 79 | items.push(item); 80 | } else { 81 | const f = this.app.vault.getAbstractFileByPath(item.path); 82 | if (f == null || !(f instanceof TFile)) { 83 | console.log(`File not found:${item.path}`); 84 | items.push(item); 85 | continue; 86 | } 87 | const title = this.plugin.getFileTitle(f); 88 | const w = await this.app.vault.read(f); 89 | await doEvents(); 90 | item.content = w; 91 | item.title = title; 92 | items.push(item); 93 | } 94 | } 95 | 96 | this.state = { ...this.state, files: [...items] }; 97 | this.store.set(this.state); 98 | } 99 | 100 | async onOpen() { 101 | const app = mount(ScrollViewComponent, 102 | { 103 | target: this.contentEl, 104 | props: { 105 | store: this.store, 106 | openfile: this.plugin.focusFile, 107 | plugin: this.plugin 108 | }, 109 | }); 110 | this.component = app; 111 | return await Promise.resolve(); 112 | } 113 | 114 | async onClose() { 115 | if (this.component) { 116 | await unmount(this.component); 117 | this.component = undefined; 118 | } 119 | return await Promise.resolve(); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /ScrollViewComponent.svelte: -------------------------------------------------------------------------------- 1 | 73 | 74 |
75 |
76 | Files with {tagPath} 77 |
78 |
79 | {#each files as file} 80 | 81 | 82 |
handleOpenFile(evt, file)} 85 | bind:this={scrollEl} 86 | > 87 |
88 | {file.title} 89 | ({file.path}) 90 |
91 | 92 |
93 |
94 | {/each} 95 |
96 | 97 | 115 | -------------------------------------------------------------------------------- /ScrollViewMarkdownComponent.svelte: -------------------------------------------------------------------------------- 1 | 67 | 68 |
69 | 70 | 76 | -------------------------------------------------------------------------------- /TagFolderList.ts: -------------------------------------------------------------------------------- 1 | import { Menu, type ViewStateResult, WorkspaceLeaf } from "obsidian"; 2 | import TagFolderViewComponent from "./TagFolderViewComponent.svelte"; 3 | import { 4 | type TagFolderListState, 5 | VIEW_TYPE_TAGFOLDER_LIST 6 | } from "./types"; 7 | import TagFolderPlugin from "./main"; 8 | import { TagFolderViewBase } from "./TagFolderViewBase"; 9 | import { mount, unmount } from "svelte"; 10 | import { writable } from "svelte/store"; 11 | 12 | export class TagFolderList extends TagFolderViewBase { 13 | 14 | plugin: TagFolderPlugin; 15 | icon = "stacked-levels"; 16 | title: string = ""; 17 | 18 | onPaneMenu(menu: Menu, source: string): void { 19 | super.onPaneMenu(menu, source); 20 | menu.addItem(item => { 21 | item.setIcon("pin") 22 | .setTitle("Pin") 23 | .onClick(() => { 24 | this.leaf.togglePinned(); 25 | }) 26 | }) 27 | } 28 | 29 | getIcon(): string { 30 | return "stacked-levels"; 31 | } 32 | 33 | state: TagFolderListState = { tags: [], title: "" }; 34 | 35 | async setState(state: TagFolderListState, result: ViewStateResult): Promise { 36 | this.state = { ...this.state, ...state }; 37 | this.title = state.tags.join(","); 38 | this.stateStore.set(this.state); 39 | result = { 40 | history: false 41 | }; 42 | return await Promise.resolve(); 43 | } 44 | stateStore = writable(this.state); 45 | 46 | getState() { 47 | return this.state; 48 | } 49 | 50 | constructor(leaf: WorkspaceLeaf, plugin: TagFolderPlugin) { 51 | super(leaf); 52 | this.plugin = plugin; 53 | 54 | this.showMenu = this.showMenu.bind(this); 55 | this.showOrder = this.showOrder.bind(this); 56 | this.newNote = this.newNote.bind(this); 57 | this.showLevelSelect = this.showLevelSelect.bind(this); 58 | this.switchView = this.switchView.bind(this); 59 | } 60 | 61 | async newNote(evt: MouseEvent) { 62 | await this.plugin.createNewNote(this.state.tags); 63 | } 64 | 65 | getViewType() { 66 | return VIEW_TYPE_TAGFOLDER_LIST; 67 | } 68 | 69 | getDisplayText() { 70 | return `Files with ${this.state.title}`; 71 | } 72 | 73 | async onOpen() { 74 | this.containerEl.empty(); 75 | this.component = mount(TagFolderViewComponent, { 76 | target: this.containerEl, 77 | props: { 78 | openFile: this.plugin.focusFile, 79 | hoverPreview: this.plugin.hoverPreview, 80 | title: "", 81 | showMenu: this.showMenu, 82 | showLevelSelect: this.showLevelSelect, 83 | showOrder: this.showOrder, 84 | newNote: this.newNote, 85 | openScrollView: this.plugin.openScrollView, 86 | isViewSwitchable: this.plugin.settings.useMultiPaneList, 87 | switchView: this.switchView, 88 | saveSettings: this.saveSettings.bind(this), 89 | stateStore: this.stateStore, 90 | }, 91 | }); 92 | return await Promise.resolve(); 93 | } 94 | 95 | async onClose() { 96 | if (this.component) { 97 | await unmount(this.component); 98 | this.component = undefined!; 99 | } 100 | return await Promise.resolve(); 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /TagFolderView.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceLeaf, type ViewState } from "obsidian"; 2 | import TagFolderViewComponent from "./TagFolderViewComponent.svelte"; 3 | import { VIEW_TYPE_TAGFOLDER, type TREE_TYPE, VIEW_TYPE_TAGFOLDER_LINK } from "./types"; 4 | import TagFolderPlugin from "./main"; 5 | import { TagFolderViewBase } from "./TagFolderViewBase"; 6 | import { mount, unmount } from 'svelte' 7 | 8 | export interface TagFolderViewState extends ViewState { 9 | treeViewType: TREE_TYPE 10 | } 11 | export class TagFolderView extends TagFolderViewBase { 12 | icon = "stacked-levels"; 13 | treeViewType?: TREE_TYPE; 14 | 15 | getIcon(): string { 16 | return "stacked-levels"; 17 | } 18 | 19 | constructor(leaf: WorkspaceLeaf, plugin: TagFolderPlugin, viewType: TREE_TYPE) { 20 | super(leaf); 21 | this.plugin = plugin; 22 | this.showMenu = this.showMenu.bind(this); 23 | this.showOrder = this.showOrder.bind(this); 24 | this.newNote = this.newNote.bind(this); 25 | this.showLevelSelect = this.showLevelSelect.bind(this); 26 | this.switchView = this.switchView.bind(this); 27 | this.treeViewType = viewType; 28 | // this.setState({ viewType: this.viewType, type: this.getViewType() }, {}); 29 | } 30 | 31 | newNote(evt: MouseEvent) { 32 | //@ts-ignore 33 | this.app.commands.executeCommandById("file-explorer:new-file"); 34 | } 35 | 36 | getViewType() { 37 | return this.treeViewType == "tags" ? VIEW_TYPE_TAGFOLDER : VIEW_TYPE_TAGFOLDER_LINK; 38 | } 39 | 40 | getDisplayText() { 41 | return this.treeViewType == "tags" ? "Tag Folder" : "Link Folder"; 42 | } 43 | 44 | async onOpen() { 45 | this.containerEl.empty(); 46 | const app = mount(TagFolderViewComponent, 47 | { 48 | target: this.containerEl, 49 | props: { 50 | openFile: this.plugin.focusFile, 51 | hoverPreview: (a: MouseEvent, b: string) => this.plugin.hoverPreview(a, b), 52 | vaultName: this.app.vault.getName(), 53 | showMenu: this.showMenu, 54 | showLevelSelect: this.showLevelSelect, 55 | showOrder: this.showOrder, 56 | newNote: this.newNote, 57 | openScrollView: this.plugin.openScrollView, 58 | isViewSwitchable: this.plugin.settings.useMultiPaneList, 59 | switchView: this.switchView, 60 | viewType: this.treeViewType, 61 | saveSettings: this.saveSettings.bind(this), 62 | }, 63 | }); 64 | this.component = app 65 | return await Promise.resolve(); 66 | } 67 | 68 | async onClose() { 69 | await unmount(this.component); 70 | this.component = undefined!; 71 | return await Promise.resolve(); 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /TagFolderViewBase.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, Menu, Notice } from "obsidian"; 2 | import { mount } from 'svelte' 3 | import TagFolderPlugin from "./main"; 4 | import { 5 | OrderDirection, 6 | OrderKeyItem, 7 | OrderKeyTag, 8 | VIEW_TYPE_TAGFOLDER, 9 | VIEW_TYPE_TAGFOLDER_LINK, 10 | VIEW_TYPE_TAGFOLDER_LIST, 11 | type TagFolderSettings, 12 | type ViewItem 13 | } from "./types"; 14 | import { maxDepth, selectedTags } from "./store"; 15 | import { ancestorToLongestTag, ancestorToTags, isSpecialTag, renderSpecialTag, joinPartialPath, removeIntermediatePath, trimTrailingSlash } from "./util"; 16 | import { askString } from "dialog"; 17 | 18 | function toggleObjectProp(obj: { [key: string]: any }, propName: string, value: string | false) { 19 | if (value === false) { 20 | const newTagInfoEntries = Object.entries(obj || {}).filter(([key]) => key != propName); 21 | if (newTagInfoEntries.length == 0) { 22 | return {}; 23 | } else { 24 | return Object.fromEntries(newTagInfoEntries); 25 | } 26 | } else { 27 | return { ...(obj ?? {}), [propName]: value }; 28 | } 29 | } 30 | export abstract class TagFolderViewBase extends ItemView { 31 | component!: ReturnType; 32 | plugin!: TagFolderPlugin; 33 | navigation = false; 34 | async saveSettings(settings: TagFolderSettings) { 35 | this.plugin.settings = { ...this.plugin.settings, ...settings }; 36 | await this.plugin.saveSettings(); 37 | this.plugin.updateFileCaches(); 38 | } 39 | showOrder(evt: MouseEvent) { 40 | const menu = new Menu(); 41 | 42 | menu.addItem((item) => { 43 | item.setTitle("Tags") 44 | .setIcon("hashtag") 45 | .onClick((evt2) => { 46 | const menu2 = new Menu(); 47 | for (const key in OrderKeyTag) { 48 | for (const direction in OrderDirection) { 49 | menu2.addItem((item) => { 50 | const newSetting = `${key}_${direction}`; 51 | item.setTitle( 52 | OrderKeyTag[key] + 53 | " " + 54 | OrderDirection[direction] 55 | ).onClick(async () => { 56 | //@ts-ignore 57 | this.plugin.settings.sortTypeTag = 58 | newSetting; 59 | await this.plugin.saveSettings(); 60 | }); 61 | if ( 62 | newSetting == 63 | this.plugin.settings.sortTypeTag 64 | ) { 65 | item.setIcon("checkmark"); 66 | } 67 | return item; 68 | }); 69 | } 70 | } 71 | menu2.showAtPosition({ x: evt.x, y: evt.y }); 72 | }); 73 | return item; 74 | }); 75 | menu.addItem((item) => { 76 | item.setTitle("Items") 77 | .setIcon("document") 78 | .onClick((evt2) => { 79 | const menu2 = new Menu(); 80 | for (const key in OrderKeyItem) { 81 | for (const direction in OrderDirection) { 82 | menu2.addItem((item) => { 83 | const newSetting = `${key}_${direction}`; 84 | item.setTitle( 85 | OrderKeyItem[key] + 86 | " " + 87 | OrderDirection[direction] 88 | ).onClick(async () => { 89 | //@ts-ignore 90 | this.plugin.settings.sortType = newSetting; 91 | await this.plugin.saveSettings(); 92 | }); 93 | if ( 94 | newSetting == this.plugin.settings.sortType 95 | ) { 96 | item.setIcon("checkmark"); 97 | } 98 | return item; 99 | }); 100 | } 101 | } 102 | menu2.showAtPosition({ x: evt.x, y: evt.y }); 103 | }); 104 | return item; 105 | }); 106 | menu.showAtMouseEvent(evt); 107 | } 108 | 109 | showLevelSelect(evt: MouseEvent) { 110 | const menu = new Menu(); 111 | const setLevel = async (level: number) => { 112 | this.plugin.settings.expandLimit = level; 113 | await this.plugin.saveSettings(); 114 | maxDepth.set(level); 115 | }; 116 | for (const level of [2, 3, 4, 5]) { 117 | menu.addItem((item) => { 118 | item.setTitle(`Level ${level - 1}`).onClick(() => { 119 | void setLevel(level); 120 | }); 121 | if (this.plugin.settings.expandLimit == level) 122 | item.setIcon("checkmark"); 123 | return item; 124 | }); 125 | } 126 | 127 | menu.addItem((item) => { 128 | item.setTitle("No limit") 129 | // .setIcon("hashtag") 130 | .onClick(() => { 131 | void setLevel(0); 132 | }); 133 | if (this.plugin.settings.expandLimit == 0) 134 | item.setIcon("checkmark"); 135 | 136 | return item; 137 | }); 138 | menu.showAtMouseEvent(evt); 139 | } 140 | 141 | abstract getViewType(): string; 142 | 143 | showMenu(evt: MouseEvent, trail: string[], targetTag?: string, targetItems?: ViewItem[]) { 144 | 145 | const isTagTree = this.getViewType() == VIEW_TYPE_TAGFOLDER; 146 | const menu = new Menu(); 147 | if (isTagTree) { 148 | 149 | const expandedTagsAll = ancestorToLongestTag(ancestorToTags(joinPartialPath(removeIntermediatePath(trail)))).map(e => trimTrailingSlash(e)); 150 | const expandedTags = expandedTagsAll 151 | .map(e => e.split("/") 152 | .filter(ee => !isSpecialTag(ee)) 153 | .join("/")).filter(e => e != "") 154 | .map((e) => "#" + e) 155 | .join(" ") 156 | .trim(); 157 | const displayExpandedTags = expandedTagsAll 158 | .map(e => e.split("/") 159 | .filter(ee => renderSpecialTag(ee)) 160 | .join("/")).filter(e => e != "") 161 | .map((e) => "#" + e) 162 | .join(" ") 163 | .trim(); 164 | 165 | 166 | if (navigator && navigator.clipboard) { 167 | menu.addItem((item) => 168 | item 169 | .setTitle(`Copy tags:${expandedTags}`) 170 | .setIcon("hashtag") 171 | .onClick(async () => { 172 | await navigator.clipboard.writeText(expandedTags); 173 | new Notice("Copied"); 174 | }) 175 | ); 176 | } 177 | menu.addItem((item) => 178 | item 179 | .setTitle(`New note ${targetTag ? "in here" : "as like this"}`) 180 | .setIcon("create-new") 181 | .onClick(async () => { 182 | await this.plugin.createNewNote(trail); 183 | }) 184 | ); 185 | if (targetTag) { 186 | if (this.plugin.settings.useTagInfo && this.plugin.tagInfo != null) { 187 | const tag = targetTag; 188 | 189 | if (tag in this.plugin.tagInfo && "key" in this.plugin.tagInfo[tag]) { 190 | menu.addItem((item) => 191 | item.setTitle(`Unpin`) 192 | .setIcon("pin") 193 | .onClick(async () => { 194 | this.plugin.tagInfo[tag] = toggleObjectProp(this.plugin.tagInfo[tag], "key", false); 195 | this.plugin.applyTagInfo(); 196 | await this.plugin.saveTagInfo(); 197 | }) 198 | ) 199 | 200 | } else { 201 | menu.addItem((item) => { 202 | item.setTitle(`Pin`) 203 | .setIcon("pin") 204 | .onClick(async () => { 205 | this.plugin.tagInfo[tag] = toggleObjectProp(this.plugin.tagInfo[tag], "key", ""); 206 | this.plugin.applyTagInfo(); 207 | await this.plugin.saveTagInfo(); 208 | }) 209 | }) 210 | } 211 | menu.addItem((item) => { 212 | item.setTitle(`Set an alternative label`) 213 | .setIcon("pencil") 214 | .onClick(async () => { 215 | const oldAlt = tag in this.plugin.tagInfo ? (this.plugin.tagInfo[tag].alt ?? "") : ""; 216 | const label = await askString(this.app, "", "", oldAlt); 217 | if (label === false) return; 218 | this.plugin.tagInfo[tag] = toggleObjectProp(this.plugin.tagInfo[tag], "alt", label == "" ? false : label); 219 | this.plugin.applyTagInfo(); 220 | await this.plugin.saveTagInfo(); 221 | }) 222 | }); 223 | menu.addItem((item) => { 224 | item.setTitle(`Change the mark`) 225 | .setIcon("pencil") 226 | .onClick(async () => { 227 | const oldMark = tag in this.plugin.tagInfo ? (this.plugin.tagInfo[tag].mark ?? "") : ""; 228 | const mark = await askString(this.app, "", "", oldMark); 229 | if (mark === false) return; 230 | this.plugin.tagInfo[tag] = toggleObjectProp(this.plugin.tagInfo[tag], "mark", mark == "" ? false : mark); 231 | this.plugin.applyTagInfo(); 232 | await this.plugin.saveTagInfo(); 233 | }) 234 | }); 235 | menu.addItem((item) => { 236 | item.setTitle(`Redirect this tag to ...`) 237 | .setIcon("pencil") 238 | .onClick(async () => { 239 | const oldRedirect = tag in this.plugin.tagInfo ? (this.plugin.tagInfo[tag].redirect ?? "") : ""; 240 | const redirect = await askString(this.app, "", "", oldRedirect); 241 | if (redirect === false) return; 242 | this.plugin.tagInfo[tag] = toggleObjectProp(this.plugin.tagInfo[tag], "redirect", redirect == "" ? false : redirect); 243 | this.plugin.applyTagInfo(); 244 | await this.plugin.saveTagInfo(); 245 | }) 246 | }); 247 | if (targetItems) { 248 | menu.addItem(item => { 249 | item.setTitle(`Open scroll view`) 250 | .setIcon("sheets-in-box") 251 | .onClick(async () => { 252 | const files = targetItems.map(e => e.path); 253 | await this.plugin.openScrollView(undefined, displayExpandedTags, expandedTagsAll.join(", "), files); 254 | }) 255 | }) 256 | menu.addItem(item => { 257 | item.setTitle(`Open list`) 258 | .setIcon("sheets-in-box") 259 | .onClick(() => { 260 | selectedTags.set( 261 | expandedTagsAll 262 | ); 263 | }) 264 | }) 265 | } 266 | } 267 | } 268 | } 269 | if (!targetTag && targetItems && targetItems.length == 1) { 270 | const path = targetItems[0].path; 271 | const file = this.app.vault.getAbstractFileByPath(path); 272 | // Trigger 273 | this.app.workspace.trigger( 274 | "file-menu", 275 | menu, 276 | file, 277 | "file-explorer" 278 | ); 279 | menu.addSeparator(); 280 | menu.addItem((item) => 281 | item 282 | .setTitle(`Open in new tab`) 283 | .setSection("open") 284 | .setIcon("lucide-file-plus") 285 | .onClick(async () => { 286 | await this.app.workspace.openLinkText(path, path, "tab"); 287 | }) 288 | ); 289 | menu.addItem((item) => 290 | item 291 | .setTitle(`Open to the right`) 292 | .setSection("open") 293 | .setIcon("lucide-separator-vertical") 294 | .onClick(async () => { 295 | await this.app.workspace.openLinkText(path, path, "split"); 296 | }) 297 | ); 298 | } else if (!isTagTree && targetTag) { 299 | const path = targetTag; 300 | const file = this.app.vault.getAbstractFileByPath(path); 301 | // Trigger 302 | this.app.workspace.trigger( 303 | "file-menu", 304 | menu, 305 | file, 306 | "file-explorer" 307 | ); 308 | menu.addSeparator(); 309 | menu.addItem((item) => 310 | item 311 | .setTitle(`Open in new tab`) 312 | .setSection("open") 313 | .setIcon("lucide-file-plus") 314 | .onClick(async () => { 315 | await this.app.workspace.openLinkText(path, path, "tab"); 316 | }) 317 | ); 318 | menu.addItem((item) => 319 | item 320 | .setTitle(`Open to the right`) 321 | .setSection("open") 322 | .setIcon("lucide-separator-vertical") 323 | .onClick(async () => { 324 | await this.app.workspace.openLinkText(path, path, "split"); 325 | }) 326 | ); 327 | } 328 | if ("screenX" in evt) { 329 | menu.showAtPosition({ x: evt.pageX, y: evt.pageY }); 330 | } else { 331 | menu.showAtPosition({ 332 | // @ts-ignore 333 | x: evt.nativeEvent.locationX, 334 | // @ts-ignore 335 | y: evt.nativeEvent.locationY, 336 | }); 337 | } 338 | evt.preventDefault(); 339 | // menu.showAtMouseEvent(evt); 340 | } 341 | 342 | switchView() { 343 | let viewType = VIEW_TYPE_TAGFOLDER; 344 | const currentType = this.getViewType(); 345 | if (currentType == VIEW_TYPE_TAGFOLDER) { 346 | viewType = VIEW_TYPE_TAGFOLDER_LIST; 347 | } else if (currentType == VIEW_TYPE_TAGFOLDER_LINK) { 348 | return 349 | } else if (currentType == VIEW_TYPE_TAGFOLDER_LIST) { 350 | viewType = VIEW_TYPE_TAGFOLDER; 351 | } 352 | 353 | const leaves = this.app.workspace.getLeavesOfType(viewType).filter(e => !e.getViewState().pinned && e != this.leaf); 354 | if (leaves.length) { 355 | void this.app.workspace.revealLeaf( 356 | leaves[0] 357 | ); 358 | } 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /TagFolderViewComponent.svelte: -------------------------------------------------------------------------------- 1 | 314 | 315 | 316 | 422 | {#if showSearch && isMainTree} 423 |
424 |
425 | 431 | 432 | 433 |
439 |
440 |
441 | {/if} 442 | 458 | 459 | 464 | -------------------------------------------------------------------------------- /V2TreeFolderComponent.svelte: -------------------------------------------------------------------------------- 1 | 753 | 754 | 755 | 756 |
{ 762 | evt.stopPropagation(); 763 | if (shouldResponsibleFor(evt)) { 764 | showMenu( 765 | evt, 766 | [...trail, ...suppressLevels], 767 | viewType == "tags" ? tagName : filename, 768 | _items, 769 | ); 770 | } 771 | }} 772 | > 773 | {#if isRoot || !isMainTree} 774 | {#if isRoot} 775 | 780 | {/if} 781 | {:else} 782 | 788 | 789 | 800 | 831 | 832 | {/if} 833 | 834 | {#if !collapsed} 835 | {#snippet treeContent(childrenDisp: V2FolderItem[][], leftOverItemsDisp:ViewItem[][])} 836 | {#each childrenDisp as items} 837 | {#each items as [f, tagName, tagNameDisp, subitems]} 838 | 854 | {/each} 855 | {/each} 856 | {#each leftOverItemsDisp as items} 857 | {#each items as item} 858 | 867 | {/each} 868 | {/each} 869 | {/snippet} 870 | {#if !isRoot} 871 | 874 | {:else} 875 | {@render treeContent(childrenDisp, leftOverItemsDisp)} 876 | {/if} 877 | {/if} 878 |
879 | -------------------------------------------------------------------------------- /V2TreeItemComponent.svelte: -------------------------------------------------------------------------------- 1 | 83 | 84 | 85 | {#snippet children({ isVisible })} 86 | 87 | 88 | 110 | {/snippet} 111 | 112 | -------------------------------------------------------------------------------- /dialog.ts: -------------------------------------------------------------------------------- 1 | import { App, SuggestModal } from "obsidian"; 2 | 3 | export const askString = (app: App, title: string, placeholder: string, initialText: string): Promise => { 4 | return new Promise((res) => { 5 | const popover = new PopoverSelectString(app, title, placeholder, initialText, (result) => res(result)); 6 | popover.open(); 7 | }); 8 | }; 9 | 10 | export class PopoverSelectString extends SuggestModal { 11 | app: App; 12 | callback?: (e: string | false) => void = () => { }; 13 | title = ""; 14 | 15 | getSuggestions(query: string): string[] | Promise { 16 | return [query]; 17 | } 18 | renderSuggestion(value: string, el: HTMLElement) { 19 | el.createDiv({ text: `${this.title}${value}` }); 20 | } 21 | onChooseSuggestion(item: string, evt: MouseEvent | KeyboardEvent) { 22 | this.callback?.(item); 23 | this.callback = undefined; 24 | } 25 | 26 | constructor(app: App, title: string, placeholder: string | null, initialText: string, callback: (e: string | false) => void) { 27 | super(app); 28 | this.app = app; 29 | this.title = title; 30 | this.setPlaceholder(placeholder ?? ">"); 31 | this.callback = callback; 32 | 33 | setTimeout(() => { 34 | this.inputEl.value = initialText; 35 | // this.inputEl.onchange(); 36 | }) 37 | const parent = this.containerEl.querySelector(".prompt"); 38 | if (parent) { 39 | parent.addClass("override-input"); 40 | } 41 | } 42 | onClose(): void { 43 | setTimeout(() => { 44 | if (this.callback) { 45 | this.callback(false); 46 | } 47 | }, 100); 48 | } 49 | } -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import sveltePlugin from "esbuild-svelte"; 5 | import { sveltePreprocess } from "svelte-preprocess"; 6 | import fs from "node:fs"; 7 | import { minify } from "terser"; 8 | const banner = `/* 9 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD AND TERSER 10 | if you want to view the source, please visit the github repository of this plugin 11 | */ 12 | `; 13 | 14 | const prod = process.argv[2] === "production"; 15 | 16 | const terserOpt = { 17 | sourceMap: !prod 18 | ? { 19 | url: "inline", 20 | } 21 | : {}, 22 | format: { 23 | indent_level: 2, 24 | beautify: true, 25 | comments: "some", 26 | ecma: 2018, 27 | preamble: banner, 28 | webkit: true, 29 | }, 30 | parse: { 31 | // parse options 32 | }, 33 | compress: { 34 | // compress options 35 | defaults: false, 36 | evaluate: true, 37 | inline: 3, 38 | join_vars: true, 39 | loops: true, 40 | passes: 4, 41 | reduce_vars: true, 42 | reduce_funcs: true, 43 | arrows: true, 44 | collapse_vars: true, 45 | comparisons: true, 46 | lhs_constants: true, 47 | hoist_props: true, 48 | side_effects: true, 49 | ecma: 2018, 50 | if_return: true, 51 | unused: true, 52 | }, 53 | mangle: false, 54 | ecma: 2018, // specify one of: 5, 2015, 2016, etc. 55 | enclose: false, // or specify true, or "args:values" 56 | keep_classnames: true, 57 | keep_fnames: true, 58 | ie8: false, 59 | module: false, 60 | safari10: false, 61 | toplevel: false, 62 | }; 63 | 64 | /** @type esbuild.Plugin[] */ 65 | const plugins = [ 66 | { 67 | name: "my-plugin", 68 | setup(build) { 69 | build.onEnd(async (result) => { 70 | if (prod) { 71 | console.log("tersering..."); 72 | const src = fs.readFileSync("./main_org.js").toString(); 73 | // @ts-ignore 74 | const ret = await minify(src, terserOpt); 75 | if (ret && ret.code) { 76 | fs.writeFileSync("./main.js", ret.code); 77 | } 78 | } else { 79 | fs.copyFileSync("./main_org.js", "./main.js"); 80 | } 81 | console.log("tersered..."); 82 | }); 83 | }, 84 | }, 85 | ]; 86 | 87 | const context = await esbuild.context({ 88 | banner: { 89 | js: banner, 90 | }, 91 | entryPoints: ["main.ts"], 92 | bundle: true, 93 | external: [ 94 | "obsidian", 95 | "electron", 96 | "@codemirror/autocomplete", 97 | "@codemirror/collab", 98 | "@codemirror/commands", 99 | "@codemirror/language", 100 | "@codemirror/lint", 101 | "@codemirror/search", 102 | "@codemirror/state", 103 | "@codemirror/view", 104 | "@lezer/common", 105 | "@lezer/highlight", 106 | "@lezer/lr", 107 | ...builtins, 108 | ], 109 | format: "cjs", 110 | target: "es2018", 111 | logLevel: "info", 112 | platform: "browser", 113 | sourcemap: prod ? false : "inline", 114 | treeShaking: true, 115 | outfile: "main_org.js", 116 | plugins: [ 117 | sveltePlugin({ 118 | preprocess: sveltePreprocess({ 119 | preserveComments: false, 120 | compilerOptions: { 121 | removeComments: true, 122 | }, 123 | }), 124 | compilerOptions: { 125 | css: "injected", 126 | preserveComments: false, 127 | preserveWhitespace: false, 128 | }, 129 | }), 130 | ...plugins, 131 | ], 132 | }); 133 | 134 | if (prod) { 135 | await context.rebuild(); 136 | process.exit(0); 137 | } else { 138 | await context.watch(); 139 | } 140 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 2 | import svelte from "eslint-plugin-svelte"; 3 | import _import from "eslint-plugin-import"; 4 | import { fixupPluginRules } from "@eslint/compat"; 5 | import tsParser from "@typescript-eslint/parser"; 6 | import path from "node:path"; 7 | import { fileURLToPath } from "node:url"; 8 | import js from "@eslint/js"; 9 | import { FlatCompat } from "@eslint/eslintrc"; 10 | 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = path.dirname(__filename); 13 | const compat = new FlatCompat({ 14 | baseDirectory: __dirname, 15 | recommendedConfig: js.configs.recommended, 16 | allConfig: js.configs.all, 17 | }); 18 | 19 | export default [ 20 | { 21 | ignores: ["**/node_modules/**", "**/dist/**", "**/build/**","**/*.config.*","*.js"], 22 | }, 23 | ...compat.extends( 24 | "eslint:recommended", 25 | "plugin:@typescript-eslint/eslint-recommended", 26 | "plugin:@typescript-eslint/recommended" 27 | ), 28 | { 29 | plugins: { 30 | "@typescript-eslint": typescriptEslint, 31 | svelte, 32 | import: fixupPluginRules(_import), 33 | }, 34 | 35 | languageOptions: { 36 | parser: tsParser, 37 | ecmaVersion: 5, 38 | sourceType: "module", 39 | 40 | parserOptions: { 41 | project: ["tsconfig.json"], 42 | }, 43 | }, 44 | 45 | rules: { 46 | "no-unused-vars": "off", 47 | 48 | "@typescript-eslint/no-unused-vars": [ 49 | "error", 50 | { 51 | args: "none", 52 | }, 53 | ], 54 | 55 | "no-unused-labels": "off", 56 | "@typescript-eslint/ban-ts-comment": "off", 57 | "no-prototype-builtins": "off", 58 | "@typescript-eslint/no-empty-function": "off", 59 | "require-await": "error", 60 | "@typescript-eslint/require-await": "warn", 61 | "@typescript-eslint/no-misused-promises": "warn", 62 | "@typescript-eslint/no-floating-promises": "warn", 63 | "no-async-promise-executor": "warn", 64 | "@typescript-eslint/no-explicit-any": "off", 65 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 66 | 67 | "no-constant-condition": [ 68 | "error", 69 | { 70 | checkLoops: false, 71 | }, 72 | ], 73 | }, 74 | }, 75 | ]; 76 | -------------------------------------------------------------------------------- /images/respect-nestedtag-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrtmrz/obsidian-tagfolder/c8f134c4b694923fd2ea8422aa2790808c00d43a/images/respect-nestedtag-1.png -------------------------------------------------------------------------------- /images/respect-nestedtag-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrtmrz/obsidian-tagfolder/c8f134c4b694923fd2ea8422aa2790808c00d43a/images/respect-nestedtag-2.png -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrtmrz/obsidian-tagfolder/c8f134c4b694923fd2ea8422aa2790808c00d43a/images/screenshot.png -------------------------------------------------------------------------------- /images/simplecase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrtmrz/obsidian-tagfolder/c8f134c4b694923fd2ea8422aa2790808c00d43a/images/simplecase.png -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { 4 | App, 5 | debounce, 6 | Editor, 7 | getAllTags, 8 | MarkdownView, 9 | normalizePath, 10 | Notice, 11 | parseYaml, 12 | Platform, 13 | Plugin, 14 | PluginSettingTab, 15 | Setting, 16 | TFile, 17 | WorkspaceLeaf, 18 | TAbstractFile, 19 | type MarkdownFileInfo, 20 | } from "obsidian"; 21 | 22 | import { 23 | DEFAULT_SETTINGS, 24 | OrderDirection, 25 | OrderKeyItem, 26 | OrderKeyTag, 27 | type ScrollViewFile, 28 | type ScrollViewState, 29 | type TagFolderListState, 30 | type TagFolderSettings, 31 | type TagInfoDict, 32 | VIEW_TYPE_SCROLL, 33 | VIEW_TYPE_TAGFOLDER, 34 | VIEW_TYPE_TAGFOLDER_LIST, 35 | type ViewItem, 36 | VIEW_TYPE_TAGFOLDER_LINK, 37 | type FileCache, 38 | enumShowListIn 39 | } from "types"; 40 | import { allViewItems, allViewItemsByLink, appliedFiles, currentFile, maxDepth, pluginInstance, searchString, selectedTags, tagFolderSetting, tagInfo } from "store"; 41 | import { 42 | compare, 43 | doEvents, 44 | fileCacheToCompare, 45 | parseAllReference, 46 | renderSpecialTag, 47 | secondsToFreshness, 48 | unique, 49 | updateItemsLinkMap, 50 | ancestorToLongestTag, 51 | ancestorToTags, 52 | joinPartialPath, 53 | removeIntermediatePath, 54 | trimTrailingSlash, 55 | isSpecialTag, 56 | trimPrefix, 57 | uniqueCaseIntensive 58 | } from "./util"; 59 | import { ScrollView } from "./ScrollView"; 60 | import { TagFolderView } from "./TagFolderView"; 61 | import { TagFolderList } from "./TagFolderList"; 62 | 63 | export type DISPLAY_METHOD = "PATH/NAME" | "NAME" | "NAME : PATH"; 64 | 65 | // The `Intermidiate` is spelt incorrectly, but it is already used as the key of the configuration. 66 | // Leave it to the future. 67 | export type HIDE_ITEMS_TYPE = "NONE" | "DEDICATED_INTERMIDIATES" | "ALL_EXCEPT_BOTTOM"; 68 | 69 | const HideItemsType: Record = { 70 | NONE: "Hide nothing", 71 | DEDICATED_INTERMIDIATES: "Only intermediates of nested tags", 72 | ALL_EXCEPT_BOTTOM: "All intermediates", 73 | }; 74 | 75 | 76 | function dotted>(object: T, notation: string) { 77 | return notation.split('.').reduce((a, b) => (a && (b in a)) ? a[b] : null, object); 78 | } 79 | 80 | function getCompareMethodItems(settings: TagFolderSettings) { 81 | const invert = settings.sortType.contains("_DESC") ? -1 : 1; 82 | switch (settings.sortType) { 83 | case "DISPNAME_ASC": 84 | case "DISPNAME_DESC": 85 | return (a: ViewItem, b: ViewItem) => 86 | compare(a.displayName, b.displayName) * invert; 87 | case "FULLPATH_ASC": 88 | case "FULLPATH_DESC": 89 | return (a: ViewItem, b: ViewItem) => 90 | compare(a.path, b.path) * invert; 91 | case "MTIME_ASC": 92 | case "MTIME_DESC": 93 | return (a: ViewItem, b: ViewItem) => (a.mtime - b.mtime) * invert; 94 | case "CTIME_ASC": 95 | case "CTIME_DESC": 96 | return (a: ViewItem, b: ViewItem) => (a.ctime - b.ctime) * invert; 97 | case "NAME_ASC": 98 | case "NAME_DESC": 99 | return (a: ViewItem, b: ViewItem) => 100 | compare(a.filename, b.filename) * invert; 101 | default: 102 | console.warn("Compare method (items) corrupted"); 103 | return (a: ViewItem, b: ViewItem) => 104 | compare(a.displayName, b.displayName) * invert; 105 | } 106 | } 107 | 108 | // Thank you @pjeby! 109 | function onElement(el: T, event: string, selector: string, callback: CallableFunction, options: EventListenerOptions) { 110 | //@ts-ignore 111 | el.on(event, selector, callback, options) 112 | //@ts-ignore 113 | return () => el.off(event, selector, callback, options); 114 | } 115 | 116 | export default class TagFolderPlugin extends Plugin { 117 | settings: TagFolderSettings = { ...DEFAULT_SETTINGS }; 118 | 119 | // Folder opening status. 120 | expandedFolders: string[] = ["root"]; 121 | 122 | // The File that now opening 123 | currentOpeningFile = ""; 124 | 125 | searchString = ""; 126 | 127 | allViewItems = [] as ViewItem[]; 128 | allViewItemsByLink = [] as ViewItem[]; 129 | 130 | compareItems: (a: ViewItem, b: ViewItem) => number = (_, __) => 0; 131 | 132 | getView(): TagFolderView | null { 133 | for (const leaf of this.app.workspace.getLeavesOfType( 134 | VIEW_TYPE_TAGFOLDER 135 | )) { 136 | const view = leaf.view; 137 | if (view instanceof TagFolderView) { 138 | return view; 139 | } 140 | } 141 | return null; 142 | } 143 | getLinkView(): TagFolderView | null { 144 | for (const leaf of this.app.workspace.getLeavesOfType( 145 | VIEW_TYPE_TAGFOLDER_LINK 146 | )) { 147 | const view = leaf.view; 148 | if (view instanceof TagFolderView) { 149 | return view; 150 | } 151 | } 152 | return null; 153 | } 154 | 155 | // Called when item clicked in the tag folder pane. 156 | readonly focusFile = (path: string, specialKey: boolean): void => { 157 | if (this.currentOpeningFile == path) return; 158 | const _targetFile = this.app.vault.getAbstractFileByPath(path); 159 | const targetFile = (_targetFile instanceof TFile) ? _targetFile : this.app.vault 160 | .getFiles() 161 | .find((f) => f.path === path); 162 | 163 | if (targetFile) { 164 | if (specialKey) { 165 | void this.app.workspace.openLinkText(targetFile.path, targetFile.path, "tab"); 166 | } else { 167 | // const leaf = this.app.workspace.getLeaf(false); 168 | // leaf.openFile(targetFile); 169 | void this.app.workspace.openLinkText(targetFile.path, targetFile.path); 170 | } 171 | } 172 | }; 173 | 174 | hoverPreview(e: MouseEvent, path: string) { 175 | this.app.workspace.trigger("hover-link", { 176 | event: e, 177 | source: "file-explorer", 178 | hoverParent: this, 179 | targetEl: e.target, 180 | linktext: path, 181 | }); 182 | } 183 | 184 | setSearchString(search: string) { 185 | searchString.set(search); 186 | } 187 | 188 | getFileTitle(file: TFile): string { 189 | if (!this.settings.useTitle) return file.basename; 190 | const metadata = this.app.metadataCache.getCache(file.path); 191 | if (metadata?.frontmatter && (this.settings.frontmatterKey)) { 192 | const d = dotted(metadata.frontmatter, this.settings.frontmatterKey); 193 | if (d) return `${d}`; 194 | } 195 | if (metadata?.headings) { 196 | const h1 = metadata.headings.find((e) => e.level == 1); 197 | if (h1) { 198 | return h1.heading; 199 | } 200 | } 201 | return file.basename; 202 | } 203 | 204 | getDisplayName(file: TFile): string { 205 | const filename = this.getFileTitle(file) || file.basename; 206 | if (this.settings.displayMethod == "NAME") { 207 | return filename; 208 | } 209 | const path = file.path.split("/"); 210 | path.pop(); 211 | const displayPath = path.join("/"); 212 | 213 | if (this.settings.displayMethod == "NAME : PATH") { 214 | return `${filename} : ${displayPath}`; 215 | } 216 | if (this.settings.displayMethod == "PATH/NAME") { 217 | return `${displayPath}/${filename}`; 218 | } 219 | return filename; 220 | } 221 | 222 | async onload() { 223 | await this.loadSettings(); 224 | this.hoverPreview = this.hoverPreview.bind(this); 225 | this.modifyFile = this.modifyFile.bind(this); 226 | this.setSearchString = this.setSearchString.bind(this); 227 | this.openScrollView = this.openScrollView.bind(this); 228 | // Make loadFileInfo debounced . 229 | this.loadFileInfo = debounce( 230 | this.loadFileInfo.bind(this), 231 | this.settings.scanDelay, 232 | true 233 | ); 234 | pluginInstance.set(this); 235 | this.registerView( 236 | VIEW_TYPE_TAGFOLDER, 237 | (leaf) => new TagFolderView(leaf, this, "tags") 238 | ); 239 | this.registerView( 240 | VIEW_TYPE_TAGFOLDER_LINK, 241 | (leaf) => new TagFolderView(leaf, this, "links") 242 | ); 243 | this.registerView( 244 | VIEW_TYPE_TAGFOLDER_LIST, 245 | (leaf) => new TagFolderList(leaf, this) 246 | ); 247 | this.registerView( 248 | VIEW_TYPE_SCROLL, 249 | (leaf) => new ScrollView(leaf, this) 250 | ); 251 | this.app.workspace.onLayoutReady(async () => { 252 | this.loadFileInfo(); 253 | if (this.settings.alwaysOpen) { 254 | await this.initView(); 255 | await this.activateView(); 256 | } 257 | if (this.settings.useTagInfo) { 258 | await this.loadTagInfo(); 259 | } 260 | }); 261 | this.addCommand({ 262 | id: "tagfolder-open", 263 | name: "Show Tag Folder", 264 | callback: () => { 265 | void this.activateView(); 266 | }, 267 | }); 268 | this.addCommand({ 269 | id: "tagfolder-link-open", 270 | name: "Show Link Folder", 271 | callback: () => { 272 | void this.activateViewLink(); 273 | }, 274 | }); 275 | this.addCommand({ 276 | id: "tagfolder-rebuild-tree", 277 | name: "Force Rebuild", 278 | callback: () => { 279 | this.refreshAllTree(); 280 | }, 281 | }); 282 | this.addCommand({ 283 | id: "tagfolder-create-similar", 284 | name: "Create a new note with the same tags", 285 | editorCallback: async (editor: Editor, view: MarkdownView | MarkdownFileInfo) => { 286 | const file = view?.file; 287 | if (!file) return; 288 | const cache = this.app.metadataCache.getFileCache(file); 289 | if (!cache) return; 290 | const tags = getAllTags(cache) ?? []; 291 | const tagsWithoutPrefix = tags.map((e) => trimPrefix(e, "#")); 292 | await this.createNewNote(tagsWithoutPrefix); 293 | }, 294 | }); 295 | this.metadataCacheChanged = this.metadataCacheChanged.bind(this); 296 | this.watchWorkspaceOpen = this.watchWorkspaceOpen.bind(this); 297 | this.metadataCacheResolve = this.metadataCacheResolve.bind(this); 298 | this.metadataCacheResolved = this.metadataCacheResolved.bind(this); 299 | this.loadFileInfo = this.loadFileInfo.bind(this); 300 | this.registerEvent( 301 | this.app.metadataCache.on("changed", this.metadataCacheChanged) 302 | ); 303 | this.registerEvent( 304 | this.app.metadataCache.on("resolve", this.metadataCacheResolve) 305 | ); 306 | this.registerEvent( 307 | this.app.metadataCache.on("resolved", this.metadataCacheResolved) 308 | ); 309 | 310 | this.refreshAllTree = this.refreshAllTree.bind(this); 311 | this.refreshTree = this.refreshTree.bind(this); 312 | this.registerEvent(this.app.vault.on("rename", this.refreshTree)); 313 | this.registerEvent(this.app.vault.on("delete", this.refreshTree)); 314 | this.registerEvent(this.app.vault.on("modify", this.modifyFile)); 315 | 316 | this.registerEvent( 317 | this.app.workspace.on("file-open", this.watchWorkspaceOpen) 318 | ); 319 | this.watchWorkspaceOpen(this.app.workspace.getActiveFile()); 320 | 321 | this.addSettingTab(new TagFolderSettingTab(this.app, this)); 322 | maxDepth.set(this.settings.expandLimit); 323 | 324 | searchString.subscribe((search => { 325 | this.searchString = search; 326 | this.refreshAllTree(); 327 | })) 328 | 329 | 330 | const setTagSearchString = (event: MouseEvent, tagString: string) => { 331 | if (tagString) { 332 | const regExpTagStr = new RegExp(`(^|\\s)${tagString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`, "u"); 333 | const regExpTagStrInv = new RegExp(`(^|\\s)-${tagString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`, "u"); 334 | if (event.altKey) { 335 | return; 336 | } else if (event.ctrlKey && event.shiftKey) { 337 | if (this.searchString.match(regExpTagStr)) { 338 | this.setSearchString(this.searchString.replace(regExpTagStr, "")); 339 | } else if (!this.searchString.match(regExpTagStrInv)) { 340 | this.setSearchString(this.searchString + (this.searchString.length == 0 ? "" : " ") + `-${tagString}`); 341 | } 342 | } else if (event.ctrlKey) { 343 | if (this.searchString.match(regExpTagStrInv)) { 344 | this.setSearchString(this.searchString.replace(regExpTagStrInv, "")); 345 | } else if (!this.searchString.match(regExpTagStr)) { 346 | this.setSearchString(this.searchString + (this.searchString.length == 0 ? "" : " ") + `${tagString}`); 347 | } 348 | } else { 349 | this.setSearchString(tagString); 350 | } 351 | event.preventDefault(); 352 | event.stopPropagation(); 353 | 354 | } 355 | } 356 | 357 | const selectorHashTagLink = 'a.tag[href^="#"]'; 358 | const selectorHashTagSpan = "span.cm-hashtag.cm-meta"; 359 | this.register( 360 | onElement(document, "click", selectorHashTagLink, (event: MouseEvent, targetEl: HTMLElement) => { 361 | if (!this.settings.overrideTagClicking) return; 362 | const tagString = targetEl.innerText.substring(1); 363 | if (tagString) { 364 | setTagSearchString(event, tagString); 365 | const leaf = this.getView()?.leaf; 366 | if (leaf) { 367 | void this.app.workspace.revealLeaf(leaf); 368 | } 369 | } 370 | }, { capture: true }) 371 | ); 372 | this.register( 373 | onElement(document, "click", selectorHashTagSpan, (event: MouseEvent, targetEl: HTMLElement) => { 374 | if (!this.settings.overrideTagClicking) return; 375 | let enumTags: Element | null = targetEl; 376 | let tagString = ""; 377 | // A tag is consisted of possibly several spans having each class. 378 | // Usually, they have been merged into two spans. but can be more. 379 | // In any event, the first item has `cm-hashtag-begin`, and the last 380 | // item has `cm-hashtag-end` but both (or all) spans possibly raises events. 381 | // So we have to find the head and trace them to the tail. 382 | while (!enumTags.classList.contains("cm-hashtag-begin")) { 383 | enumTags = enumTags.previousElementSibling; 384 | if (!enumTags) { 385 | console.log("Error! start tag not found."); 386 | return; 387 | } 388 | } 389 | 390 | do { 391 | if (enumTags instanceof HTMLElement) { 392 | tagString += enumTags.innerText; 393 | if (enumTags.classList.contains("cm-hashtag-end")) { 394 | break; 395 | } 396 | } 397 | enumTags = enumTags.nextElementSibling; 398 | 399 | } while (enumTags); 400 | tagString = tagString.substring(1) //Snip hash. 401 | setTagSearchString(event, tagString); 402 | const leaf = this.getView()?.leaf; 403 | if (leaf) { 404 | void this.app.workspace.revealLeaf(leaf); 405 | } 406 | }, { capture: true }) 407 | ); 408 | selectedTags.subscribe(newTags => { 409 | void this.openListView(newTags) 410 | }) 411 | } 412 | 413 | watchWorkspaceOpen(file: TFile | null) { 414 | if (file) { 415 | this.currentOpeningFile = file.path; 416 | } else { 417 | this.currentOpeningFile = ""; 418 | } 419 | currentFile.set(this.currentOpeningFile); 420 | } 421 | 422 | metadataCacheChanged(file: TFile) { 423 | void this.loadFileInfoAsync(file); 424 | } 425 | metadataCacheResolve(file: TFile) { 426 | if (this.getLinkView() != null) { 427 | void this.loadFileInfoAsync(file); 428 | } 429 | } 430 | metadataCacheResolved() { 431 | if (this.getLinkView() != null) { 432 | // console.warn("MetaCache Resolved") 433 | // this.loadFileInfo(); 434 | } 435 | } 436 | 437 | refreshTree(file: TAbstractFile, oldName?: string) { 438 | if (oldName) { 439 | this.refreshAllTree(); 440 | } else { 441 | if (file instanceof TFile) { 442 | this.loadFileInfo(file); 443 | } 444 | } 445 | } 446 | 447 | refreshAllTree() { 448 | this.loadFileInfo(); 449 | } 450 | 451 | fileCaches: FileCache[] = []; 452 | 453 | oldFileCache = ""; 454 | 455 | 456 | parsedFileCache = new Map(); 457 | 458 | getFileCacheLinks(file: TFile) { 459 | const cachedLinks = this.app.metadataCache.resolvedLinks; 460 | const allLinks = this.getLinkView() == null ? [] : parseAllReference(cachedLinks, file.path, this.settings.linkConfig); 461 | 462 | const links = [...allLinks.filter(e => e.endsWith(".md")).map(e => `${e}`)]; 463 | return links; 464 | } 465 | getFileCacheData(file: TFile): FileCache | false { 466 | const metadata = this.app.metadataCache.getFileCache(file); 467 | if (!metadata) return false; 468 | const links = this.getFileCacheLinks(file); 469 | return { 470 | file: file, 471 | links: links, 472 | tags: getAllTags(metadata) || [], 473 | }; 474 | } 475 | updateFileCachesAll(): boolean { 476 | const filesAll = [...this.app.vault.getMarkdownFiles(), ...this.app.vault.getAllLoadedFiles().filter(e => "extension" in e && e.extension == "canvas") as TFile[]]; 477 | const processFiles = filesAll.filter(file => this.parsedFileCache.get(file.path) ?? 0 != file.stat.mtime); 478 | const caches = processFiles.map(entry => this.getFileCacheData(entry)).filter(e => e !== false) 479 | this.fileCaches = [...caches]; 480 | return this.isFileCacheChanged(); 481 | } 482 | isFileCacheChanged() { 483 | const fileCacheDump = JSON.stringify( 484 | this.fileCaches.map((e) => ({ 485 | path: e.file.path, 486 | links: e.links, 487 | tags: e.tags, 488 | })) 489 | ); 490 | if (this.oldFileCache == fileCacheDump) { 491 | return false; 492 | } else { 493 | this.oldFileCache = fileCacheDump; 494 | return true; 495 | } 496 | } 497 | 498 | 499 | updateFileCaches(diffs: (TFile | undefined)[] = []): boolean { 500 | let anyUpdated = false; 501 | 502 | if (this.fileCaches.length == 0 || diffs.length == 0) { 503 | return this.updateFileCachesAll(); 504 | } else { 505 | const processDiffs = [...diffs]; 506 | let newCaches = [...this.fileCaches]; 507 | let diff = processDiffs.shift(); 508 | do { 509 | const procDiff = diff; 510 | if (!procDiff) break; 511 | // Find old one and remove if exist once. 512 | const old = newCaches.find( 513 | (fileCache) => fileCache.file.path == procDiff.path 514 | ); 515 | 516 | if (old) { 517 | newCaches = newCaches.filter( 518 | (fileCache) => fileCache !== old 519 | ); 520 | } 521 | const newCache = this.getFileCacheData(procDiff); 522 | if (newCache) { 523 | // Update about references 524 | if (this.getLinkView() != null) { 525 | const oldLinks = old?.links || []; 526 | const newLinks = newCache.links; 527 | const all = unique([...oldLinks, ...newLinks]); 528 | // Updated or Deleted reference 529 | const diffs = all.filter(link => !oldLinks.contains(link) || !newLinks.contains(link)) 530 | for (const filename of diffs) { 531 | const file = this.app.vault.getAbstractFileByPath(filename); 532 | if (file instanceof TFile) processDiffs.push(file); 533 | } 534 | } 535 | newCaches.push(newCache); 536 | } 537 | anyUpdated = anyUpdated || (JSON.stringify(fileCacheToCompare(old)) != JSON.stringify(fileCacheToCompare(newCache))); 538 | diff = processDiffs.shift(); 539 | } while (diff !== undefined); 540 | this.fileCaches = newCaches; 541 | 542 | } 543 | return anyUpdated; 544 | } 545 | 546 | async getItemsList(mode: "tag" | "link"): Promise { 547 | const items: ViewItem[] = []; 548 | const ignoreDocTags = this.settings.ignoreDocTags 549 | .toLowerCase() 550 | .replace(/[\n ]/g, "") 551 | .split(","); 552 | const ignoreTags = this.settings.ignoreTags 553 | .toLowerCase() 554 | .replace(/[\n ]/g, "") 555 | .split(","); 556 | 557 | const ignoreFolders = this.settings.ignoreFolders 558 | .toLowerCase() 559 | .replace(/\n/g, "") 560 | .split(",") 561 | .map((e) => e.trim()) 562 | .filter((e) => !!e); 563 | const targetFolders = this.settings.targetFolders 564 | .toLowerCase() 565 | .replace(/\n/g, "") 566 | .split(",") 567 | .map((e) => e.trim()) 568 | .filter((e) => !!e); 569 | 570 | 571 | const searchItems = this.searchString 572 | .toLowerCase() 573 | .split("|") 574 | .map((ee) => ee.split(" ").map((e) => e.trim())); 575 | 576 | 577 | const today = Date.now(); 578 | const archiveTags = this.settings.archiveTags 579 | .toLowerCase() 580 | .replace(/[\n ]/g, "") 581 | .split(","); 582 | 583 | for (const fileCache of this.fileCaches) { 584 | if ( 585 | targetFolders.length > 0 && 586 | !targetFolders.some( 587 | (e) => { 588 | return e != "" && 589 | fileCache.file.path.toLowerCase().startsWith(e) 590 | } 591 | ) 592 | ) { 593 | continue; 594 | } 595 | if ( 596 | ignoreFolders.some( 597 | (e) => 598 | e != "" && 599 | fileCache.file.path.toLowerCase().startsWith(e) 600 | ) 601 | ) { 602 | continue; 603 | } 604 | await doEvents(); 605 | const tagRedirectList = {} as { [key: string]: string }; 606 | if (this.settings.useTagInfo && this.tagInfo) { 607 | for (const [key, taginfo] of Object.entries(this.tagInfo)) { 608 | if (taginfo?.redirect) { 609 | tagRedirectList[key] = taginfo.redirect; 610 | } 611 | } 612 | } 613 | 614 | let allTags = [] as string[]; 615 | if (mode == "tag") { 616 | const allTagsDocs = unique(fileCache.tags); 617 | allTags = unique(allTagsDocs.map((e) => e.substring(1)).map(e => e in tagRedirectList ? tagRedirectList[e] : e)); 618 | } else { 619 | allTags = unique(fileCache.links) 620 | } 621 | if (this.settings.disableNestedTags && mode == "tag") { 622 | allTags = allTags.map((e) => e.split("/")).flat(); 623 | } 624 | if (allTags.length == 0) { 625 | if (mode == "tag") { 626 | allTags = ["_untagged"]; 627 | } else if (mode == "link") { 628 | allTags = ["_unlinked"]; 629 | } 630 | } 631 | if (fileCache.file.extension == "canvas") { 632 | allTags.push("_VIRTUAL_TAG_CANVAS") 633 | } 634 | if (this.settings.useVirtualTag) { 635 | const mtime = fileCache.file.stat.mtime; 636 | const diff = today - mtime 637 | const disp = secondsToFreshness(diff); 638 | allTags.push(`_VIRTUAL_TAG_FRESHNESS/${disp}`); 639 | } 640 | // Display folder as tag 641 | if (this.settings.displayFolderAsTag) { 642 | const path = ["_VIRTUAL_TAG_FOLDER", ...fileCache.file.path.split("/")]; 643 | path.pop();// Remove filename 644 | if (path.length > 0) { 645 | allTags.push(`${path.join("/")}`); 646 | } 647 | } 648 | 649 | // Again for the additional tags. 650 | allTags = uniqueCaseIntensive(allTags.map(e => e in tagRedirectList ? tagRedirectList[e] : e)); 651 | 652 | if ( 653 | allTags.some((tag) => 654 | ignoreDocTags.contains(tag.toLowerCase()) 655 | ) 656 | ) { 657 | continue; 658 | } 659 | 660 | // filter the items 661 | const w = searchItems.map((searchItem) => { 662 | let bx = false; 663 | if (allTags.length == 0) return false; 664 | for (const searchSrc of searchItem) { 665 | let search = searchSrc; 666 | let func = "contains" as "contains" | "startsWith"; 667 | if (search.startsWith("#")) { 668 | search = search.substring(1); 669 | func = "startsWith"; 670 | } 671 | if (search.startsWith("-")) { 672 | bx = 673 | bx || 674 | allTags.some((tag) => 675 | tag 676 | .toLowerCase()[func](search.substring(1)) 677 | ); 678 | // if (bx) continue; 679 | } else { 680 | bx = 681 | bx || 682 | allTags.every( 683 | (tag) => 684 | !tag.toLowerCase()[func](search) 685 | ); 686 | // if (bx) continue; 687 | } 688 | } 689 | return bx; 690 | }); 691 | 692 | if (w.every((e) => e)) continue; 693 | 694 | allTags = allTags.filter( 695 | (tag) => !ignoreTags.contains(tag.toLowerCase()) 696 | ); 697 | 698 | // if (this.settings.reduceNestedParent) { 699 | // allTags = mergeSameParents(allTags); 700 | // } 701 | 702 | const links = [...fileCache.links]; 703 | if (links.length == 0) links.push("_unlinked"); 704 | if (this.settings.disableNarrowingDown && mode == "tag") { 705 | const archiveTagsMatched = allTags.filter(e => archiveTags.contains(e.toLowerCase())); 706 | const targetTags = archiveTagsMatched.length == 0 ? allTags : archiveTagsMatched; 707 | for (const tags of targetTags) { 708 | items.push({ 709 | tags: [tags], 710 | extraTags: allTags.filter(e => e != tags), 711 | path: fileCache.file.path, 712 | displayName: this.getDisplayName(fileCache.file), 713 | ancestors: [], 714 | mtime: fileCache.file.stat.mtime, 715 | ctime: fileCache.file.stat.ctime, 716 | filename: fileCache.file.basename, 717 | links: links, 718 | }); 719 | } 720 | } else { 721 | items.push({ 722 | tags: allTags, 723 | extraTags: [], 724 | path: fileCache.file.path, 725 | displayName: this.getDisplayName(fileCache.file), 726 | ancestors: [], 727 | mtime: fileCache.file.stat.mtime, 728 | ctime: fileCache.file.stat.ctime, 729 | filename: fileCache.file.basename, 730 | links: links, 731 | }); 732 | } 733 | } 734 | return items; 735 | } 736 | 737 | lastSettings = ""; 738 | lastSearchString = ""; 739 | 740 | loadFileInfo(diff?: TFile) { 741 | void this.loadFileInfoAsync(diff).then(e => { 742 | /* NO op*/ 743 | }); 744 | } 745 | 746 | processingFileInfo = false; 747 | isSettingChanged() { 748 | const strSetting = JSON.stringify(this.settings); 749 | const isSettingChanged = strSetting != this.lastSettings; 750 | const isSearchStringModified = 751 | this.searchString != this.lastSearchString; 752 | if (isSettingChanged) { 753 | this.lastSettings = strSetting; 754 | } 755 | if (isSearchStringModified) { 756 | this.lastSearchString = this.searchString; 757 | } 758 | return isSearchStringModified || isSettingChanged; 759 | } 760 | loadFileQueue = [] as TFile[]; 761 | loadFileTimer?: ReturnType = undefined; 762 | async loadFileInfos(diffs: TFile[]) { 763 | if (this.processingFileInfo) { 764 | diffs.forEach(e => void this.loadFileInfoAsync(e)); 765 | return; 766 | } 767 | try { 768 | this.processingFileInfo = true; 769 | const cacheUpdated = this.updateFileCaches(diffs); 770 | if (this.isSettingChanged() || cacheUpdated) { 771 | appliedFiles.set(diffs.map(e => e.path)); 772 | await this.applyFileInfoToView(); 773 | } 774 | // Apply content of diffs to each view. 775 | await this.applyUpdateIntoScroll(diffs); 776 | const af = this.app.workspace.getActiveFile(); 777 | if (af && this.currentOpeningFile != af.path) { 778 | this.currentOpeningFile = af.path; 779 | currentFile.set(this.currentOpeningFile); 780 | } 781 | 782 | } finally { 783 | this.processingFileInfo = false; 784 | } 785 | } 786 | async applyFileInfoToView() { 787 | const items = await this.getItemsList("tag"); 788 | const itemsSorted = items.sort(this.compareItems); 789 | this.allViewItems = itemsSorted; 790 | allViewItems.set(this.allViewItems); 791 | if (this.getLinkView() != null) { 792 | const itemsLink = await this.getItemsList("link"); 793 | updateItemsLinkMap(itemsLink); 794 | const itemsLinkSorted = itemsLink.sort(this.compareItems); 795 | this.allViewItemsByLink = itemsLinkSorted; 796 | allViewItemsByLink.set(this.allViewItemsByLink); 797 | } 798 | } 799 | 800 | // Sweep updated file or all files to retrieve tags. 801 | async loadFileInfoAsync(diff?: TFile) { 802 | if (!diff) { 803 | this.loadFileQueue = []; 804 | if (this.loadFileTimer) { 805 | clearTimeout(this.loadFileTimer); 806 | this.loadFileTimer = undefined; 807 | } 808 | await this.loadFileInfos([]); 809 | return; 810 | } 811 | if (diff && this.loadFileQueue.some(e => e.path == diff?.path)) { 812 | //console.log(`LoadFileInfo already in queue:${diff?.path}`) 813 | } else { 814 | this.loadFileQueue.push(diff); 815 | //console.log(`LoadFileInfo queued:${diff.path}`); 816 | } 817 | if (this.loadFileTimer) { 818 | clearTimeout(this.loadFileTimer); 819 | } 820 | this.loadFileTimer = setTimeout(() => { 821 | if (this.loadFileQueue.length === 0) { 822 | // console.log(`No need to LoadFile`); 823 | } else { 824 | const diffs = [...this.loadFileQueue]; 825 | this.loadFileQueue = []; 826 | void this.loadFileInfos(diffs); 827 | } 828 | }, 200); 829 | } 830 | 831 | onunload() { 832 | pluginInstance.set(undefined!); 833 | } 834 | 835 | async openScrollView(leaf: WorkspaceLeaf | undefined, title: string, tagPath: string, files: string[]) { 836 | if (!leaf) { 837 | leaf = this.app.workspace.getLeaf("split"); 838 | } 839 | // this.app.workspace.create 840 | await leaf.setViewState({ 841 | type: VIEW_TYPE_SCROLL, 842 | active: true, 843 | state: { files: files.map(e => ({ path: e })), title: title, tagPath: tagPath } as ScrollViewState 844 | }); 845 | 846 | void this.app.workspace.revealLeaf( 847 | leaf 848 | ); 849 | } 850 | 851 | async applyUpdateIntoScroll(files: TFile[]) { 852 | const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_SCROLL); 853 | for (const leaf of leaves) { 854 | const view = leaf.view as ScrollView; 855 | if (!view) continue; 856 | const viewState = leaf.getViewState(); 857 | const scrollViewState = view?.getScrollViewState(); 858 | if (!viewState || !scrollViewState) continue; 859 | const viewStat = { ...viewState, state: { ...scrollViewState } } 860 | for (const file of files) { 861 | if (file && view.isFileOpened(file.path)) { 862 | 863 | const newStat = { 864 | ...viewStat, 865 | state: { 866 | ...viewStat.state, 867 | files: viewStat.state.files.map(e => e.path == file.path ? ({ 868 | path: file.path 869 | } as ScrollViewFile) : e) 870 | 871 | } 872 | } 873 | await leaf.setViewState(newStat); 874 | } 875 | } 876 | const tagPath = viewStat.state.tagPath; 877 | const tags = tagPath.split(", "); 878 | 879 | let matchedFiles = this.allViewItems; 880 | for (const tag of tags) { 881 | matchedFiles = matchedFiles.filter((item) => 882 | item.tags 883 | .map((tag) => tag.toLowerCase()) 884 | .some( 885 | (itemTag) => 886 | itemTag == 887 | tag.toLowerCase() || 888 | (itemTag + "/").startsWith( 889 | tag.toLowerCase() + 890 | (tag.endsWith("/") 891 | ? "" 892 | : "/") 893 | ) 894 | ) 895 | ) 896 | } 897 | 898 | const newFilesArray = matchedFiles.map(e => e.path); 899 | const newFiles = newFilesArray.sort().join("-"); 900 | const oldFiles = viewStat.state.files.map(e => e.path).sort().join("-"); 901 | if (newFiles != oldFiles) { 902 | // List has changed 903 | const newStat = { 904 | ...viewStat, 905 | state: { 906 | ...viewStat.state, 907 | files: newFilesArray.map(path => { 908 | const old = viewStat.state.files.find(e => e.path == path); 909 | if (old) return old; 910 | return { 911 | path: path 912 | } as ScrollViewFile; 913 | 914 | 915 | }) 916 | } 917 | } 918 | await leaf.setViewState(newStat); 919 | } 920 | } 921 | } 922 | 923 | async _initTagView() { 924 | const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_TAGFOLDER); 925 | if (leaves.length == 0) { 926 | await this.app.workspace.getLeftLeaf(false)?.setViewState({ 927 | type: VIEW_TYPE_TAGFOLDER, 928 | state: { treeViewType: "tags" } 929 | }); 930 | } else { 931 | const newState = leaves[0].getViewState(); 932 | await leaves[0].setViewState({ 933 | type: VIEW_TYPE_TAGFOLDER, 934 | state: { ...newState, treeViewType: "tags" } 935 | }) 936 | } 937 | } 938 | async _initLinkView() { 939 | const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_TAGFOLDER_LINK); 940 | if (leaves.length == 0) { 941 | await this.app.workspace.getLeftLeaf(false)?.setViewState({ 942 | type: VIEW_TYPE_TAGFOLDER_LINK, 943 | state: { treeViewType: "links" } 944 | }); 945 | } else { 946 | const newState = leaves[0].getViewState(); 947 | await leaves[0].setViewState({ 948 | type: VIEW_TYPE_TAGFOLDER_LINK, 949 | state: { ...newState, treeViewType: "links" } 950 | }) 951 | } 952 | } 953 | async initView() { 954 | this.loadFileInfo(); 955 | await this._initTagView(); 956 | } 957 | async initLinkView() { 958 | this.loadFileInfo(); 959 | await this._initLinkView(); 960 | } 961 | 962 | async activateView() { 963 | const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_TAGFOLDER); 964 | await this.initView(); 965 | if (leaves.length > 0) { 966 | await this.app.workspace.revealLeaf( 967 | leaves[0] 968 | ); 969 | } 970 | } 971 | async activateViewLink() { 972 | const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_TAGFOLDER_LINK); 973 | await this.initLinkView(); 974 | if (leaves.length > 0) { 975 | await this.app.workspace.revealLeaf( 976 | leaves[0] 977 | ); 978 | } 979 | 980 | } 981 | 982 | tagInfo: TagInfoDict = {}; 983 | tagInfoFrontMatterBuffer: Record = {}; 984 | skipOnce = false; 985 | tagInfoBody = ""; 986 | 987 | async modifyFile(file: TAbstractFile) { 988 | if (!this.settings.useTagInfo) return; 989 | if (this.skipOnce) { 990 | this.skipOnce = false; 991 | return; 992 | } 993 | if (file.name == this.getTagInfoFilename()) { 994 | await this.loadTagInfo(); 995 | } 996 | } 997 | 998 | getTagInfoFilename() { 999 | return normalizePath(this.settings.tagInfo); 1000 | } 1001 | 1002 | getTagInfoFile() { 1003 | const file = this.app.vault.getAbstractFileByPath(this.getTagInfoFilename()); 1004 | if (file instanceof TFile) { 1005 | return file; 1006 | } 1007 | return null; 1008 | } 1009 | 1010 | applyTagInfo() { 1011 | if (this.tagInfo == null) return; 1012 | if (!this.settings.useTagInfo) return; 1013 | tagInfo.set(this.tagInfo); 1014 | } 1015 | 1016 | async loadTagInfo() { 1017 | if (!this.settings.useTagInfo) return; 1018 | if (this.tagInfo == null) this.tagInfo = {}; 1019 | const file = this.getTagInfoFile(); 1020 | if (file == null) return; 1021 | const data = await this.app.vault.read(file); 1022 | try { 1023 | const bodyStartIndex = data.indexOf("\n---"); 1024 | if (!data.startsWith("---") || bodyStartIndex === -1) { 1025 | return; 1026 | } 1027 | const yaml = data.substring(3, bodyStartIndex); 1028 | const yamlData = parseYaml(yaml) as TagInfoDict; 1029 | 1030 | const keys = Object.keys(yamlData); 1031 | this.tagInfoBody = data.substring(bodyStartIndex + 5); 1032 | this.tagInfoFrontMatterBuffer = yamlData; 1033 | 1034 | const newTagInfo = {} as TagInfoDict; 1035 | for (const key of keys) { 1036 | const w = yamlData[key]; 1037 | if (!w) continue; 1038 | if (typeof (w) != "object") continue; 1039 | // snip unexpected keys 1040 | // but we can use xkey, xmark or something like that for preserving entries. 1041 | const keys = ["key", "mark", "alt", "redirect"]; 1042 | const entries = Object.entries(w).filter(([key]) => keys.some(e => key.contains(e))); 1043 | if (entries.length == 0) continue; 1044 | newTagInfo[key] = Object.fromEntries(entries); 1045 | } 1046 | this.tagInfo = newTagInfo; 1047 | this.applyTagInfo(); 1048 | } catch (ex) { 1049 | console.log(ex); 1050 | // NO OP. 1051 | } 1052 | 1053 | } 1054 | 1055 | async saveTagInfo() { 1056 | if (!this.settings.useTagInfo) return; 1057 | if (this.tagInfo == null) return; 1058 | let file = this.getTagInfoFile(); 1059 | if (file == null) { 1060 | file = await this.app.vault.create(this.getTagInfoFilename(), ""); 1061 | } 1062 | await this.app.fileManager.processFrontMatter(file, matter => { 1063 | const ti = Object.entries(this.tagInfo); 1064 | for (const [key, value] of ti) { 1065 | if (value === undefined) { 1066 | delete matter[key]; 1067 | } else { 1068 | matter[key] = value; 1069 | } 1070 | } 1071 | }); 1072 | } 1073 | 1074 | async refreshAllViewItems() { 1075 | this.parsedFileCache.clear(); 1076 | const items = await this.getItemsList("tag"); 1077 | const itemsSorted = items.sort(this.compareItems); 1078 | this.allViewItems = itemsSorted; 1079 | allViewItems.set(this.allViewItems); 1080 | 1081 | const itemsLink = await this.getItemsList("link"); 1082 | const itemsLinkSorted = itemsLink.sort(this.compareItems); 1083 | this.allViewItemsByLink = itemsLinkSorted; 1084 | allViewItemsByLink.set(this.allViewItemsByLink); 1085 | } 1086 | async loadSettings() { 1087 | this.settings = Object.assign( 1088 | {}, 1089 | DEFAULT_SETTINGS, 1090 | await this.loadData() 1091 | ); 1092 | await this.loadTagInfo(); 1093 | tagFolderSetting.set(this.settings); 1094 | this.compareItems = getCompareMethodItems(this.settings); 1095 | // this.compareTags = getCompareMethodTags(this.settings); 1096 | } 1097 | 1098 | async saveSettings() { 1099 | await this.saveData(this.settings); 1100 | await this.saveTagInfo(); 1101 | tagFolderSetting.set(this.settings); 1102 | this.compareItems = getCompareMethodItems(this.settings); 1103 | void this.refreshAllViewItems(); // (Do not wait for it) 1104 | // this.compareTags = getCompareMethodTags(this.settings); 1105 | } 1106 | 1107 | async openListView(tagSrc: string[]) { 1108 | if (!tagSrc) return; 1109 | const tags = tagSrc.first() == "root" ? tagSrc.slice(1) : tagSrc; 1110 | 1111 | let theLeaf: WorkspaceLeaf | undefined = undefined; 1112 | for (const leaf of this.app.workspace.getLeavesOfType( 1113 | VIEW_TYPE_TAGFOLDER_LIST 1114 | )) { 1115 | const state = leaf.getViewState(); 1116 | if (!state.state?.tags) continue; 1117 | if ((state.state.tags as string[]).slice().sort().join("-") == tags.slice().sort().join("-")) { 1118 | // already shown. 1119 | this.app.workspace.setActiveLeaf(leaf, { focus: true }); 1120 | return; 1121 | } 1122 | if (state.pinned) { 1123 | // NO OP. 1124 | } else { 1125 | theLeaf = leaf 1126 | } 1127 | } 1128 | if (!theLeaf) { 1129 | const parent = this.app.workspace.getLeavesOfType(VIEW_TYPE_TAGFOLDER)?.first(); 1130 | if (!parent) { 1131 | // Cancel if the tagfolder has been disappeared. 1132 | return; 1133 | } 1134 | switch (this.settings.showListIn) { 1135 | case "CURRENT_PANE": 1136 | theLeaf = this.app.workspace.getLeaf(); 1137 | break; 1138 | case "SPLIT_PANE": 1139 | theLeaf = this.app.workspace.getLeaf("split", "horizontal"); 1140 | break; 1141 | case "": 1142 | default: 1143 | if (!Platform.isMobile) { 1144 | theLeaf = this.app.workspace.createLeafBySplit(parent, "horizontal", false); 1145 | } else { 1146 | theLeaf = this.app.workspace.getLeftLeaf(false) as WorkspaceLeaf; 1147 | } 1148 | break; 1149 | } 1150 | } 1151 | const title = tags.map((e) => 1152 | e 1153 | .split("/") 1154 | .map((ee) => renderSpecialTag(ee)) 1155 | .join("/") 1156 | ).join(" "); 1157 | await theLeaf.setViewState({ 1158 | type: VIEW_TYPE_TAGFOLDER_LIST, 1159 | active: true, 1160 | state: { tags: tags, title: title } as TagFolderListState 1161 | }); 1162 | 1163 | await this.app.workspace.revealLeaf( 1164 | theLeaf 1165 | ); 1166 | } 1167 | 1168 | async createNewNote(tags?: string[]) { 1169 | const expandedTagsAll = ancestorToLongestTag(ancestorToTags(joinPartialPath(removeIntermediatePath(tags ?? [])))) 1170 | .map((e) => trimTrailingSlash(e)); 1171 | 1172 | const expandedTags = expandedTagsAll 1173 | .map((e) => e 1174 | .split("/") 1175 | .filter((ee) => !isSpecialTag(ee)) 1176 | .join("/")) 1177 | .filter((e) => e != "") 1178 | .map((e) => "#" + e) 1179 | .join(" ") 1180 | .trim(); 1181 | 1182 | //@ts-ignore 1183 | const ww = await this.app.fileManager.createAndOpenMarkdownFile() as TFile; 1184 | if (this.settings.useFrontmatterTagsForNewNotes) { 1185 | await this.app.fileManager.processFrontMatter(ww, (matter) => { 1186 | matter.tags = matter.tags ?? []; 1187 | matter.tags = expandedTagsAll 1188 | .filter(e => !isSpecialTag(e)) 1189 | .filter(e => matter.tags.indexOf(e) < 0) 1190 | .concat(matter.tags); 1191 | }); 1192 | } 1193 | else { 1194 | await this.app.vault.append(ww, expandedTags); 1195 | } 1196 | } 1197 | } 1198 | 1199 | class TagFolderSettingTab extends PluginSettingTab { 1200 | plugin: TagFolderPlugin; 1201 | 1202 | constructor(app: App, plugin: TagFolderPlugin) { 1203 | super(app, plugin); 1204 | this.plugin = plugin; 1205 | } 1206 | 1207 | hide() { 1208 | this.plugin.loadFileInfo(); 1209 | } 1210 | 1211 | display(): void { 1212 | const { containerEl } = this; 1213 | containerEl.empty(); 1214 | 1215 | containerEl.createEl("h2", { text: "Behavior" }); 1216 | new Setting(containerEl) 1217 | .setName("Always Open") 1218 | .setDesc("Place TagFolder on the left pane and activate it at every Obsidian launch") 1219 | .addToggle((toggle) => 1220 | toggle 1221 | .setValue(this.plugin.settings.alwaysOpen) 1222 | .onChange(async (value) => { 1223 | this.plugin.settings.alwaysOpen = value; 1224 | await this.plugin.saveSettings(); 1225 | }) 1226 | ); 1227 | new Setting(containerEl) 1228 | .setName("Use pinning") 1229 | .setDesc( 1230 | "When this feature is enabled, the pin information is saved in the file set in the next configuration." 1231 | ) 1232 | .addToggle((toggle) => { 1233 | toggle 1234 | .setValue(this.plugin.settings.useTagInfo) 1235 | .onChange(async (value) => { 1236 | this.plugin.settings.useTagInfo = value; 1237 | if (this.plugin.settings.useTagInfo) { 1238 | await this.plugin.loadTagInfo(); 1239 | } 1240 | await this.plugin.saveSettings(); 1241 | pi.setDisabled(!value); 1242 | }); 1243 | }); 1244 | const pi = new Setting(containerEl) 1245 | .setName("Pin information file") 1246 | .setDisabled(!this.plugin.settings.useTagInfo) 1247 | .addText((text) => { 1248 | text 1249 | .setValue(this.plugin.settings.tagInfo) 1250 | .onChange(async (value) => { 1251 | this.plugin.settings.tagInfo = value; 1252 | if (this.plugin.settings.useTagInfo) { 1253 | await this.plugin.loadTagInfo(); 1254 | } 1255 | await this.plugin.saveSettings(); 1256 | }); 1257 | }); 1258 | new Setting(containerEl) 1259 | .setName("Disable narrowing down") 1260 | .setDesc( 1261 | "When this feature is enabled, relevant tags will be shown with the title instead of making a sub-structure." 1262 | ) 1263 | .addToggle((toggle) => { 1264 | toggle 1265 | .setValue(this.plugin.settings.disableNarrowingDown) 1266 | .onChange(async (value) => { 1267 | this.plugin.settings.disableNarrowingDown = value; 1268 | await this.plugin.saveSettings(); 1269 | }); 1270 | }); 1271 | containerEl.createEl("h2", { text: "Files" }); 1272 | new Setting(containerEl) 1273 | .setName("Display method") 1274 | .setDesc("How to show a title of files") 1275 | .addDropdown((dropdown) => 1276 | dropdown 1277 | .addOptions({ 1278 | "PATH/NAME": "PATH/NAME", 1279 | NAME: "NAME", 1280 | "NAME : PATH": "NAME : PATH", 1281 | }) 1282 | .setValue(this.plugin.settings.displayMethod) 1283 | .onChange(async (value) => { 1284 | this.plugin.settings.displayMethod = value as DISPLAY_METHOD; 1285 | this.plugin.loadFileInfo(); 1286 | await this.plugin.saveSettings(); 1287 | }) 1288 | ); 1289 | const setOrderMethod = async (key?: string, order?: string) => { 1290 | const oldSetting = this.plugin.settings.sortType.split("_"); 1291 | if (!key) key = oldSetting[0]; 1292 | if (!order) order = oldSetting[1]; 1293 | //@ts-ignore 1294 | this.plugin.settings.sortType = `${key}_${order}`; 1295 | await this.plugin.saveSettings(); 1296 | // this.plugin.setRoot(this.plugin.root); 1297 | }; 1298 | new Setting(containerEl) 1299 | .setName("Order method") 1300 | .setDesc("how to order items") 1301 | .addDropdown((dd) => { 1302 | dd.addOptions(OrderKeyItem) 1303 | .setValue(this.plugin.settings.sortType.split("_")[0]) 1304 | .onChange((key) => setOrderMethod(key, undefined)); 1305 | }) 1306 | .addDropdown((dd) => { 1307 | dd.addOptions(OrderDirection) 1308 | .setValue(this.plugin.settings.sortType.split("_")[1]) 1309 | .onChange((order) => setOrderMethod(undefined, order)); 1310 | }); 1311 | new Setting(containerEl) 1312 | .setName("Prioritize items which are not contained in sub-folder") 1313 | .setDesc("If this has been enabled, the items which have no more extra tags are first.") 1314 | .addToggle((toggle) => { 1315 | toggle 1316 | .setValue(this.plugin.settings.sortExactFirst) 1317 | .onChange(async (value) => { 1318 | this.plugin.settings.sortExactFirst = value; 1319 | await this.plugin.saveSettings(); 1320 | }); 1321 | }); 1322 | new Setting(containerEl) 1323 | .setName("Use title") 1324 | .setDesc( 1325 | "Use value in the frontmatter or first level one heading for `NAME`." 1326 | ) 1327 | .addToggle((toggle) => { 1328 | toggle 1329 | .setValue(this.plugin.settings.useTitle) 1330 | .onChange(async (value) => { 1331 | this.plugin.settings.useTitle = value; 1332 | fpath.setDisabled(!value); 1333 | await this.plugin.saveSettings(); 1334 | }); 1335 | }); 1336 | const fpath = new Setting(containerEl) 1337 | .setName("Frontmatter path") 1338 | .setDisabled(!this.plugin.settings.useTitle) 1339 | .addText((text) => { 1340 | text 1341 | .setValue(this.plugin.settings.frontmatterKey) 1342 | .onChange(async (value) => { 1343 | this.plugin.settings.frontmatterKey = value; 1344 | await this.plugin.saveSettings(); 1345 | }); 1346 | }); 1347 | 1348 | containerEl.createEl("h2", { text: "Tags" }); 1349 | 1350 | const setOrderMethodTag = async (key?: string, order?: string) => { 1351 | const oldSetting = this.plugin.settings.sortTypeTag.split("_"); 1352 | if (!key) key = oldSetting[0]; 1353 | if (!order) order = oldSetting[1]; 1354 | //@ts-ignore 1355 | this.plugin.settings.sortTypeTag = `${key}_${order}`; 1356 | await this.plugin.saveSettings(); 1357 | // this.plugin.setRoot(this.plugin.root); 1358 | }; 1359 | new Setting(containerEl) 1360 | .setName("Order method") 1361 | .setDesc("how to order tags") 1362 | .addDropdown((dd) => { 1363 | dd.addOptions(OrderKeyTag) 1364 | .setValue(this.plugin.settings.sortTypeTag.split("_")[0]) 1365 | .onChange((key) => setOrderMethodTag(key, undefined)); 1366 | }) 1367 | .addDropdown((dd) => { 1368 | dd.addOptions(OrderDirection) 1369 | .setValue(this.plugin.settings.sortTypeTag.split("_")[1]) 1370 | .onChange((order) => setOrderMethodTag(undefined, order)); 1371 | }); 1372 | 1373 | 1374 | new Setting(containerEl) 1375 | .setName("Use virtual tags") 1376 | .addToggle((toggle) => { 1377 | toggle 1378 | .setValue(this.plugin.settings.useVirtualTag) 1379 | .onChange(async (value) => { 1380 | this.plugin.settings.useVirtualTag = value; 1381 | await this.plugin.saveSettings(); 1382 | }); 1383 | }); 1384 | new Setting(containerEl) 1385 | .setName("Display folder as tag") 1386 | .addToggle((toggle) => { 1387 | toggle 1388 | .setValue(this.plugin.settings.displayFolderAsTag) 1389 | .onChange(async (value) => { 1390 | this.plugin.settings.displayFolderAsTag = value; 1391 | await this.plugin.saveSettings(); 1392 | }); 1393 | }); 1394 | new Setting(containerEl) 1395 | .setName("Store tags in frontmatter for new notes") 1396 | .setDesc("Otherwise, tags are stored with #hashtags at the top of the note") 1397 | .addToggle((toggle) => { 1398 | toggle 1399 | .setValue(this.plugin.settings.useFrontmatterTagsForNewNotes) 1400 | .onChange(async (value) => { 1401 | this.plugin.settings.useFrontmatterTagsForNewNotes = value; 1402 | await this.plugin.saveSettings(); 1403 | }); 1404 | }); 1405 | 1406 | containerEl.createEl("h2", { text: "Actions" }); 1407 | new Setting(containerEl) 1408 | .setName("Search tags inside TagFolder when clicking tags") 1409 | .addToggle((toggle) => { 1410 | toggle 1411 | .setValue(this.plugin.settings.overrideTagClicking) 1412 | .onChange(async (value) => { 1413 | this.plugin.settings.overrideTagClicking = value; 1414 | await this.plugin.saveSettings(); 1415 | }); 1416 | }); 1417 | new Setting(containerEl) 1418 | .setName("List files in a separated pane") 1419 | .addToggle((toggle) => { 1420 | toggle 1421 | .setValue(this.plugin.settings.useMultiPaneList) 1422 | .onChange(async (value) => { 1423 | this.plugin.settings.useMultiPaneList = value; 1424 | await this.plugin.saveSettings(); 1425 | }); 1426 | }); 1427 | new Setting(containerEl) 1428 | .setName("Show list in") 1429 | .setDesc("This option applies to the newly opened list") 1430 | .addDropdown((dropdown) => { 1431 | dropdown 1432 | .addOptions(enumShowListIn) 1433 | .setValue(this.plugin.settings.showListIn) 1434 | .onChange(async (value) => { 1435 | this.plugin.settings.showListIn = value as keyof typeof enumShowListIn; 1436 | await this.plugin.saveSettings(); 1437 | }); 1438 | }); 1439 | containerEl.createEl("h2", { text: "Arrangements" }); 1440 | 1441 | new Setting(containerEl) 1442 | .setName("Hide Items") 1443 | .setDesc("Hide items on the landing or nested tags") 1444 | .addDropdown((dd) => { 1445 | dd.addOptions(HideItemsType) 1446 | .setValue(this.plugin.settings.hideItems) 1447 | .onChange(async (key) => { 1448 | if ( 1449 | key == "NONE" || 1450 | key == "DEDICATED_INTERMIDIATES" || 1451 | key == "ALL_EXCEPT_BOTTOM" 1452 | ) { 1453 | this.plugin.settings.hideItems = key; 1454 | } 1455 | await this.plugin.saveSettings(); 1456 | }); 1457 | }); 1458 | new Setting(containerEl) 1459 | .setName("Merge redundant combinations") 1460 | .setDesc( 1461 | "When this feature is enabled, a/b and b/a are merged into a/b if there is no intermediates." 1462 | ) 1463 | .addToggle((toggle) => { 1464 | toggle 1465 | .setValue(this.plugin.settings.mergeRedundantCombination) 1466 | .onChange(async (value) => { 1467 | this.plugin.settings.mergeRedundantCombination = value; 1468 | await this.plugin.saveSettings(); 1469 | }); 1470 | }); 1471 | new Setting(containerEl) 1472 | .setName("Do not simplify empty folders") 1473 | .setDesc( 1474 | "Keep empty folders, even if they can be simplified." 1475 | ) 1476 | .addToggle((toggle) => { 1477 | toggle 1478 | .setValue(this.plugin.settings.doNotSimplifyTags) 1479 | .onChange(async (value) => { 1480 | this.plugin.settings.doNotSimplifyTags = value; 1481 | await this.plugin.saveSettings(); 1482 | }); 1483 | }); 1484 | 1485 | new Setting(containerEl) 1486 | .setName("Do not treat nested tags as dedicated levels") 1487 | .setDesc("Treat nested tags as normal tags") 1488 | .addToggle((toggle) => { 1489 | toggle 1490 | .setValue(this.plugin.settings.disableNestedTags) 1491 | .onChange(async (value) => { 1492 | this.plugin.settings.disableNestedTags = value; 1493 | await this.plugin.saveSettings(); 1494 | }); 1495 | }); 1496 | new Setting(containerEl) 1497 | .setName("Reduce duplicated parents in nested tags") 1498 | .setDesc("If enabled, #web/css, #web/javascript will merged into web -> css -> javascript") 1499 | .addToggle((toggle) => { 1500 | toggle 1501 | .setValue(this.plugin.settings.reduceNestedParent) 1502 | .onChange(async (value) => { 1503 | this.plugin.settings.reduceNestedParent = value; 1504 | await this.plugin.saveSettings(); 1505 | }); 1506 | }); 1507 | 1508 | new Setting(containerEl) 1509 | .setName("Keep untagged items on the root") 1510 | .addToggle((toggle) => { 1511 | toggle 1512 | .setValue(this.plugin.settings.expandUntaggedToRoot) 1513 | .onChange(async (value) => { 1514 | this.plugin.settings.expandUntaggedToRoot = value; 1515 | await this.plugin.saveSettings(); 1516 | }); 1517 | }); 1518 | 1519 | containerEl.createEl("h2", { text: "Link Folder" }); 1520 | new Setting(containerEl) 1521 | .setName("Use Incoming") 1522 | .setDesc("") 1523 | .addToggle((toggle) => 1524 | toggle 1525 | .setValue(this.plugin.settings.linkConfig.incoming.enabled) 1526 | .onChange(async (value) => { 1527 | this.plugin.settings.linkConfig.incoming.enabled = value; 1528 | await this.plugin.saveSettings(); 1529 | }) 1530 | ); 1531 | new Setting(containerEl) 1532 | .setName("Use Outgoing") 1533 | .setDesc("") 1534 | .addToggle((toggle) => 1535 | toggle 1536 | .setValue(this.plugin.settings.linkConfig.outgoing.enabled) 1537 | .onChange(async (value) => { 1538 | this.plugin.settings.linkConfig.outgoing.enabled = value; 1539 | await this.plugin.saveSettings(); 1540 | }) 1541 | ); 1542 | new Setting(containerEl) 1543 | .setName("Hide indirectly linked notes") 1544 | .setDesc("") 1545 | .addToggle((toggle) => 1546 | toggle 1547 | .setValue(this.plugin.settings.linkShowOnlyFDR) 1548 | .onChange(async (value) => { 1549 | this.plugin.settings.linkShowOnlyFDR = value; 1550 | await this.plugin.saveSettings(); 1551 | }) 1552 | ); 1553 | new Setting(containerEl) 1554 | .setName("Connect linked tree") 1555 | .setDesc("") 1556 | .addToggle((toggle) => 1557 | toggle 1558 | .setValue(this.plugin.settings.linkCombineOtherTree) 1559 | .onChange(async (value) => { 1560 | this.plugin.settings.linkCombineOtherTree = value; 1561 | await this.plugin.saveSettings(); 1562 | }) 1563 | ); 1564 | 1565 | containerEl.createEl("h2", { text: "Filters" }); 1566 | new Setting(containerEl) 1567 | .setName("Target Folders") 1568 | .setDesc("If configured, the plugin will only target files in it.") 1569 | .addTextArea((text) => 1570 | text 1571 | .setValue(this.plugin.settings.targetFolders) 1572 | .setPlaceholder("study,documents/summary") 1573 | .onChange(async (value) => { 1574 | this.plugin.settings.targetFolders = value; 1575 | await this.plugin.saveSettings(); 1576 | }) 1577 | ); 1578 | new Setting(containerEl) 1579 | .setName("Ignore Folders") 1580 | .setDesc("Ignore documents in specific folders.") 1581 | .addTextArea((text) => 1582 | text 1583 | .setValue(this.plugin.settings.ignoreFolders) 1584 | .setPlaceholder("template,list/standard_tags") 1585 | .onChange(async (value) => { 1586 | this.plugin.settings.ignoreFolders = value; 1587 | await this.plugin.saveSettings(); 1588 | }) 1589 | ); 1590 | new Setting(containerEl) 1591 | .setName("Ignore note Tag") 1592 | .setDesc( 1593 | "If the note has the tag listed below, the note would be treated as there was not." 1594 | ) 1595 | .addTextArea((text) => 1596 | text 1597 | .setValue(this.plugin.settings.ignoreDocTags) 1598 | .setPlaceholder("test,test1,test2") 1599 | .onChange(async (value) => { 1600 | this.plugin.settings.ignoreDocTags = value; 1601 | await this.plugin.saveSettings(); 1602 | }) 1603 | ); 1604 | new Setting(containerEl) 1605 | .setName("Ignore Tag") 1606 | .setDesc("Tags in the list would be treated as there were not.") 1607 | .addTextArea((text) => 1608 | text 1609 | .setValue(this.plugin.settings.ignoreTags) 1610 | .setPlaceholder("test,test1,test2") 1611 | .onChange(async (value) => { 1612 | this.plugin.settings.ignoreTags = value; 1613 | await this.plugin.saveSettings(); 1614 | }) 1615 | ); 1616 | new Setting(containerEl) 1617 | .setName("Archive tags") 1618 | .setDesc("If configured, notes with these tags will be moved under the tag.") 1619 | .addTextArea((text) => 1620 | text 1621 | .setValue(this.plugin.settings.archiveTags) 1622 | .setPlaceholder("archived, discontinued") 1623 | .onChange(async (value) => { 1624 | this.plugin.settings.archiveTags = value; 1625 | await this.plugin.saveSettings(); 1626 | }) 1627 | ); 1628 | 1629 | containerEl.createEl("h2", { text: "Misc" }); 1630 | 1631 | new Setting(containerEl) 1632 | .setName("Tag scanning delay") 1633 | .setDesc( 1634 | "Sets the delay for reflecting metadata changes to the tag tree. (Plugin reload is required.)" 1635 | ) 1636 | .addText((text) => { 1637 | text = text 1638 | .setValue(this.plugin.settings.scanDelay + "") 1639 | 1640 | .onChange(async (value) => { 1641 | const newDelay = Number.parseInt(value, 10); 1642 | if (newDelay) { 1643 | this.plugin.settings.scanDelay = newDelay; 1644 | await this.plugin.saveSettings(); 1645 | } 1646 | }); 1647 | text.inputEl.setAttribute("type", "number"); 1648 | text.inputEl.setAttribute("min", "250"); 1649 | return text; 1650 | }); 1651 | new Setting(containerEl) 1652 | .setName("Disable dragging tags") 1653 | .setDesc("The `Dragging tags` is using internal APIs. If something happens, please disable this once and try again.") 1654 | .addToggle((toggle) => { 1655 | toggle 1656 | .setValue(this.plugin.settings.disableDragging) 1657 | .onChange(async (value) => { 1658 | this.plugin.settings.disableDragging = value; 1659 | await this.plugin.saveSettings(); 1660 | }); 1661 | }); 1662 | containerEl.createEl("h2", { text: "Utilities" }); 1663 | 1664 | new Setting(containerEl) 1665 | .setName("Dumping tags for reporting bugs") 1666 | .setDesc( 1667 | "If you want to open an issue to the GitHub, this information can be useful. and, also if you want to keep secrets about names of tags, you can use `disguised`." 1668 | ) 1669 | .addButton((button) => 1670 | button 1671 | .setButtonText("Copy tags") 1672 | .setDisabled(false) 1673 | .onClick(async () => { 1674 | const itemsAll = await this.plugin.getItemsList("tag"); 1675 | const items = itemsAll.map(e => e.tags.filter(e => e != "_untagged")).filter(e => e.length); 1676 | await navigator.clipboard.writeText(items.map(e => e.map(e => `#${e}`).join(", ")).join("\n")); 1677 | new Notice("Copied to clipboard"); 1678 | })) 1679 | .addButton((button) => 1680 | button 1681 | .setButtonText("Copy disguised tags") 1682 | .setDisabled(false) 1683 | .onClick(async () => { 1684 | const x = new Map(); 1685 | let i = 0; 1686 | const itemsAll = await this.plugin.getItemsList("tag"); 1687 | const items = itemsAll.map(e => e.tags.filter(e => e != "_untagged").map(e => 1688 | e.split("/").map(e => e.startsWith("_VIRTUAL") ? e : x.has(e) ? x.get(e) : (x.set(e, `tag${i++}`), i)).join("/")).filter(e => e.length)); 1689 | 1690 | await navigator.clipboard.writeText(items.map(e => e.map(e => `#${e}`).join(", ")).join("\n")); 1691 | new Notice("Copied to clipboard"); 1692 | }) 1693 | ); 1694 | } 1695 | } 1696 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-tagfolder", 3 | "name": "TagFolder", 4 | "version": "0.18.11", 5 | "minAppVersion": "0.12.0", 6 | "description": "Show tags as folder", 7 | "author": "vorotamoroz", 8 | "authorUrl": "https://github.com/vrtmrz", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-tagfolder", 3 | "version": "0.18.11", 4 | "description": "Show tags as folder", 5 | "main": "main.js", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "node esbuild.config.mjs", 9 | "build": "node esbuild.config.mjs production", 10 | "lint": "eslint .", 11 | "svelte-check": "svelte-check --tsconfig ./tsconfig.json", 12 | "tsc-check": "tsc --noEmit", 13 | "check": "npm run lint && npm run svelte-check && npm run tsc-check" 14 | }, 15 | "keywords": [], 16 | "author": "vorotamoroz", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@eslint/compat": "^1.2.8", 20 | "@eslint/eslintrc": "^3.3.1", 21 | "@eslint/js": "^9.24.0", 22 | "@tsconfig/svelte": "^5.0.4", 23 | "@types/node": "^22.7.9", 24 | "@typescript-eslint/eslint-plugin": "^8.30.1", 25 | "@typescript-eslint/parser": "^8.11.0", 26 | "builtin-modules": "^4.0.0", 27 | "esbuild": "^0.24.0", 28 | "esbuild-svelte": "^0.8.2", 29 | "eslint": "^9.24.0", 30 | "eslint-plugin-import": "^2.31.0", 31 | "eslint-plugin-svelte": "^3.5.1", 32 | "obsidian": "^1.8.7", 33 | "svelte": "^5.27.0", 34 | "svelte-check": "^4.1.6", 35 | "svelte-preprocess": "^6.0.3", 36 | "terser": "^5.39.0", 37 | "typescript": "^5.8.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /store.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | import { DEFAULT_SETTINGS, type TagFolderSettings, type TagInfoDict, type ViewItem } from "types"; 3 | import type TagFolderPlugin from "./main"; 4 | 5 | export const currentFile = writable(""); 6 | 7 | export const maxDepth = writable(0); 8 | 9 | export const searchString = writable(""); 10 | export const tagInfo = writable({}); 11 | 12 | export const tagFolderSetting = writable(DEFAULT_SETTINGS); 13 | 14 | export const selectedTags = writable(); 15 | 16 | //v2 17 | export const allViewItems = writable(); 18 | export const allViewItemsByLink = writable(); 19 | export const appliedFiles = writable(); 20 | export const v2expandedTags = writable(new Set()); 21 | 22 | export const performHide = writable(0); 23 | 24 | export const pluginInstance = writable(undefined); 25 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .workspace-leaf-content[data-type="tagfolder-view"]>.view-content, 2 | .workspace-leaf-content[data-type="tagfolder-view-list"]>.view-content { 3 | padding: 0; 4 | height: 100%; 5 | overflow: unset; 6 | } 7 | 8 | .override-input { 9 | flex-direction: column-reverse; 10 | } 11 | 12 | .tagfolder-tag::before { 13 | all: inherit; 14 | color: inherit; 15 | font: inherit; 16 | } 17 | 18 | .tagfolder-tag.tag-tag::before { 19 | margin-left: 0px; 20 | margin-right: 0px; 21 | } 22 | 23 | .tagfolder-tag.tag-tag span.tf-tag-each:empty { 24 | display: none; 25 | } 26 | 27 | .tagfolder-tag.tag-tag.tf-tag span.tf-tag-each:first-child:not(:empty):before { 28 | content: "#"; 29 | margin-left: 0.3em; 30 | } 31 | 32 | .tagfolder-tag.tag-tag.tf-link span.tf-tag-each:first-child:not(:empty):before { 33 | content: ""; 34 | margin-left: 0.3em; 35 | } 36 | 37 | .tagfolder-tag.tag-tag.tf-link.link-cross span.tf-tag-each:first-child:not(:empty):before { 38 | content: "⇔ "; 39 | margin-left: 0.3em; 40 | } 41 | 42 | .tagfolder-tag.tag-tag.tf-link.link-forward span.tf-tag-each:first-child:not(:empty):before { 43 | content: "⇒ "; 44 | margin-left: 0.3em; 45 | } 46 | 47 | .tagfolder-tag.tag-tag.tf-link.link-reverse span.tf-tag-each:first-child:not(:empty):before { 48 | content: "⇐ "; 49 | margin-left: 0.3em; 50 | } 51 | 52 | .tagfolder-tag.tag-tag span.tf-tag-each::before { 53 | content: "→ "; 54 | margin-left: 0; 55 | margin-right: 0; 56 | } 57 | 58 | 59 | .tf-taglist { 60 | white-space: nowrap; 61 | text-overflow: ellipsis; 62 | overflow: hidden; 63 | display: inline-block; 64 | flex-shrink: 100; 65 | margin-left: auto; 66 | text-align: right; 67 | } 68 | 69 | .tf-taglist:not(:empty) { 70 | min-width: 3em; 71 | } 72 | 73 | .tf-taglist .tf-tag { 74 | background-color: var(--background-secondary-alt); 75 | border-radius: 4px; 76 | padding: 2px 4px; 77 | margin-left: 4px; 78 | color: var(--nav-item-color); 79 | } 80 | 81 | .nav-folder-title:hover .tagfolder-quantity, 82 | .nav-file-title:hover .tf-taglist { 83 | color: var(--text-on-accent); 84 | } 85 | 86 | .nav-folder-title:hover .tagfolder-quantity span, 87 | .nav-file-title:hover .tf-taglist span.tf-tag { 88 | color: var(--text-on-accent); 89 | background-color: var(--interactive-accent-hover); 90 | } 91 | 92 | 93 | .lsl-f { 94 | flex-direction: row; 95 | display: flex; 96 | flex-grow: 1; 97 | overflow: hidden; 98 | flex-shrink: 1; 99 | } 100 | 101 | .lsl-f:not(:last-child) { 102 | min-width: 3em; 103 | } 104 | 105 | .lsl-f:empty::before { 106 | content: "..."; 107 | } 108 | 109 | .tagfolder-titletagname { 110 | flex-grow: 1; 111 | text-overflow: ellipsis; 112 | white-space: nowrap; 113 | overflow: hidden; 114 | } 115 | 116 | .tagfolder-quantity span { 117 | background-color: var(--background-secondary-alt); 118 | color: var(--nav-item-color); 119 | border-radius: 4px; 120 | padding: 2px 4px; 121 | } 122 | 123 | .tagfolder-quantity { 124 | width: 3em; 125 | text-align: right; 126 | cursor: pointer; 127 | margin-left: auto; 128 | } 129 | 130 | .tag-folder-title { 131 | max-width: 100%; 132 | } 133 | 134 | .tree-item.nav-folder.updating { 135 | background: linear-gradient(135deg, var(--interactive-accent-hover) 0%, var(--interactive-accent-hover) 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0) 100%); 136 | background-repeat: no-repeat; 137 | background-position: 0 0; 138 | background-size: 10px 10px; 139 | } -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import { sveltePreprocess } from "svelte-preprocess"; 2 | 3 | /** 4 | * This will add autocompletion if you're working with SvelteKit 5 | * 6 | * @type {import('@sveltejs/kit').Config} 7 | */ 8 | const config = { 9 | preprocess: sveltePreprocess({ 10 | // ...svelte-preprocess options 11 | }), 12 | cache: false, 13 | compilerOptions: { css: "injected" }, 14 | // ...other svelte options 15 | }; 16 | 17 | export default config; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "inlineSourceMap": true, 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "module": "ESNext", 7 | "target": "ES2018", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "types": ["svelte", "node"], 12 | // "importsNotUsedAsValues": "error", 13 | "importHelpers": false, 14 | "alwaysStrict": true, 15 | "lib": [ 16 | "es2018", 17 | "DOM", 18 | "ES5", 19 | "ES6", 20 | "ES7", 21 | "es2019.array" 22 | ] 23 | }, 24 | "include": ["**/*.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import type { TFile } from "obsidian"; 2 | import { type DISPLAY_METHOD, type HIDE_ITEMS_TYPE } from "./main"; 3 | 4 | export interface ViewItem { 5 | /** 6 | * Historical reason, `tags` consists the list of either tag or link. 7 | */ 8 | tags: string[]; 9 | extraTags: string[]; 10 | path: string; 11 | displayName: string; 12 | ancestors: string[]; 13 | mtime: number; 14 | ctime: number; 15 | filename: string; 16 | links: string[]; 17 | } 18 | export interface TagInfoDict { 19 | [key: string]: TagInfo; 20 | } 21 | export interface TagInfo { 22 | key?: string; 23 | mark?: string; 24 | alt?: string; 25 | redirect?: string; 26 | } 27 | 28 | 29 | export type LinkParseConf = { 30 | outgoing: { 31 | enabled: boolean, 32 | key: string, 33 | }, 34 | incoming: { 35 | enabled: boolean, 36 | key: string 37 | } 38 | } 39 | export const enumShowListIn = { 40 | "": "Sidebar", 41 | "CURRENT_PANE": "Current pane", 42 | "SPLIT_PANE": "New pane", 43 | } 44 | 45 | export interface TagFolderSettings { 46 | displayMethod: DISPLAY_METHOD; 47 | alwaysOpen: boolean; 48 | ignoreDocTags: string; 49 | ignoreTags: string; 50 | ignoreFolders: string; 51 | targetFolders: string; 52 | hideOnRootTags: string; 53 | sortType: "DISPNAME_ASC" | 54 | "DISPNAME_DESC" | 55 | "NAME_ASC" | 56 | "NAME_DESC" | 57 | "MTIME_ASC" | 58 | "MTIME_DESC" | 59 | "CTIME_ASC" | 60 | "CTIME_DESC" | 61 | "FULLPATH_ASC" | 62 | "FULLPATH_DESC"; 63 | sortExactFirst: boolean; 64 | sortTypeTag: "NAME_ASC" | "NAME_DESC" | "ITEMS_ASC" | "ITEMS_DESC"; 65 | expandLimit: number; 66 | disableNestedTags: boolean; 67 | 68 | hideItems: HIDE_ITEMS_TYPE; 69 | scanDelay: number; 70 | useTitle: boolean; 71 | reduceNestedParent: boolean; 72 | frontmatterKey: string; 73 | useTagInfo: boolean; 74 | tagInfo: string; 75 | mergeRedundantCombination: boolean; 76 | useVirtualTag: boolean; 77 | useFrontmatterTagsForNewNotes: boolean, 78 | doNotSimplifyTags: boolean; 79 | overrideTagClicking: boolean; 80 | useMultiPaneList: boolean; 81 | archiveTags: string; 82 | disableNarrowingDown: boolean; 83 | expandUntaggedToRoot: boolean; 84 | disableDragging: boolean; 85 | linkConfig: LinkParseConf; 86 | linkShowOnlyFDR: boolean; 87 | linkCombineOtherTree: boolean; 88 | showListIn: keyof typeof enumShowListIn; 89 | displayFolderAsTag: boolean; 90 | } 91 | 92 | export const DEFAULT_SETTINGS: TagFolderSettings = { 93 | displayMethod: "NAME", 94 | alwaysOpen: false, 95 | ignoreDocTags: "", 96 | ignoreTags: "", 97 | hideOnRootTags: "", 98 | sortType: "DISPNAME_ASC", 99 | sortExactFirst: false, 100 | sortTypeTag: "NAME_ASC", 101 | expandLimit: 0, 102 | disableNestedTags: false, 103 | hideItems: "NONE", 104 | ignoreFolders: "", 105 | targetFolders: "", 106 | scanDelay: 250, 107 | useTitle: true, 108 | reduceNestedParent: true, 109 | frontmatterKey: "title", 110 | useTagInfo: false, 111 | tagInfo: "pininfo.md", 112 | mergeRedundantCombination: false, 113 | useVirtualTag: false, 114 | useFrontmatterTagsForNewNotes: false, 115 | doNotSimplifyTags: false, 116 | overrideTagClicking: false, 117 | useMultiPaneList: false, 118 | archiveTags: "", 119 | disableNarrowingDown: false, 120 | expandUntaggedToRoot: false, 121 | disableDragging: false, 122 | linkConfig: { 123 | incoming: { 124 | enabled: true, 125 | key: "", 126 | }, 127 | outgoing: { 128 | enabled: true, 129 | key: "" 130 | } 131 | }, 132 | linkShowOnlyFDR: true, 133 | linkCombineOtherTree: true, 134 | showListIn: "", 135 | displayFolderAsTag: false, 136 | }; 137 | 138 | export const VIEW_TYPE_SCROLL = "tagfolder-view-scroll"; 139 | 140 | export type ScrollViewFile = { 141 | path: string; 142 | title?: string; 143 | content?: string; 144 | renderedHTML?: string; 145 | } 146 | export type ScrollViewState = { 147 | files: ScrollViewFile[], 148 | title: string, 149 | tagPath: string, 150 | } 151 | 152 | export const EPOCH_MINUTE = 60; 153 | export const EPOCH_HOUR = EPOCH_MINUTE * 60; 154 | export const EPOCH_DAY = EPOCH_HOUR * 24; 155 | 156 | export const FRESHNESS_1 = "FRESHNESS_01"; 157 | export const FRESHNESS_2 = "FRESHNESS_02"; 158 | export const FRESHNESS_3 = "FRESHNESS_03"; 159 | export const FRESHNESS_4 = "FRESHNESS_04"; 160 | export const FRESHNESS_5 = "FRESHNESS_05"; 161 | 162 | 163 | export const tagDispDict: { [key: string]: string } = { 164 | FRESHNESS_01: "🕐", 165 | FRESHNESS_02: "📖", 166 | FRESHNESS_03: "📗", 167 | FRESHNESS_04: "📚", 168 | FRESHNESS_05: "🗄", 169 | _VIRTUAL_TAG_FRESHNESS: "⌛", 170 | _VIRTUAL_TAG_CANVAS: "📋 Canvas", 171 | _VIRTUAL_TAG_FOLDER: "📁" 172 | }; 173 | 174 | export const VIEW_TYPE_TAGFOLDER = "tagfolder-view"; 175 | export const VIEW_TYPE_TAGFOLDER_LINK = "tagfolder-link-view"; 176 | export const VIEW_TYPE_TAGFOLDER_LIST = "tagfolder-view-list"; 177 | export type TREE_TYPE = "tags" | "links"; 178 | 179 | export const OrderKeyTag: Record = { 180 | NAME: "Tag name", 181 | ITEMS: "Count of items", 182 | }; 183 | export const OrderDirection: Record = { 184 | ASC: "Ascending", 185 | DESC: "Descending", 186 | }; 187 | export const OrderKeyItem: Record = { 188 | DISPNAME: "Displaying name", 189 | NAME: "File name", 190 | MTIME: "Modified time", 191 | CTIME: "Created time", 192 | FULLPATH: "Fullpath of the file", 193 | }; 194 | 195 | 196 | export type TagFolderListState = { 197 | tags: string[]; 198 | title: string; 199 | } 200 | 201 | 202 | export type FileCache = { 203 | file: TFile; 204 | links: string[]; 205 | tags: string[]; 206 | } 207 | -------------------------------------------------------------------------------- /updates.md: -------------------------------------------------------------------------------- 1 | ## 0.18.11 2 | 3 | Since 0.18.11, I have written this changelog as same as the Self-hosted LiveSync. It is quite useful for us. 4 | 5 | ### Fixes 6 | 7 | - Now `Disable narrowing down` works correctly again (#114). 8 | 9 | ### New features 10 | 11 | - Redirecting tags are applied to virtual tags (#110) 12 | - Collapse all expanded tags are implemented (#112) 13 | 14 | ### Digging the weeds 15 | 16 | - Many dependencies are updated to the latest version. And also the Svelte compiler is updated to the latest version. 17 | - Now `npm run check` implemented in the package.json. Runs tsc, eslint, svelte-check at once. 18 | -------------------------------------------------------------------------------- /util.ts: -------------------------------------------------------------------------------- 1 | import { allViewItemsByLink, tagInfo } from "store"; 2 | import { 3 | EPOCH_DAY, 4 | EPOCH_HOUR, 5 | FRESHNESS_1, 6 | FRESHNESS_2, 7 | FRESHNESS_3, 8 | FRESHNESS_4, 9 | FRESHNESS_5, 10 | tagDispDict, 11 | type TagFolderSettings, 12 | type TagInfo, 13 | type TagInfoDict, 14 | type ViewItem, 15 | type LinkParseConf, 16 | type FileCache 17 | } from "types"; 18 | 19 | export function unique(items: T[]) { 20 | return [...new Set([...items])]; 21 | } 22 | 23 | export function trimSlash(src: string, keepStart = false, keepEnd = false) { 24 | const st = keepStart ? 0 : (src[0] == "/" ? 1 : 0); 25 | const end = keepEnd ? undefined : (src.endsWith("/") ? -1 : undefined); 26 | if (st == 0 && end == undefined) return src; 27 | return src.slice(st, end); 28 | } 29 | export function trimPrefix(source: string, prefix: string) { 30 | if (source.startsWith(prefix)) { 31 | return source.substring(prefix.length); 32 | } 33 | return source; 34 | } 35 | 36 | export function ancestorToTags(ancestors: string[]): string[] { 37 | return [...ancestors].reduce( 38 | (p, i) => 39 | i[0] != "/" 40 | ? [...p, i] 41 | : [ 42 | ...p, 43 | p.pop() + 44 | "/" + 45 | i.substring(1), 46 | ], 47 | [] as string[] 48 | ) 49 | } 50 | 51 | export function ancestorToLongestTag(ancestors: string[]): string[] { 52 | return ancestors.reduceRight((a: string[], e) => !a ? [e] : (a[0]?.startsWith(e) ? a : [e, ...a]), []); 53 | } 54 | 55 | export function isSpecialTag(tagSrc: string) { 56 | const tag = trimSlash(tagSrc); 57 | return tag == "_untagged" || tag in tagDispDict; 58 | } 59 | 60 | let tagDispAlternativeDict: { [key: string]: string } = {}; 61 | tagInfo.subscribe(tagInfo => { 62 | tagDispAlternativeDict = { ...tagDispDict }; 63 | if (tagInfo == null) { 64 | return; 65 | } 66 | const items = Object.entries(tagInfo); 67 | for (const [key, info] of items) { 68 | if (info?.alt) { 69 | tagDispAlternativeDict[key] = info.alt; 70 | } 71 | } 72 | }); 73 | 74 | export function renderSpecialTag(tagSrc: string) { 75 | const tag = trimSlash(tagSrc); 76 | return tag in tagDispAlternativeDict ? tagDispAlternativeDict[tag] : tagSrc; 77 | } 78 | 79 | export function secondsToFreshness(totalAsMSec: number) { 80 | const totalAsSec = ~~(totalAsMSec / 1000); 81 | const sign = totalAsSec / Math.abs(totalAsSec); 82 | const totalSec = ~~(totalAsSec * sign); 83 | if (totalSec < EPOCH_HOUR) return FRESHNESS_1 84 | if (totalSec < EPOCH_HOUR * 6) return FRESHNESS_2 85 | if (totalSec < EPOCH_DAY * 3) return FRESHNESS_3 86 | if (totalSec < EPOCH_DAY * 7) return FRESHNESS_4 87 | return FRESHNESS_5 88 | } 89 | 90 | const queues = [] as (() => void)[]; 91 | 92 | export function waitForRequestAnimationFrame() { 93 | return new Promise(res => requestAnimationFrame(() => res())); 94 | } 95 | function delay(num?: number) { 96 | return new Promise(res => setTimeout(() => res(), num || 5)); 97 | } 98 | function nextTick() { 99 | return new Promise(res => setTimeout(() => res(), 0)); 100 | } 101 | 102 | // This is based on nothing. 103 | const waits = [nextTick, delay, nextTick, delay, delay, nextTick];//[waitForRequestAnimationFrame, nextTick, nextTick, nextTick, waitForRequestAnimationFrame, delay, delay, nextTick]; 104 | let waitIdx = 0; 105 | let pumping = false; 106 | let startContinuousProcessing = Date.now(); 107 | 108 | async function pump() { 109 | if (pumping) return; 110 | try { 111 | pumping = true; 112 | do { 113 | const proc = queues.shift(); 114 | if (proc) { 115 | proc(); 116 | const now = Date.now(); 117 | if (now - startContinuousProcessing > 120) { 118 | const w = waits[waitIdx]; 119 | waitIdx = (waitIdx + 1) % waits.length; 120 | await w(); 121 | startContinuousProcessing = Date.now(); 122 | } 123 | } else { 124 | break; 125 | } 126 | } while (true); 127 | } finally { 128 | pumping = false; 129 | } 130 | 131 | 132 | } 133 | 134 | // The message pump having ancient name. 135 | export const doEvents = () => { 136 | 137 | return new Promise(res => { 138 | const proc = () => { 139 | res(); 140 | }; 141 | queues.push(proc); 142 | void pump(); 143 | }) 144 | } 145 | 146 | 147 | export function compare(x: string, y: string) { 148 | return `${x || ""}`.localeCompare(y, undefined, { numeric: true }) 149 | } 150 | 151 | 152 | export function getTagName(tagName: string, subtreePrefix: string, tagInfo: TagInfoDict | undefined, invert: number) { 153 | if (tagInfo == undefined) return tagName; 154 | const prefix = invert == -1 ? `\uffff` : `\u0001`; 155 | const unpinned = invert == 1 ? `\uffff` : `\u0001`; 156 | 157 | if (tagName in tagInfo && tagInfo[tagName]) { 158 | if ("key" in tagInfo[tagName]) { 159 | return `${prefix}_${subtreePrefix}_-${tagInfo[tagName].key}__${tagName}`; 160 | } 161 | } 162 | return `${prefix}_${subtreePrefix}_${unpinned}_${tagName}` 163 | } 164 | 165 | function lc(str: string) { 166 | return str.toLowerCase(); 167 | } 168 | 169 | /** 170 | * returns paths without intermediate paths. 171 | * i.e.) "test", "test/a" and "test/b/c" should be "test/a" and "test/b/c"; 172 | * However, "test", "test/a", "test/b/c", "test", should be "test/a", "test/b/c", "test" 173 | * @param paths array of path 174 | */ 175 | export function removeIntermediatePath(paths: string[]) { 176 | const passed = [] as string[]; 177 | for (const v of paths) { 178 | const last = passed.pop(); 179 | if (last !== undefined) { 180 | if (!(trimTrailingSlash(v.toLowerCase()) + "/").startsWith(trimTrailingSlash(last.toLowerCase()) + "/")) { 181 | // back to the stack 182 | passed.push(last); 183 | } 184 | } 185 | passed.push(v); 186 | } 187 | return passed.reverse(); 188 | } 189 | 190 | export function removeIntermediatePathOld(paths: string[]) { 191 | const out = [...paths]; 192 | const pathEntries = paths.sort((a, b) => a.length - b.length); 193 | const removeList = [] as string[]; 194 | for (const el of pathEntries) { 195 | const elLower = lc(el); 196 | const elCapped = elLower.endsWith("/") ? elLower : (elLower + "/"); 197 | if (out.some(e => lc(e).startsWith(elCapped) && lc(e) !== elCapped)) { 198 | removeList.push(el); 199 | } 200 | } 201 | return out.filter(e => removeList.indexOf(e) == -1) 202 | } 203 | 204 | export function getTagMark(tagInfo: TagInfo | undefined) { 205 | if (!tagInfo) return ""; 206 | if ("key" in tagInfo) { 207 | if ("mark" in tagInfo && tagInfo.mark != "") { 208 | return tagInfo.mark; 209 | } else { 210 | return "📌"; 211 | } 212 | } else { 213 | if ("mark" in tagInfo && tagInfo.mark != "") { 214 | return tagInfo.mark; 215 | } else { 216 | return ""; 217 | } 218 | } 219 | } 220 | 221 | export function escapeStringToHTML(str: string) { 222 | if (!str) return ""; 223 | return str.replace(/[<>&"'`]/g, (match) => { 224 | const escape: Record = { 225 | "<": "<", 226 | ">": ">", 227 | "&": "&", 228 | '"': """, 229 | "'": "'", 230 | "`": "`", 231 | }; 232 | return escape[match]; 233 | }); 234 | } 235 | 236 | export type V2FolderItem = [tag: string, tagName: string, tagNameDisp: string[], children: ViewItem[]]; 237 | export const V2FI_IDX_TAG = 0; 238 | export const V2FI_IDX_TAGNAME = 1; 239 | export const V2FI_IDX_TAGDISP = 2; 240 | export const V2FI_IDX_CHILDREN = 3; 241 | 242 | 243 | /** 244 | * Select compare methods for tags from configurations and tag information. 245 | * @param settings 246 | * @param tagInfo 247 | * @returns 248 | */ 249 | export function selectCompareMethodTags(settings: TagFolderSettings, tagInfo: TagInfoDict) { 250 | const _tagInfo = tagInfo; 251 | const invert = settings.sortTypeTag.contains("_DESC") ? -1 : 1; 252 | const subTreeChar: Record = { 253 | [-1]: `\u{10ffff}`, 254 | [1]: `_` 255 | } 256 | ; 257 | const sortByName = (a: V2FolderItem, b: V2FolderItem) => { 258 | const isASubTree = a[V2FI_IDX_TAGDISP][0] == ""; 259 | const isBSubTree = b[V2FI_IDX_TAGDISP][0] == ""; 260 | const aName = a[V2FI_IDX_TAGNAME]; 261 | const bName = b[V2FI_IDX_TAGNAME]; 262 | const aPrefix = isASubTree ? subTreeChar[invert] : ""; 263 | const bPrefix = isBSubTree ? subTreeChar[invert] : ""; 264 | return compare(getTagName(aName, aPrefix, settings.useTagInfo ? _tagInfo : undefined, invert), getTagName(bName, bPrefix, settings.useTagInfo ? _tagInfo : undefined, invert)) * invert; 265 | } 266 | switch (settings.sortTypeTag) { 267 | case "ITEMS_ASC": 268 | case "ITEMS_DESC": 269 | return (a: V2FolderItem, b: V2FolderItem) => { 270 | const aName = a[V2FI_IDX_TAGNAME]; 271 | const bName = b[V2FI_IDX_TAGNAME]; 272 | const aCount = a[V2FI_IDX_CHILDREN].length - ((settings.useTagInfo && (aName in _tagInfo && "key" in _tagInfo[aName])) ? 100000 * invert : 0); 273 | const bCount = b[V2FI_IDX_CHILDREN].length - ((settings.useTagInfo && (bName in _tagInfo && "key" in _tagInfo[bName])) ? 100000 * invert : 0); 274 | if (aCount == bCount) return sortByName(a, b); 275 | return (aCount - bCount) * invert; 276 | } 277 | case "NAME_ASC": 278 | case "NAME_DESC": 279 | return sortByName 280 | default: 281 | console.warn("Compare method (tags) corrupted"); 282 | return (a: V2FolderItem, b: V2FolderItem) => { 283 | const isASubTree = a[V2FI_IDX_TAGDISP][0] == ""; 284 | const isBSubTree = b[V2FI_IDX_TAGDISP][0] == ""; 285 | const aName = a[V2FI_IDX_TAGNAME]; 286 | const bName = b[V2FI_IDX_TAGNAME]; 287 | const aPrefix = isASubTree ? subTreeChar[invert] : ""; 288 | const bPrefix = isBSubTree ? subTreeChar[invert] : ""; 289 | return compare(aPrefix + aName, bPrefix + bName) * invert; 290 | } 291 | } 292 | } 293 | 294 | /** 295 | * Extracts unique set in case insensitive. 296 | * @param pieces 297 | * @returns 298 | */ 299 | export function uniqueCaseIntensive(pieces: string[]): string[] { 300 | const delMap = new Set(); 301 | const ret = []; 302 | for (const piece of pieces) { 303 | if (!delMap.has(piece.toLowerCase())) { 304 | ret.push(piece); 305 | delMap.add(piece.toLowerCase()); 306 | } 307 | } 308 | return ret; 309 | } 310 | 311 | export function _sorterTagLength(a: string, b: string, invert: boolean) { 312 | const lenA = a.split("/").length; 313 | const lenB = b.split("/").length; 314 | const diff = lenA - lenB; 315 | if (diff != 0) return diff * (invert ? -1 : 1); 316 | return (a.length - b.length) * (invert ? -1 : 1); 317 | } 318 | 319 | export function getExtraTags(tags: string[], trail: string[], reduceNestedParent: boolean) { 320 | let tagsLeft = uniqueCaseIntensive(tags); 321 | let removeTrailItems = [] as string[]; 322 | 323 | if (reduceNestedParent) { 324 | removeTrailItems = trail.sort((a, b) => _sorterTagLength(a, b, true)); 325 | } else { 326 | removeTrailItems = removeIntermediatePath(trail); 327 | } 328 | 329 | for (const t of removeTrailItems) { 330 | const inDedicatedTree = t.endsWith("/"); 331 | const trimLength = inDedicatedTree ? t.length : t.length; 332 | // If reduceNestedParent is enabled, we have to remove prefix of all tags. 333 | // Note: if the nested parent has been reduced, the prefix will be appeared only once in the trail. 334 | // In that case, if `test/a`, `test/b` exist and expanded as test -> a -> b, trails should be `test/` `test/a` `test/b` 335 | if (reduceNestedParent) { 336 | tagsLeft = tagsLeft.map((e) => 337 | (e + "/").toLowerCase().startsWith(t.toLowerCase()) 338 | ? e.substring(trimLength) 339 | : e 340 | ); 341 | } else { 342 | // Otherwise, we have to remove the prefix only of the first one. 343 | // test -> a test -> b, trails should be `test/` `test/a` `test/` `test/b` 344 | const f = tagsLeft.findIndex((e) => 345 | (e + "/") 346 | .toLowerCase() 347 | .startsWith(t.toLowerCase()) 348 | ); 349 | if (f !== -1) { 350 | tagsLeft[f] = tagsLeft[f].substring(trimLength); 351 | } 352 | } 353 | } 354 | return tagsLeft.filter((e) => e.trim() != ""); 355 | } 356 | 357 | 358 | export function trimTrailingSlash(src: string) { 359 | return trimSlash(src, true, false); 360 | } 361 | 362 | export function joinPartialPath(path: string[]) { 363 | return path.reduce((p, c) => (c.endsWith("/") && p.length > 0) ? [c + p[0], ...p.slice(1)] : [c, ...p], [] as string[]); 364 | } 365 | 366 | export function pathMatch(haystackLC: string, needleLC: string) { 367 | if (haystackLC == needleLC) return true; 368 | if (needleLC[needleLC.length - 1] == "/") { 369 | if ((haystackLC + "/").indexOf(needleLC) === 0) return true; 370 | } 371 | return false; 372 | } 373 | 374 | export function parseTagName(thisName: string, _tagInfo: TagInfoDict): [string, string[]] { 375 | let tagNameDisp = [""]; 376 | const names = thisName.split("/").filter((e) => e.trim() != ""); 377 | let inSubTree = false; 378 | let tagName = ""; 379 | if (names.length > 1) { 380 | tagName = `${names[names.length - 1]}`; 381 | inSubTree = true; 382 | } else { 383 | tagName = thisName; 384 | } 385 | if (tagName.endsWith("/")) { 386 | tagName = tagName.substring(0, tagName.length - 1); 387 | } 388 | const tagInfo = tagName in _tagInfo ? _tagInfo[tagName] : undefined; 389 | const tagMark = getTagMark(tagInfo); 390 | tagNameDisp = [`${tagMark}${renderSpecialTag(tagName)}`]; 391 | if (inSubTree) 392 | tagNameDisp = [`${tagMark}`, `${renderSpecialTag(tagName)}`]; 393 | 394 | return [tagName, tagNameDisp] 395 | } 396 | 397 | function parseAllForwardReference(metaCache: Record>, filename: string, passed: string[]) { 398 | 399 | const allForwardLinks = Object.keys(metaCache?.[filename] ?? {}).filter(e => !passed.contains(e)); 400 | const ret = unique(allForwardLinks); 401 | return ret; 402 | } 403 | function parseAllReverseReference(metaCache: Record>, filename: string, passed: string[]) { 404 | const allReverseLinks = Object.entries((metaCache)).filter(([, links]) => filename in links).map(([name,]) => name).filter(e => !passed.contains(e)); 405 | const ret = unique(allReverseLinks); 406 | return ret; 407 | } 408 | 409 | export function parseAllReference(metaCache: Record>, filename: string, conf: LinkParseConf): string[] { 410 | const allForwardLinks = (!conf?.outgoing?.enabled) ? [] : parseAllForwardReference(metaCache, filename, []); 411 | const allReverseLinks = (!conf?.incoming?.enabled) ? [] : parseAllReverseReference(metaCache, filename, []); 412 | let linked = [...allForwardLinks, ...allReverseLinks]; 413 | if (linked.length != 0) linked = unique([filename, ...linked]); 414 | 415 | return linked; 416 | } 417 | 418 | export function isIntersect(a: T[], b: T[]) { 419 | if (a.length == 0 && b.length != 0) return false; 420 | if (a.length != 0 && b.length == 0) return false; 421 | const allKeys = [...unique(a), ...unique(b)]; 422 | const dedupeKey = unique(allKeys); 423 | return allKeys.length != dedupeKey.length; 424 | } 425 | 426 | export function isValid(obj: T | false): obj is T { 427 | return obj !== false; 428 | } 429 | 430 | export function fileCacheToCompare(cache: FileCache | undefined | false) { 431 | if (!cache) return ""; 432 | return ({ l: cache.links, t: cache.tags }) 433 | } 434 | 435 | const allViewItemsMap = new Map(); 436 | allViewItemsByLink.subscribe(e => { 437 | updateItemsLinkMap(e); 438 | }); 439 | export function updateItemsLinkMap(e: ViewItem[]) { 440 | allViewItemsMap.clear(); 441 | if (e) e.forEach(item => allViewItemsMap.set(item.path, item)); 442 | } 443 | 444 | export function getViewItemFromPath(path: string) { 445 | return allViewItemsMap.get(path); 446 | } 447 | 448 | export function getAllLinksRecursive(item: ViewItem, trail: string[]): string[] { 449 | const allLinks = item.links; 450 | const leftLinks = allLinks.filter(e => !trail.contains(e)); 451 | const allChildLinks = leftLinks.flatMap(itemName => { 452 | const item = getViewItemFromPath(itemName); 453 | if (!item) return []; 454 | return getAllLinksRecursive(item, [...trail, itemName]); 455 | }) 456 | return unique([...leftLinks, ...allChildLinks]); 457 | } 458 | /* 459 | let showResultTimer: ReturnType; 460 | const measured = {} as Record; 464 | const pf = window.performance; 465 | export function measure(key: string) { 466 | const start = pf.now(); 467 | return function end() { 468 | const end = pf.now(); 469 | const spent = end - start; 470 | measured[key] = { count: (measured[key]?.count ?? 0) + 1, spent: (measured[key]?.spent ?? 0) + spent } 471 | if (showResultTimer) clearTimeout(showResultTimer); 472 | showResultTimer = setTimeout(() => { 473 | console.table(Object.fromEntries(Object.entries(measured).map(e => [e[0], { ...e[1], each: e[1].spent / e[1].count }]))); 474 | }, 500) 475 | } 476 | } 477 | */ 478 | 479 | export function isSameViewItems(a: ViewItem[][], b: ViewItem[][]) { 480 | if (a === b) return true; 481 | if (a.length != b.length) return false; 482 | for (const i in a) { 483 | if (a[i].length != b[i].length) { 484 | return false; 485 | } 486 | if (!_isSameViewItem(a[i], b[i])) return false; 487 | 488 | } 489 | return true; 490 | } 491 | export function _isSameViewItem(a: ViewItem[], b: ViewItem[]) { 492 | if (!a || !b) return false; 493 | if (a === b) return true; 494 | if (a.length != b.length) return false; 495 | for (const j in a) { 496 | if (a[j] === b[j]) return true; 497 | for (const k in a[j]) { 498 | if (!isSameObj(a[j][k as keyof ViewItem], b[j][k as keyof ViewItem])) return false; 499 | } 500 | } 501 | return true; 502 | } 503 | export function isSameV2FolderItem(a: V2FolderItem[][], b: V2FolderItem[][]) { 504 | if (a === b) return true; 505 | if (a.length != b.length) return false; 506 | for (const i in a) { 507 | if (a[i].length != b[i].length) { 508 | return false; 509 | } 510 | if (a[i] === b[i]) return true; 511 | for (const j in a[i]) { 512 | if (a[i][j][V2FI_IDX_TAG] !== b[i][j][V2FI_IDX_TAG]) return false; 513 | if (a[i][j][V2FI_IDX_TAGNAME] !== b[i][j][V2FI_IDX_TAGNAME]) return false; 514 | if (!isSameObj(a[i][j][V2FI_IDX_TAGDISP], b[i][j][V2FI_IDX_TAGDISP])) return false; 515 | if (!_isSameViewItem(a[i][j][V2FI_IDX_CHILDREN], b[i][j][V2FI_IDX_CHILDREN])) return false; 516 | } 517 | } 518 | return true; 519 | } 520 | 521 | export function isSameObj(a: T, b: typeof a) { 522 | if (a === b) return true; 523 | if (typeof a == "string" || typeof a == "number") { 524 | return a == b; 525 | } 526 | if (a.length != (b as string[]).length) return false; 527 | const len = a.length; 528 | for (let i = 0; i < len; i++) { 529 | if (!isSameObj(a[i], (b as string[])[i])) return false; 530 | } 531 | return true; 532 | } 533 | 534 | const waitingProcess = new Map Promise>(); 535 | const runningProcess = new Set(); 536 | 537 | 538 | 539 | export async function scheduleOnceIfDuplicated(key: string, proc: () => Promise): Promise { 540 | if (runningProcess.has(key)) { 541 | waitingProcess.set(key, proc); 542 | return; 543 | } 544 | try { 545 | runningProcess.add(key); 546 | await delay(3); 547 | if (waitingProcess.has(key)) { 548 | const nextProc = waitingProcess.get(key)!; 549 | waitingProcess.delete(key); 550 | runningProcess.delete(key); 551 | return scheduleOnceIfDuplicated(key, nextProc); 552 | } else { 553 | //console.log(`run!! ${key}`); 554 | await proc(); 555 | } 556 | } 557 | finally { 558 | runningProcess.delete(key); 559 | } 560 | 561 | } 562 | 563 | export function isSameAny(a: unknown, b: unknown) { 564 | if (typeof a != typeof b) return false; 565 | switch (typeof a) { 566 | case "string": 567 | case "number": 568 | case "bigint": 569 | case "boolean": 570 | case "symbol": 571 | case "function": 572 | case "undefined": 573 | return a == b; 574 | case "object": 575 | if (a === b) return true; 576 | if (a instanceof Map || a instanceof Set) { 577 | if (a.size != (b as typeof a).size) return false; 578 | const v = [...a] 579 | const w = [...(b as typeof a)]; 580 | for (let i = 0; i < v.length; i++) { 581 | if (v[i] != w[i]) return false; 582 | } 583 | return true; 584 | } 585 | if (Array.isArray(a)) { 586 | for (let i = 0; i < a.length; i++) { 587 | if (!isSameAny(a[i], (b as typeof a)[i])) return false; 588 | } 589 | return true; 590 | } 591 | { 592 | const x = Object.values(a!); 593 | const y = Object.values(b!); 594 | if (x.length != y.length) return false; 595 | for (let i = 0; i < x.length; i++) { 596 | if (!isSameAny(x[i], y[i])) return false; 597 | } 598 | return true; 599 | } 600 | default: 601 | return false; 602 | } 603 | 604 | } 605 | -------------------------------------------------------------------------------- /v2codebehind.ts: -------------------------------------------------------------------------------- 1 | 2 | import type { TREE_TYPE, TagFolderSettings, TagInfoDict, ViewItem } from "./types"; 3 | import { V2FI_IDX_CHILDREN, type V2FolderItem, trimPrefix, parseTagName, pathMatch, getExtraTags, getViewItemFromPath, V2FI_IDX_TAG, V2FI_IDX_TAGNAME, V2FI_IDX_TAGDISP, waitForRequestAnimationFrame } from "./util"; 4 | 5 | export function performSortExactFirst(_items: ViewItem[], children: V2FolderItem[], leftOverItems: ViewItem[]) { 6 | 7 | // const m2 = measure("new"); 8 | const childrenPathsArr = children.map((e) => 9 | (e[V2FI_IDX_CHILDREN]).map((ee) => ee.path)).flat() 10 | 11 | const childrenPaths = new Set( 12 | childrenPathsArr 13 | ); 14 | const exactHerePaths = new Set(_items.map((e) => e.path)); 15 | childrenPaths.forEach((path) => exactHerePaths.delete(path)); 16 | 17 | //const isHerePaths = 18 | 19 | const wk2 = [...leftOverItems].sort((a, b) => { 20 | const aIsInChildren = exactHerePaths.has(a.path); 21 | const bIsInChildren = exactHerePaths.has(b.path); 22 | return (aIsInChildren ? -1 : 0) + (bIsInChildren ? 1 : 0); 23 | }); 24 | // m2(); 25 | 26 | 27 | return [...wk2]; 28 | } 29 | function delay() { 30 | return new Promise(res => setTimeout(() => res(), 5)); 31 | } 32 | function nextTick() { 33 | return new Promise(res => setTimeout(() => res(), 0)); 34 | } 35 | const delays = [nextTick, delay, nextTick, waitForRequestAnimationFrame]; 36 | let delayIdx = 0; 37 | export async function collectChildren(previousTrail: string, tags: string[], _tagInfo: TagInfoDict, _items: ViewItem[]) { 38 | const previousTrailLC = previousTrail.toLowerCase(); 39 | 40 | const children: V2FolderItem[] = []; 41 | const tagPerItem = new Map(); 42 | const lowercaseMap = new Map(); 43 | for (const item of _items) { 44 | const itemTags = item.tags; 45 | itemTags.forEach(itemTag => { 46 | const tagLc = lowercaseMap.get(itemTag) ?? lowercaseMap.set(itemTag, itemTag.toLowerCase()).get(itemTag)!; 47 | if (!tagPerItem.has(tagLc)) tagPerItem.set(tagLc, []); 48 | tagPerItem.get(tagLc)!.push(item); 49 | }) 50 | } 51 | for (const tag of tags) { 52 | const tagLC = tag.toLowerCase(); 53 | const tagNestedLC = trimPrefix(tagLC, previousTrailLC); 54 | const items: ViewItem[] = []; 55 | for (const [itemTag, tempItems] of tagPerItem) { 56 | if (pathMatch(itemTag, tagLC)) { 57 | items.push(...tempItems); 58 | } else if (pathMatch(itemTag, tagNestedLC)) { 59 | items.push(...tempItems); 60 | } 61 | } 62 | children.push( 63 | [ 64 | tag, 65 | ...parseTagName(tag, _tagInfo), 66 | [...new Set(items)] 67 | ] 68 | ) 69 | // Prevent UI freezing. 70 | delayIdx++; delayIdx %= 4; 71 | await (delays[delayIdx])(); 72 | } 73 | return children; 74 | } 75 | 76 | export async function collectTreeChildren( 77 | { key, 78 | expandLimit, depth, tags, trailLower, _setting, isMainTree, 79 | isSuppressibleLevel, viewType, previousTrail, _tagInfo, _items, linkedItems, isRoot, 80 | sortFunc } 81 | : 82 | { 83 | key: string, 84 | expandLimit: number, depth: number, tags: string[], trailLower: string[], _setting: TagFolderSettings, 85 | isMainTree: boolean, isSuppressibleLevel: boolean, viewType: TREE_TYPE, previousTrail: string, 86 | _tagInfo: TagInfoDict, _items: ViewItem[], linkedItems: Map, isRoot: boolean, 87 | sortFunc: (a: V2FolderItem, b: V2FolderItem) => number 88 | } 89 | ): Promise<{ suppressLevels: string[], children: V2FolderItem[] }> { 90 | let suppressLevels: string[] = []; // This will be shown as chip. 91 | let children: V2FolderItem[] = []; 92 | if (expandLimit && depth >= expandLimit) { 93 | // If expand limit had been configured and we have reached it, 94 | // suppress sub-folders and show that information as extraTags. 95 | children = []; 96 | suppressLevels = getExtraTags( 97 | tags, 98 | trailLower, 99 | _setting.reduceNestedParent 100 | ); 101 | } else if (!isMainTree) { 102 | // If not in main tree, suppress sub-folders. 103 | children = []; 104 | } else if (isSuppressibleLevel) { 105 | // If we determined it was a suppressible, 106 | // suppress sub-folders and show that information as extraTags. 107 | children = []; 108 | suppressLevels = getExtraTags( 109 | tags, 110 | trailLower, 111 | _setting.reduceNestedParent 112 | ); 113 | } else { 114 | let wChildren = [] as V2FolderItem[]; 115 | if (viewType == "tags") { 116 | wChildren = await collectChildren( 117 | previousTrail, 118 | tags, 119 | _tagInfo, 120 | _items 121 | ); 122 | } else if (viewType == "links") { 123 | // We made the list in the previous step. 124 | wChildren = tags.map((tag) => { 125 | const selfInfo = getViewItemFromPath(tag); 126 | const dispName = !selfInfo ? tag : selfInfo.displayName; 127 | const children = linkedItems.get(tag) ?? []; 128 | return [ 129 | tag, 130 | dispName, 131 | [dispName], 132 | children, 133 | ] as V2FolderItem; 134 | }); 135 | } 136 | if (viewType == "tags") { 137 | // -- Check redundant combination if configured. 138 | if (_setting.mergeRedundantCombination) { 139 | const out = [] as typeof wChildren; 140 | const isShown = new Set(); 141 | for (const [tag, tagName, tagsDisp, items] of wChildren) { 142 | const list = [] as ViewItem[]; 143 | for (const v of items) { 144 | if (!isShown.has(v.path)) { 145 | list.push(v); 146 | isShown.add(v.path); 147 | } 148 | } 149 | if (list.length != 0) 150 | out.push([tag, tagName, tagsDisp, list]); 151 | } 152 | wChildren = out; 153 | } 154 | 155 | // -- MainTree and Root specific structure modification. 156 | if (isMainTree && isRoot) { 157 | // Remove all items which have been already archived except is on the root. 158 | 159 | const archiveTags = _setting.archiveTags 160 | .toLowerCase() 161 | .replace(/[\n ]/g, "") 162 | .split(","); 163 | wChildren = wChildren 164 | .map((e) => 165 | archiveTags.some((aTag) => 166 | `${aTag}//`.startsWith( 167 | e[V2FI_IDX_TAG].toLowerCase() + "/" 168 | ) 169 | ) 170 | ? e 171 | : ([ 172 | e[V2FI_IDX_TAG], 173 | e[V2FI_IDX_TAGNAME], 174 | e[V2FI_IDX_TAGDISP], 175 | e[V2FI_IDX_CHILDREN].filter( 176 | (items) => 177 | !items.tags.some((e) => 178 | archiveTags.contains( 179 | e.toLowerCase() 180 | ) 181 | ) 182 | ), 183 | ] as V2FolderItem) 184 | ) 185 | .filter( 186 | (child) => child[V2FI_IDX_CHILDREN].length != 0 187 | ); 188 | } 189 | } 190 | wChildren = wChildren.sort(sortFunc); 191 | children = wChildren; 192 | } 193 | return { suppressLevels, children } 194 | } -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.1": "0.9.12", 3 | "1.0.0": "0.9.7" 4 | } 5 | --------------------------------------------------------------------------------