├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .npmrc ├── LICENSE.md ├── README-obsidian-versions-compatibility.md ├── README.md ├── docs ├── advanced-README.md ├── examples │ ├── 1 │ │ └── sortspec.md │ ├── 5 │ │ └── sortspec.md │ ├── basic │ │ └── sortspec.md │ └── quickstart │ │ └── sortspec.md ├── icons │ ├── icon-active.png │ ├── icon-error.png │ ├── icon-general-error.png │ ├── icon-inactive.png │ ├── icon-mobile-initial.png │ ├── icon-not-applied.png │ └── parsing-succeeded.png ├── img │ ├── different-sorting-order-per-folder.png │ ├── separators-by-replete.png │ ├── sortspec-md-bright.jpg │ └── sortspec-md-dark.jpg ├── manual.md ├── svg │ ├── by-suffix.svg │ ├── files-go-first.svg │ ├── multi-folder.svg │ ├── p_a_r_a.svg │ ├── pin-focus-note.svg │ ├── priorities-example-a.svg │ ├── priorities-example-b.svg │ ├── roman-chapters.svg │ ├── roman-suffix.svg │ ├── simplest-example-2.svg │ ├── simplest-example-3.svg │ ├── simplest-example.svg │ ├── syntax-1.svg │ ├── syntax-2.svg │ ├── syntax-3.svg │ └── syntax-4.svg └── syntax-reference.md ├── esbuild.config.mjs ├── jest.config.js ├── manifest.json ├── package.json ├── src ├── custom-sort-plugin.ts ├── custom-sort │ ├── custom-sort-types.ts │ ├── custom-sort-utils.ts │ ├── custom-sort.ts │ ├── folder-matching-rules.ts │ ├── icons.ts │ ├── macros.ts │ ├── matchers.ts │ ├── mdata-extractors.ts │ └── sorting-spec-processor.ts ├── main.ts ├── settings.ts ├── test │ ├── int │ │ ├── dates-in-names.int.test.ts │ │ └── folder-dates.int.test.ts │ ├── mocks.ts │ └── unit │ │ ├── BookmarksCorePluginSignature.spec.ts │ │ ├── custom-sort-getComparator.spec.ts │ │ ├── custom-sort-utils.spec.ts │ │ ├── custom-sort.spec.ts │ │ ├── folder-matching-rules.spec.ts │ │ ├── macros.spec.ts │ │ ├── matchers.spec.ts │ │ ├── mdata-extractors.spec.ts │ │ ├── sorting-spec-processor.spec.ts │ │ ├── utils.spec.ts │ │ └── week-of-year.spec.ts ├── types │ └── types.d.ts └── utils │ ├── Bookmarks Core Plugin integration design.md │ ├── BookmarksCorePluginSignature.ts │ ├── ObsidianIconFolderPluginSignature.ts │ ├── utils.ts │ └── week-of-year.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs ├── versions.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '44 2 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'typescript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | with: 74 | category: "/language:${{matrix.language}}" 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | /yarn.lock 24 | /.run/test.run.xml 25 | /.run/build.run.xml 26 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /README-obsidian-versions-compatibility.md: -------------------------------------------------------------------------------- 1 | # Obsidian versions compatibility 2 | 3 | > | :exclamation: To avoid issues resulting from breaking changes in Obsidian always use the newest version of the plugin| 4 | > |----------------------------------------------| 5 | > | - Obsidian 1.7.2 - update the plugin to 3.1.0 or newer --> More details in a dedicated section below.| 6 | > | - Obsidian 1.6.3 - update the plugin to 2.1.11 or newer --> More details in a dedicated section below.| 7 | > | - Obsidian 1.6.0 - update the plugin to 2.1.9 or newer --> More details in a dedicated section below.| 8 | > | - Obsidian 1.5.4 - update the plugin to 2.1.7 or newer --> More details in a dedicated section below.| 9 | 10 | --- 11 | > | :exclamation: Breaking changes in Obsidian 1.7.2 - update the plugin to 3.1.0 or newer| 12 | > |----------------------------------------------| 13 | > 14 | > Obsidian team introduced some more breaking changes in a not-backward-compatible way starting from Obsidian 1.7.2. 15 | > 16 | > The observed issues were minor and related to initial automatic application of custom sorting. 17 | > 1. The custom sort plugin is **unable to automatically apply the custom sort on start** 18 | > - when the File Explorer is not visible on app start 19 | > - for the _Lazy Plugin Loader_ plugin occurs by definition 20 | > - prevalent on mobile 21 | > 22 | > The release 3.1.0 fixed some of the scenarios by introducing a delay in initial auto-application 23 | > of custom sorting. The delay is by default set to 1 second and can be increased up to 30 seconds, 24 | > which can be needed for large vaults, large number of plugins or on mobile. 25 | > 26 | > At the same time, when the File Explorer is not visible on app start, the custom sorting can't be 27 | > applied automatically by definition: there is no instance of File Explorer. This is an unfortunate 28 | > side effect of the [deferred views](https://docs.obsidian.md/Plugins/Guides/Understanding+deferred+views) 29 | > introduced by Obsidian 1.7.2. 30 | > There is no cure for this scenario and the custom sorting has to be applied manually when the 31 | > File Explorer is eventually displayed. The simplest way is to click the ribbon icon (on desktop) 32 | > or use the command 'sort-on' on mobile. 33 | > 34 | > 2. The custom sort plugin **keeps showing the notifications** with each change to any note 35 | > 36 | > The release 3.1.0 fixed this fully 37 | > 38 | > --- 39 | > For more details of the observed misbehaviors you can go to: 40 | > - [#161: Find out how to automatically apply custom sort on app start / vault (re)load etc.](https://github.com/SebastianMC/obsidian-custom-sort/issues/161) 41 | > - [#162: \[bug\]\[minor\] Obsidian 1.7.2 breaking changes - when File Explorer is not displayed an attempt to apply custom sort fails with error](https://github.com/SebastianMC/obsidian-custom-sort/issues/162) 42 | > - [#163: Obsidian 1.7.2 - automatic sorting fails when launching Obsidian](https://github.com/SebastianMC/obsidian-custom-sort/issues/163) 43 | > - [#165: Obsidian 1.7.3 - Constant Notifications "Custom sorting ON" and "Parsing custom sorting specification SUCCEEDED!"](https://github.com/SebastianMC/obsidian-custom-sort/issues/165) 44 | > - [#169: Not working on mobile](https://github.com/SebastianMC/obsidian-custom-sort/issues/169) 45 | > 46 | 47 | --- 48 | > | :exclamation: Breaking changes in Obsidian 1.6.3 causing a minor issue - update the plugin to 2.1.11 or newer| 49 | > |----------------------------------------------| 50 | > 51 | > Obsidian team introduced yet another breaking change in 1.6.3 probably related to plugin's lifecycle or File Explorer module lifecycle. 52 | > In result, due to race condition, the custom sort order is not always applied automatically after app reload or vault reload. 53 | > Manual (re)application is needed via ribbon click of via 'sort on' command. 54 | > The [2.1.11](https://github.com/SebastianMC/obsidian-custom-sort/releases/tag/2.1.11) release of the plugin fixes this inconvenience. 55 | > 56 | > For more details of the observed misbehavior (of not updated plugin) you can go to [#147](https://github.com/SebastianMC/obsidian-custom-sort/issues/147) 57 | 58 | --- 59 | > | :exclamation: Breaking changes in Obsidian 1.6.0 (and newer) - update the plugin to 2.1.9 or newer| 60 | > |----------------------------------------------| 61 | > 62 | > Obsidian team introduced some breaking changes in File Explorer sorting code in a not-backward-compatible way starting from Obsidian 1.6.0. 63 | > 64 | > The **custom sort** plugin starting from release **2.1.9** was adjusted to work correctly with these breaking changes in Obsidian 1.6.0 and newer. 65 | > The plugin remains backward compatible, so you can safely update the plugin for Obsidian earlier than 1.6.0 66 | > 67 | > For more details of the observed misbehavior (of not updated plugin) you can go to [#145](https://github.com/SebastianMC/obsidian-custom-sort/issues/145) 68 | 69 | --- 70 | > | :exclamation: Breaking changes in Obsidian 1.5.4 (and newer) - update the plugin to 2.1.7 or newer| 71 | > |----------------------------------------------| 72 | > 73 | > Obsidian team introduced some breaking changes in File Explorer in a not-backward-compatible way starting from Obsidian 1.5.4. 74 | > 75 | > The **custom sort** plugin starting from release **2.1.7** was adjusted to work correctly with these breaking changes in Obsidian 1.5.4 and newer. 76 | > The plugin remains backward compatible, so you can safely update the plugin for Obsidian earlier than 1.5.4 77 | > 78 | > For more details of the observed misbehavior (of not updated plugin) you can go to [#131](https://github.com/SebastianMC/obsidian-custom-sort/issues/131) or [#135](https://github.com/SebastianMC/obsidian-custom-sort/issues/135) or [#139](https://github.com/SebastianMC/obsidian-custom-sort/issues/139) 79 | > 80 | 81 | -------------------------------------------------------------------------------- /docs/examples/1/sortspec.md: -------------------------------------------------------------------------------- 1 | --- 2 | sorting-spec: | 3 | // 4 | // A simple configuration for obsidian-custom-sort plugin 5 | // (https://github.com/SebastianMC/obsidian-custom-sort) 6 | // It causes the plugin to take over the control of the order of items in the root folder ('/') of the vault 7 | // It explicitly sets the sorting to ascending ('<') alphabetical ('a-z') 8 | // Folders and files are treated equally by the plugin (by default) so expect them intermixed 9 | // after enabling the custom sort plugin 10 | // 11 | target-folder: / 12 | < a-z 13 | --- 14 | -------------------------------------------------------------------------------- /docs/examples/5/sortspec.md: -------------------------------------------------------------------------------- 1 | --- 2 | sorting-spec: | 3 | target-folder: / 4 | Projects 5 | Areas 6 | Responsibilities 7 | Archive 8 | --- 9 | -------------------------------------------------------------------------------- /docs/examples/basic/sortspec.md: -------------------------------------------------------------------------------- 1 | --- 2 | sorting-spec: | 3 | order-desc: a-z 4 | --- 5 | -------------------------------------------------------------------------------- /docs/examples/quickstart/sortspec.md: -------------------------------------------------------------------------------- 1 | --- 2 | sorting-spec: | 3 | // 4 | // A simple configuration for obsidian-custom-sort plugin 5 | // (https://github.com/SebastianMC/obsidian-custom-sort) 6 | // It causes the plugin to take over the control of the order of items in the root folder ('/') of the vault 7 | // It explicitly sets the sorting to descending ('>') alphabetical ('a-z') 8 | // Folders and files are treated equally by the plugin (by default) so expect them intermixed 9 | // in the root vault folder after enabling the custom sort plugin 10 | // 11 | // To play with more examples go to https://github.com/SebastianMC/obsidian-custom-sort#readme 12 | 13 | target-folder: / 14 | > a-z 15 | --- 16 | -------------------------------------------------------------------------------- /docs/icons/icon-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SebastianMC/obsidian-custom-sort/17f6a04c3536908fbf3a3333aa42e1921f6dffd2/docs/icons/icon-active.png -------------------------------------------------------------------------------- /docs/icons/icon-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SebastianMC/obsidian-custom-sort/17f6a04c3536908fbf3a3333aa42e1921f6dffd2/docs/icons/icon-error.png -------------------------------------------------------------------------------- /docs/icons/icon-general-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SebastianMC/obsidian-custom-sort/17f6a04c3536908fbf3a3333aa42e1921f6dffd2/docs/icons/icon-general-error.png -------------------------------------------------------------------------------- /docs/icons/icon-inactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SebastianMC/obsidian-custom-sort/17f6a04c3536908fbf3a3333aa42e1921f6dffd2/docs/icons/icon-inactive.png -------------------------------------------------------------------------------- /docs/icons/icon-mobile-initial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SebastianMC/obsidian-custom-sort/17f6a04c3536908fbf3a3333aa42e1921f6dffd2/docs/icons/icon-mobile-initial.png -------------------------------------------------------------------------------- /docs/icons/icon-not-applied.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SebastianMC/obsidian-custom-sort/17f6a04c3536908fbf3a3333aa42e1921f6dffd2/docs/icons/icon-not-applied.png -------------------------------------------------------------------------------- /docs/icons/parsing-succeeded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SebastianMC/obsidian-custom-sort/17f6a04c3536908fbf3a3333aa42e1921f6dffd2/docs/icons/parsing-succeeded.png -------------------------------------------------------------------------------- /docs/img/different-sorting-order-per-folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SebastianMC/obsidian-custom-sort/17f6a04c3536908fbf3a3333aa42e1921f6dffd2/docs/img/different-sorting-order-per-folder.png -------------------------------------------------------------------------------- /docs/img/separators-by-replete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SebastianMC/obsidian-custom-sort/17f6a04c3536908fbf3a3333aa42e1921f6dffd2/docs/img/separators-by-replete.png -------------------------------------------------------------------------------- /docs/img/sortspec-md-bright.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SebastianMC/obsidian-custom-sort/17f6a04c3536908fbf3a3333aa42e1921f6dffd2/docs/img/sortspec-md-bright.jpg -------------------------------------------------------------------------------- /docs/img/sortspec-md-dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SebastianMC/obsidian-custom-sort/17f6a04c3536908fbf3a3333aa42e1921f6dffd2/docs/img/sortspec-md-dark.jpg -------------------------------------------------------------------------------- /docs/svg/by-suffix.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | Produced by OmniGraffle 7.20\n2022-08-06 00:43:11 +0000 7 | 8 | by suffix 9 | 10 | Layer 1 11 | 12 | 13 | 14 | 15 | 16 | Data 17 | 18 | 19 | 20 | 21 | Inbox 22 | 23 | 24 | 25 | 26 | Mhmmm part 1 27 | 28 | 29 | 30 | 31 | Interesting part 20 32 | 33 | 34 | 35 | 36 | 37 | Interim part 333 38 | 39 | 40 | 41 | 42 | 43 | 45 | 46 | My 47 | Vault 48 | 49 | 50 | 51 | 52 | 53 | Final part 401 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | Note with no suffix 65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /docs/svg/files-go-first.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | Produced by OmniGraffle 7.20\n2022-08-05 22:37:20 +0000 7 | 8 | Files go first 9 | 10 | Layer 1 11 | 12 | 13 | 14 | 15 | 16 | Note (oldest) 17 | 18 | 19 | 20 | 21 | Note (old) 22 | 23 | 24 | 25 | 26 | Note (newest) 27 | 28 | 29 | 30 | 31 | Subfolder B 32 | 33 | 34 | 35 | 36 | Subfolder A 37 | 38 | 39 | 40 | 41 | 43 | 44 | My 45 | Vault 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | Subfolder 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /docs/svg/multi-folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | Produced by OmniGraffle 7.20\n2022-08-05 22:01:57 +0000 7 | 8 | Multi-folder spec 9 | 10 | Layer 1 11 | 12 | 13 | 14 | 15 | 16 | Projects 17 | 18 | 19 | 20 | 21 | Top Secret 22 | 23 | 24 | 25 | 26 | Experiment A.5-3 27 | 28 | 29 | 30 | 31 | 32 | Archive 33 | 34 | 35 | 36 | 37 | Something newer 38 | 39 | 40 | 41 | 42 | 44 | 45 | My 46 | Vault 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | Oooold 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /docs/svg/p_a_r_a.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | Produced by OmniGraffle 7.20\n2022-08-05 16:57:13 +0000 7 | 8 | P.A.R.A 9 | 10 | Layer 1 11 | 12 | 13 | 14 | 15 | 16 | Projects 17 | 18 | 19 | 20 | 21 | Top Secret 22 | 23 | 24 | 25 | 26 | Experiment A.5-3 27 | 28 | 29 | 30 | 31 | 32 | Areas 33 | 34 | 35 | 36 | 37 | Responsibilities 38 | 39 | 40 | 41 | 42 | 43 | 45 | 46 | My 47 | Vault 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | Archive 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /docs/svg/pin-focus-note.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | Produced by OmniGraffle 7.20\n2022-08-05 23:01:43 +0000 7 | 8 | Pin focus note 9 | 10 | Layer 1 11 | 12 | 13 | 14 | 15 | 16 | Focus note XYZ 17 | 18 | 19 | 20 | 21 | Inbox 22 | 23 | 24 | 25 | 26 | Some note 27 | 28 | 29 | 30 | 31 | Yet another note 32 | 33 | 34 | 35 | 36 | 37 | Archive 38 | 39 | 40 | 41 | 42 | 44 | 45 | My 46 | Vault 47 | 48 | 49 | 50 | 51 | 52 | sortspec 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /docs/svg/priorities-example-a.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Produced by OmniGraffle 7.20\n2022-08-06 14:27:13 +0000 6 | 7 | Roman sections 8 | 9 | Layer 1 10 | 11 | 12 | 13 | 14 | 15 | Some folder 16 | 17 | 18 | 19 | 20 | Alpha 21 | 22 | 23 | 24 | 25 | Archive April 26 | 27 | 28 | 29 | 30 | Archive Mai 31 | 32 | 33 | 34 | 35 | Archive March 36 | 37 | 38 | 39 | 40 | 41 | 42 | My Vault 43 | 44 | 45 | 46 | 47 | Beta 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | Gamma 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /docs/svg/priorities-example-b.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Produced by OmniGraffle 7.20\n2022-08-06 14:27:13 +0000 6 | 7 | Roman sections 8 | 9 | Layer 1 10 | 11 | 12 | 13 | 14 | 15 | Some folder 16 | 17 | 18 | 19 | 20 | Alpha 21 | 22 | 23 | 24 | 25 | Beta 26 | 27 | 28 | 29 | 30 | Gamma 31 | 32 | 33 | 34 | 35 | Archive April 36 | 37 | 38 | 39 | 40 | 41 | 42 | My Vault 43 | 44 | 45 | 46 | 47 | Archive Mai 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | Archive March 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /docs/svg/roman-chapters.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Produced by OmniGraffle 7.20\n2022-08-06 14:46:09 +0000 6 | 7 | Roman chapters 8 | 9 | Layer 1 10 | 11 | 12 | 13 | 14 | 15 | Book 16 | 17 | 18 | 19 | 20 | Preface 21 | 22 | 23 | 24 | 25 | Chapter I - How it all began 26 | 27 | 28 | 29 | 30 | Chapter V - Expect the unexpected 31 | 32 | 33 | 34 | 35 | Chapter XIII - Why? 36 | 37 | 38 | 39 | 40 | 41 | 42 | My Vault 43 | 44 | 45 | 46 | 47 | Chapter L - Happy ending? 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | Epilogue 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /docs/svg/roman-suffix.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Produced by OmniGraffle 7.20\n2022-08-06 14:27:13 +0000 6 | 7 | Roman sections 8 | 9 | Layer 1 10 | 11 | 12 | 13 | 14 | 15 | Research pub 16 | 17 | 18 | 19 | 20 | Summary 21 | 22 | 23 | 24 | 25 | Why is this needed? I 26 | 27 | 28 | 29 | 30 | Additional rationale. I.iii 31 | 32 | 33 | 34 | 35 | All the details. vii 36 | 37 | 38 | 39 | 40 | 41 | 42 | My Vault 43 | 44 | 45 | 46 | 47 | The promising outcomes. L 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | Final words 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /docs/svg/simplest-example-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | Produced by OmniGraffle 7.20\n2022-08-05 22:12:33 +0000 7 | 8 | Simplest 2 9 | 10 | Layer 1 11 | 12 | 13 | 14 | 15 | 16 | Note 1 17 | 18 | 19 | 20 | 21 | Z Archive 22 | 23 | 24 | 25 | 26 | Some note 27 | 28 | 29 | 30 | 31 | Some folder 32 | 33 | 34 | 35 | 36 | A folder 37 | 38 | 39 | 40 | 41 | 43 | 44 | My 45 | Vault 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | Note 2 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /docs/svg/simplest-example-3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | Produced by OmniGraffle 7.20\n2022-08-05 22:12:33 +0000 7 | 8 | Simplest 2 9 | 10 | Layer 1 11 | 12 | 13 | 14 | 15 | 16 | Z note 17 | 18 | 19 | 20 | 21 | XYZ archive folder 22 | 23 | 24 | 25 | 26 | Some note 27 | 28 | 29 | 30 | 31 | Some folder 32 | 33 | 34 | 35 | 36 | A.11 folder 37 | 38 | 39 | 40 | 41 | 43 | 44 | My 45 | Vault 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | A.2 Note 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /docs/svg/simplest-example.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | Produced by OmniGraffle 7.20\n2022-08-05 19:48:11 +0000 7 | 8 | Simplest 9 | 10 | Layer 1 11 | 12 | 13 | 14 | 15 | 16 | A folder 17 | 18 | 19 | 20 | 21 | Note 1 22 | 23 | 24 | 25 | 26 | Note 2 27 | 28 | 29 | 30 | 31 | Some folder 32 | 33 | 34 | 35 | 36 | Some note 37 | 38 | 39 | 40 | 41 | 43 | 44 | My 45 | Vault 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | Z Archive folder 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /docs/svg/syntax-1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Produced by OmniGraffle 7.20\n2022-10-07 10:18:50 +0000 12 | 13 | syntax-1 14 | 15 | Layer 1 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | Some note 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | --- 75 | 76 | 77 | 78 | 79 | sorting-spec: | 80 | 81 | 82 | 83 | 84 | .......... 85 | 86 | 87 | 88 | 89 | .......... 90 | 91 | 92 | 93 | 94 | .......... 95 | 96 | 97 | 98 | 99 | .......... 100 | 101 | 102 | 103 | 104 | .......... 105 | 106 | 107 | 108 | 109 | .......... 110 | 111 | 112 | 113 | 114 | .......... 115 | 116 | 117 | 118 | 119 | .......... 120 | 121 | 122 | 123 | 124 | section 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | section 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | --- 147 | 148 | 149 | 150 | 151 | ... some other YAML content 152 | 153 | 154 | 155 | 156 | Note text 157 | 158 | 159 | 160 | 161 | ... 162 | 163 | 164 | 165 | 166 | 167 | 168 | Folder note 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | --- 177 | 178 | 179 | 180 | 181 | sorting-spec: .......... 182 | 183 | 184 | 185 | 186 | --- 187 | 188 | 189 | 190 | 191 | ... some other YAML content 192 | 193 | 194 | 195 | 196 | Note text 197 | 198 | 199 | 200 | 201 | ... 202 | 203 | 204 | 205 | 206 | 207 | 208 | sortspec 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | --- 217 | 218 | 219 | 220 | 221 | sorting-spec: | 222 | 223 | 224 | 225 | 226 | .......... 227 | 228 | 229 | 230 | 231 | .......... 232 | 233 | 234 | 235 | 236 | .......... 237 | 238 | 239 | 240 | 241 | .......... 242 | 243 | 244 | 245 | 246 | section 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | section 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | --- 266 | 267 | 268 | 269 | 270 | 271 | My vault 272 | 273 | 274 | 275 | 276 | 277 | -------------------------------------------------------------------------------- /docs/svg/syntax-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Produced by OmniGraffle 7.20\n2022-10-07 13:22:34 +0000 6 | 7 | syntax-2 8 | 9 | Layer 1 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | a section 20 | 21 | 22 | 23 | 24 | sorting-spec: \< a-z 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | simplest 36 | example 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /docs/svg/syntax-3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Produced by OmniGraffle 7.20\n2022-10-07 13:22:34 +0000 6 | 7 | syntax-3 8 | 9 | Layer 1 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | a section 20 | 21 | 22 | 23 | 24 | target-folder: Reviews/* 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | typical 36 | example 37 | 38 | 39 | 40 | 41 | > advanced modified 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /docs/svg/syntax-4.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Produced by OmniGraffle 7.20\n2022-10-07 13:22:34 +0000 6 | 7 | syntax-4 8 | 9 | Layer 1 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | a section 20 | 21 | 22 | 23 | 24 | target-folder: Meeting minutes 25 | 26 | 27 | 28 | 29 | target-folder: Archive/... 30 | 31 | 32 | 33 | 34 | .......... default sorting for the folder(s) 35 | 36 | 37 | 38 | 39 | .......... group 1 definition 40 | 41 | 42 | 43 | 44 | .......... sorting within group 1 45 | 46 | 47 | 48 | 49 | .......... group 2 definition 50 | 51 | 52 | 53 | 54 | .......... group 3 definition 55 | 56 | 57 | 58 | 59 | .......... group 4 definition 60 | 61 | 62 | 63 | 64 | .......... sorting within group 2 65 | 66 | 67 | 68 | 69 | .......... 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | complex 81 | example 82 | of the idea 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /docs/syntax-reference.md: -------------------------------------------------------------------------------- 1 | > Document is partial, creation in progress 2 | > Please refer to [README.md](https://github.com/SebastianMC/obsidian-custom-sort/blob/master/README.md) for usage examples 3 | > Check [manual.md](https://github.com/SebastianMC/obsidian-custom-sort/blob/master/doc/manual.md), maybe that file has already some content? 4 | 5 | # Table of contents 6 | 7 | TBD 8 | 9 | # Introduction 10 | 11 | The syntax of sorting specification presented here intentionally uses an informal language (instead of a formal grammar definition). 12 | The intention is to make it understandable to non-technical persons. 13 | 14 | # The syntax 15 | 16 | **Visual presentation of the sorting specification structure** 17 | 18 | ![Visual idea of the syntax 1](./svg/syntax-1.svg) 19 | 20 | ![Visual idea of the syntax 2](./svg/syntax-2.svg) 21 | 22 | ![Visual idea of the syntax 3](./svg/syntax-3.svg) 23 | 24 | ![Visual idea of the syntax 4](./svg/syntax-4.svg) 25 | 26 | ## Sorting specification 27 | 28 | - the `sorting specification` has textual format 29 | - typically it is a multiline text 30 | - at the same time, in the simplest case, a single line specification is also possible 31 | - it has to be placed inside the frontmatter YAML in one or more Obsidian notes of the vault 32 | - the YAML key `sorting-spec:` has to be used 33 | - because indentation matters, the recommended multiline YAML syntax is `sorting-spec: |` 34 | - refer to YAML syntax reference for details on how the pipe `|` character works (e.g. https://en.wikipedia.org/wiki/YAML#Basic_components) 35 | - the `sorting specification` comprises one or more [sections](#Section) 36 | - each `section` has to reside in a single location (a single entry in frontmatter YAML of a note) 37 | - multiple `sections` can exist under a single `sorting-spec: |` YAML entry, one after another 38 | - the following Obsidian notes are scanned for `sorting-spec:` YAML entries: 39 | - `sortspec` notes (that is `sortspec.md` files actually) in all folders of the vault, including the root folder 40 | - the so called `folder notes` - by convention these are notes named exactly the same as their parent folder, e.g. `Inbox/Inbox.md` 41 | - this works regardless if any 'Folder note' plugin is installed or not in the vault 42 | - the single designated note, as configured in plugin settings 43 | - all found `sections` in all above locations comprise the `sorting specification` for the vault 44 | - the order doesn't matter 45 | - it is a matter of personal preference whether to put all `sections` in a single YAML entry in a single note in the vault or if spread the specification over frontmatter of many notes 46 | - the Obsidian standard text search for `sorting-spec:` is convenient enough to list all specification entries in the vault 47 | - empty lines inside the `sorting-spec: |` multiline value are ignored. They don't carry any semantic information 48 | - specifically: the empty lines _DON'T_ indicate a new `section` 49 | 50 | ## Section 51 | 52 | - a [section](#Section) specifies sorting for one or more folders of the vault 53 | - a [section](#Section) starts with one or more [target-folder:](#target-folder) lines, each one specifying a folder or a folders (sub)tree 54 | - the [target-folder:](#target-folder) is optional for the very first [section](#Section) in a `sorting-spec:` YAML entry. 55 | In that case it is assumed that the [target-folder:](#target-folder) is the parent folder of the note containing the `sorting-spec:` 56 | - if a [target-folder:](#target-folder) is specified, the above default behavior is not applicable. 57 | - subsequent lines with [target-folder:](#target-folder) are collected and treated as multi-target-folder specification 58 | - empty lines are ignored 59 | - [comments](#Comments) are ignored as well 60 | - one (or more) [target-folder:](#target-folder) lines has to be followed by [sorting instruction(s)](#sorting-instructions) for these folders 61 | - the `sorting instruction(s)` can be ignored only for the last (or only) [target-folder:](#target-folder) line of `sorting-spec:`. 62 | In that case, the default alphabetical sorting is assumed for the specified folders, treating the folders and notes equally 63 | - occurrence of [target-folder:](#target-folder) line after one or more `sorting-instruction(s)` indicates a beginning of a new [section](#Section) 64 | 65 | ## target-folder: 66 | 67 | TBD 68 | 69 | ## Sorting instruction(s) 70 | 71 | ## Comments 72 | 73 | Lines starting with `//` are ignored 74 | > **NOTE:** 75 | > 76 | > Depending on what indentation was used in the very first line of `sorting-spec: |`, it must be preserved also on comment lines. 77 | For example: 78 | ```yaml 79 | sorting-spec: | 80 | target-folder: / 81 | target-folder: Archive 82 | // This is some comment 83 | // This is also a valid comment 84 | > modified 85 | // This is not a valid comment -> indentation is smaller than of the very first line of sorting-spec: 86 | ``` 87 | 88 | 89 | ### Supported sorting methods 90 | 91 | #### At folder level only 92 | 93 | - `sorting: standard` - gives back the control on order of items in hands of standard Obsidian mechanisms (UI driven). 94 | Typical (and intended) use: exclude a folder (or folders subtree) from a custom sorting resulting from wilcard-based target folder rule 95 | 96 | #### At folder and group level 97 | 98 | - `< a-z` - alphabetical 99 | - `> a-z` - alphabetical reverse, aka alphabetical descending, 'z' goes before 'a' 100 | - `< true a-z` - true alphabetical, to understand the difference between this one and alphabetical refer to [Alphabetical, Natural and True Alphabetical sorting orders](../README.md#alphabetical-natural-and-true-alphabetical-sorting-orders) 101 | - `> true a-z` - true alphabetical reverse, aka true alphabetical descending, 'z' goes before 'a' 102 | - `< modified` - by modified time, the long untouched item goes first (modified time of folder is assumed the beginning of the world, so folders go first and alphabetical) 103 | - `> modified` - by modified time reverse, the most recently modified item goes first (modified time of folder is assumed the beginning of the world, so folders land in the bottom and alphabetical) 104 | - `< created` - by created time, the oldest item goes first (modified time of folder is assumed the beginning of the world, so folders go first and alphabetical) 105 | - `> created` - by created time reverse, the newest item goes first (modified time of folder is assumed the beginning of the world, so folders land in the bottom and alphabetical) 106 | - `< advanced modified` - by modified time, the long untouched item goes first. For folders, their modification date is derived from the most recently modified direct child file. 107 | For extremely large vaults use with caution, as the sorting needs to scan all files inside a folder to determine the folder's modified date 108 | - `> advanced modified` - by modified time reverse, the most recently modified item goes first. For folders, their modification date is derived from the most recently modified direct child file. 109 | For extremely large vaults use with caution, as the sorting needs to scan all files inside a folder to determine the folder's modified date 110 | - `< advanced created` - by created time, the oldest item goes first. For folders, their creation date is derived from the oldest (ctime) direct child file. 111 | For extremely large vaults use with caution, as the sorting needs to scan all files inside a folder to determine the folder's created date 112 | - `> advanced created` - by created time reverse, the newest item goes first. For folders, their creation date is derived from the oldest (ctime) direct child file. 113 | For extremely large vaults use with caution, as the sorting needs to scan all files inside a folder to determine the folder's created date 114 | 115 | #### At group level only (aka secondary sorting rule) 116 | 117 | > Only applicable in edge cases based on numerical symbols, when the regex-based match is equal for more than one item 118 | and need to apply a secondary order on same matches. 119 | 120 | - `< a-z, created` 121 | - `> a-z, created` 122 | - `< a-z, created desc` 123 | - `> a-z, created desc` 124 | - `< a-z, modified` 125 | - `> a-z, modified` 126 | - `< a-z, modified desc` 127 | - `> a-z, modified desc` 128 | - `< a-z, advanced created` 129 | - `> a-z, advanced created` 130 | - `< a-z, advanced created desc` 131 | - `> a-z, advanced created desc` 132 | - `< a-z, advanced created` 133 | - `> a-z, advanced created` 134 | - `< a-z, advanced created desc` 135 | - `> a-z, advanced created desc` 136 | 137 | ### Alternate tokens 138 | 139 | Some tokens have shorter equivalents, which can be used interchangeably: 140 | 141 | - `target-folder:` --> `::::` e.g `target-folder: /` is equivalent to `:::: /` 142 | - `order-asc:` --> `<` e.g. `order-asc: modified` is equivalent to `< modified` 143 | - `order-desc:` --> `>` e.g. `order-desc: a-z` is equivalent to `> a-z` 144 | - `/:files` --> `/:` e.g. `/:files Chapter \.d+ ...` is equivalent to `/: Chapter \.d+ ...` 145 | - `/:files.` --> `/:.` e.g. `/:files. ... \-D+.md` is equivalent to `/:. ... \-D+.md` 146 | - `/folders` --> `/` e.g. `/folders Archive...` is equivalent to `/ Archive...` 147 | - `/folders:files` --> `%` e.g. `/folders:files Chapter...` is equivalent to `% Chapter...` 148 | 149 | Additional shorter equivalents to allow single-liners like `sorting-spec: \< a-z`: 150 | - `order-asc:` --> `\<` e.g. `order-asc: modified` is equivalent to `\< modified` 151 | - `order-desc:` --> `\>` e.g. `order-desc: a-z` is equivalent to `\> a-z` 152 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from 'builtin-modules' 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ['src/main.ts'], 19 | bundle: true, 20 | external: [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/collab', 25 | '@codemirror/commands', 26 | '@codemirror/language', 27 | '@codemirror/lint', 28 | '@codemirror/search', 29 | '@codemirror/state', 30 | '@codemirror/view', 31 | '@lezer/common', 32 | '@lezer/highlight', 33 | '@lezer/lr', 34 | ...builtins], 35 | format: 'cjs', 36 | target: 'es2018', 37 | logLevel: "info", 38 | sourcemap: prod ? false : 'inline', 39 | minify: prod, 40 | treeShaking: true, 41 | outfile: 'dist/main.js', 42 | }).catch(() => process.exit(1)); 43 | 44 | if (prod) { 45 | await context.rebuild(); 46 | process.exit(0); 47 | } else { 48 | await context.watch(); 49 | } 50 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | roots: [""], 6 | moduleNameMapper: { 7 | "obsidian": "/node_modules/obsidian/obsidian.d.ts" 8 | }, 9 | transformIgnorePatterns: [ 10 | 'node_modules/(?!obsidian/.*)' 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "custom-sort", 3 | "name": "Custom File Explorer sorting", 4 | "version": "3.1.5", 5 | "minAppVersion": "1.7.2", 6 | "description": "Allows for manual and automatic, config-driven reordering and sorting of files and folders in File Explorer", 7 | "author": "SebastianMC", 8 | "authorUrl": "https://github.com/SebastianMC", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-custom-sort", 3 | "version": "3.1.5", 4 | "description": "Custom Sort plugin for Obsidian (https://obsidian.md)", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json", 10 | "test": "jest" 11 | }, 12 | "keywords": [ 13 | "obsidian", 14 | "custom sorting" 15 | ], 16 | "author": "SebastianMC", 17 | "repository": "https://github.com/SebastianMC/obsidian-custom-sort", 18 | "license": "MIT", 19 | "devDependencies": { 20 | "@types/jest": "^28.1.2", 21 | "@types/node": "^16.11.6", 22 | "@typescript-eslint/eslint-plugin": "5.29.0", 23 | "@typescript-eslint/parser": "5.29.0", 24 | "builtin-modules": "3.3.0", 25 | "esbuild": "0.17.3", 26 | "eslint": "^8.29.0", 27 | "jest": "^29.7.0", 28 | "monkey-around": "^3.0.0", 29 | "obsidian": "^1.7.2", 30 | "ts-jest": "^29.2.5", 31 | "tslib": "2.8.1", 32 | "typescript": "5.7.2" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/custom-sort-plugin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Plugin 3 | } from 'obsidian' 4 | 5 | export interface CustomSortPluginAPI extends Plugin { 6 | indexNoteBasename(): string|undefined 7 | } 8 | -------------------------------------------------------------------------------- /src/custom-sort/custom-sort-types.ts: -------------------------------------------------------------------------------- 1 | import {MDataExtractor} from "./mdata-extractors"; 2 | 3 | export enum CustomSortGroupType { 4 | Outsiders, // Not belonging to any of other groups 5 | MatchAll, // like a wildard *, used in connection with foldersOnly or filesOnly. The difference between the MatchAll and Outsiders is 6 | ExactName, // ... that MatchAll captures the item (folder, note) and prevents further matching against other rules 7 | ExactPrefix, // ... while the Outsiders captures items which didn't match any of other defined groups 8 | ExactSuffix, 9 | ExactHeadAndTail, // Like W...n or Un...ed, which is shorter variant of typing the entire title 10 | HasMetadataField, // Notes (or folder's notes) containing a specific metadata field 11 | BookmarkedOnly, 12 | HasIcon 13 | } 14 | 15 | export enum CustomSortOrder { 16 | alphabetical = 1, // = 1 to allow: if (customSortOrder) { ... 17 | alphabeticalWithFileExt, 18 | trueAlphabetical, 19 | trueAlphabeticalWithFileExt, 20 | alphabeticalReverse, 21 | alphabeticalReverseWithFileExt, 22 | trueAlphabeticalReverse, 23 | trueAlphabeticalReverseWithFileExt, 24 | byModifiedTime, // New to old 25 | byModifiedTimeAdvanced, 26 | byModifiedTimeAdvancedRecursive, 27 | byModifiedTimeReverse, // Old to new 28 | byModifiedTimeReverseAdvanced, 29 | byModifiedTimeReverseAdvancedRecursive, 30 | byCreatedTime, // New to old 31 | byCreatedTimeAdvanced, 32 | byCreatedTimeAdvancedRecursive, 33 | byCreatedTimeReverse, 34 | byCreatedTimeReverseAdvanced, 35 | byCreatedTimeReverseAdvancedRecursive, 36 | byMetadataFieldAlphabetical, 37 | byMetadataFieldTrueAlphabetical, 38 | byMetadataFieldAlphabeticalReverse, 39 | byMetadataFieldTrueAlphabeticalReverse, 40 | standardObsidian, // whatever user selected in the UI 41 | byBookmarkOrder, 42 | byBookmarkOrderReverse, 43 | fileFirst, 44 | folderFirst, 45 | alphabeticalWithFilesPreferred, // When the (base)names are equal, the file has precedence over a folder 46 | alphabeticalWithFoldersPreferred, // When the (base)names are equal, the file has precedence over a folder, 47 | vscUnicode, // the Visual Studio Code lexicographic order named 'unicode' (which is very misleading, at the same time familiar to VS Code users 48 | vscUnicodeReverse, // ... see compareFilesUnicode function https://github.com/microsoft/vscode/blob/a19b2d5fb0202e00fb930dc850d2695ec512e495/src/vs/base/common/comparers.ts#L80 49 | default = alphabeticalWithFilesPreferred 50 | } 51 | 52 | export type NormalizerFn = (s: string) => string | null 53 | export const IdentityNormalizerFn: NormalizerFn = (s: string) => s 54 | 55 | export interface RegExpSpec { 56 | regex: RegExp 57 | normalizerFn?: NormalizerFn 58 | } 59 | 60 | export interface CustomSort { 61 | order: CustomSortOrder // mandatory 62 | byMetadata?: string 63 | metadataValueExtractor?: MDataExtractor 64 | } 65 | 66 | export interface RecognizedSorting { 67 | primary?: CustomSort 68 | secondary?: CustomSort 69 | } 70 | 71 | export interface CustomSortGroup { 72 | type: CustomSortGroupType 73 | exactText?: string 74 | exactPrefix?: string 75 | regexPrefix?: RegExpSpec 76 | exactSuffix?: string 77 | regexSuffix?: RegExpSpec 78 | sorting?: CustomSort 79 | secondarySorting?: CustomSort 80 | filesOnly?: boolean 81 | matchFilenameWithExt?: boolean 82 | foldersOnly?: boolean 83 | withMetadataFieldName?: string // for 'with-metadata:' grouping 84 | iconName?: string // for integration with obsidian-folder-icon community plugin 85 | priority?: number 86 | combineWithIdx?: number 87 | } 88 | 89 | export interface CustomSortSpec { 90 | // plays only informative role about the original parsed 'target-folder:' values 91 | targetFoldersPaths: Array // For root use '/' 92 | defaultSorting?: CustomSort 93 | defaultSecondarySorting?: CustomSort 94 | groups: Array 95 | groupsShadow?: Array // A shallow copy of groups, used at applying sorting for items in a folder. 96 | // Stores folder-specific values (e.g. macros expanded with folder-specific values) 97 | outsidersGroupIdx?: number 98 | outsidersFilesGroupIdx?: number 99 | outsidersFoldersGroupIdx?: number 100 | itemsToHide?: Set 101 | priorityOrder?: Array // Indexes of groups in evaluation order 102 | implicit?: boolean // spec applied automatically (e.g. auto integration with a plugin) 103 | } 104 | 105 | export const DEFAULT_METADATA_FIELD_FOR_SORTING: string = 'sort-index-value' 106 | -------------------------------------------------------------------------------- /src/custom-sort/custom-sort-utils.ts: -------------------------------------------------------------------------------- 1 | import {CustomSortGroupType, CustomSortOrder, CustomSortSpec} from "./custom-sort-types"; 2 | 3 | // Put here to allow unit tests coverage of this specific implicit sorting spec 4 | export const ImplicitSortspecForBookmarksIntegration: string = ` 5 | target-folder: /* 6 | bookmarked: 7 | < by-bookmarks-order 8 | sorting: standard 9 | ` 10 | 11 | export interface HasSortingTypes { 12 | byBookmarks: number 13 | standardObsidian: number 14 | total: number 15 | } 16 | 17 | export interface HasGroupingTypes { 18 | byBookmarks: number 19 | byIcon: number 20 | total: number 21 | } 22 | 23 | export interface HasSortingOrGrouping { 24 | sorting: HasSortingTypes 25 | grouping: HasGroupingTypes 26 | } 27 | 28 | export const checkByBookmark = (has: HasSortingOrGrouping, order?: CustomSortOrder, groupType?: CustomSortGroupType ) => { 29 | groupType === CustomSortGroupType.BookmarkedOnly && has.grouping.byBookmarks++; 30 | (order === CustomSortOrder.byBookmarkOrder || order === CustomSortOrder.byBookmarkOrderReverse) && has.sorting.byBookmarks++; 31 | } 32 | 33 | export const checkByIcon = (has: HasSortingOrGrouping, order?: CustomSortOrder, groupType?: CustomSortGroupType ) => { 34 | groupType === CustomSortGroupType.HasIcon && has.grouping.byIcon++; 35 | } 36 | 37 | export const checkStandardObsidian = (has: HasSortingOrGrouping, order?: CustomSortOrder, groupType?: CustomSortGroupType ) => { 38 | order === CustomSortOrder.standardObsidian && has.sorting.standardObsidian++; 39 | } 40 | 41 | export const doCheck = (has: HasSortingOrGrouping, order?: CustomSortOrder, groupType?: CustomSortGroupType) => { 42 | checkByBookmark(has, order, groupType) 43 | checkByIcon(has, order, groupType) 44 | checkStandardObsidian(has, order, groupType) 45 | 46 | order !== undefined && has.sorting.total++ 47 | groupType !== undefined && groupType !== CustomSortGroupType.Outsiders && has.grouping.total++; 48 | } 49 | 50 | export const collectSortingAndGroupingTypes = (sortSpec?: CustomSortSpec|null): HasSortingOrGrouping => { 51 | const has: HasSortingOrGrouping = { 52 | grouping: { 53 | byIcon: 0, byBookmarks: 0, total: 0 54 | }, 55 | sorting: { 56 | byBookmarks: 0, standardObsidian: 0, total: 0 57 | } 58 | } 59 | if (!sortSpec) return has 60 | doCheck(has, sortSpec.defaultSorting?.order) 61 | doCheck(has, sortSpec.defaultSecondarySorting?.order) 62 | if (sortSpec.groups) { 63 | for (let group of sortSpec.groups) { 64 | doCheck(has, group.sorting?.order, group.type) 65 | doCheck(has, group.secondarySorting?.order) 66 | } 67 | } 68 | return has 69 | } 70 | 71 | export const hasOnlyByBookmarkOrStandardObsidian = (has: HasSortingOrGrouping): boolean => { 72 | return has.sorting.total === has.sorting.standardObsidian + has.sorting.byBookmarks && 73 | has.grouping.total === has.grouping.byBookmarks 74 | } 75 | -------------------------------------------------------------------------------- /src/custom-sort/folder-matching-rules.ts: -------------------------------------------------------------------------------- 1 | export type DeterminedSortingSpec = { 2 | spec?: SortingSpec 3 | } 4 | 5 | export interface FolderMatchingTreeNode { 6 | path?: string 7 | name?: string 8 | matchChildren?: SortingSpec 9 | matchAll?: SortingSpec 10 | subtree: { [key: string]: FolderMatchingTreeNode } 11 | } 12 | 13 | export interface FolderMatchingRegexp { 14 | regexp: RegExp 15 | againstName: boolean 16 | priority: number 17 | logMatches: boolean 18 | sortingSpec: SortingSpec 19 | } 20 | 21 | const SLASH: string = '/' 22 | export const MATCH_CHILDREN_PATH_TOKEN: string = '...' 23 | export const MATCH_ALL_PATH_TOKEN: string = '*' 24 | export const MATCH_CHILDREN_1_SUFFIX: string = `/${MATCH_CHILDREN_PATH_TOKEN}` 25 | export const MATCH_CHILDREN_2_SUFFIX: string = `/${MATCH_CHILDREN_PATH_TOKEN}/` 26 | export const MATCH_ALL_SUFFIX: string = `/${MATCH_ALL_PATH_TOKEN}` 27 | 28 | export const NO_PRIORITY = 0 29 | 30 | export const splitPath = (path: string): Array => { 31 | return path.split(SLASH).filter((name) => !!name) 32 | } 33 | 34 | export interface AddingWildcardFailure { 35 | errorMsg: string 36 | } 37 | 38 | export type CheckIfImplicitSpec = (s: SortingSpec) => boolean 39 | 40 | export class FolderWildcardMatching { 41 | 42 | // mimics the structure of folders, so for example tree.matchAll contains the matchAll flag for the root '/' 43 | tree: FolderMatchingTreeNode = { 44 | subtree: {} 45 | } 46 | 47 | regexps: Array> 48 | 49 | constructor(private checkIfImplicitSpec: CheckIfImplicitSpec) { 50 | } 51 | 52 | // cache 53 | determinedWildcardRules: { [key: string]: DeterminedSortingSpec } = {} 54 | 55 | addWildcardDefinition = (wilcardDefinition: string, rule: SortingSpec): AddingWildcardFailure | null | undefined => { 56 | const pathComponents: Array = splitPath(wilcardDefinition) 57 | const lastComponent: string | undefined = pathComponents.pop() 58 | if (lastComponent !== MATCH_ALL_PATH_TOKEN && lastComponent !== MATCH_CHILDREN_PATH_TOKEN) { 59 | return null 60 | } 61 | let leafNode: FolderMatchingTreeNode = this.tree 62 | pathComponents.forEach((pathComponent) => { 63 | let subtree: FolderMatchingTreeNode = leafNode.subtree[pathComponent] 64 | if (subtree) { 65 | leafNode = subtree 66 | } else { 67 | const newSubtree: FolderMatchingTreeNode = { 68 | name: pathComponent, 69 | subtree: {} 70 | } 71 | leafNode.subtree[pathComponent] = newSubtree 72 | leafNode = newSubtree 73 | } 74 | }) 75 | if (lastComponent === MATCH_CHILDREN_PATH_TOKEN) { 76 | if (leafNode.matchChildren && !this.checkIfImplicitSpec(leafNode.matchChildren)) { 77 | return {errorMsg: `Duplicate wildcard '${lastComponent}' specification for ${wilcardDefinition}`} 78 | } else { 79 | leafNode.matchChildren = rule 80 | } 81 | } else { // Implicitly: MATCH_ALL_PATH_TOKEN 82 | if (leafNode.matchAll && !this.checkIfImplicitSpec(leafNode.matchAll)) { 83 | return {errorMsg: `Duplicate wildcard '${lastComponent}' specification for ${wilcardDefinition}`} 84 | } else { 85 | leafNode.matchAll = rule 86 | } 87 | } 88 | } 89 | 90 | addRegexpDefinition = (regexp: RegExp, 91 | againstName: boolean, 92 | priority: number | undefined, 93 | log: boolean | undefined, 94 | rule: SortingSpec 95 | ) => { 96 | const newItem: FolderMatchingRegexp = { 97 | regexp: regexp, 98 | againstName: againstName, 99 | priority: priority || NO_PRIORITY, 100 | sortingSpec: rule, 101 | logMatches: !!log 102 | } 103 | if (this.regexps === undefined || this.regexps.length === 0) { 104 | this.regexps = [newItem] 105 | } else { 106 | // priority is present ==> consciously determine where to insert the regexp 107 | let idx = 0 108 | while (idx < this.regexps.length && this.regexps[idx].priority > newItem.priority) { 109 | idx++ 110 | } 111 | this.regexps.splice(idx, 0, newItem) 112 | } 113 | } 114 | 115 | folderMatch = (folderPath: string, folderName?: string): SortingSpec | null => { 116 | const spec: DeterminedSortingSpec = this.determinedWildcardRules[folderPath] 117 | 118 | if (spec) { 119 | return spec.spec ?? null 120 | } else { 121 | let rule: SortingSpec | null | undefined 122 | // regexp matching 123 | if (this.regexps) { 124 | for (let r of this.regexps) { 125 | if (r.againstName && !folderName) { 126 | // exclude the edge case: 127 | // - root folder which has empty name (and path /) 128 | // AND name-matching regexp allows zero-length matches 129 | continue 130 | } 131 | if (r.regexp.test(r.againstName ? (folderName || '') : folderPath)) { 132 | rule = r.sortingSpec 133 | if (r.logMatches) { 134 | const msgDetails: string = (r.againstName) ? `name: ${folderName}` : `path: ${folderPath}` 135 | console.log(`custom-sort plugin - regexp <${r.regexp.source}> matched folder ${msgDetails}`) 136 | } 137 | break 138 | } 139 | } 140 | } 141 | 142 | // simple wildards matching 143 | if (!rule) { 144 | rule = this.tree.matchChildren 145 | let inheritedRule: SortingSpec | undefined = this.tree.matchAll 146 | const pathComponents: Array = splitPath(folderPath) 147 | let parentNode: FolderMatchingTreeNode = this.tree 148 | let lastIdx: number = pathComponents.length - 1 149 | for (let i = 0; i <= lastIdx; i++) { 150 | const name: string = pathComponents[i] 151 | let matchedPath: FolderMatchingTreeNode = parentNode.subtree[name] 152 | if (matchedPath) { 153 | parentNode = matchedPath 154 | rule = matchedPath?.matchChildren ?? null 155 | inheritedRule = matchedPath.matchAll ?? inheritedRule 156 | } else { 157 | if (i < lastIdx) { 158 | rule = inheritedRule 159 | } 160 | break 161 | } 162 | } 163 | 164 | rule ??= inheritedRule 165 | } 166 | 167 | if (rule) { 168 | this.determinedWildcardRules[folderPath] = {spec: rule} 169 | return rule 170 | } else { 171 | this.determinedWildcardRules[folderPath] = {} 172 | return null 173 | } 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/custom-sort/icons.ts: -------------------------------------------------------------------------------- 1 | import {addIcon} from "obsidian"; 2 | 3 | export const ICON_SORT_ENABLED_ACTIVE: string = 'custom-sort-icon-active' 4 | export const ICON_SORT_MOBILE_INITIAL: string = 'custom-sort-icon-mobile-initial' 5 | export const ICON_SORT_SUSPENDED: string = 'custom-sort-icon-suspended' 6 | export const ICON_SORT_ENABLED_NOT_APPLIED: string = 'custom-sort-icon-enabled-not-applied' 7 | export const ICON_SORT_SUSPENDED_SYNTAX_ERROR: string = 'custom-sort-icon-syntax-error' 8 | export const ICON_SORT_SUSPENDED_GENERAL_ERROR: string = 'custom-sort-icon-general-error' 9 | 10 | export function addIcons() { 11 | addIcon(ICON_SORT_ENABLED_ACTIVE, 12 | ` 13 | 14 | 15 | 16 | 17 | ` 18 | ) 19 | addIcon(ICON_SORT_MOBILE_INITIAL, 20 | ` 21 | 22 | 23 | ` 24 | ) 25 | addIcon(ICON_SORT_SUSPENDED, 26 | ` 27 | ` 28 | ) 29 | addIcon(ICON_SORT_SUSPENDED_SYNTAX_ERROR, 30 | ` 31 | 32 | 33 | 34 | 35 | ` 36 | ) 37 | addIcon(ICON_SORT_SUSPENDED_GENERAL_ERROR, 38 | ` 39 | 40 | 41 | 42 | 43 | ` 44 | ) 45 | addIcon(ICON_SORT_ENABLED_NOT_APPLIED, 46 | ` 47 | 48 | 49 | ` 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/custom-sort/macros.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomSortSpec 3 | } from "./custom-sort-types"; 4 | 5 | const MACRO_PREFIX: string = '{:' 6 | const MACRO_SUFFIX: string = ':}' 7 | 8 | const PARENT_FOLDER_NAME_PLACEHOLDER: string = '%parent-folder-name%' 9 | 10 | const PARENT_FOLDER_NAME_MACRO: string = MACRO_PREFIX + PARENT_FOLDER_NAME_PLACEHOLDER + MACRO_SUFFIX 11 | 12 | export const expandMacrosInString = function(source: string|undefined, parentFolderName?: string|undefined): string|undefined { 13 | if (source && parentFolderName) { 14 | return source.replace(PARENT_FOLDER_NAME_MACRO, parentFolderName) 15 | } else { 16 | return source 17 | } 18 | } 19 | 20 | export const expandMacros = function(sortingSpec: CustomSortSpec, parentFolderName: string|undefined) { 21 | sortingSpec.groupsShadow?.forEach((shadowGroup) => { 22 | if (parentFolderName) { // root has no parent folder, ignore relevant macros for the root 23 | if (shadowGroup.exactText) { 24 | shadowGroup.exactText = expandMacrosInString(shadowGroup.exactText, parentFolderName) 25 | } 26 | if (shadowGroup.exactPrefix) { 27 | shadowGroup.exactPrefix = expandMacrosInString(shadowGroup.exactPrefix, parentFolderName) 28 | } 29 | if (shadowGroup.exactSuffix) { 30 | shadowGroup.exactSuffix = expandMacrosInString(shadowGroup.exactSuffix, parentFolderName) 31 | } 32 | } 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /src/custom-sort/matchers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getDateForWeekOfYear 3 | } from "../utils/week-of-year"; 4 | 5 | export const RomanNumberRegexStr: string = ' *([MDCLXVI]+)'; // Roman number 6 | export const CompoundRomanNumberDotRegexStr: string = ' *([MDCLXVI]+(?:\\.[MDCLXVI]+)*)';// Compound Roman number with dot as separator 7 | export const CompoundRomanNumberDashRegexStr: string = ' *([MDCLXVI]+(?:-[MDCLXVI]+)*)'; // Compound Roman number with dash as separator 8 | 9 | export const NumberRegexStr: string = ' *(\\d+)'; // Plain number 10 | export const CompoundNumberDotRegexStr: string = ' *(\\d+(?:\\.\\d+)*)'; // Compound number with dot as separator 11 | export const CompoundNumberDashRegexStr: string = ' *(\\d+(?:-\\d+)*)'; // Compound number with dash as separator 12 | 13 | export const Date_yyyy_mm_dd_RegexStr: string = ' *(\\d{4}-[0-3]*[0-9]-[0-3]*[0-9])' 14 | export const Date_yyyy_dd_mm_RegexStr: string = Date_yyyy_mm_dd_RegexStr 15 | 16 | export const Date_mm_dd_yyyy_RegexStr: string = ' *([0-3]*[0-9]-[0-3]*[0-9]-\\d{4})' 17 | export const Date_dd_mm_yyyy_RegexStr: string = Date_mm_dd_yyyy_RegexStr 18 | 19 | export const Date_dd_Mmm_yyyy_RegexStr: string = ' *([0-3]*[0-9]-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\\d{4})'; // Date like 01-Jan-2020 20 | export const Date_Mmm_dd_yyyy_RegexStr: string = ' *((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-[0-3]*[0-9]-\\d{4})'; // Date like Jan-01-2020 21 | 22 | export const Date_yyyy_Www_mm_dd_RegexStr: string = ' *(\\d{4}-W[0-5]*[0-9] \\([0-3]*[0-9]-[0-3]*[0-9]\\))' 23 | export const Date_yyyy_WwwISO_RegexStr: string = ' *(\\d{4}-W[0-5]*[0-9][-+]?)' 24 | export const Date_yyyy_Www_RegexStr: string = Date_yyyy_WwwISO_RegexStr 25 | 26 | export const DOT_SEPARATOR = '.' // ASCII 46 27 | export const DASH_SEPARATOR = '-' 28 | 29 | const SLASH_SEPARATOR = '/' // ASCII 47, right before ASCII 48 = '0' 30 | const GT_SEPARATOR = '>' // ASCII 62, alphabetical sorting in Collator puts it after / 31 | const PIPE_SEPARATOR = '|' // ASCII 124 32 | 33 | const EARLIER_THAN_SLASH_SEPARATOR = DOT_SEPARATOR 34 | const LATER_THAN_SLASH_SEPARATOR = GT_SEPARATOR 35 | 36 | export const DEFAULT_NORMALIZATION_PLACES = 8; // Fixed width of a normalized number (with leading zeros) 37 | 38 | // Property escapes: 39 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Unicode_Property_Escapes 40 | // https://stackoverflow.com/a/48902765 41 | // 42 | // Using Unicode property escapes to express 'a letter in any modern language' 43 | export const WordInAnyLanguageRegexStr = '(\\p{Letter}+)' // remember about the /u option -> /\p{Letter}+/u 44 | 45 | export const WordInASCIIRegexStr = '([a-zA-Z]+)' 46 | 47 | export function prependWithZeros(s: string|undefined, minLength: number): string { 48 | if ('string' === typeof s) { 49 | if (s.length < minLength) { 50 | const delta: number = minLength - s.length; 51 | return '000000000000000000000000000'.substring(0, delta) + s; 52 | } else { 53 | return s; 54 | } 55 | } else { 56 | return prependWithZeros((s ?? '').toString(), minLength) 57 | } 58 | } 59 | 60 | // Accepts trimmed number (compound or not) as parameter. No internal verification!!! 61 | 62 | export function getNormalizedNumber(s: string = '', separator?: string, places?: number): string | null { 63 | // The strange PIPE_SEPARATOR and trailing // are to allow correct sorting of compound numbers: 64 | // 1-1 should go before 1-1-1 and 1 should go yet earlier. 65 | // That's why the conversion to: 66 | // 1// 67 | // 1|1// 68 | // 1|1|1// 69 | // guarantees correct order (/ = ASCII 47, | = ASCII 124) 70 | if (separator) { 71 | const components: Array = s.split(separator).filter(s => s) 72 | return `${components.map((c) => prependWithZeros(c, places ?? DEFAULT_NORMALIZATION_PLACES)).join(PIPE_SEPARATOR)}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` 73 | } else { 74 | return `${prependWithZeros(s, places ?? DEFAULT_NORMALIZATION_PLACES)}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` 75 | } 76 | } 77 | 78 | function RomanCharToInt(c: string): number { 79 | const Roman: string = '0iIvVxXlLcCdDmM'; 80 | const RomanValues: Array = [0, 1, 1, 5, 5, 10, 10, 50, 50, 100, 100, 500, 500, 1000, 1000]; 81 | if (c) { 82 | const idx: number = Roman.indexOf(c[0]) 83 | return idx > 0 ? RomanValues[idx] : 0; 84 | } else { 85 | return 0; 86 | } 87 | } 88 | 89 | export function romanToIntStr(rs: string): string { 90 | if (rs == null) return '0'; 91 | 92 | let num = RomanCharToInt(rs.charAt(0)); 93 | let prev, curr; 94 | 95 | for (let i = 1; i < rs.length; i++) { 96 | curr = RomanCharToInt(rs.charAt(i)); 97 | prev = RomanCharToInt(rs.charAt(i - 1)); 98 | if (curr <= prev) { 99 | num += curr; 100 | } else { 101 | num = num - prev * 2 + curr; 102 | } 103 | } 104 | 105 | return `${num}`; 106 | } 107 | 108 | export function getNormalizedRomanNumber(s: string, separator?: string, places?: number): string | null { 109 | // The strange PIPE_SEPARATOR and trailing // are to allow correct sorting of compound numbers: 110 | // 1-1 should go before 1-1-1 and 1 should go yet earlier. 111 | // That's why the conversion to: 112 | // 1// 113 | // 1|1// 114 | // 1|1|1// 115 | // guarantees correct order (/ = ASCII 47, | = ASCII 124) 116 | if (separator) { 117 | const components: Array = s.split(separator).filter(s => s) 118 | return `${components.map((c) => prependWithZeros(romanToIntStr(c), places ?? DEFAULT_NORMALIZATION_PLACES)).join(PIPE_SEPARATOR)}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` 119 | } else { 120 | return `${prependWithZeros(romanToIntStr(s), places ?? DEFAULT_NORMALIZATION_PLACES)}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` 121 | } 122 | } 123 | 124 | export const DAY_POSITIONS = '00'.length 125 | export const MONTH_POSITIONS = '00'.length 126 | export const YEAR_POSITIONS = '0000'.length 127 | 128 | const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] 129 | 130 | export function getNormalizedDate_NormalizerFn_for(separator: string, dayIdx: number, monthIdx: number, yearIdx: number, months?: string[]) { 131 | return (s: string): string | null => { 132 | // Assumption - the regex date matched against input s, no extensive defensive coding needed 133 | const components = s.split(separator) 134 | const day = prependWithZeros(components[dayIdx], DAY_POSITIONS) 135 | const monthValue = months ? `${1 + MONTHS.indexOf(components[monthIdx])}` : components[monthIdx] 136 | const month = prependWithZeros(monthValue, MONTH_POSITIONS) 137 | const year = prependWithZeros(components[yearIdx], YEAR_POSITIONS) 138 | return `${year}-${month}-${day}${SLASH_SEPARATOR}${SLASH_SEPARATOR}` 139 | } 140 | } 141 | 142 | export const getNormalizedDate_yyyy_mm_dd_NormalizerFn = getNormalizedDate_NormalizerFn_for('-', 2, 1, 0) 143 | export const getNormalizedDate_yyyy_dd_mm_NormalizerFn = getNormalizedDate_NormalizerFn_for('-', 1, 2, 0) 144 | export const getNormalizedDate_mm_dd_yyyy_NormalizerFn = getNormalizedDate_NormalizerFn_for('-', 1, 0, 2) 145 | export const getNormalizedDate_dd_mm_yyyy_NormalizerFn = getNormalizedDate_NormalizerFn_for('-', 0, 1, 2) 146 | export const getNormalizedDate_dd_Mmm_yyyy_NormalizerFn = getNormalizedDate_NormalizerFn_for('-', 0, 1, 2, MONTHS) 147 | export const getNormalizedDate_Mmm_dd_yyyy_NormalizerFn = getNormalizedDate_NormalizerFn_for('-', 1, 0, 2, MONTHS) 148 | 149 | const DateExtractor_orderModifier_earlier_than = '-' 150 | const DateExtractor_orderModifier_later_than = '+' 151 | 152 | const DateExtractor_yyyy_Www_mm_dd_Regex = /(\d{4})-W(\d{1,2}) \((\d{2})-(\d{2})\)/ 153 | const DateExtractor_yyyy_Www_Regex = /(\d{4})-W(\d{1,2})([-+]?)/ 154 | 155 | // Matching groups 156 | const YEAR_IDX = 1 157 | const WEEK_IDX = 2 158 | const MONTH_IDX = 3 159 | const DAY_IDX = 4 160 | const RELATIVE_ORDER_IDX = 3 // For the yyyy-Www only: yyyy-Www- or yyyy-Www+ 161 | 162 | const DECEMBER = 12 163 | const JANUARY = 1 164 | 165 | export function getNormalizedDate_NormalizerFn_yyyy_Www_mm_dd(consumeWeek: boolean, weeksISO?: boolean) { 166 | return (s: string): string | null => { 167 | // Assumption - the regex date matched against input s, no extensive defensive coding needed 168 | const matches = consumeWeek ? DateExtractor_yyyy_Www_Regex.exec(s) : DateExtractor_yyyy_Www_mm_dd_Regex.exec(s) 169 | const yearStr = matches![YEAR_IDX] 170 | let yearNumber = Number.parseInt(yearStr,10) 171 | let monthNumber: number 172 | let dayNumber: number 173 | let separator = SLASH_SEPARATOR // different values enforce relative > < order of same dates 174 | let useLastDayOfWeek: boolean = false 175 | if (consumeWeek) { 176 | const weekNumberStr = matches![WEEK_IDX] 177 | const weekNumber = Number.parseInt(weekNumberStr, 10) 178 | const orderModifier: string|undefined = matches![RELATIVE_ORDER_IDX] 179 | if (orderModifier === DateExtractor_orderModifier_earlier_than) { 180 | separator = EARLIER_THAN_SLASH_SEPARATOR 181 | } else if (orderModifier === DateExtractor_orderModifier_later_than) { 182 | separator = LATER_THAN_SLASH_SEPARATOR // Will also need to adjust the date to the last day of the week 183 | useLastDayOfWeek = true 184 | } 185 | const dateForWeek = getDateForWeekOfYear(yearNumber, weekNumber, weeksISO, useLastDayOfWeek) 186 | monthNumber = dateForWeek.getMonth()+1 // 1 - 12 187 | dayNumber = dateForWeek.getDate() // 1 - 31 188 | // Be careful with edge dates, which can belong to previous or next year 189 | if (weekNumber === 1) { 190 | if (monthNumber === DECEMBER) { 191 | yearNumber-- 192 | } 193 | } 194 | if (weekNumber >= 50) { 195 | if (monthNumber === JANUARY) { 196 | yearNumber++ 197 | } 198 | } 199 | } else { // ignore week 200 | monthNumber = Number.parseInt(matches![MONTH_IDX],10) 201 | dayNumber = Number.parseInt(matches![DAY_IDX], 10) 202 | } 203 | return `${prependWithZeros(`${yearNumber}`, YEAR_POSITIONS)}` + 204 | `-${prependWithZeros(`${monthNumber}`, MONTH_POSITIONS)}` + 205 | `-${prependWithZeros(`${dayNumber}`, DAY_POSITIONS)}` + 206 | `${separator}${SLASH_SEPARATOR}` 207 | } 208 | } 209 | 210 | export const getNormalizedDate_yyyy_Www_mm_dd_NormalizerFn = getNormalizedDate_NormalizerFn_yyyy_Www_mm_dd(false) 211 | export const getNormalizedDate_yyyy_WwwISO_NormalizerFn = getNormalizedDate_NormalizerFn_yyyy_Www_mm_dd(true, true) 212 | export const getNormalizedDate_yyyy_Www_NormalizerFn = getNormalizedDate_NormalizerFn_yyyy_Www_mm_dd(true, false) 213 | -------------------------------------------------------------------------------- /src/custom-sort/mdata-extractors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getNormalizedDate_NormalizerFn_for 3 | } from "./matchers"; 4 | import {NormalizerFn} from "./custom-sort-types"; 5 | 6 | type ExtractorFn = (mdataValue: string) => string|undefined 7 | 8 | interface DateExtractorSpec { 9 | specPattern: string|RegExp, 10 | extractorFn: ExtractorFn 11 | } 12 | 13 | export interface MDataExtractor { 14 | (mdataValue: string): string|undefined 15 | } 16 | 17 | export interface MDataExtractorParseResult { 18 | m: MDataExtractor 19 | remainder: string 20 | } 21 | 22 | function getGenericPlainRegexpExtractorFn(extractorRegexp: RegExp, extractedValueNormalizer: NormalizerFn) { 23 | return (mdataValue: string): string | undefined => { 24 | const hasMatch = mdataValue?.match(extractorRegexp) 25 | if (hasMatch && hasMatch[0]) { 26 | return extractedValueNormalizer(hasMatch[0]) ?? undefined 27 | } else { 28 | return undefined 29 | } 30 | } 31 | } 32 | 33 | const Extractors: DateExtractorSpec[] = [ 34 | { specPattern: 'date(dd/mm/yyyy)', 35 | extractorFn: getGenericPlainRegexpExtractorFn( 36 | new RegExp('\\d{2}/\\d{2}/\\d{4}'), 37 | getNormalizedDate_NormalizerFn_for('/', 0, 1, 2) 38 | ) 39 | }, { 40 | specPattern: 'date(mm/dd/yyyy)', 41 | extractorFn: getGenericPlainRegexpExtractorFn( 42 | new RegExp('\\d{2}/\\d{2}/\\d{4}'), 43 | getNormalizedDate_NormalizerFn_for('/', 1, 0, 2) 44 | ) 45 | } 46 | ] 47 | 48 | export const tryParseAsMDataExtractorSpec = (s: string): MDataExtractorParseResult|undefined => { 49 | // Simplistic initial implementation of the idea with hardcoded two extractors 50 | for (const extrSpec of Extractors) { 51 | if ('string' === typeof extrSpec.specPattern && s.trim().startsWith(extrSpec.specPattern)) { 52 | return { 53 | m: extrSpec.extractorFn, 54 | remainder: s.substring(extrSpec.specPattern.length).trim() 55 | } 56 | } 57 | } 58 | return undefined 59 | } 60 | 61 | export const _unitTests = { 62 | extractorFnForDate_ddmmyyyy: Extractors.find((it) => it.specPattern === 'date(dd/mm/yyyy)')?.extractorFn!, 63 | extractorFnForDate_mmddyyyy: Extractors.find((it) => it.specPattern === 'date(mm/dd/yyyy)')?.extractorFn!, 64 | } 65 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import {App, normalizePath, PluginSettingTab, sanitizeHTMLToDom, Setting} from "obsidian"; 2 | import {groupNameForPath} from "./utils/BookmarksCorePluginSignature"; 3 | import CustomSortPlugin from "./main"; 4 | 5 | export interface CustomSortPluginSettings { 6 | additionalSortspecFile: string 7 | indexNoteNameForFolderNotes: string 8 | suspended: boolean 9 | statusBarEntryEnabled: boolean 10 | notificationsEnabled: boolean 11 | mobileNotificationsEnabled: boolean 12 | automaticBookmarksIntegration: boolean 13 | customSortContextSubmenu: boolean 14 | bookmarksContextMenus: boolean 15 | bookmarksGroupToConsumeAsOrderingReference: string 16 | delayForInitialApplication: number // miliseconds 17 | } 18 | 19 | const MILIS = 1000 20 | const DEFAULT_DELAY_SECONDS = 1 21 | const DELAY_MIN_SECONDS = 0 22 | const DELAY_MAX_SECONDS = 30 23 | const DEFAULT_DELAY = DEFAULT_DELAY_SECONDS * MILIS 24 | 25 | export const DEFAULT_SETTINGS: CustomSortPluginSettings = { 26 | additionalSortspecFile: '', 27 | indexNoteNameForFolderNotes: '', 28 | suspended: true, // if false by default, it would be hard to handle the auto-parse after plugin install 29 | statusBarEntryEnabled: true, 30 | notificationsEnabled: true, 31 | mobileNotificationsEnabled: false, 32 | customSortContextSubmenu: true, 33 | automaticBookmarksIntegration: false, 34 | bookmarksContextMenus: false, 35 | bookmarksGroupToConsumeAsOrderingReference: 'sortspec', 36 | delayForInitialApplication: DEFAULT_DELAY 37 | } 38 | 39 | // On API 1.2.x+ enable the bookmarks integration by default 40 | export const DEFAULT_SETTING_FOR_1_2_0_UP: Partial = { 41 | automaticBookmarksIntegration: true, 42 | bookmarksContextMenus: true 43 | } 44 | 45 | const pathToFlatString = (path: string): string => { 46 | return path.replace(/\//g,'_').replace(/\\/g, '_') 47 | } 48 | 49 | export class CustomSortSettingTab extends PluginSettingTab { 50 | plugin: CustomSortPlugin; 51 | 52 | constructor(app: App, plugin: CustomSortPlugin) { 53 | super(app, plugin); 54 | this.plugin = plugin; 55 | } 56 | 57 | display(): void { 58 | const {containerEl} = this; 59 | 60 | containerEl.empty(); 61 | 62 | const delayDescr: DocumentFragment = sanitizeHTMLToDom( 63 | 'Number of seconds to wait before applying custom ordering on plugin / app start.' 64 | + '
' 65 | + 'For large vaults, multi-plugin vaults or on mobile the value might need to be increased if you encounter issues with auto-applying' 66 | + ' of custom ordering on start. The delay gives Obsidian additional time to sync notes from cloud storages, to populate notes metadata caches,' 67 | + ' etc.' 68 | + '
' 69 | + 'At the same time if your vault is relatively small or only used on desktop, or not synced with other copies,' 70 | + ' decreasing the delay to 0 could be a safe option.' 71 | + '
' 72 | + `Min: ${DELAY_MIN_SECONDS} sec., max. ${DELAY_MAX_SECONDS} sec.` 73 | ) 74 | 75 | new Setting(containerEl) 76 | .setName('Delay for initial automatic application of custom ordering') 77 | .setDesc(delayDescr) 78 | .addText(text => text 79 | .setValue(`${this.plugin.settings.delayForInitialApplication/MILIS}`) 80 | .onChange(async (value) => { 81 | let delayS = parseFloat(value) 82 | delayS = (Number.isNaN(delayS) || !Number.isFinite((delayS))) ? DEFAULT_DELAY_SECONDS : (delayS < DELAY_MIN_SECONDS ? DELAY_MIN_SECONDS :(delayS > DELAY_MAX_SECONDS ? DELAY_MAX_SECONDS : delayS)) 83 | delayS = Math.round(delayS*10) / 10 // allow values like 0.2 84 | this.plugin.settings.delayForInitialApplication = delayS * MILIS 85 | await this.plugin.saveSettings() 86 | })) 87 | 88 | const additionalSortspecFileDescr: DocumentFragment = sanitizeHTMLToDom( 89 | 'A note name or note path to scan (YAML frontmatter) for sorting specification in addition to the `sortspec` notes and Folder Notes.' 90 | + '
' 91 | + ' The `.md` filename suffix is optional.' 92 | + '
' 93 | + '

NOTE: After updating this setting remember to refresh the custom sorting via clicking on the ribbon icon or via the sort-on command' 94 | + ' or by restarting Obsidian or reloading the vault

' 95 | ) 96 | 97 | new Setting(containerEl) 98 | .setName('Path or name of additional note(s) containing sorting specification') 99 | .setDesc(additionalSortspecFileDescr) 100 | .addText(text => text 101 | .setPlaceholder('e.g. sorting-configuration') 102 | .setValue(this.plugin.settings.additionalSortspecFile) 103 | .onChange(async (value) => { 104 | this.plugin.settings.additionalSortspecFile = value.trim() ? normalizePath(value) : ''; 105 | await this.plugin.saveSettings(); 106 | })); 107 | 108 | const indexNoteNameDescr: DocumentFragment = sanitizeHTMLToDom( 109 | 'If you employ the Index-File based approach to folder notes (as documented in ' 110 | + 'Aidenlx Folder Note preferences' 112 | + ') enter here the index note name, e.g. _about_ or index' 113 | + '
' 114 | + ' The `.md` filename suffix is optional.' 115 | + '
' 116 | + 'This will tell the plugin to read sorting specs and also folders metadata from these files.' 117 | + '
' 118 | + 'The Inside Folder, with Same Name Recommended mode of Folder Notes is handled automatically, no additional configuration needed.' 119 | + '

' 120 | + '

NOTE: After updating this setting remember to refresh the custom sorting via clicking on the ribbon icon or via the sort-on command' 121 | + ' or by restarting Obsidian or reloading the vault

' 122 | ) 123 | 124 | new Setting(containerEl) 125 | .setName('Name of index note (Folder Notes support)') 126 | .setDesc(indexNoteNameDescr) 127 | .addText(text => text 128 | .setPlaceholder('e.g. _about_ or index') 129 | .setValue(this.plugin.settings.indexNoteNameForFolderNotes) 130 | .onChange(async (value) => { 131 | this.plugin.settings.indexNoteNameForFolderNotes = value.trim() ? normalizePath(value) : ''; 132 | await this.plugin.saveSettings(); 133 | })); 134 | 135 | new Setting(containerEl) 136 | .setName('Enable the status bar entry') 137 | .setDesc('The status bar entry shows the label `Custom sort:ON` or `Custom sort:OFF`, representing the current state of the plugin.') 138 | .addToggle(toggle => toggle 139 | .setValue(this.plugin.settings.statusBarEntryEnabled) 140 | .onChange(async (value) => { 141 | this.plugin.settings.statusBarEntryEnabled = value; 142 | if (value) { 143 | // Enabling 144 | if (this.plugin.statusBarItemEl) { 145 | // for sanity 146 | this.plugin.statusBarItemEl.detach() 147 | } 148 | this.plugin.statusBarItemEl = this.plugin.addStatusBarItem(); 149 | this.plugin.updateStatusBar() 150 | 151 | } else { // disabling 152 | if (this.plugin.statusBarItemEl) { 153 | this.plugin.statusBarItemEl.detach() 154 | } 155 | } 156 | await this.plugin.saveSettings(); 157 | })); 158 | 159 | new Setting(containerEl) 160 | .setName('Enable notifications of plugin state changes') 161 | .setDesc('The plugin can show notifications about its state changes: e.g. when successfully parsed and applied' 162 | + ' the custom sorting specification, or, when the parsing failed. If the notifications are disabled,' 163 | + ' the only indicator of plugin state is the ribbon button icon. The developer console presents the parsing' 164 | + ' error messages regardless if the notifications are enabled or not.') 165 | .addToggle(toggle => toggle 166 | .setValue(this.plugin.settings.notificationsEnabled) 167 | .onChange(async (value) => { 168 | this.plugin.settings.notificationsEnabled = value; 169 | await this.plugin.saveSettings(); 170 | })); 171 | 172 | new Setting(containerEl) 173 | .setName('Enable notifications of plugin state changes for mobile devices only') 174 | .setDesc('See above.') 175 | .addToggle(toggle => toggle 176 | .setValue(this.plugin.settings.mobileNotificationsEnabled) 177 | .onChange(async (value) => { 178 | this.plugin.settings.mobileNotificationsEnabled = value; 179 | await this.plugin.saveSettings(); 180 | })); 181 | 182 | new Setting(containerEl) 183 | .setName('Enable File Explorer context submenu`Custom sort:`') 184 | .setDesc('Gives access to operations relevant for custom sorting, e.g. applying custom sorting.') 185 | .addToggle(toggle => toggle 186 | .setValue(this.plugin.settings.customSortContextSubmenu) 187 | .onChange(async (value) => { 188 | this.plugin.settings.customSortContextSubmenu = value; 189 | await this.plugin.saveSettings(); 190 | })); 191 | 192 | containerEl.createEl('h2', {text: 'Bookmarks integration'}); 193 | const bookmarksIntegrationDescription: DocumentFragment = sanitizeHTMLToDom( 194 | 'If enabled, order of files and folders in File Explorer will reflect the order ' 195 | + 'of bookmarked items in the bookmarks (core plugin) view. Automatically, without any ' 196 | + 'need for sorting configuration. At the same time, it integrates seamlessly with' 197 | + '
sorting-spec:
configurations and they can nicely cooperate.' 198 | + '
' 199 | + '

To separate regular bookmarks from the bookmarks created for sorting, you can put ' 200 | + 'the latter in a separate dedicated bookmarks group. The default name of the group is ' 201 | + "'" + DEFAULT_SETTINGS.bookmarksGroupToConsumeAsOrderingReference + "' " 202 | + 'and you can change the group name in the configuration field below.' 203 | + '
' 204 | + 'If left empty, all the bookmarked items will be used to impose the order in File Explorer.

' 205 | + '

More information on this functionality in the ' 206 | + '' 207 | + 'manual of this custom-sort plugin.' 208 | + '

' 209 | ) 210 | 211 | new Setting(containerEl) 212 | .setName('Automatic integration with core Bookmarks plugin (for indirect drag & drop ordering)') 213 | .setDesc(bookmarksIntegrationDescription) 214 | .addToggle(toggle => toggle 215 | .setValue(this.plugin.settings.automaticBookmarksIntegration) 216 | .onChange(async (value) => { 217 | this.plugin.settings.automaticBookmarksIntegration = value; 218 | await this.plugin.saveSettings(); 219 | })); 220 | 221 | new Setting(containerEl) 222 | .setName('Name of the group in Bookmarks from which to read the order of items') 223 | .setDesc('See above.') 224 | .addText(text => text 225 | .setPlaceholder('e.g. Group for sorting') 226 | .setValue(this.plugin.settings.bookmarksGroupToConsumeAsOrderingReference) 227 | .onChange(async (value) => { 228 | value = groupNameForPath(value.trim()).trim() 229 | this.plugin.settings.bookmarksGroupToConsumeAsOrderingReference = value ? pathToFlatString(normalizePath(value)) : ''; 230 | await this.plugin.saveSettings(); 231 | })); 232 | 233 | const bookmarksIntegrationContextMenusDescription: DocumentFragment = sanitizeHTMLToDom( 234 | 'Enable Custom-sort: bookmark for sorting and Custom-sort: bookmark+siblings for sorting (and related) entries ' 235 | + 'in context menu in File Explorer' 236 | ) 237 | new Setting(containerEl) 238 | .setName('Context menus for Bookmarks integration') 239 | .setDesc(bookmarksIntegrationContextMenusDescription) 240 | .addToggle(toggle => toggle 241 | .setValue(this.plugin.settings.bookmarksContextMenus) 242 | .onChange(async (value) => { 243 | this.plugin.settings.bookmarksContextMenus = value; 244 | if (value) { 245 | this.plugin.settings.customSortContextSubmenu = true; // automatically enable custom sort context submenu 246 | } 247 | await this.plugin.saveSettings(); 248 | })) 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/test/int/dates-in-names.int.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TAbstractFile, TFile, 3 | TFolder, 4 | Vault 5 | } from "obsidian"; 6 | import { 7 | DEFAULT_FOLDER_CTIME, 8 | determineFolderDatesIfNeeded, 9 | determineSortingGroup, 10 | FolderItemForSorting, 11 | OS_alphabetical, 12 | OS_byCreatedTime, 13 | ProcessingContext, 14 | sortFolderItems 15 | } from "../../custom-sort/custom-sort"; 16 | import { 17 | CustomSortGroupType, 18 | CustomSortOrder, 19 | CustomSortSpec 20 | } from "../../custom-sort/custom-sort-types"; 21 | import { 22 | TIMESTAMP_OLDEST, 23 | TIMESTAMP_NEWEST, 24 | mockTFolderWithChildren, 25 | mockTFolderWithDateNamedChildren, 26 | TIMESTAMP_DEEP_NEWEST, 27 | TIMESTAMP_DEEP_OLDEST, 28 | mockTFolderWithDateWeekNamedChildrenForISOvsUSweekNumberingTest, 29 | mockTFolderWithDateWeekNamedChildren, mockTFile, mockTFolder, 30 | } from "../mocks"; 31 | import { 32 | SortingSpecProcessor 33 | } from "../../custom-sort/sorting-spec-processor"; 34 | 35 | describe('sortFolderItems', () => { 36 | it('should correctly handle Mmm-dd-yyyy pattern in file and folder names', () => { 37 | // given 38 | const processor: SortingSpecProcessor = new SortingSpecProcessor() 39 | const sortSpecTxt = 40 | ` 41 | ... \\[Mmm-dd-yyyy] 42 | > a-z 43 | ` 44 | const PARENT_PATH = 'parent/folder/path' 45 | const sortSpecsCollection = processor.parseSortSpecFromText( 46 | sortSpecTxt.split('\n'), 47 | PARENT_PATH, 48 | 'file name with the sorting, irrelevant here' 49 | ) 50 | 51 | const folder: TFolder = mockTFolderWithDateNamedChildren(PARENT_PATH) 52 | const sortSpec: CustomSortSpec = sortSpecsCollection?.sortSpecByPath![PARENT_PATH]! 53 | 54 | const ctx: ProcessingContext = {} 55 | 56 | // when 57 | const result: Array = sortFolderItems(folder, folder.children, sortSpec, ctx, OS_alphabetical) 58 | 59 | // then 60 | const orderedNames = result.map(f => f.name) 61 | expect(orderedNames).toEqual([ 62 | 'CCC Feb-28-2025', 63 | 'BBB Dec-23-2024.md', 64 | 'DDD Jul-15-2024.md', 65 | 'AAA Jan-01-2012' 66 | ]) 67 | }) 68 | it('should correctly handle yyyy-Www (mm-dd) pattern in file and folder names', () => { 69 | // given 70 | const processor: SortingSpecProcessor = new SortingSpecProcessor() 71 | const sortSpecTxt = 72 | ` 73 | ... \\[yyyy-Www (mm-dd)] 74 | < a-z 75 | ------ 76 | ` 77 | const PARENT_PATH = 'parent/folder/path' 78 | const sortSpecsCollection = processor.parseSortSpecFromText( 79 | sortSpecTxt.split('\n'), 80 | PARENT_PATH, 81 | 'file name with the sorting, irrelevant here' 82 | ) 83 | 84 | const folder: TFolder = mockTFolderWithDateWeekNamedChildren(PARENT_PATH) 85 | const sortSpec: CustomSortSpec = sortSpecsCollection?.sortSpecByPath![PARENT_PATH]! 86 | 87 | const ctx: ProcessingContext = {} 88 | 89 | // when 90 | const result: Array = sortFolderItems(folder, folder.children, sortSpec, ctx, OS_alphabetical) 91 | 92 | // then 93 | const orderedNames = result.map(f => f.name) 94 | expect(orderedNames).toEqual([ 95 | "GHI 2021-W1 (01-04)", 96 | "DEF 2021-W9 (03-01).md", 97 | "ABC 2021-W13 (03-29)", 98 | "MNO 2021-W45 (11-08).md", 99 | "JKL 2021-W52 (12-27).md", 100 | "------.md" 101 | ]) 102 | }) 103 | it('should correctly handle yyyy-WwwISO pattern in file and folder names', () => { 104 | // given 105 | const processor: SortingSpecProcessor = new SortingSpecProcessor() 106 | const sortSpecTxt = 107 | ` 108 | /+ ... \\[yyyy-Www (mm-dd)] 109 | /+ ... \\[yyyy-WwwISO] 110 | < a-z 111 | ` 112 | const PARENT_PATH = 'parent/folder/path' 113 | const sortSpecsCollection = processor.parseSortSpecFromText( 114 | sortSpecTxt.split('\n'), 115 | PARENT_PATH, 116 | 'file name with the sorting, irrelevant here' 117 | ) 118 | 119 | const folder: TFolder = mockTFolderWithDateWeekNamedChildrenForISOvsUSweekNumberingTest(PARENT_PATH) 120 | const sortSpec: CustomSortSpec = sortSpecsCollection?.sortSpecByPath![PARENT_PATH]! 121 | 122 | const ctx: ProcessingContext = {} 123 | 124 | // when 125 | const result: Array = sortFolderItems(folder, folder.children, sortSpec, ctx, OS_alphabetical) 126 | 127 | // then 128 | // ISO standard of weeks numbering 129 | const orderedNames = result.map(f => f.name) 130 | expect(orderedNames).toEqual([ 131 | 'E 2021-W1 (01-01)', 132 | 'F ISO:2021-01-04 US:2020-12-28 2021-W1', 133 | 'A 2021-W10 (03-05).md', 134 | 'B ISO:2021-03-08 US:2021-03-01 2021-W10', 135 | 'C 2021-W51 (12-17).md', 136 | 'D ISO:2021-12-20 US:2021-12-13 2021-W51.md', 137 | 'FFF2 ISO:2021-12-27 US:2021-12-20 2021-W52.md', 138 | 'FFF1 ISO:2022-01-03 US:2021-12-27 2021-W53.md', 139 | "------.md" 140 | ]) 141 | }) 142 | it('should correctly handle yyyy-Www pattern in file and folder names', () => { 143 | // given 144 | const processor: SortingSpecProcessor = new SortingSpecProcessor() 145 | const sortSpecTxt = 146 | ` 147 | /+ ... \\[yyyy-Www (mm-dd)] 148 | /+ ... \\[yyyy-Www] 149 | > a-z 150 | ... \\-d+ 151 | ` 152 | const PARENT_PATH = 'parent/folder/path' 153 | const sortSpecsCollection = processor.parseSortSpecFromText( 154 | sortSpecTxt.split('\n'), 155 | PARENT_PATH, 156 | 'file name with the sorting, irrelevant here' 157 | ) 158 | 159 | const folder: TFolder = mockTFolderWithDateWeekNamedChildrenForISOvsUSweekNumberingTest(PARENT_PATH) 160 | const sortSpec: CustomSortSpec = sortSpecsCollection?.sortSpecByPath![PARENT_PATH]! 161 | 162 | const ctx: ProcessingContext = {} 163 | 164 | // when 165 | const result: Array = sortFolderItems(folder, folder.children, sortSpec, ctx, OS_alphabetical) 166 | 167 | // then 168 | // U.S. standard of weeks numbering 169 | const orderedNames = result.map(f => f.name) 170 | expect(orderedNames).toEqual([ 171 | 'FFF1 ISO:2022-01-03 US:2021-12-27 2021-W53.md', 172 | 'FFF2 ISO:2021-12-27 US:2021-12-20 2021-W52.md', 173 | 'C 2021-W51 (12-17).md', 174 | 'D ISO:2021-12-20 US:2021-12-13 2021-W51.md', 175 | 'A 2021-W10 (03-05).md', 176 | 'B ISO:2021-03-08 US:2021-03-01 2021-W10', 177 | 'E 2021-W1 (01-01)', 178 | 'F ISO:2021-01-04 US:2020-12-28 2021-W1', 179 | "------.md" 180 | ]) 181 | }) 182 | it('should correctly mix for sorting different date formats in file and folder names', () => { 183 | // given 184 | const processor: SortingSpecProcessor = new SortingSpecProcessor() 185 | const sortSpecTxt = 186 | ` 187 | /+ ... \\[yyyy-Www (mm-dd)] 188 | /+ ... \\[yyyy-Www] 189 | /+ ... mm-dd \\[yyyy-mm-dd] 190 | /+ ... dd-mm \\[yyyy-dd-mm] 191 | /+ ... \\[yyyy-mm-dd] 192 | /+ ... \\[Mmm-dd-yyyy] 193 | /+ \\[dd-Mmm-yyyy] ... 194 | > a-z 195 | ` 196 | const PARENT_PATH = 'parent/folder/path' 197 | const sortSpecsCollection = processor.parseSortSpecFromText( 198 | sortSpecTxt.split('\n'), 199 | PARENT_PATH, 200 | 'file name with the sorting, irrelevant here' 201 | ) 202 | 203 | const folder: TFolder = mockTFolderWithDateWeekNamedChildrenForISOvsUSweekNumberingTest(PARENT_PATH) 204 | folder.children.push(...[ 205 | mockTFile('File 2021-12-14', 'md'), 206 | mockTFile('File mm-dd 2020-12-30', 'md'), // mm-dd 207 | mockTFile('File dd-mm 2020-31-12', 'md'), // dd-mm 208 | mockTFile('File Mar-08-2021', 'md'), 209 | mockTFile('18-Dec-2021 file', 'md'), 210 | ]) 211 | 212 | const sortSpec: CustomSortSpec = sortSpecsCollection?.sortSpecByPath![PARENT_PATH]! 213 | 214 | const ctx: ProcessingContext = {} 215 | 216 | // when 217 | const result: Array = sortFolderItems(folder, folder.children, sortSpec, ctx, OS_alphabetical) 218 | 219 | // then 220 | // U.S. standard of weeks numbering 221 | const orderedNames = result.map(f => f.name) 222 | expect(orderedNames).toEqual([ 223 | 'FFF1 ISO:2022-01-03 US:2021-12-27 2021-W53.md', 224 | 'FFF2 ISO:2021-12-27 US:2021-12-20 2021-W52.md', 225 | "18-Dec-2021 file.md", 226 | 'C 2021-W51 (12-17).md', 227 | "File 2021-12-14.md", 228 | 'D ISO:2021-12-20 US:2021-12-13 2021-W51.md', 229 | "File Mar-08-2021.md", 230 | 'A 2021-W10 (03-05).md', 231 | 'B ISO:2021-03-08 US:2021-03-01 2021-W10', 232 | 'E 2021-W1 (01-01)', 233 | "File dd-mm 2020-31-12.md", 234 | "File mm-dd 2020-12-30.md", 235 | 'F ISO:2021-01-04 US:2020-12-28 2021-W1', 236 | "------.md" 237 | ]) 238 | }) 239 | it('should correctly order the week number with specifiers', () => { 240 | // given 241 | const processor: SortingSpecProcessor = new SortingSpecProcessor() 242 | const sortSpecTxt = 243 | ` 244 | /+ \\[yyyy-Www] 245 | /+ \\[yyyy-mm-dd] 246 | > a-z 247 | ` 248 | const PARENT_PATH = 'parent/folder/path' 249 | const sortSpecsCollection = processor.parseSortSpecFromText( 250 | sortSpecTxt.split('\n'), 251 | PARENT_PATH, 252 | 'file name with the sorting, irrelevant here' 253 | ) 254 | 255 | const folder: TFolder = mockTFolder(PARENT_PATH,[ 256 | // ISO and U.S. standard for 2025 give the same week numbers (remark for clarity) 257 | mockTFile('2025-03-09', 'md'), // sunday of W10 258 | mockTFile('2025-W11-', 'md'), // earlier than monday of W11 259 | mockTFile('2025-03-10', 'md'), // monday W11 260 | mockTFile('2025-W11', 'md'), // monday of W11 261 | mockTFile('2025-03-16', 'md'), // sunday W11 262 | mockTFile('2025-W11+', 'md'), // later than sunday W11 // expected 263 | mockTFile('2025-03-17', 'md'), // monday of W12 264 | ]) 265 | 266 | const sortSpec: CustomSortSpec = sortSpecsCollection?.sortSpecByPath![PARENT_PATH]! 267 | 268 | const ctx: ProcessingContext = {} 269 | 270 | // when 271 | const result: Array = sortFolderItems(folder, folder.children, sortSpec, ctx, OS_alphabetical) 272 | 273 | // then 274 | // U.S. standard of weeks numbering 275 | const orderedNames = result.map(f => f.name) 276 | expect(orderedNames).toEqual([ 277 | "2025-03-17.md", 278 | '2025-W11+.md', 279 | "2025-03-16.md", 280 | '2025-W11.md', 281 | "2025-03-10.md", 282 | "2025-W11-.md", 283 | "2025-03-09.md", 284 | ]) 285 | }) 286 | }) 287 | 288 | 289 | 290 | -------------------------------------------------------------------------------- /src/test/int/folder-dates.int.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TAbstractFile, 3 | TFolder, 4 | Vault 5 | } from "obsidian"; 6 | import { 7 | DEFAULT_FOLDER_CTIME, 8 | determineFolderDatesIfNeeded, 9 | determineSortingGroup, 10 | FolderItemForSorting 11 | } from "../../custom-sort/custom-sort"; 12 | import { 13 | CustomSortGroupType, 14 | CustomSortOrder, 15 | CustomSortSpec 16 | } from "../../custom-sort/custom-sort-types"; 17 | import { 18 | TIMESTAMP_OLDEST, 19 | TIMESTAMP_NEWEST, 20 | mockTFolderWithChildren, TIMESTAMP_DEEP_NEWEST, TIMESTAMP_DEEP_OLDEST 21 | } from "../mocks"; 22 | 23 | describe('determineFolderDatesIfNeeded', () => { 24 | it('should not be triggered if not needed - sorting method does not require it', () => { 25 | // given 26 | const folder: TFolder = mockTFolderWithChildren('Test folder 1') 27 | const OUTSIDERS_GROUP_IDX = 0 28 | const sortSpec: CustomSortSpec = { 29 | targetFoldersPaths: ['/'], 30 | groups: [{ 31 | type: CustomSortGroupType.Outsiders, 32 | sorting: { order: CustomSortOrder.alphabetical } 33 | }], 34 | outsidersGroupIdx: OUTSIDERS_GROUP_IDX 35 | } 36 | 37 | // when 38 | const result: FolderItemForSorting = determineSortingGroup(folder, sortSpec) 39 | determineFolderDatesIfNeeded([result], sortSpec) 40 | 41 | // then 42 | expect(result.ctime).toEqual(DEFAULT_FOLDER_CTIME) 43 | expect(result.mtime).toEqual(DEFAULT_FOLDER_CTIME) 44 | }) 45 | it.each( 46 | [ 47 | [CustomSortOrder.byCreatedTimeReverseAdvanced, undefined], 48 | [CustomSortOrder.byCreatedTimeAdvanced, undefined], 49 | [CustomSortOrder.byModifiedTimeAdvanced, undefined], 50 | [CustomSortOrder.byModifiedTimeReverseAdvanced, undefined], 51 | [CustomSortOrder.alphabetical, CustomSortOrder.byCreatedTimeReverseAdvanced], 52 | [CustomSortOrder.alphabetical, CustomSortOrder.byCreatedTimeAdvanced], 53 | [CustomSortOrder.alphabetical, CustomSortOrder.byModifiedTimeAdvanced], 54 | [CustomSortOrder.alphabetical, CustomSortOrder.byModifiedTimeReverseAdvanced], 55 | ])('should correctly determine dates, if triggered by %s under default %s (no deep orders requested)', (order: CustomSortOrder, folderOrder: CustomSortOrder | undefined) => { 56 | // given 57 | const folder: TFolder = mockTFolderWithChildren('Test folder 1') 58 | const OUTSIDERS_GROUP_IDX = 0 59 | const sortSpec: CustomSortSpec = { 60 | targetFoldersPaths: ['/'], 61 | defaultSorting: folderOrder ? { order: folderOrder } : undefined, 62 | groups: [{ 63 | type: CustomSortGroupType.Outsiders, 64 | sorting: { order: order } 65 | }], 66 | outsidersGroupIdx: OUTSIDERS_GROUP_IDX 67 | } 68 | 69 | // when 70 | const result: FolderItemForSorting = determineSortingGroup(folder, sortSpec) 71 | determineFolderDatesIfNeeded([result], sortSpec) 72 | 73 | // then 74 | expect(result.ctime).toEqual(TIMESTAMP_OLDEST) 75 | expect(result.mtime).toEqual(TIMESTAMP_NEWEST) 76 | }) 77 | it.each( 78 | [ 79 | [CustomSortOrder.alphabetical, CustomSortOrder.byCreatedTimeReverseAdvancedRecursive], 80 | [CustomSortOrder.alphabetical, CustomSortOrder.byCreatedTimeAdvancedRecursive], 81 | [CustomSortOrder.alphabetical, CustomSortOrder.byModifiedTimeAdvancedRecursive], 82 | [CustomSortOrder.alphabetical, CustomSortOrder.byModifiedTimeReverseAdvancedRecursive], 83 | [CustomSortOrder.byCreatedTimeReverseAdvancedRecursive, CustomSortOrder.byCreatedTimeReverseAdvanced], 84 | [CustomSortOrder.byCreatedTimeAdvancedRecursive, CustomSortOrder.byCreatedTimeAdvanced], 85 | [CustomSortOrder.byModifiedTimeAdvancedRecursive, CustomSortOrder.byModifiedTimeAdvanced], 86 | [CustomSortOrder.byModifiedTimeReverseAdvancedRecursive, CustomSortOrder.byModifiedTimeReverseAdvanced], 87 | ])('should correctly determine dates, if triggered by %s under default %s (deep orders)', (order: CustomSortOrder, folderOrder: CustomSortOrder | undefined) => { 88 | // given 89 | const folder: TFolder = mockTFolderWithChildren('Test folder 1') 90 | const OUTSIDERS_GROUP_IDX = 0 91 | const sortSpec: CustomSortSpec = { 92 | targetFoldersPaths: ['/'], 93 | defaultSorting: folderOrder ? { order: folderOrder} : undefined, 94 | groups: [{ 95 | type: CustomSortGroupType.Outsiders, 96 | sorting: { order: order } 97 | }], 98 | outsidersGroupIdx: OUTSIDERS_GROUP_IDX 99 | } 100 | 101 | // when 102 | const result: FolderItemForSorting = determineSortingGroup(folder, sortSpec) 103 | determineFolderDatesIfNeeded([result], sortSpec) 104 | 105 | // then 106 | expect(result.ctime).toEqual(TIMESTAMP_DEEP_OLDEST) 107 | expect(result.mtime).toEqual(TIMESTAMP_DEEP_NEWEST) 108 | }) 109 | }) 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /src/test/mocks.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TFile, 3 | TFolder, 4 | Vault 5 | } from "obsidian"; 6 | import { 7 | lastPathComponent 8 | } from "../utils/utils"; 9 | 10 | export const mockTFile = (basename: string, ext: string, size?: number, ctime?: number, mtime?: number): TFile => { 11 | return { 12 | stat: { 13 | ctime: ctime ?? 0, 14 | mtime: mtime ?? 0, 15 | size: size ?? 0 16 | }, 17 | basename: basename, 18 | extension: ext, 19 | vault: {} as Vault, // To satisfy TS typechecking 20 | path: `Some parent folder/${basename}.${ext}`, 21 | name: `${basename}.${ext}`, 22 | parent: {} as TFolder // To satisfy TS typechecking 23 | } 24 | } 25 | 26 | export const mockTFolder = (name: string, children?: Array, parent?: TFolder): TFolder => { 27 | return { 28 | isRoot(): boolean { return name === '/' }, 29 | vault: {} as Vault, // To satisfy TS typechecking 30 | path: `${name}`, 31 | name: lastPathComponent(name), 32 | parent: parent ?? ({} as TFolder), // To satisfy TS typechecking 33 | children: children ?? [] 34 | } 35 | } 36 | 37 | export const MOCK_TIMESTAMP: number = 1656417542418 38 | export const TIMESTAMP_OLDEST: number = MOCK_TIMESTAMP 39 | export const TIMESTAMP_DEEP_OLDEST: number = TIMESTAMP_OLDEST - 1000 40 | export const TIMESTAMP_NEWEST: number = MOCK_TIMESTAMP + 1000 41 | export const TIMESTAMP_DEEP_NEWEST: number = TIMESTAMP_NEWEST + 1000 42 | export const TIMESTAMP_INBETWEEN: number = MOCK_TIMESTAMP + 500 43 | 44 | export const mockTFolderWithChildren = (name: string): TFolder => { 45 | const subchild1: TFile = mockTFile('Sub-child file 1 created as deep oldest, modified recently', 'md', 100, TIMESTAMP_DEEP_OLDEST, TIMESTAMP_NEWEST) 46 | const subfolder1: TFolder = mockTFolder('Subfolder with deep-oldest child file', [subchild1]) 47 | 48 | const subchild2: TFile = mockTFile('Sub-child file 1 created as deep newest, modified recently', 'md', 100, TIMESTAMP_OLDEST, TIMESTAMP_DEEP_NEWEST) 49 | const subfolder2: TFolder = mockTFolder('Subfolder with deep-newest child file', [subchild2]) 50 | 51 | const child1: TFolder = mockTFolder('Section A') 52 | const child2: TFolder = mockTFolder('Section B') 53 | const child3: TFile = mockTFile('Child file 1 created as oldest, modified recently', 'md', 100, TIMESTAMP_OLDEST, TIMESTAMP_NEWEST) 54 | const child4: TFile = mockTFile('Child file 2 created as newest, not modified at all', 'md', 100, TIMESTAMP_NEWEST, TIMESTAMP_NEWEST) 55 | const child5: TFile = mockTFile('Child file 3 created inbetween, modified inbetween', 'md', 100, TIMESTAMP_INBETWEEN, TIMESTAMP_INBETWEEN) 56 | 57 | return mockTFolder(name, [child1, child2, child3, child4, child5, subfolder1, subfolder2]) 58 | } 59 | 60 | export const mockTFolderWithDateNamedChildren = (name: string): TFolder => { 61 | const child1: TFolder = mockTFolder('AAA Jan-01-2012') 62 | const child2: TFile = mockTFile('BBB Dec-23-2024', 'md') 63 | const child3: TFolder = mockTFolder('CCC Feb-28-2025') 64 | const child4: TFile = mockTFile('DDD Jul-15-2024', 'md') 65 | 66 | return mockTFolder(name, [child1, child2, child3, child4]) 67 | } 68 | 69 | export const mockTFolderWithDateWeekNamedChildren = (name: string): TFolder => { 70 | // Assume ISO week numbers 71 | const child0: TFile = mockTFile('------', 'md') 72 | const child1: TFolder = mockTFolder('ABC 2021-W13 (03-29)') 73 | const child2: TFile = mockTFile('DEF 2021-W9 (03-01)', 'md') 74 | const child3: TFolder = mockTFolder('GHI 2021-W1 (01-04)') 75 | const child4: TFile = mockTFile('JKL 2021-W52 (12-27)', 'md') 76 | const child5: TFile = mockTFile('MNO 2021-W45 (11-08)', 'md') 77 | 78 | return mockTFolder(name, [child0, child1, child2, child3, child4, child5]) 79 | } 80 | 81 | export const mockTFolderWithDateWeekNamedChildrenForISOvsUSweekNumberingTest = (name: string): TFolder => { 82 | // Tricky to test handling of both ISO and U.S. weeks numbering. 83 | // Sample year with different week numbers in ISO vs. U.S. is 2021 with 1st Jan on Fri, ISO != U.S. 84 | // Plain files and folder names to match both week-only and week+date syntax 85 | // Their relative ordering depends on week numbering 86 | const child0: TFile = mockTFile('------', 'md') 87 | const child1: TFile = mockTFile('A 2021-W10 (03-05)', 'md') // Tue date, (ISO) week number invalid, ignored 88 | const child2: TFolder = mockTFolder('B ISO:2021-03-08 US:2021-03-01 2021-W10') 89 | const child3: TFile = mockTFile('C 2021-W51 (12-17)', 'md') // Tue date, (ISO) week number invalid, ignored 90 | const child4: TFile = mockTFile('D ISO:2021-12-20 US:2021-12-13 2021-W51', 'md') 91 | const child5: TFolder = mockTFolder('E 2021-W1 (01-01)') // Tue date, to (ISO) week number invalid, ignored 92 | const child6: TFolder = mockTFolder('F ISO:2021-01-04 US:2020-12-28 2021-W1') 93 | const child7: TFile = mockTFile('FFF2 ISO:2021-12-27 US:2021-12-20 2021-W52', 'md') 94 | const child8: TFile = mockTFile('FFF1 ISO:2022-01-03 US:2021-12-27 2021-W53', 'md') // Invalid week, should fall to next year 95 | 96 | return mockTFolder(name, [child0, child1, child2, child3, child4, child5, child6, child7, child8]) 97 | } 98 | -------------------------------------------------------------------------------- /src/test/unit/custom-sort-getComparator.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FolderItemForSorting, 3 | getComparator, 4 | OS_byCreatedTime, 5 | OS_byModifiedTime, 6 | OS_byModifiedTimeReverse, SortingLevelId 7 | } from '../../custom-sort/custom-sort'; 8 | import * as CustomSortModule from '../../custom-sort/custom-sort'; 9 | import {CustomSortGroupType, CustomSortOrder, CustomSortSpec} from '../../custom-sort/custom-sort-types'; 10 | 11 | const MOCK_TIMESTAMP: number = 1656417542418 12 | 13 | const FlatLevelSortSpec: CustomSortSpec = { 14 | groups: [{ // Not relevant in unit test 15 | exactText: "Nothing", 16 | filesOnly: true, 17 | sorting: { order: CustomSortOrder.alphabetical, }, 18 | type: CustomSortGroupType.ExactName 19 | },{ // prepared for unit test 20 | exactPrefix: "Fi", 21 | sorting: { order: CustomSortOrder.byMetadataFieldAlphabeticalReverse, }, 22 | type: CustomSortGroupType.ExactPrefix 23 | },{ // Not relevant in unit test 24 | type: CustomSortGroupType.Outsiders, 25 | sorting: { order: CustomSortOrder.byCreatedTime }, 26 | }], 27 | outsidersGroupIdx: 2, 28 | defaultSorting: { order: CustomSortOrder.byCreatedTime, }, 29 | targetFoldersPaths: ['parent folder'] 30 | } 31 | 32 | const MultiLevelSortSpecGroupLevel: CustomSortSpec = { 33 | groups: [{ // Not relevant in unit test 34 | exactText: "Nothing", 35 | filesOnly: true, 36 | sorting: { order: CustomSortOrder.alphabetical, }, 37 | type: CustomSortGroupType.ExactName 38 | },{ // prepared for unit test 39 | exactPrefix: "Fi", 40 | sorting: { order: CustomSortOrder.byMetadataFieldAlphabeticalReverse, }, 41 | secondarySorting: { order: CustomSortOrder.byMetadataFieldTrueAlphabetical, }, 42 | type: CustomSortGroupType.ExactPrefix 43 | },{ // Not relevant in unit test 44 | type: CustomSortGroupType.Outsiders, 45 | sorting: { order: CustomSortOrder.byCreatedTime }, 46 | }], 47 | outsidersGroupIdx: 2, 48 | defaultSorting: { order: CustomSortOrder.byCreatedTime, }, 49 | targetFoldersPaths: ['parent folder'] 50 | } 51 | 52 | const MultiLevelSortSpecTargetFolderLevel: CustomSortSpec = { 53 | groups: [{ // Not relevant in unit test 54 | exactText: "Nothing", 55 | filesOnly: true, 56 | sorting: { order: CustomSortOrder.alphabetical, }, 57 | type: CustomSortGroupType.ExactName 58 | },{ // prepared for unit test 59 | exactPrefix: "Fi", 60 | sorting: { order: CustomSortOrder.byMetadataFieldAlphabeticalReverse, }, 61 | type: CustomSortGroupType.ExactPrefix 62 | },{ // Not relevant in unit test 63 | type: CustomSortGroupType.Outsiders, 64 | sorting: { order: CustomSortOrder.byCreatedTime }, 65 | }], 66 | outsidersGroupIdx: 2, 67 | defaultSorting: { order: CustomSortOrder.byCreatedTime, }, 68 | defaultSecondarySorting: { order: CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse, }, 69 | targetFoldersPaths: ['parent folder'] 70 | } 71 | 72 | const MultiLevelSortSpecAndTargetFolderLevel: CustomSortSpec = { 73 | groups: [{ // Not relevant in unit test 74 | exactText: "Nothing", 75 | filesOnly: true, 76 | sorting: { order: CustomSortOrder.alphabetical, }, 77 | type: CustomSortGroupType.ExactName 78 | },{ // prepared for unit test 79 | exactPrefix: "Fi", 80 | sorting: { order: CustomSortOrder.byMetadataFieldAlphabetical, }, 81 | secondarySorting: { order: CustomSortOrder.byMetadataFieldAlphabeticalReverse, }, 82 | type: CustomSortGroupType.ExactPrefix 83 | },{ // Not relevant in unit test 84 | type: CustomSortGroupType.Outsiders, 85 | sorting: { order: CustomSortOrder.byCreatedTime }, 86 | }], 87 | outsidersGroupIdx: 2, 88 | defaultSorting: { order: CustomSortOrder.byMetadataFieldTrueAlphabetical, }, 89 | defaultSecondarySorting: { order: CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse, }, 90 | targetFoldersPaths: ['parent folder'] 91 | } 92 | 93 | const A_GOES_FIRST: number = -1 94 | const B_GOES_FIRST: number = 1 95 | const AB_EQUAL: number = 0 96 | 97 | const BaseItemForSorting1: FolderItemForSorting = { 98 | groupIdx: 1, 99 | isFolder: false, 100 | sortString: "References", 101 | sortStringWithExt: "References.md", 102 | ctime: MOCK_TIMESTAMP + 222, 103 | mtime: MOCK_TIMESTAMP + 333, 104 | path: 'parent folder/References.md', 105 | metadataFieldValue: 'direct metadata on file, under default name', 106 | metadataFieldValueSecondary: 'only used if secondary sort by metadata is used', 107 | metadataFieldValueForDerived: 'only used if derived primary sort by metadata is used', 108 | metadataFieldValueForDerivedSecondary: 'only used if derived secondary sort by metadata is used' 109 | } 110 | 111 | function getBaseItemForSorting(overrides?: Partial): FolderItemForSorting { 112 | return Object.assign({}, BaseItemForSorting1, overrides) 113 | } 114 | 115 | describe('getComparator', () => { 116 | const sp = jest.spyOn(CustomSortModule, 'getSorterFnFor') 117 | const collatorCmp = jest.spyOn(CustomSortModule, 'CollatorCompare') 118 | beforeEach(() => { 119 | sp.mockClear() 120 | }) 121 | describe('should correctly handle flat sorting spec', () => { 122 | const comparator = getComparator(FlatLevelSortSpec, OS_byModifiedTime) 123 | it( 'in simple case - group-level comparison succeeds', () => { 124 | const a = getBaseItemForSorting({ 125 | metadataFieldValue: 'value X' 126 | }) 127 | const b= getBaseItemForSorting({ 128 | metadataFieldValue: 'value Y' 129 | }) 130 | const result = comparator(a,b) 131 | expect(result).toBe(B_GOES_FIRST) 132 | expect(sp).toBeCalledTimes(1) 133 | expect(sp).toBeCalledWith(CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byModifiedTime, SortingLevelId.forPrimary) 134 | }) 135 | it( 'in simple case - group-level comparison fails, use folder-level', () => { 136 | const a = getBaseItemForSorting() 137 | const b= getBaseItemForSorting({ 138 | ctime: a.ctime - 100 139 | }) 140 | const result = Math.sign(comparator(a,b)) 141 | expect(result).toBe(B_GOES_FIRST) 142 | expect(sp).toBeCalledTimes(2) 143 | expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byModifiedTime, SortingLevelId.forPrimary) 144 | expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byCreatedTime, OS_byModifiedTime, SortingLevelId.forDerivedPrimary) 145 | }) 146 | it( 'in simple case - group-level comparison fails, folder-level fails, the last resort default comes into play - case A', () => { 147 | const a = getBaseItemForSorting({ 148 | sortString: 'Second' 149 | }) 150 | const b= getBaseItemForSorting({ 151 | sortString: 'First' 152 | }) 153 | const result = comparator(a,b) 154 | expect(result).toBe(B_GOES_FIRST) 155 | expect(sp).toBeCalledTimes(3) 156 | expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byModifiedTime, SortingLevelId.forPrimary) 157 | expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byCreatedTime, OS_byModifiedTime, SortingLevelId.forDerivedPrimary) 158 | expect(sp).toHaveBeenNthCalledWith(3, CustomSortOrder.default, undefined, SortingLevelId.forDefaultWhenUnspecified) 159 | }) 160 | }) 161 | describe('should correctly handle secondary sorting spec', () => { 162 | beforeEach(() => { 163 | sp.mockClear() 164 | }) 165 | describe('at group level', () => { 166 | const comparator = getComparator(MultiLevelSortSpecGroupLevel, OS_byModifiedTimeReverse) 167 | it('in simple case - secondary sort comparison succeeds', () => { 168 | const a = getBaseItemForSorting({ 169 | metadataFieldValueSecondary: 'This goes 1' 170 | }) 171 | const b = getBaseItemForSorting({ 172 | metadataFieldValueSecondary: 'This goes 2' 173 | }) 174 | const result = comparator(a, b) 175 | expect(result).toBe(A_GOES_FIRST) 176 | expect(sp).toBeCalledTimes(2) 177 | expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byModifiedTimeReverse, SortingLevelId.forPrimary) 178 | expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byMetadataFieldTrueAlphabetical, OS_byModifiedTimeReverse, SortingLevelId.forSecondary) 179 | }) 180 | it( 'in complex case - secondary sort comparison fails, last resort default comes into play', () => { 181 | const a = getBaseItemForSorting({ 182 | sortString: 'Second' 183 | }) 184 | const b= getBaseItemForSorting({ 185 | sortString: 'First' 186 | }) 187 | const result = comparator(a,b) 188 | expect(result).toBe(B_GOES_FIRST) 189 | expect(sp).toBeCalledTimes(4) 190 | expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byModifiedTimeReverse, SortingLevelId.forPrimary) 191 | expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byMetadataFieldTrueAlphabetical, OS_byModifiedTimeReverse, SortingLevelId.forSecondary) 192 | expect(sp).toHaveBeenNthCalledWith(3, CustomSortOrder.byCreatedTime, OS_byModifiedTimeReverse, SortingLevelId.forDerivedPrimary ) 193 | expect(sp).toHaveBeenNthCalledWith(4, CustomSortOrder.default, undefined, SortingLevelId.forDefaultWhenUnspecified) 194 | }) 195 | }) 196 | describe('at target folder level (aka derived)', () => { 197 | const comparator = getComparator(MultiLevelSortSpecTargetFolderLevel, OS_byModifiedTimeReverse) 198 | it('in simple case - derived secondary sort comparison succeeds', () => { 199 | const a = getBaseItemForSorting({ 200 | metadataFieldValueForDerivedSecondary: 'This goes 2 first (reverse is in effect)' 201 | }) 202 | const b = getBaseItemForSorting({ 203 | metadataFieldValueForDerivedSecondary: 'This goes 1 second (reverse is in effect)' 204 | }) 205 | const result = comparator(a, b) 206 | expect(result).toBe(A_GOES_FIRST) 207 | expect(sp).toBeCalledTimes(3) 208 | expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byModifiedTimeReverse, SortingLevelId.forPrimary) 209 | expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byCreatedTime, OS_byModifiedTimeReverse, SortingLevelId.forDerivedPrimary) 210 | expect(sp).toHaveBeenNthCalledWith(3, CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse, OS_byModifiedTimeReverse, SortingLevelId.forDerivedSecondary) 211 | }) 212 | it( 'in complex case - secondary sort comparison fails, last resort default comes into play', () => { 213 | const a = getBaseItemForSorting({ 214 | sortString: 'Second' 215 | }) 216 | const b= getBaseItemForSorting({ 217 | sortString: 'First' 218 | }) 219 | const result = comparator(a,b) 220 | expect(result).toBe(B_GOES_FIRST) 221 | expect(sp).toBeCalledTimes(4) 222 | expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byModifiedTimeReverse, SortingLevelId.forPrimary) 223 | expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byCreatedTime, OS_byModifiedTimeReverse, SortingLevelId.forDerivedPrimary) 224 | expect(sp).toHaveBeenNthCalledWith(3, CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse, OS_byModifiedTimeReverse, SortingLevelId.forDerivedSecondary) 225 | expect(sp).toHaveBeenNthCalledWith(4, CustomSortOrder.default, undefined, SortingLevelId.forDefaultWhenUnspecified) 226 | }) 227 | }) 228 | describe('at group and at target folder level (aka derived)', () => { 229 | const comparator = getComparator(MultiLevelSortSpecAndTargetFolderLevel, OS_byCreatedTime) 230 | const mdataGetter = jest.spyOn(CustomSortModule, 'getMdata') 231 | beforeEach(() => { 232 | mdataGetter.mockClear() 233 | }) 234 | it('most complex case - last resort default comes into play, all sort levels present, all involve metadata', () => { 235 | const a = getBaseItemForSorting({ 236 | path: 'test 1', // Not used in comparisons, used only to identify source of compared metadata 237 | metadataFieldValue: 'm', 238 | metadataFieldValueSecondary: 'ms', 239 | metadataFieldValueForDerived: 'dm', 240 | metadataFieldValueForDerivedSecondary: 'dms' 241 | }) 242 | const b= getBaseItemForSorting({ 243 | path: 'test 2', // Not used in comparisons, used only to identify source of compared metadata 244 | metadataFieldValue: 'm', 245 | metadataFieldValueSecondary: 'ms', 246 | metadataFieldValueForDerived: 'dm', 247 | metadataFieldValueForDerivedSecondary: 'dms' 248 | }) 249 | const result = Math.sign(comparator(a,b)) 250 | expect(result).toBe(AB_EQUAL) 251 | expect(sp).toBeCalledTimes(5) 252 | expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabetical, OS_byCreatedTime, SortingLevelId.forPrimary) 253 | expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byCreatedTime, SortingLevelId.forSecondary) 254 | expect(sp).toHaveBeenNthCalledWith(3, CustomSortOrder.byMetadataFieldTrueAlphabetical, OS_byCreatedTime, SortingLevelId.forDerivedPrimary) 255 | expect(sp).toHaveBeenNthCalledWith(4, CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse, OS_byCreatedTime, SortingLevelId.forDerivedSecondary) 256 | expect(sp).toHaveBeenNthCalledWith(5, CustomSortOrder.default, undefined, SortingLevelId.forDefaultWhenUnspecified) 257 | expect(mdataGetter).toHaveBeenCalledTimes(8) 258 | expect(mdataGetter).toHaveBeenNthCalledWith(1, expect.objectContaining({path: 'test 1'}), SortingLevelId.forPrimary) 259 | expect(mdataGetter).toHaveNthReturnedWith(1, 'm') 260 | expect(mdataGetter).toHaveBeenNthCalledWith(2, expect.objectContaining({path: 'test 2'}), SortingLevelId.forPrimary) 261 | expect(mdataGetter).toHaveNthReturnedWith(2, 'm') 262 | expect(mdataGetter).toHaveBeenNthCalledWith(3, expect.objectContaining({path: 'test 1'}), SortingLevelId.forSecondary) 263 | expect(mdataGetter).toHaveNthReturnedWith(3, 'ms') 264 | expect(mdataGetter).toHaveBeenNthCalledWith(4, expect.objectContaining({path: 'test 2'}), SortingLevelId.forSecondary) 265 | expect(mdataGetter).toHaveNthReturnedWith(4, 'ms') 266 | expect(mdataGetter).toHaveBeenNthCalledWith(5, expect.objectContaining({path: 'test 1'}), SortingLevelId.forDerivedPrimary) 267 | expect(mdataGetter).toHaveNthReturnedWith(5, 'dm') 268 | expect(mdataGetter).toHaveBeenNthCalledWith(6, expect.objectContaining({path: 'test 2'}), SortingLevelId.forDerivedPrimary) 269 | expect(mdataGetter).toHaveNthReturnedWith(6, 'dm') 270 | expect(mdataGetter).toHaveBeenNthCalledWith(7, expect.objectContaining({path: 'test 1'}), SortingLevelId.forDerivedSecondary) 271 | expect(mdataGetter).toHaveNthReturnedWith(7, 'dms') 272 | expect(mdataGetter).toHaveBeenNthCalledWith(8, expect.objectContaining({path: 'test 2'}), SortingLevelId.forDerivedSecondary) 273 | expect(mdataGetter).toHaveNthReturnedWith(8, 'dms') 274 | }) 275 | }) 276 | }) 277 | }) 278 | -------------------------------------------------------------------------------- /src/test/unit/custom-sort-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomSortGroupType, 3 | CustomSortOrder, 4 | CustomSortSpec 5 | } from "../../custom-sort/custom-sort-types"; 6 | import { 7 | collectSortingAndGroupingTypes, 8 | hasOnlyByBookmarkOrStandardObsidian, 9 | HasSortingOrGrouping, 10 | ImplicitSortspecForBookmarksIntegration 11 | } from "../../custom-sort/custom-sort-utils"; 12 | import {SortingSpecProcessor, SortSpecsCollection} from "../../custom-sort/sorting-spec-processor"; 13 | 14 | type NM = number 15 | 16 | const getHas = (gTotal?: NM, gBkmrk?: NM, gStar?: NM, gIcon?: NM, sTot?: NM, sBkmrk?: NM, sStd?: NM) => { 17 | const has: HasSortingOrGrouping = { 18 | grouping: { 19 | total: gTotal ||0, 20 | byBookmarks: gBkmrk ||0, 21 | byIcon: gIcon ||0 22 | }, 23 | sorting: { 24 | total: sTot ||0, 25 | byBookmarks: sBkmrk ||0, 26 | standardObsidian: sStd ||0 27 | } 28 | } 29 | return has 30 | } 31 | 32 | describe('hasOnlyByBookmarkOrStandardObsidian and collectSortingAndGroupingTypes', () => { 33 | it('should handle empty spec correctly', () => { 34 | const spec: Partial|undefined|null = undefined 35 | const expectedHas: HasSortingOrGrouping = getHas() 36 | const has = collectSortingAndGroupingTypes(spec) 37 | const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) 38 | expect(has).toEqual(expectedHas) 39 | expect(hasOnly).toBeTruthy() 40 | }) 41 | it('should handle empty spec correctly (null variant)', () => { 42 | const spec: Partial|undefined|null = null 43 | const expectedHas: HasSortingOrGrouping = getHas() 44 | const has = collectSortingAndGroupingTypes(spec) 45 | const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) 46 | expect(hasOnly).toBeTruthy() 47 | expect(has).toEqual(expectedHas) 48 | }) 49 | it('should handle spec with empty orders correctly', () => { 50 | const spec: Partial|undefined = { 51 | groups: [ 52 | {type: CustomSortGroupType.Outsiders, filesOnly: true}, 53 | {type: CustomSortGroupType.Outsiders} 54 | ] 55 | } 56 | const expectedHas: HasSortingOrGrouping = getHas() 57 | const has = collectSortingAndGroupingTypes(spec as CustomSortSpec) 58 | const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) 59 | expect(hasOnly).toBeTruthy() 60 | expect(has).toEqual(expectedHas) 61 | }) 62 | it('should detect not matching default order', () => { 63 | const spec: Partial|undefined = { 64 | defaultSorting: { order: CustomSortOrder.default }, 65 | groups: [ 66 | { 67 | type: CustomSortGroupType.ExactName, 68 | }, 69 | { 70 | type: CustomSortGroupType.Outsiders, 71 | } 72 | ] 73 | } 74 | const expectedHas: HasSortingOrGrouping = getHas(1, 0, 0, 0, 1, 0, 0) 75 | const has = collectSortingAndGroupingTypes(spec as CustomSortSpec) 76 | const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) 77 | expect(hasOnly).toBeFalsy() 78 | expect(has).toEqual(expectedHas) 79 | }) 80 | it('should detect not matching default secondary order', () => { 81 | const spec: Partial|undefined = { 82 | defaultSorting: { order: CustomSortOrder.byBookmarkOrder }, 83 | defaultSecondarySorting: { order: CustomSortOrder.default }, 84 | groups: [ 85 | { 86 | type: CustomSortGroupType.BookmarkedOnly, 87 | }, 88 | { 89 | type: CustomSortGroupType.Outsiders, 90 | } 91 | ] 92 | } 93 | const expectedHas: HasSortingOrGrouping = getHas(1, 1, 0, 0, 2, 1, 0) 94 | const has = collectSortingAndGroupingTypes(spec as CustomSortSpec) 95 | const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) 96 | expect(hasOnly).toBeFalsy() 97 | expect(has).toEqual(expectedHas) 98 | }) 99 | it('should detect not matching order in group', () => { 100 | const spec: Partial|undefined = { 101 | defaultSorting: { order: CustomSortOrder.byBookmarkOrder }, 102 | defaultSecondarySorting: { order: CustomSortOrder.standardObsidian }, 103 | groups: [ 104 | { 105 | type: CustomSortGroupType.ExactName, 106 | sorting: { order: CustomSortOrder.byCreatedTimeReverse } 107 | }, 108 | { 109 | type: CustomSortGroupType.Outsiders, 110 | } 111 | ] 112 | } 113 | const expectedHas: HasSortingOrGrouping = getHas(1, 0, 0, 0, 3, 1, 1) 114 | const has = collectSortingAndGroupingTypes(spec as CustomSortSpec) 115 | const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) 116 | expect(hasOnly).toBeFalsy() 117 | expect(has).toEqual(expectedHas) 118 | }) 119 | it('should detect not matching secondary order in group', () => { 120 | const spec: Partial|undefined = { 121 | defaultSorting: { order: CustomSortOrder.byBookmarkOrder }, 122 | defaultSecondarySorting: { order: CustomSortOrder.standardObsidian }, 123 | groups: [ 124 | { 125 | type: CustomSortGroupType.ExactName, 126 | sorting: { order: CustomSortOrder.byBookmarkOrderReverse }, 127 | secondarySorting: { order: CustomSortOrder.standardObsidian } 128 | }, 129 | { 130 | type: CustomSortGroupType.Outsiders, 131 | sorting: { order: CustomSortOrder.byBookmarkOrder }, 132 | secondarySorting: { order: CustomSortOrder.alphabetical } 133 | } 134 | ] 135 | } 136 | const expectedHas: HasSortingOrGrouping = getHas(1, 0, 0, 0, 6, 3, 2) 137 | const has = collectSortingAndGroupingTypes(spec as CustomSortSpec) 138 | const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) 139 | expect(hasOnly).toBeFalsy() 140 | expect(has).toEqual(expectedHas) 141 | }) 142 | it('should detect matching orders at all levels', () => { 143 | const spec: Partial|undefined = { 144 | defaultSorting: { order: CustomSortOrder.byBookmarkOrder }, 145 | defaultSecondarySorting: { order: CustomSortOrder.standardObsidian }, 146 | groups: [ 147 | { 148 | type: CustomSortGroupType.BookmarkedOnly, 149 | sorting: { order: CustomSortOrder.byBookmarkOrderReverse }, 150 | secondarySorting: { order: CustomSortOrder.standardObsidian } 151 | }, 152 | { 153 | type: CustomSortGroupType.Outsiders, 154 | sorting: { order: CustomSortOrder.byBookmarkOrder }, 155 | secondarySorting: { order: CustomSortOrder.byBookmarkOrderReverse } 156 | } 157 | ] 158 | } 159 | const expectedHas: HasSortingOrGrouping = getHas(1, 1, 0, 0, 6, 4, 2) 160 | const has = collectSortingAndGroupingTypes(spec as CustomSortSpec) 161 | const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) 162 | expect(hasOnly).toBeTruthy() 163 | expect(has).toEqual(expectedHas) 164 | }) 165 | }) 166 | 167 | describe('ImplicitSortspecForBookmarksIntegration', () => { 168 | it('should correctly be recognized as only bookmark and obsidian standard', () => { 169 | const processor: SortingSpecProcessor = new SortingSpecProcessor(); 170 | const inputTxtArr: Array = ImplicitSortspecForBookmarksIntegration.replace(/\t/gi, '').split('\n') 171 | const spec: SortSpecsCollection|null|undefined = processor.parseSortSpecFromText( 172 | inputTxtArr, 173 | 'mock-folder', 174 | 'custom-name-note.md', 175 | null, 176 | true 177 | ) 178 | const expectedHas: HasSortingOrGrouping = getHas(1, 1, 0, 0, 2, 1, 1) 179 | const has = collectSortingAndGroupingTypes(spec?.sortSpecByPath!['/']) 180 | const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) 181 | expect(hasOnly).toBeTruthy() 182 | expect(has).toEqual(expectedHas) 183 | }) 184 | }) 185 | 186 | // TODO - czy tamto sprawdzanie dla itemów w rootowym filderze hasBookmarkInFolder dobrze zadziala 187 | -------------------------------------------------------------------------------- /src/test/unit/macros.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expandMacros, 3 | expandMacrosInString 4 | } from "../../custom-sort/macros"; 5 | import * as MacrosModule from '../../custom-sort/macros' 6 | import { 7 | CustomSortGroup, 8 | CustomSortSpec 9 | } from "../../custom-sort/custom-sort-types"; 10 | 11 | describe('expandMacrosInString', () => { 12 | it.each([ 13 | ['', ''], 14 | ['123', '123'], 15 | [' 123 ', ' 123 '], 16 | [' Abc{:%parent-folder-name%:}Def ', ' Abc{:%parent-folder-name%:}Def '], 17 | ['{:%parent-folder-name%:}Def ', '{:%parent-folder-name%:}Def '], 18 | [' Abc{:%parent-folder-name%:}', ' Abc{:%parent-folder-name%:}'], 19 | [' {:%parent-folder-name%:} xyz {:%parent-folder-name%:}', ' {:%parent-folder-name%:} xyz {:%parent-folder-name%:}'], 20 | [' {:%unknown%:} ',' {:%unknown%:} '] 21 | ])('%s should transform to %s when no parent folder', (source: string, expanded: string) => { 22 | const result1 = expandMacrosInString(source) 23 | const result2 = expandMacrosInString(source, '') 24 | expect(result1).toBe(expanded) 25 | expect(result2).toBe(expanded) 26 | }) 27 | it.each([ 28 | ['', ''], 29 | ['123', '123'], 30 | [' 123 ', ' 123 '], 31 | [' Abc{:%parent-folder-name%:}Def ', ' AbcSubFolder 5Def '], 32 | ['{:%parent-folder-name%:}Def ', 'SubFolder 5Def '], 33 | [' Abc{:%parent-folder-name%:}', ' AbcSubFolder 5'], 34 | [' {:%parent-folder-name%:} xyz {:%parent-folder-name%:}', ' SubFolder 5 xyz {:%parent-folder-name%:}'], 35 | [' {:%unknown%:} ',' {:%unknown%:} '] 36 | ])('%s should transform to %s when parent folder specified', (source: string, expanded: string) => { 37 | const PARENT = 'SubFolder 5' 38 | const result = expandMacrosInString(source, PARENT) 39 | expect(result).toBe(expanded) 40 | }) 41 | }) 42 | 43 | function mockGroup(gprefix: string, group: string, prefix: string, full: string, suffix: string): CustomSortGroup { 44 | const g: Partial = { 45 | exactText: gprefix + group + full, 46 | exactPrefix: gprefix + group + prefix, 47 | exactSuffix: gprefix + group + suffix 48 | } 49 | return g as CustomSortGroup 50 | } 51 | 52 | describe('expandMacros', () => { 53 | it('should invoke expand in all relevant text fields on all groups', () => { 54 | const sortSpec: Partial = { 55 | groups: [ 56 | mockGroup('g-', '1-', 'abc', 'def', 'ghi'), 57 | mockGroup('g-', '2-', 'abc', 'def', 'ghi'), 58 | ], 59 | groupsShadow: [ 60 | mockGroup('gs-', '1-', 'abc', 'def', 'ghi'), 61 | mockGroup('gs-', '2-', 'abc', 'def', 'ghi'), 62 | ] 63 | } 64 | const sp = jest.spyOn(MacrosModule, 'expandMacrosInString') 65 | const ParentFolder = 'Parent folder name' 66 | expandMacros(sortSpec as CustomSortSpec, ParentFolder) 67 | expect(sp).toBeCalledTimes(6) 68 | expect(sp).toHaveBeenNthCalledWith(1, 'gs-1-def', ParentFolder) 69 | expect(sp).toHaveBeenNthCalledWith(2, 'gs-1-abc', ParentFolder) 70 | expect(sp).toHaveBeenNthCalledWith(3, 'gs-1-ghi', ParentFolder) 71 | expect(sp).toHaveBeenNthCalledWith(4, 'gs-2-def', ParentFolder) 72 | expect(sp).toHaveBeenNthCalledWith(5, 'gs-2-abc', ParentFolder) 73 | expect(sp).toHaveBeenNthCalledWith(6, 'gs-2-ghi', ParentFolder) 74 | }) 75 | it('should expand correctly in all relevant text fields on all groups, based on shadow groups', () => { 76 | const sortSpec: Partial = { 77 | groups: [ 78 | mockGroup('g-', '1-', 'abc{:%parent-folder-name%:}', 'de{:%parent-folder-name%:}f', '{:%parent-folder-name%:}ghi'), 79 | mockGroup('g-', '2-', '{:%parent-folder-name%:}abc', 'd{:%parent-folder-name%:}ef', 'ghi{:%parent-folder-name%:}'), 80 | ], 81 | groupsShadow: [ 82 | mockGroup('gs-', '1-', 'abc{:%parent-folder-name%:}', 'de{:%parent-folder-name%:}f', '{:%parent-folder-name%:}ghi'), 83 | mockGroup('gs-', '2-', '{:%parent-folder-name%:}abc', 'd{:%parent-folder-name%:}ef', 'ghi{:%parent-folder-name%:}'), 84 | ] 85 | } 86 | const originalSortSpec: Partial = { 87 | groups: [...sortSpec.groups!], 88 | groupsShadow: [...sortSpec.groupsShadow!] 89 | } 90 | const ParentFolder = 'Parent folder name' 91 | expandMacros(sortSpec as CustomSortSpec, ParentFolder) 92 | expect(sortSpec.groups![0].exactText).toBe(originalSortSpec.groups![0].exactText) 93 | expect(sortSpec.groups![0].exactPrefix).toBe(originalSortSpec.groups![0].exactPrefix) 94 | expect(sortSpec.groups![0].exactSuffix).toBe(originalSortSpec.groups![0].exactSuffix) 95 | expect(sortSpec.groups![1].exactText).toBe(originalSortSpec.groups![1].exactText) 96 | expect(sortSpec.groups![1].exactPrefix).toBe(originalSortSpec.groups![1].exactPrefix) 97 | expect(sortSpec.groups![1].exactSuffix).toBe(originalSortSpec.groups![1].exactSuffix) 98 | expect(sortSpec.groupsShadow![0].exactText).toBe('gs-1-deParent folder namef') 99 | expect(sortSpec.groupsShadow![0].exactPrefix).toBe('gs-1-abcParent folder name') 100 | expect(sortSpec.groupsShadow![0].exactSuffix).toBe('gs-1-Parent folder nameghi') 101 | expect(sortSpec.groupsShadow![1].exactText).toBe('gs-2-dParent folder nameef') 102 | expect(sortSpec.groupsShadow![1].exactPrefix).toBe('gs-2-Parent folder nameabc') 103 | expect(sortSpec.groupsShadow![1].exactSuffix).toBe('gs-2-ghiParent folder name') 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /src/test/unit/mdata-extractors.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | _unitTests 3 | } from '../../custom-sort/mdata-extractors' 4 | 5 | describe('extractor for date(dd/mm/yyyy)', () => { 6 | const params = [ 7 | // Positive 8 | ['03/05/2019', '2019-05-03//'], 9 | ['103/05/2019', '2019-05-03//'], 10 | ['103/05/20193232', '2019-05-03//'], 11 | ['99/99/9999', '9999-99-99//'], 12 | ['00/00/0000', '0000-00-00//'], 13 | ['Created at: 03/05/2019', '2019-05-03//'], 14 | ['03/05/2019 | 22:00', '2019-05-03//'], 15 | ['Created at: 03/05/2019 | 22:00', '2019-05-03//'], 16 | 17 | // Negative 18 | ['88-Dec-2012', undefined], 19 | ['13-JANUARY-2012', undefined], 20 | ['1 .1', undefined], 21 | ['', undefined], 22 | ['abc', undefined], 23 | ['def-abc', undefined], 24 | ['3/5/2019', undefined], 25 | ]; 26 | it.each(params)('>%s< should become %s', (s: string, out: string) => { 27 | expect(_unitTests.extractorFnForDate_ddmmyyyy(s)).toBe(out) 28 | }) 29 | }) 30 | 31 | describe('extractor for date(mm/dd/yyyy)', () => { 32 | const params = [ 33 | // Positive 34 | ['03/05/2019', '2019-03-05//'], 35 | ['103/05/2019', '2019-03-05//'], 36 | ['103/05/20193232', '2019-03-05//'], 37 | ['99/99/9999', '9999-99-99//'], 38 | ['00/00/0000', '0000-00-00//'], 39 | ['Created at: 03/05/2019', '2019-03-05//'], 40 | ['03/05/2019 | 22:00', '2019-03-05//'], 41 | ['Created at: 03/05/2019 | 22:00', '2019-03-05//'], 42 | 43 | // Negative 44 | ['88-Dec-2012', undefined], 45 | ['13-JANUARY-2012', undefined], 46 | ['1 .1', undefined], 47 | ['', undefined], 48 | ['abc', undefined], 49 | ['def-abc', undefined], 50 | ['3/5/2019', undefined], 51 | ]; 52 | it.each(params)('>%s< should become %s', (s: string, out: string) => { 53 | expect(_unitTests.extractorFnForDate_mmddyyyy(s)).toBe(out) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/test/unit/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | lastPathComponent, 3 | extractParentFolderPath, 4 | extractBasename 5 | } from "../../utils/utils"; 6 | 7 | describe('lastPathComponent and extractParentFolderPath', () => { 8 | it.each([ 9 | ['a folder', '', 'a folder'], 10 | ['a/subfolder', 'a', 'subfolder'], 11 | ['parent/child', 'parent', 'child'], 12 | ['','',''], 13 | [' ','',' '], 14 | ['/strange', '', 'strange'], 15 | ['a/b/c/', 'a/b/c', ''], 16 | ['d d d/e e e/f f f/ggg ggg', 'd d d/e e e/f f f', 'ggg ggg'], 17 | ['/','',''], 18 | [' / ',' ',' '], 19 | [' /',' ',''], 20 | ['/ ','',' '] 21 | ])('should from %s extract %s and %s', (path: string, parentPath: string, lastComponent: string) => { 22 | const extractedParentPath: string = extractParentFolderPath(path) 23 | const extractedLastComponent: string = lastPathComponent(path) 24 | expect(extractedParentPath).toBe(parentPath) 25 | expect(extractedLastComponent).toBe(lastComponent) 26 | } 27 | ) 28 | }) 29 | 30 | describe('extractBasename', () => { 31 | const params: Array<(string|undefined)[]> = [ 32 | // Obvious 33 | ['index', 'index'], 34 | ['index.md', 'index'], 35 | // Edge cases 36 | ['',''], 37 | [undefined,undefined], 38 | ['.','.'], 39 | ['.md',''], 40 | ['.md.md','.md'] 41 | ]; 42 | it.each(params)('>%s< should result in %s', (s: string|undefined, out: string|undefined) => { 43 | expect(extractBasename(s)).toBe(out) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /src/test/unit/week-of-year.spec.ts: -------------------------------------------------------------------------------- 1 | import {_unitTests, getDateForWeekOfYear} from "../../utils/week-of-year" 2 | 3 | const paramsForWeekOf1stOfJan = [ 4 | [2015,'2014-12-29T00:00:00.000Z','same as U.S.'], // 1st Jan on Thu, ISO = U.S. 5 | [2020,'2019-12-30T00:00:00.000Z','same as U.S.'], // 1st Jan on Wed, ISO = U.S. 6 | [2021,'2020-12-28T00:00:00.000Z','2021-01-04T00:00:00.000Z'], // 1st Jan on Fri, ISO != U.S. 7 | [2022,'2021-12-27T00:00:00.000Z','2022-01-03T00:00:00.000Z'], // 1st Jan on Sat, ISO != U.S. 8 | [2023,'2022-12-26T00:00:00.000Z','2023-01-02T00:00:00.000Z'], // 1st Jan on Sun, ISO != U.S. 9 | [2024,'2024-01-01T00:00:00.000Z','same as U.S.'], // 1st Jan on Mon, ISO = U.S. 10 | [2025,'2024-12-30T00:00:00.000Z','same as U.S.'] // 1st Jan on Wed, ISO = U.S. 11 | ] 12 | 13 | const paramsFor10thWeek = [ 14 | [2019,'2019-03-04T00:00:00.000Z','same as U.S.'], 15 | [1999,'1999-03-01T00:00:00.000Z','1999-03-08T00:00:00.000Z'], 16 | [1683,'1683-03-01T00:00:00.000Z','1683-03-08T00:00:00.000Z'], 17 | [1410,'1410-03-05T00:00:00.000Z','same as U.S.'], 18 | [1996,'1996-03-04T00:00:00.000Z','same as U.S.'], 19 | [2023,'2023-02-27T00:00:00.000Z','2023-03-06T00:00:00.000Z'], 20 | [2025,'2025-03-03T00:00:00.000Z','same as U.S.'] 21 | ] 22 | 23 | describe('calculateMondayDateIn2stWeekOfYear', () => { 24 | it.each(paramsForWeekOf1stOfJan)('year >%s< should result in %s (U.S.) and %s (ISO)', (year: number, dateOfMondayUS: string, dateOfMondayISO: string) => { 25 | const dateUS = new Date(dateOfMondayUS).getTime() 26 | const dateISO = 'same as U.S.' === dateOfMondayISO ? dateUS : new Date(dateOfMondayISO).getTime() 27 | const mondayData = _unitTests.calculateMondayDateIn2stWeekOfYear(year) 28 | expect(mondayData.mondayDateOf1stWeekUS).toBe(dateUS) 29 | expect(mondayData.mondayDateOf1stWeekISO).toBe(dateISO) 30 | }) 31 | }) 32 | 33 | describe('getDateForWeekOfYear', () => { 34 | it.each(paramsForWeekOf1stOfJan)('For year >%s< 1st week should start on %s (U.S.) and %s (ISO)', (year: number, dateOfMondayUS: string, dateOfMondayISO: string) => { 35 | const dateUS = new Date(dateOfMondayUS) 36 | const dateISO = 'same as U.S.' === dateOfMondayISO ? dateUS : new Date(dateOfMondayISO) 37 | expect(getDateForWeekOfYear(year, 1)).toStrictEqual(dateUS) 38 | expect(getDateForWeekOfYear(year, 1, true)).toStrictEqual(dateISO) 39 | }) 40 | it.each(paramsFor10thWeek)('For year >%s< 10th week should start on %s (U.S.) and %s (ISO)', (year: number, dateOfMondayUS: string, dateOfMondayISO: string) => { 41 | const dateUS = new Date(dateOfMondayUS) 42 | const dateISO = 'same as U.S.' === dateOfMondayISO ? dateUS : new Date(dateOfMondayISO) 43 | expect(getDateForWeekOfYear(year, 10)).toStrictEqual(dateUS) 44 | expect(getDateForWeekOfYear(year, 10, true)).toStrictEqual(dateISO) 45 | }) 46 | it('should correctly handle edge case - a year spanning 54 weeks (leap year starting on Sun)', () => { 47 | const USstandard = false 48 | const SUNDAY = true 49 | // This works in U.S. standard only, where 1st week can start on Sunday 50 | expect(getDateForWeekOfYear(2012,1)).toStrictEqual(new Date('2011-12-26T00:00:00.000Z')) 51 | expect(getDateForWeekOfYear(2012,1, USstandard, SUNDAY)).toStrictEqual(new Date('2012-01-01T00:00:00.000Z')) 52 | expect(getDateForWeekOfYear(2012,54)).toStrictEqual(new Date('2012-12-31T00:00:00.000Z')) 53 | expect(getDateForWeekOfYear(2012,54, USstandard, SUNDAY)).toStrictEqual(new Date('2013-01-06T00:00:00.000Z')) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/types/types.d.ts: -------------------------------------------------------------------------------- 1 | import {PluginInstance, TFolder, WorkspaceLeaf} from "obsidian"; 2 | 3 | // Needed to support monkey-patching functions of FileExplorerLeaf or FileExplorerView 4 | 5 | declare module 'obsidian' { 6 | export interface ViewRegistry { 7 | viewByType: Record unknown>; 8 | } 9 | 10 | // undocumented internal interface - for experimental features 11 | export interface PluginInstance { 12 | id: string; 13 | } 14 | 15 | export type CommunityPluginId = string 16 | 17 | // undocumented internal interface - for experimental features 18 | export interface CommunityPlugin { 19 | manifest: { 20 | id: CommunityPluginId 21 | } 22 | _loaded: boolean 23 | } 24 | 25 | // undocumented internal interface - for experimental features 26 | export interface CommunityPlugins { 27 | enabledPlugins: Set 28 | plugins: {[key: CommunityPluginId]: CommunityPlugin} 29 | } 30 | 31 | export interface App { 32 | plugins: CommunityPlugins; 33 | internalPlugins: InternalPlugins; // undocumented internal API - for experimental features 34 | viewRegistry: ViewRegistry; 35 | } 36 | 37 | // undocumented internal interface - for experimental features 38 | export interface InstalledPlugin { 39 | enabled: boolean; 40 | instance: PluginInstance; 41 | } 42 | 43 | // undocumented internal interface - for experimental features 44 | export interface InternalPlugins { 45 | plugins: Record; 46 | getPluginById(id: string): InstalledPlugin; 47 | } 48 | 49 | export interface FileExplorerView extends View { 50 | getSortedFolderItems(sortedFolder: TFolder): any[]; 51 | 52 | requestSort(): void; 53 | 54 | sortOrder: string 55 | } 56 | 57 | export interface FileExplorerLeaf extends WorkspaceLeaf { 58 | view: FileExplorerView 59 | get isDeferred(): boolean // since Obsidian 1.7.2 60 | } 61 | 62 | interface MenuItem { 63 | setSubmenu: () => Menu; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/utils/Bookmarks Core Plugin integration design.md: -------------------------------------------------------------------------------- 1 | Integration with Bookmarks core plugin: 2 | - support two approaches _at the same time_: 3 | - (A) structured bookmarks inside a dedicated bookmarks group, and 4 | - (B) a flat list of bookmarks inside the dedicated bookmarks group 5 | 6 | For (A): 7 | - preferred 8 | - a folder is represented by a group in bookmarks 9 | - a file is represented by a file-with-block 10 | - this also applied to non-md files, like jpg and others 11 | - guarantees _'hiding'_ the bookmarks-for-sorting from regular bookmarks usage scenarios 12 | - bookmark entries for sorting are encapsulated in the dedicated group 13 | - they don't interfere with bookmarking of files and folders via standard bookmarking 14 | - only exact location of file bookmark / group matches for sorting order in file explorer 15 | - the contextual bookmark menus always work in (A) mode 16 | - the contextual menus create / modify the bookmarks structure on-the-fly 17 | 18 | For (B): 19 | - discouraged, yet supported (exception for some edge cases) 20 | - typically a result of manual bookmarks management 21 | - for small number of items seems reasonable 22 | - for flat vaults it could look same as for (A) 23 | - groups don't have a 'path' attribute, their path is determined by their location 24 | - bookmarked folders represent folders if inside the bookmarks group for sorting 25 | - yet in this way they interfere with regular bookmarks scenario 26 | - file bookmarks work correctly in non-interfering way thanks to the _'artificial block reference'_ 27 | - file bookmarks not having the _'artificial block ref'_ work as well 28 | - if they are in the designated bookmarks group 29 | - if there isn't a duplicate, which has the _'artificial block ref'_ 30 | - yet in this way they interfere with regular bookmarks scenario 31 | 32 | -[ ] TODO: review again the 'item moved' and 'item deleted' scenarios (they look ok, check if they don't delete/move too much) 33 | - [x] fundamental question 1: should 'move' create a bookmark entry/structure if it is not covered by bookmarks? 34 | - Answer: the moved item is removed from bookmarks. If it is a group with descendants not transparent for sorting, 35 | it is renamed to become transparent for sorting. 36 | By design, the order of items is property of the parent folder (the container) and not the items 37 | - [x] fundamental question 2: should 'move' create a bookmark entry if moved item was not bookmarked, yet is moved to a folder covered by bookmarks? 38 | - Answer: same as for previous point. 39 | - [x] review from (A) and (B) perspective 40 | - Answer: scenario (A) is fully handled by 'item moved' and 'item deleted'. 41 | scenario (B) is partially handled for 'item moved'. Details to be read from code (too complex to cover here) 42 | - [x] consider deletion of item outside of bookmarks sorting container group 43 | Answer: bookmark items outside of bookmarks sorting container are not manipulated by custom-sort plugin 44 | to not interfere with standard Bookmarks scenarios 45 | - [x] consider moving an item outside of bookmarks group 46 | - Answer: question not relevant. Items are moved in file explorer and bookmarks only reflect that, if needed. 47 | Hence there is no concept of 'moving an item outside of bookmarks group' - bookmarks group only exists in bookmarks 48 | - [x] edge case: bookmarked item is a group, the deleted/moved is a file, not a folder --> what to do? 49 | - Answer: for moved files, only file bookmarks are scanned (and handles), for moved folders, only groups are scanned (and handled). 50 | - [x] delete all instances at any level of bookmarks structure in 'delete' handler 51 | - Answer: only instances of (A) or (B) are deleted. Items outside of bookmarks container for sorting or 52 | in invalid locations in bookmarks hierarchy are ignored 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/utils/ObsidianIconFolderPluginSignature.ts: -------------------------------------------------------------------------------- 1 | import {App, CommunityPlugin, TAbstractFile} from "obsidian"; 2 | 3 | // For https://github.com/FlorianWoelki/obsidian-icon-folder 4 | 5 | export const ObsidianIconFolderPlugin_getData_methodName = 'getData' 6 | 7 | export interface FolderIconObject { 8 | iconName: string | null; 9 | inheritanceIcon: string; 10 | } 11 | 12 | export type ObsidianIconFolderPlugin_Data = Record 13 | 14 | export interface ObsidianIconFolder_PluginInstance extends CommunityPlugin { 15 | [ObsidianIconFolderPlugin_getData_methodName]: () => ObsidianIconFolderPlugin_Data 16 | } 17 | 18 | // https://github.com/FlorianWoelki/obsidian-icon-folder/blob/fd9c7df1486744450cec3d7ee9cee2b34d008e56/manifest.json#L2 19 | export const ObsidianIconFolderPluginId: string = 'obsidian-icon-folder' 20 | 21 | export const getIconFolderPlugin = (app: App): ObsidianIconFolder_PluginInstance | undefined => { 22 | const iconFolderPlugin: CommunityPlugin | undefined = app?.plugins?.plugins?.[ObsidianIconFolderPluginId] 23 | if (iconFolderPlugin && iconFolderPlugin._loaded && app?.plugins?.enabledPlugins?.has(ObsidianIconFolderPluginId)) { 24 | const iconFolderPluginInstance: ObsidianIconFolder_PluginInstance = iconFolderPlugin as ObsidianIconFolder_PluginInstance 25 | // defensive programming, in case the community plugin changes its internal APIs 26 | if (typeof iconFolderPluginInstance?.[ObsidianIconFolderPlugin_getData_methodName] === 'function') { 27 | return iconFolderPluginInstance 28 | } 29 | } 30 | } 31 | 32 | // Intentionally partial and simplified, only detect icons configured directly, 33 | // ignoring any icon inheritance or regexp-based applied icons 34 | export const determineIconOf = (entry: TAbstractFile, iconFolderPluginInstance: ObsidianIconFolder_PluginInstance): string | undefined => { 35 | const iconsData: ObsidianIconFolderPlugin_Data | undefined = iconFolderPluginInstance[ObsidianIconFolderPlugin_getData_methodName]() 36 | const entryForPath: any = iconsData?.[entry.path] 37 | // Icons configured directly 38 | if (typeof entryForPath === 'string') { 39 | return entryForPath 40 | } else if (typeof (entryForPath as FolderIconObject)?.iconName === 'string') { 41 | return (entryForPath as FolderIconObject)?.iconName ?? undefined 42 | } else { 43 | return undefined 44 | } 45 | } 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | // syntax sugar for checking for optional numbers equal to zero (and other similar cases) 2 | export function isDefined(o: any): boolean { 3 | return o !== undefined && o !== null; 4 | } 5 | 6 | export function last(o: Array): T | undefined { 7 | return o?.length > 0 ? o[o.length - 1] : undefined 8 | } 9 | 10 | export function lastPathComponent(path: string): string { 11 | const lastPathSeparatorIdx = (path ?? '').lastIndexOf('/') 12 | return lastPathSeparatorIdx >= 0 ? path.substring(lastPathSeparatorIdx + 1) : path 13 | } 14 | 15 | export function extractParentFolderPath(path: string): string { 16 | const lastPathSeparatorIdx = (path ?? '').lastIndexOf('/') 17 | return lastPathSeparatorIdx > 0 ? path.substring(0, lastPathSeparatorIdx) : '' 18 | } 19 | 20 | export function extractBasename (configEntry: string | undefined): string | undefined { 21 | if (typeof configEntry === 'string' && configEntry.endsWith('.md')) { 22 | return configEntry.slice(0, -'.md'.length) 23 | } else { 24 | return configEntry 25 | } 26 | } 27 | 28 | export class ValueOrError { 29 | constructor(private value?: V, private error?: E) { 30 | if (value) this.error = undefined 31 | } 32 | public setValue(value: V): ValueOrError { 33 | this.value = value 34 | this.error = undefined 35 | return this 36 | } 37 | public setError(error: E): ValueOrError { 38 | this.value = undefined 39 | this.error = error 40 | return this 41 | } 42 | public get v() { 43 | return this.value 44 | } 45 | public get e() { 46 | return this.error 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/week-of-year.ts: -------------------------------------------------------------------------------- 1 | 2 | // Cache of start of years and number of days in the 1st week 3 | interface MondayCache { 4 | year: number // full year, e.g. 2015 5 | mondayDateOf1stWeekUS: number // U.S. standard, the 1st of Jan determines the first week, monday can be in Dec of previous year 6 | sundayDateOf1stWeekUS: number 7 | mondayDateOf1stWeekISO: number // ISO standard, when the first Thursday of the year determines week numbering 8 | sundayDateOf1stWeekISO: number 9 | } 10 | 11 | type YEAR = number 12 | const DAY_OF_MILIS = 60*60*24*1000 13 | const DAYS_IN_WEEK = 7 14 | 15 | const MondaysCache: { [key: YEAR]: MondayCache } = {} 16 | 17 | const calculateMondayDateIn1stWeekOfYear = (year: number): MondayCache => { 18 | const firstSecondOfYear = new Date(`${year}-01-01T00:00:00.000Z`) 19 | const SUNDAY = 0 20 | const MONDAY = 1 21 | const THURSDAY = 4 22 | const FRIDAY = 5 23 | const SATURDAY = 6 24 | 25 | const dayOfWeek = firstSecondOfYear.getDay() 26 | let daysToPrevMonday: number = 0 // For the Monday itself 27 | if (dayOfWeek === SUNDAY) { // Sunday 28 | daysToPrevMonday = DAYS_IN_WEEK - 1 29 | } else if (dayOfWeek > MONDAY) { // Tue - Sat 30 | daysToPrevMonday = dayOfWeek - MONDAY 31 | } 32 | 33 | // for U.S. the first week is the one with Jan 1st, 34 | // for ISO standard, the first week is the one which contains the 1st Thursday of the year 35 | const useISOoffset = [FRIDAY, SATURDAY, SUNDAY].includes(dayOfWeek) ? DAYS_IN_WEEK : 0 36 | 37 | return { 38 | year: year, 39 | mondayDateOf1stWeekUS: new Date(firstSecondOfYear).setDate(firstSecondOfYear.getDate() - daysToPrevMonday), 40 | sundayDateOf1stWeekUS: new Date(firstSecondOfYear).setDate(firstSecondOfYear.getDate() - daysToPrevMonday + DAYS_IN_WEEK - 1), 41 | mondayDateOf1stWeekISO: new Date(firstSecondOfYear).setDate(firstSecondOfYear.getDate() - daysToPrevMonday + useISOoffset), 42 | sundayDateOf1stWeekISO: new Date(firstSecondOfYear).setDate(firstSecondOfYear.getDate() - daysToPrevMonday + useISOoffset + DAYS_IN_WEEK - 1), 43 | } 44 | } 45 | 46 | // Week number = 1 to 54, U.S. standard by default, can also work in ISO (parameter driven) 47 | export const getDateForWeekOfYear = (year: number, weekNumber: number, useISO?: boolean, sunday?: boolean): Date => { 48 | const WEEK_OF_MILIS = DAYS_IN_WEEK * DAY_OF_MILIS 49 | const dataOfMondayIn1stWeekOfYear = (MondaysCache[year] ??= calculateMondayDateIn1stWeekOfYear(year)) 50 | const mondayOfTheRequestedWeek = 51 | (useISO ? dataOfMondayIn1stWeekOfYear.mondayDateOf1stWeekISO : dataOfMondayIn1stWeekOfYear.mondayDateOf1stWeekUS) 52 | + (weekNumber-1)*WEEK_OF_MILIS 53 | 54 | const sundayOfTheRequestedWeek = 55 | (useISO ? dataOfMondayIn1stWeekOfYear.sundayDateOf1stWeekISO : dataOfMondayIn1stWeekOfYear.sundayDateOf1stWeekUS) 56 | + (weekNumber-1)*WEEK_OF_MILIS 57 | 58 | return new Date(sunday ? sundayOfTheRequestedWeek : mondayOfTheRequestedWeek) 59 | } 60 | 61 | export const _unitTests = { 62 | calculateMondayDateIn2stWeekOfYear: calculateMondayDateIn1stWeekOfYear 63 | } 64 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* Unsure if this file of plugin is required (even empty) or not */ 2 | 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "allowSyntheticDefaultImports": true, 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "allowJs": false, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "strictNullChecks": true, 15 | "lib": [ 16 | "DOM", 17 | "ES5", 18 | "ES6", 19 | "ES7" 20 | ] 21 | }, 22 | "include": [ 23 | "**/*.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.5.188": "0.12.0", 3 | "0.5.189": "0.12.0", 4 | "0.6.0": "0.12.0", 5 | "0.6.1": "0.12.0", 6 | "0.6.2": "0.12.0", 7 | "0.7.0": "0.15.0", 8 | "0.7.1": "0.15.0", 9 | "0.7.2": "0.15.0", 10 | "0.8.0": "0.15.0", 11 | "0.8.1": "0.15.0", 12 | "0.8.2": "0.15.0", 13 | "0.8.3": "0.15.0", 14 | "0.8.4": "0.15.0", 15 | "1.0.0": "0.15.0", 16 | "1.0.1": "0.15.0", 17 | "1.0.2": "0.15.0", 18 | "1.0.3": "0.15.0", 19 | "1.1.0": "0.15.0", 20 | "1.2.0": "0.15.0", 21 | "1.3.0": "0.15.0", 22 | "1.4.0": "0.15.0", 23 | "1.5.0": "0.15.0", 24 | "1.6.0": "0.15.0", 25 | "1.6.1": "0.15.0", 26 | "1.6.2": "0.15.0", 27 | "1.6.3": "0.15.0", 28 | "1.7.0": "0.15.0", 29 | "1.7.1": "0.15.0", 30 | "1.7.2": "0.15.0", 31 | "1.8.2": "0.15.0", 32 | "1.9.0": "0.15.0", 33 | "1.9.1": "0.15.0", 34 | "1.9.2": "0.15.0", 35 | "2.0.1": "0.16.2", 36 | "2.0.2": "0.16.2", 37 | "2.1.0": "0.16.2", 38 | "2.1.1": "0.16.2", 39 | "2.1.2": "0.16.2", 40 | "2.1.3": "0.16.2", 41 | "2.1.4": "0.16.2", 42 | "2.1.5": "0.16.2", 43 | "2.1.7": "0.16.2", 44 | "2.1.8": "0.16.2", 45 | "2.1.9": "0.16.2", 46 | "2.1.10": "0.16.2", 47 | "2.1.11": "0.16.2", 48 | "2.1.12": "0.16.2", 49 | "2.1.13": "0.16.2", 50 | "2.1.14": "0.16.2", 51 | "2.1.15": "0.16.2", 52 | "3.0.0": "1.7.2", 53 | "3.0.1": "1.7.2", 54 | "3.1.0": "1.7.2", 55 | "3.1.1": "1.7.2", 56 | "3.1.2": "1.7.2", 57 | "3.1.3": "1.7.2", 58 | "3.1.4": "1.7.2", 59 | "3.1.5": "1.7.2" 60 | } 61 | --------------------------------------------------------------------------------