First public release of the Obsidian custom sort plugin

This commit is contained in:
SebastianMC 2022-08-06 03:15:44 +02:00
parent f85d2b74b5
commit 93daf529d3
33 changed files with 3914 additions and 247 deletions

45
.gitignore vendored
View File

@ -1,22 +1,23 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
/yarn.lock

367
README.md
View File

@ -1,73 +1,334 @@
# Obsidian Sample Plugin
## Custom Sorting Order in File Explorer (https://obsidian.md plugin)
This is a sample plugin for Obsidian (https://obsidian.md).
Take full control of the order of your notes and folders in File Explorer
This project uses Typescript to provide type checking and documentation.
The repo depends on the latest plugin API (obsidian.d.ts) in Typescript Definition format, which contains TSDoc comments describing what it does.
- folder-level configuration
- each folder can have its own order or use the global Obsidian setting
- use sorting and grouping rules or direct order specification or mixed
- versatile configuration options
- order configuration stored directly in your note(s) front matter
- use a dedicated key in YAML
**Note:** The Obsidian API is still in early alpha and is subject to change at any time!
## TL;DR Usage
This sample plugin demonstrates some of the basic functionality the plugin API can do.
- Changes the default font color to red using `styles.css`.
- Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page.
- Registers a global click event and output 'click' to the console.
- Registers a global interval which logs 'setInterval' to the console.
For full version of the manual go to [./docs/manual.md]() and [./docs/syntax-reference.md]()
> REMARK: as of this version of documentation, the manual and syntax reference are empty :-)
## First time developing plugins?
Below go examples of (some of) the key features, ready to copy & paste to your vault.
Quick starting guide for new plugin devs:
For simplicity (if you are examining the plugin for the first time) copy and paste the below YAML snippets to the front
matter of the `sortspec` note (which is `sortspec.md` file under the hood). Create such note at any location in your
vault if you don't have one.
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
- Install NodeJS, then run `npm i` in the command line under your repo folder.
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
- Reload Obsidian to load the new version of your plugin.
- Enable plugin in settings window.
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
Each time after creating or updating the sorting specification click the [ribbon icon](#ribbon_icon) to parse the
specification and actually apply the custom sorting in File Explorer
## Releasing new releases
Click the [ribbon icon](#ribbon_icon) again to disable custom sorting and switch back to the standard Obsidian sorting.
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
- Publish the release.
The [ribbon icon](#ribbon_icon) acts also as the visual indicator of the current state of the plugin - see
the [ribbon icon](#ribbon_icon) section for details
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
### Simple case 1: in root folder sort items by a rule, intermixing folders and files
## Adding your plugin to the community plugin list
The specified rule is to sort items alphabetically
- Check https://github.com/obsidianmd/obsidian-releases/blob/master/plugin-review.md
- Publish an initial version.
- Make sure you have a `README.md` file in the root of your repo.
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
> IMPORTANT: indentation matters in all of the examples
## How to use
```yaml
---
sorting-spec: |
target-folder: /
< a-z
---
```
- Clone this repo.
- `npm i` or `yarn` to install dependencies
- `npm run dev` to start compilation in watch mode.
which can result in:
## Manually installing the plugin
![Simplest example](./docs/svg/simplest-example.svg)
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
### Simple case 2: impose manual order of some items in root folder
## Improve code quality with eslint (optional)
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
- To use eslint with this project, make sure to install eslint from terminal:
- `npm install -g eslint`
- To use eslint to analyze this project use this command:
- `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder:
- `eslint .\src\`
The specification here lists items (files and folders) by name in the desired order
Notice, that only a subset of items was listed. Unlisted items go after the specified ones, if the specification
doesn't say otherwise
## API Documentation
```yaml
---
sorting-spec: |
target-folder: /
Note 1
Z Archive
Some note
Some folder
---
```
produces:
![Simplest example](./docs/svg/simplest-example-2.svg)
### Example 3: In root folder, let files go first and folders get pushed to the bottom
Files go first, sorted by modification date descending (newest note in the top)
Then go folders, sorted in reverse alphabetical order
> IMPORTANT: Again, indentation matters in all of the examples. Notice that the order specification `< modified` for
> the `/:files` and the order `> a-z` for `/folders` are indented by one more space. The indentation says the order
> applies
> to the group and not to the 'target-folder' directly.
>
> And yes, each group can have a different order in the same parent folder
```yaml
---
sorting-spec: |
target-folder: /
/:files
< modified
/folders
> a-z
---
```
will order items as:
![Files go first example](./docs/svg/files-go-first.svg)
### Example 4: In root folder, pin a focus note, then Inbox folder, and push archive to the bottom
The specification below says:
- first go items which name starts with 'Focus' (e.g. the notes to pin to the top)
- notice the usage of '...' wildcard
- then goes an item named 'Inbox' (my Inbox folder)
- then go all items not matching any of the above or below rules/names/patterns
- the special symbol `%` has that meaning
- then, second to the bottom goes the 'Archive' (a folder which doesn't need focus)
- and finally, in the very bottom, the `sortspec.md` file, which probably contains this sorting specification ;-)
```yaml
---
sorting-spec: |
target-folder: .
Focus...
Inbox
%
Archive
sortspec
---
```
and the result will be:
![Result of the example](./docs/svg/pin-focus-note.svg)
> Remarks for the `target-folder:`
>
> In this example the dot '.' symbol was used `target-folder: .` which means _apply the sorting specification to the
folder which contains the note with the specification_.
>
> If the `target-folder:` line is omitted, the specification will be applied to the parent folder of the note, which has
> the same effect as `target-folder: .`
### Example 5: P.A.R.A. method example
The P.A.R.A. system for organizing digital information is based on the four specifically named folders ordered as in the
acronym: Projects — Areas — Resources — Archives
To put folders in the desired order you can simply list them by name in the needed sequence:
```yaml
---
sorting-spec: |
target-folder: /
Projects
Areas
Responsibilities
Archive
---
```
which will have the effect of:
![Result of the example](./docs/svg/p_a_r_a.svg)
### Example 6: P.A.R.A. example with smart syntax
Instead of listing full names of folders or notes, you can use the prefix or suffix of prefix+suffix notation with the
special syntax of '...' which acts as a wildcard here, matching any sequence of characters:
```yaml
---
sorting-spec: |
target-folder: /
Pro...
A...s
Res...es
...ive
---
```
It will give exactly the same order as in previous example:
![Result of the example](./docs/svg/p_a_r_a.svg)
```
REMARK: the wildcard expression '...' can be used only once per line
```
### Example 7: Apply the same sorting rules to two folders
Let's tell a few folders to sort their child notes and child folders by created date reverse order (newer go first)
```yaml
---
sorting-spec: |
target-folder: Some subfolder
target-folder: Archive
target-folder: Archive/2021/Completed projects
> created
---
```
No visualization for this example needed
### Example 8: Specify rules for multiple folders
The specification can contain rules and orders for more than one folder
Personally I find convenient to keep sorting specification of all folders in a vault in a single place, e.g. in a
dedicated note Inbox/Inbox.md
```yaml
---
sorting-spec: |
target-folder: /
Pro...
Archive
target-folder: Projects
Top Secret
target-folder: Archive
> a-z
---
```
will have the effect of:
![Result of the example](./docs/svg/multi-folder.svg)
### Example 9: Sort by numerical suffix
This is interesting.
Sorting by numerical prefix is easy and doesn't require any additional plugin in Obsidian.
At the same time sorting by numerical suffix is not feasible without a plugin like this one.
Use the specification like below to order notes in 'Inbox' subfolder of 'Data' folder by the numerical suffix indicated
by the 'part' token (an arbitrary example)
```yaml
---
sorting-spec: |
target-folder: Data/Inbox
... part \d+
< a-z
---
```
the line `... part \d+` says: group all notes and folders with name ending with 'part' followed by a number. Then order
them by the number. And for clarity the subsequent (indented) line is added ` < a-z` which sets the order to
alphanumerical ascending.
The effect is:
![Order by numerical suffix](./docs/svg/by-suffix.svg)
## Location of sorting specification YAML entry
You can keep the custom sorting specifications in any of the following locations (or in all of them):
- in the front matter of the `sortspec` note (which is the `sortspec.md` file under the hood)
- you can keep one global `sortspec` note or one `sortspec` in each folder for which you set up a custom sorting
- YAML in front matter of all existing `sortspec` notes is scanned, so feel free to choose your preferred approach
- in the front matter of the - so called - _folder note_. For instance '/References/References.md'
- the 'folder note' is a concept of note named exactly as its parent folder, e.g. `references` note (
actually `references.md` file) residing inside the `/references/` folder
- there are popular Obsidian plugins which allow convenient access and editing of folder note, plus hiding it in the
notes list
- in the front matter of a **designated note** configured in setting
- in settings page of the plugin in obsidian you can set the exact path to the designated note
- by default, it is `Inbox/Inbox.md`
- feel free to adjust it to your preferences
- primary intention is to use this setting as the reminder note to yourself, to easily locate the note containing
sorting specifications for the vault
A sorting specification for a folder has to reside in a single YAML entry in one of the listed locations.
At the same time, you can put specifications for different target folders into different notes, according to your
preference.
My personal approach is to keep the sorting specification for all desired folders in a single note (
e.g. `Inbox/Inbox.md`). And for clarity, I keep the name of that designated note in the plugin settings, for easy
reference.
<a name="ribbon_icon"></a>
## Ribbon icon
Click the ribbon icon to toggle the plugin between enabled and suspended states.
States of the ribbon icon:
- ![Inactive](./docs/icons/icon-inactive.png) Plugin suspended. Custom sorting NOT applied.
- Click to enable and apply custom sorting.
- Note: parsing of the custom sorting specification happens after clicking the icon. If the specification contains
errors, they will show up in the notice baloon and also in developer console.
- ![Active](./docs/icons/icon-active.png) Plugin active, custom sorting applied.
- Click to suspend and return to the standard Obsidian sorting in File Explorer.
- ![Error](./docs/icons/icon-error.png) Syntax error in custom sorting configuration.
- Fix the problem in specification and click the ribbon icon to re-enable custom sorting.
- If syntax error is not fixed, the notice baloon with show error details. Syntax error details are also visible in
the developer console
- ![Sorting not applied](./docs/icons/icon-not-applied.png) Plugin enabled but the custom sorting was not applied.
- This can happen when reinstalling the plugin and in similar cases
- Click the ribbon icon twice to re-enable the custom sorting.
## Installing the plugin
As for now, the plugin can be installed manually or via the BRAT plugin
### Installing the plugin using BRAT
1. Install the BRAT plugin
1. Open `Settings` -> `Community Plugins`
2. Disable restricted (formerly 'safe') mode, if enabled
3. *Browse*, and search for "BRAT"
4. Install the latest version of **Obsidian 42 - BRAT**
2. Open BRAT settings (`Settings` -> `Obsidian 42 - BRAT`)
1. Scroll to the `Beta Plugin List` section
2. `Add Beta Plugin`
3. Specify this repository: `SebastianMC/obsidian-custom-sort`
3. Enable the `Custom File Explorer sorting` plugin (`Settings` -> `Community Plugins`)
### Manually installing the plugin
1. Go to Github for releases: https://github.com/SebastianMC/obsidian-custom-sort/releases
2. Download the Latest Release from the Releases section of the GitHub Repository
3. Copy the downloaded files `main.js`, `styles.css`, `manifest.json` over to your
vault `VaultFolder/.obsidian/plugins/custom-sort/`.
- you might need to manually create the `/custom-sort/` folder under `VaultFolder/.obsidian/plugins/`
4. Reload Obsidian
5. If prompted about Restricted (formerly 'Safe') Mode, you can disable restricted mode and enable the plugin.
-Otherwise, go to `Settings` -> `Community plugins`, make sure restricted mode is off and enable the plugin from
there.
> Note: The `.obsidian` folder may be hidden.
> On macOS, you should be able to press Command+Shift+Dot to show the folder in Finder.
## Credits
Thanks to [Nothingislost](https://github.com/nothingislost) for the monkey-patching ideas of File Explorer
in [obsidian-bartender](https://github.com/nothingislost/obsidian-bartender)
See https://github.com/obsidianmd/obsidian-api

BIN
docs/icons/icon-active.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
docs/icons/icon-error.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

3
docs/manual.md Normal file
View File

@ -0,0 +1,3 @@
Yet to be filled with content ;-)
See [syntax-reference.md](), maybe that file has already some content?

70
docs/svg/by-suffix.svg Normal file
View File

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xl="http://www.w3.org/1999/xlink"
xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="-.5 -.5 176.5 187" width="176.5" height="187">
<defs/>
<metadata>Produced by OmniGraffle 7.20\n2022-08-06 00:43:11 +0000</metadata>
<g id="by_suffix" stroke-opacity="1" fill-opacity="1" stroke="none" fill="none" stroke-dasharray="none">
<title>by suffix</title>
<g id="by_suffix_Layer_1">
<title>Layer 1</title>
<g id="Graphic_8">
<rect x="0" y="27" width="175.5" height="159.5" fill="#e9e9e9"/>
</g>
<g id="Graphic_2">
<text transform="translate(18 29.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Data</tspan>
</text>
</g>
<g id="Graphic_4">
<text transform="translate(35 51.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Inbox</tspan>
</text>
</g>
<g id="Graphic_5">
<text transform="translate(52 73.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Mhmmm part 1</tspan>
</text>
</g>
<g id="Graphic_6">
<text transform="translate(52.27273 95.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Interesting part 20
</tspan>
</text>
</g>
<g id="Graphic_7">
<text transform="translate(52 117.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Interim part 333
</tspan>
</text>
</g>
<g id="Graphic_9">
<rect x="0" y="0" width="175.5" height="27" fill="white"/>
<rect x="0" y="0" width="175.5" height="27" stroke="black" stroke-linecap="round"
stroke-linejoin="round" stroke-width="1"/>
<text transform="translate(5 2.753441)" fill="black">
<tspan font-family="Helvetica Neue" font-weight="bold" font-size="17" fill="black" x="0" y="17">My
Vault
</tspan>
</text>
</g>
<g id="Graphic_27">
<text transform="translate(52 139.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Final part 401</tspan>
</text>
</g>
<g id="Graphic_28">
<path d="M 20.068182 57.5 L 22.568182 62.5 L 25.068182 57.5 Z" fill="black"/>
</g>
<g id="Graphic_30">
<path d="M 4.5 35.56818 L 7 40.56818 L 9.5 35.56818 Z" fill="black"/>
</g>
<g id="Graphic_32">
<text transform="translate(52 161.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Note with no suffix
</tspan>
</text>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xl="http://www.w3.org/1999/xlink"
xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="-.5 -.5 166 159.5" width="166" height="159.5">
<defs/>
<metadata>Produced by OmniGraffle 7.20\n2022-08-05 22:37:20 +0000</metadata>
<g id="Files_go_first" stroke-opacity="1" fill-opacity="1" stroke="none" fill="none" stroke-dasharray="none">
<title>Files go first</title>
<g id="Files_go_first_Layer_1">
<title>Layer 1</title>
<g id="Graphic_8">
<rect x="0" y="27" width="165" height="132" fill="#e9e9e9"/>
</g>
<g id="Graphic_2">
<text transform="translate(18 29.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Note (oldest)</tspan>
</text>
</g>
<g id="Graphic_4">
<text transform="translate(18 51.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Note (old)</tspan>
</text>
</g>
<g id="Graphic_5">
<text transform="translate(18 73.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Note (newest)</tspan>
</text>
</g>
<g id="Graphic_6">
<text transform="translate(18 95.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Subfolder B</tspan>
</text>
</g>
<g id="Graphic_7">
<text transform="translate(18 117.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Subfolder A</tspan>
</text>
</g>
<g id="Graphic_9">
<rect x="0" y="0" width="165" height="27" fill="white"/>
<rect x="0" y="0" width="165" height="27" stroke="black" stroke-linecap="round" stroke-linejoin="round"
stroke-width="1"/>
<text transform="translate(5 2.753441)" fill="black">
<tspan font-family="Helvetica Neue" font-weight="bold" font-size="17" fill="black" x="0" y="17">My
Vault
</tspan>
</text>
</g>
<g id="Graphic_26">
<path d="M 4.5 145.6927 L 9.5 148.1927 L 4.5 150.6927 Z" fill="black"/>
</g>
<g id="Graphic_27">
<text transform="translate(18 139.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Subfolder</tspan>
</text>
</g>
<g id="Graphic_28">
<path d="M 4.5 101.5 L 9.5 104 L 4.5 106.5 Z" fill="black"/>
</g>
<g id="Graphic_30">
<path d="M 4.5 123.34254 L 9.5 125.84254 L 4.5 128.34254 Z" fill="black"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

63
docs/svg/multi-folder.svg Normal file
View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xl="http://www.w3.org/1999/xlink"
xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="-.5 -.5 166 159.5" width="166" height="159.5">
<defs/>
<metadata>Produced by OmniGraffle 7.20\n2022-08-05 22:01:57 +0000</metadata>
<g id="Multi-folder_spec" stroke-opacity="1" fill-opacity="1" stroke="none" fill="none" stroke-dasharray="none">
<title>Multi-folder spec</title>
<g id="Multi-folder_spec_Layer_1">
<title>Layer 1</title>
<g id="Graphic_8">
<rect x="0" y="27" width="165" height="132" fill="#e9e9e9"/>
</g>
<g id="Graphic_2">
<text transform="translate(18 29.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Projects</tspan>
</text>
</g>
<g id="Graphic_4">
<text transform="translate(35 51.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Top Secret</tspan>
</text>
</g>
<g id="Graphic_5">
<text transform="translate(35 73.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Experiment A.5-3
</tspan>
</text>
</g>
<g id="Graphic_6">
<text transform="translate(18 95.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Archive</tspan>
</text>
</g>
<g id="Graphic_7">
<text transform="translate(35 117.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Something newer</tspan>
</text>
</g>
<g id="Graphic_9">
<rect x="0" y="0" width="165" height="27" fill="white"/>
<rect x="0" y="0" width="165" height="27" stroke="black" stroke-linecap="round" stroke-linejoin="round"
stroke-width="1"/>
<text transform="translate(5 2.753441)" fill="black">
<tspan font-family="Helvetica Neue" font-weight="bold" font-size="17" fill="black" x="0" y="17">My
Vault
</tspan>
</text>
</g>
<g id="Graphic_26">
<path d="M 4.5 35.5 L 7 40.5 L 9.5 35.5 Z" fill="black"/>
</g>
<g id="Graphic_27">
<text transform="translate(35 139.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Oooold</tspan>
</text>
</g>
<g id="Graphic_28">
<path d="M 4.5 101.5 L 7 106.5 L 9.5 101.5 Z" fill="black"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

70
docs/svg/p_a_r_a.svg Normal file
View File

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xl="http://www.w3.org/1999/xlink"
xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="-.5 -.5 166 159.5" width="166" height="159.5">
<defs/>
<metadata>Produced by OmniGraffle 7.20\n2022-08-05 16:57:13 +0000</metadata>
<g id="P_A_R_A" stroke-opacity="1" fill-opacity="1" stroke="none" fill="none" stroke-dasharray="none">
<title>P.A.R.A</title>
<g id="P_A_R_A_Layer_1">
<title>Layer 1</title>
<g id="Graphic_8">
<rect x="0" y="27" width="165" height="132" fill="#e9e9e9"/>
</g>
<g id="Graphic_2">
<text transform="translate(18 29.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Projects</tspan>
</text>
</g>
<g id="Graphic_4">
<text transform="translate(35 51.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Top Secret</tspan>
</text>
</g>
<g id="Graphic_5">
<text transform="translate(35 73.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Experiment A.5-3
</tspan>
</text>
</g>
<g id="Graphic_6">
<text transform="translate(18 95.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Areas</tspan>
</text>
</g>
<g id="Graphic_7">
<text transform="translate(18 117.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Responsibilities
</tspan>
</text>
</g>
<g id="Graphic_9">
<rect x="0" y="0" width="165" height="27" fill="white"/>
<rect x="0" y="0" width="165" height="27" stroke="black" stroke-linecap="round" stroke-linejoin="round"
stroke-width="1"/>
<text transform="translate(5 2.753441)" fill="black">
<tspan font-family="Helvetica Neue" font-weight="bold" font-size="17" fill="black" x="0" y="17">My
Vault
</tspan>
</text>
</g>
<g id="Graphic_26">
<path d="M 4.5 35.5 L 7 40.5 L 9.5 35.5 Z" fill="black"/>
</g>
<g id="Graphic_27">
<text transform="translate(18 139.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Archive</tspan>
</text>
</g>
<g id="Graphic_28">
<path d="M 4.5 101.5 L 9.5 104 L 4.5 106.5 Z" fill="black"/>
</g>
<g id="Graphic_29">
<path d="M 4.5 123.5 L 9.5 126 L 4.5 128.5 Z" fill="black"/>
</g>
<g id="Graphic_30">
<path d="M 4.5 145.5 L 9.5 148 L 4.5 150.5 Z" fill="black"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xl="http://www.w3.org/1999/xlink"
xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="-.5 -.5 166 159.5" width="166" height="159.5">
<defs/>
<metadata>Produced by OmniGraffle 7.20\n2022-08-05 23:01:43 +0000</metadata>
<g id="Pin_focus_note" stroke-opacity="1" fill-opacity="1" stroke="none" fill="none" stroke-dasharray="none">
<title>Pin focus note</title>
<g id="Pin_focus_note_Layer_1">
<title>Layer 1</title>
<g id="Graphic_8">
<rect x="0" y="27" width="165" height="132" fill="#e9e9e9"/>
</g>
<g id="Graphic_2">
<text transform="translate(18 29.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Focus note XYZ</tspan>
</text>
</g>
<g id="Graphic_4">
<text transform="translate(18 51.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Inbox</tspan>
</text>
</g>
<g id="Graphic_5">
<text transform="translate(18 73.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Some note</tspan>
</text>
</g>
<g id="Graphic_6">
<text transform="translate(18 95.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Yet another note
</tspan>
</text>
</g>
<g id="Graphic_7">
<text transform="translate(18 117.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Archive</tspan>
</text>
</g>
<g id="Graphic_9">
<rect x="0" y="0" width="165" height="27" fill="white"/>
<rect x="0" y="0" width="165" height="27" stroke="black" stroke-linecap="round" stroke-linejoin="round"
stroke-width="1"/>
<text transform="translate(5 2.753441)" fill="black">
<tspan font-family="Helvetica Neue" font-weight="bold" font-size="17" fill="black" x="0" y="17">My
Vault
</tspan>
</text>
</g>
<g id="Graphic_27">
<text transform="translate(18 139.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">sortspec</tspan>
</text>
</g>
<g id="Graphic_28">
<path d="M 4.5 57.5 L 9.5 60 L 4.5 62.5 Z" fill="black"/>
</g>
<g id="Graphic_30">
<path d="M 4.5 123.34254 L 9.5 125.84254 L 4.5 128.34254 Z" fill="black"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xl="http://www.w3.org/1999/xlink"
xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="-.5 -.5 166 159.5" width="166" height="159.5">
<defs/>
<metadata>Produced by OmniGraffle 7.20\n2022-08-05 22:12:33 +0000</metadata>
<g id="Simplest_2" stroke-opacity="1" fill-opacity="1" stroke="none" fill="none" stroke-dasharray="none">
<title>Simplest 2</title>
<g id="Simplest_2_Layer_1">
<title>Layer 1</title>
<g id="Graphic_8">
<rect x="0" y="27" width="165" height="132" fill="#e9e9e9"/>
</g>
<g id="Graphic_2">
<text transform="translate(18 29.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Note 1</tspan>
</text>
</g>
<g id="Graphic_4">
<text transform="translate(18 51.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Z Archive</tspan>
</text>
</g>
<g id="Graphic_5">
<text transform="translate(18 73.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Some note</tspan>
</text>
</g>
<g id="Graphic_6">
<text transform="translate(18 95.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Some folder</tspan>
</text>
</g>
<g id="Graphic_7">
<text transform="translate(18 117.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">A folder</tspan>
</text>
</g>
<g id="Graphic_9">
<rect x="0" y="0" width="165" height="27" fill="white"/>
<rect x="0" y="0" width="165" height="27" stroke="black" stroke-linecap="round" stroke-linejoin="round"
stroke-width="1"/>
<text transform="translate(5 2.753441)" fill="black">
<tspan font-family="Helvetica Neue" font-weight="bold" font-size="17" fill="black" x="0" y="17">My
Vault
</tspan>
</text>
</g>
<g id="Graphic_26">
<path d="M 4.5 56.947514 L 9.5 59.447514 L 4.5 61.947514 Z" fill="black"/>
</g>
<g id="Graphic_27">
<text transform="translate(18 139.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Note 2</tspan>
</text>
</g>
<g id="Graphic_28">
<path d="M 4.5 101.5 L 9.5 104 L 4.5 106.5 Z" fill="black"/>
</g>
<g id="Graphic_30">
<path d="M 4.5 123.34254 L 9.5 125.84254 L 4.5 128.34254 Z" fill="black"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xl="http://www.w3.org/1999/xlink"
xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="-.5 -.5 166 159.5" width="166" height="159.5">
<defs/>
<metadata>Produced by OmniGraffle 7.20\n2022-08-05 19:48:11 +0000</metadata>
<g id="Simplest" stroke-opacity="1" fill-opacity="1" stroke="none" fill="none" stroke-dasharray="none">
<title>Simplest</title>
<g id="Simplest_Layer_1">
<title>Layer 1</title>
<g id="Graphic_8">
<rect x="0" y="27" width="165" height="132" fill="#e9e9e9"/>
</g>
<g id="Graphic_2">
<text transform="translate(18 29.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">A folder</tspan>
</text>
</g>
<g id="Graphic_4">
<text transform="translate(18 51.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Note 1</tspan>
</text>
</g>
<g id="Graphic_5">
<text transform="translate(18 73.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Note 2</tspan>
</text>
</g>
<g id="Graphic_6">
<text transform="translate(18 95.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Some folder</tspan>
</text>
</g>
<g id="Graphic_7">
<text transform="translate(18 117.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Some note</tspan>
</text>
</g>
<g id="Graphic_9">
<rect x="0" y="0" width="165" height="27" fill="white"/>
<rect x="0" y="0" width="165" height="27" stroke="black" stroke-linecap="round" stroke-linejoin="round"
stroke-width="1"/>
<text transform="translate(5 2.753441)" fill="black">
<tspan font-family="Helvetica Neue" font-weight="bold" font-size="17" fill="black" x="0" y="17">My
Vault
</tspan>
</text>
</g>
<g id="Graphic_26">
<path d="M 4.5 35.5 L 9.5 38 L 4.5 40.5 Z" fill="black"/>
</g>
<g id="Graphic_27">
<text transform="translate(18 139.804)" fill="black">
<tspan font-family="Helvetica Neue" font-size="14" fill="black" x="0" y="13">Z Archive folder
</tspan>
</text>
</g>
<g id="Graphic_28">
<path d="M 4.5 101.5 L 9.5 104 L 4.5 106.5 Z" fill="black"/>
</g>
<g id="Graphic_30">
<path d="M 4.5 145.5 L 9.5 148 L 4.5 150.5 Z" fill="black"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

3
docs/syntax-reference.md Normal file
View File

@ -0,0 +1,3 @@
Yet to be filled with content ;-)
Check [manual.md](), maybe that file has already some content?

View File

@ -15,7 +15,7 @@ esbuild.build({
banner: {
js: banner,
},
entryPoints: ['main.ts'],
entryPoints: ['src/main.ts'],
bundle: true,
external: [
'obsidian',
@ -47,6 +47,7 @@ esbuild.build({
target: 'es2016',
logLevel: "info",
sourcemap: prod ? false : 'inline',
minify: prod,
treeShaking: true,
outfile: 'main.js',
outfile: 'dist/main.js',
}).catch(() => process.exit(1));

12
jest.config.js Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ["<rootDir>"],
moduleNameMapper: {
"obsidian": "<rootDir>/node_modules/obsidian/obsidian.d.ts"
},
transformIgnorePatterns: [
'node_modules/(?!obsidian/.*)'
]
};

137
main.ts
View File

@ -1,137 +0,0 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
// Remember to rename these classes and interfaces!
interface MyPluginSettings {
mySetting: string;
}
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload() {
await this.loadSettings();
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
});
// Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class');
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text');
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: 'open-sample-modal-simple',
name: 'Open sample modal (simple)',
callback: () => {
new SampleModal(this.app).open();
}
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'sample-editor-command',
name: 'Sample editor command',
editorCallback: (editor: Editor, view: MarkdownView) => {
console.log(editor.getSelection());
editor.replaceSelection('Sample Editor Command');
}
});
// This adds a complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-sample-modal-complex',
name: 'Open sample modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
}
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
}
onunload() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class SampleModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen() {
const {contentEl} = this;
contentEl.setText('Woah!');
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
containerEl.createEl('h2', {text: 'Settings for my awesome plugin.'});
new Setting(containerEl)
.setName('Setting #1')
.setDesc('It\'s a secret')
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => {
console.log('Secret: ' + value);
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
}));
}
}

View File

@ -1,10 +1,10 @@
{
"id": "obsidian-sample-plugin",
"name": "Sample Plugin",
"version": "1.0.1",
"id": "custom-sort",
"name": "Custom File Explorer sorting",
"version": "0.5.188",
"minAppVersion": "0.12.0",
"description": "This is a sample plugin for Obsidian. This plugin demonstrates some of the capabilities of the Obsidian API.",
"author": "Obsidian",
"authorUrl": "https://obsidian.md",
"description": "Allows for manual and automatic, config-driven reordering and sorting of files and folders in File Explorer",
"author": "SebastianMC <SebastianMC.github@gmail.com>",
"authorUrl": "https://github.com/SebastianMC",
"isDesktopOnly": false
}

View File

@ -1,23 +1,31 @@
{
"name": "obsidian-sample-plugin",
"version": "1.0.1",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"name": "obsidian-custom-sort",
"version": "0.5.188",
"description": "Custom Sort plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
"version": "node version-bump.mjs && git add manifest.json versions.json",
"test": "jest"
},
"keywords": [],
"author": "",
"keywords": [
"obsidian",
"custom sorting"
],
"author": "SebastianMC <SebastianMC.github@gmail.com>",
"license": "MIT",
"devDependencies": {
"@types/jest": "^28.1.2",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "^5.2.0",
"@typescript-eslint/parser": "^5.2.0",
"builtin-modules": "^3.2.0",
"esbuild": "0.13.12",
"obsidian": "latest",
"jest": "^28.1.1",
"monkey-around": "^2.3.0",
"obsidian": "^0.15.4",
"ts-jest": "^28.0.5",
"tslib": "2.3.1",
"typescript": "4.4.4"
}

View File

@ -0,0 +1,60 @@
export const SortSpecFileName: string = 'sortspec.md';
export enum CustomSortGroupType {
Outsiders, // Not belonging to any of other groups
MatchAll, // like a wildard *, used in connection with foldersOnly or filesOnly. The difference between the MatchAll and Outsiders is
ExactName, // ... that MatchAll captures the item (folder, note) and prevents further matching against other rules
ExactPrefix, // ... while the Outsiders captures items which didn't match any of other defined groups
ExactSuffix,
ExactHeadAndTail, // Like W...n or Un...ed, which is shorter variant of typing the entire title
}
export enum CustomSortOrder {
alphabetical = 1, // = 1 to allow: if (customSortOrder) { ...
alphabeticalReverse,
byModifiedTime,
byModifiedTimeReverse,
byCreatedTime,
byCreatedTimeReverse
}
export interface RecognizedOrderValue {
order: CustomSortOrder
secondaryOrder?: CustomSortOrder
}
export type NormalizerFn = (s: string) => string
export interface RegExpSpec {
regex: RegExp
normalizerFn: NormalizerFn
}
export interface CustomSortGroup {
type: CustomSortGroupType
regexSpec?: RegExpSpec
exactText?: string
exactPrefix?: string
exactSuffix?: string
order?: CustomSortOrder
secondaryOrder?: CustomSortOrder
filesOnly?: boolean
matchFilenameWithExt?: boolean
foldersOnly?: boolean,
}
export interface CustomSortSpec {
targetFoldersPaths: Array<string> // For root use '/'
defaultOrder?: CustomSortOrder
groups: Array<CustomSortGroup>
outsidersGroupIdx?: number
outsidersFilesGroupIdx?: number
outsidersFoldersGroupIdx?: number
itemsToHide?: Set<string>
}
export interface FolderPathToSortSpecMap {
[key: string]: CustomSortSpec
}
export type SortSpecsCollection = FolderPathToSortSpecMap

View File

@ -0,0 +1,269 @@
import {TFile} from 'obsidian';
import {determineSortingGroup} from './custom-sort';
import {CustomSortGroupType, CustomSortSpec} from './custom-sort-types';
import {CompoundDashNumberNormalizerFn, CompoundDotRomanNumberNormalizerFn} from "./sorting-spec-processor";
const mockTFile = (basename: string, ext: string, size?: number, ctime?: number, mtime?: number): TFile => {
return {
stat: {
ctime: ctime ?? 0,
mtime: mtime ?? 0,
size: size ?? 0
},
basename: basename,
extension: ext,
vault: null,
path: `Some parent folder/${basename}.${ext}`,
name: `${basename}.${ext}`,
parent: null
}
}
const MOCK_TIMESTAMP: number = 1656417542418
describe('determineSortingGroup', () => {
describe('CustomSortGroupType.ExactHeadAndTail', () => {
it('should correctly recognize head and tail', () => {
// given
const file: TFile = mockTFile('References', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333);
const sortSpec: CustomSortSpec = {
targetFoldersPaths: ['/'],
groups: [{
type: CustomSortGroupType.ExactHeadAndTail,
exactPrefix: 'Ref',
exactSuffix: 'ces'
}]
}
// when
const result = determineSortingGroup(file, sortSpec)
// then
expect(result).toEqual({
groupIdx: 0,
isFolder: false,
sortString: "References.md",
ctime: MOCK_TIMESTAMP + 222,
mtime: MOCK_TIMESTAMP + 333,
path: 'Some parent folder/References.md'
});
})
it('should not allow overlap of head and tail', () => {
// given
const file: TFile = mockTFile('References', 'md', 444, MOCK_TIMESTAMP + 555, MOCK_TIMESTAMP + 666);
const sortSpec: CustomSortSpec = {
targetFoldersPaths: ['/'],
groups: [{
type: CustomSortGroupType.ExactHeadAndTail,
exactPrefix: 'Referen',
exactSuffix: 'rences'
}]
}
// when
const result = determineSortingGroup(file, sortSpec)
// then
expect(result).toEqual({
groupIdx: 1, // This indicates the last+1 idx
isFolder: false,
sortString: "References.md",
ctime: MOCK_TIMESTAMP + 555,
mtime: MOCK_TIMESTAMP + 666,
path: 'Some parent folder/References.md'
});
})
it('should not allow overlap of head and tail, when regexp in head', () => {
// given
const file: TFile = mockTFile('Part123:-icle', 'md', 444, MOCK_TIMESTAMP + 555, MOCK_TIMESTAMP + 666);
const sortSpec: CustomSortSpec = {
targetFoldersPaths: ['Some parent folder'],
groups: [{
type: CustomSortGroupType.ExactHeadAndTail,
regexSpec: {
regex: /^Part *(\d+(?:-\d+)*):/i,
normalizerFn: CompoundDashNumberNormalizerFn
},
exactSuffix: ':-icle'
}]
}
// when
const result = determineSortingGroup(file, sortSpec)
// then
expect(result).toEqual({
groupIdx: 1, // This indicates the last+1 idx
isFolder: false,
sortString: "Part123:-icle.md",
ctime: MOCK_TIMESTAMP + 555,
mtime: MOCK_TIMESTAMP + 666,
path: 'Some parent folder/Part123:-icle.md'
});
})
it('should match head and tail, when regexp in head', () => {
// given
const file: TFile = mockTFile('Part123:-icle', 'md', 444, MOCK_TIMESTAMP + 555, MOCK_TIMESTAMP + 666);
const sortSpec: CustomSortSpec = {
targetFoldersPaths: ['Some parent folder'],
groups: [{
type: CustomSortGroupType.ExactHeadAndTail,
regexSpec: {
regex: /^Part *(\d+(?:-\d+)*):/i,
normalizerFn: CompoundDashNumberNormalizerFn
},
exactSuffix: '-icle'
}]
}
// when
const result = determineSortingGroup(file, sortSpec)
// then
expect(result).toEqual({
groupIdx: 0, // Matched!
isFolder: false,
sortString: "00000123////Part123:-icle.md",
matchGroup: '00000123//',
ctime: MOCK_TIMESTAMP + 555,
mtime: MOCK_TIMESTAMP + 666,
path: 'Some parent folder/Part123:-icle.md'
});
})
it('should not allow overlap of head and tail, when regexp in tail', () => {
// given
const file: TFile = mockTFile('Part:123-icle', 'md', 444, MOCK_TIMESTAMP + 555, MOCK_TIMESTAMP + 666);
const sortSpec: CustomSortSpec = {
targetFoldersPaths: ['Some parent folder'],
groups: [{
type: CustomSortGroupType.ExactHeadAndTail,
exactPrefix: 'Part:',
regexSpec: {
regex: /: *(\d+(?:-\d+)*)-icle$/i,
normalizerFn: CompoundDashNumberNormalizerFn
}
}]
}
// when
const result = determineSortingGroup(file, sortSpec)
// then
expect(result).toEqual({
groupIdx: 1, // This indicates the last+1 idx
isFolder: false,
sortString: "Part:123-icle.md",
ctime: MOCK_TIMESTAMP + 555,
mtime: MOCK_TIMESTAMP + 666,
path: 'Some parent folder/Part:123-icle.md'
});
});
it('should match head and tail, when regexp in tail', () => {
// given
const file: TFile = mockTFile('Part:123-icle', 'md', 444, MOCK_TIMESTAMP + 555, MOCK_TIMESTAMP + 666);
const sortSpec: CustomSortSpec = {
targetFoldersPaths: ['Some parent folder'],
groups: [{
type: CustomSortGroupType.ExactHeadAndTail,
exactPrefix: 'Part',
regexSpec: {
regex: /: *(\d+(?:-\d+)*)-icle$/i,
normalizerFn: CompoundDashNumberNormalizerFn
}
}]
}
// when
const result = determineSortingGroup(file, sortSpec)
// then
expect(result).toEqual({
groupIdx: 0, // Matched!
isFolder: false,
sortString: "00000123////Part:123-icle.md",
matchGroup: '00000123//',
ctime: MOCK_TIMESTAMP + 555,
mtime: MOCK_TIMESTAMP + 666,
path: 'Some parent folder/Part:123-icle.md'
});
});
})
describe('CustomSortGroupType.ExactPrefix', () => {
it('should correctly recognize exact prefix', () => {
// given
const file: TFile = mockTFile('References', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333);
const sortSpec: CustomSortSpec = {
targetFoldersPaths: ['/'],
groups: [{
type: CustomSortGroupType.ExactPrefix,
exactPrefix: 'Ref'
}]
}
// when
const result = determineSortingGroup(file, sortSpec)
// then
expect(result).toEqual({
groupIdx: 0,
isFolder: false,
sortString: "References.md",
ctime: MOCK_TIMESTAMP + 222,
mtime: MOCK_TIMESTAMP + 333,
path: 'Some parent folder/References.md'
});
})
it('should correctly recognize exact prefix, regex variant', () => {
// given
const file: TFile = mockTFile('Reference i.xxx.vi.mcm', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333);
const sortSpec: CustomSortSpec = {
targetFoldersPaths: ['/'],
groups: [{
type: CustomSortGroupType.ExactPrefix,
regexSpec: {
regex: /^Reference *([MDCLXVI]+(?:\.[MDCLXVI]+)*)/i,
normalizerFn: CompoundDotRomanNumberNormalizerFn
}
}]
}
// when
const result = determineSortingGroup(file, sortSpec)
// then
expect(result).toEqual({
groupIdx: 0,
isFolder: false,
sortString: '00000001|00000030|00000006|00001900////Reference i.xxx.vi.mcm.md',
matchGroup: "00000001|00000030|00000006|00001900//",
ctime: MOCK_TIMESTAMP + 222,
mtime: MOCK_TIMESTAMP + 333,
path: 'Some parent folder/Reference i.xxx.vi.mcm.md'
});
})
it('should correctly process not matching prefix', () => {
// given
const file: TFile = mockTFile('References', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333);
const sortSpec: CustomSortSpec = {
targetFoldersPaths: ['/'],
groups: [{
type: CustomSortGroupType.ExactPrefix,
exactPrefix: 'Pref'
}]
}
// when
const result = determineSortingGroup(file, sortSpec)
// then
expect(result).toEqual({
groupIdx: 1, // This indicates the last+1 idx
isFolder: false,
sortString: "References.md",
ctime: MOCK_TIMESTAMP + 222,
mtime: MOCK_TIMESTAMP + 333,
path: 'Some parent folder/References.md'
});
})
})
})

View File

@ -0,0 +1,195 @@
import {TFile, TFolder, requireApiVersion} from 'obsidian';
import {
CustomSortGroup,
CustomSortGroupType,
CustomSortOrder,
CustomSortSpec
} from "./custom-sort-types";
import {isDefined} from "../utils/utils";
let Collator = new Intl.Collator(undefined, {
usage: "sort",
sensitivity: "base",
numeric: true,
}).compare;
interface FolderItemForSorting {
path: string
groupIdx: number // the index itself represents order for groups
sortString: string // fragment (or full name) to be used for sorting
matchGroup?: string // advanced - used for secondary sorting rule, to recognize 'same regex match'
ctime: number
mtime: number
isFolder: boolean
}
type SorterFn = (a: FolderItemForSorting, b: FolderItemForSorting) => number
let Sorters: { [key in CustomSortOrder]: SorterFn } = {
[CustomSortOrder.alphabetical]: (a: FolderItemForSorting, b: FolderItemForSorting) => Collator(a.sortString, b.sortString),
[CustomSortOrder.alphabeticalReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => Collator(b.sortString, a.sortString),
[CustomSortOrder.byModifiedTime]: (a: FolderItemForSorting, b: FolderItemForSorting) => a.mtime - b.mtime,
[CustomSortOrder.byModifiedTimeReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => b.mtime - a.mtime,
[CustomSortOrder.byCreatedTime]: (a: FolderItemForSorting, b: FolderItemForSorting) => a.ctime - b.ctime,
[CustomSortOrder.byCreatedTimeReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => b.ctime - a.ctime
};
function compareTwoItems(itA: FolderItemForSorting, itB: FolderItemForSorting, sortSpec: CustomSortSpec) {
if (itA.groupIdx === itB.groupIdx) {
const group: CustomSortGroup = sortSpec.groups[itA.groupIdx]
if (group.regexSpec && group.secondaryOrder && itA.matchGroup === itB.matchGroup) {
return Sorters[group.secondaryOrder](itA, itB)
} else {
return Sorters[group.order](itA, itB)
}
} else {
return itA.groupIdx - itB.groupIdx;
}
}
const isFolder = (entry: TFile | TFolder) => {
// The plain obvious 'entry instanceof TFolder' doesn't work inside Jest unit tests, hence a workaround below
return !!((entry as any).isRoot);
}
export const determineSortingGroup = function (entry: TFile | TFolder, spec: CustomSortSpec): FolderItemForSorting {
let groupIdx: number = 0
let determined: boolean = false
let matchedGroup: string
const aFolder: boolean = isFolder(entry)
const aFile: boolean = !aFolder
const entryAsTFile: TFile = entry as TFile
const basename: string = aFolder ? entry.name : entryAsTFile.basename
for (groupIdx = 0; groupIdx < spec.groups.length; groupIdx++) {
matchedGroup = null
const group: CustomSortGroup = spec.groups[groupIdx];
if (group.foldersOnly && aFile) continue;
if (group.filesOnly && aFolder) continue;
const nameForMatching: string = group.matchFilenameWithExt ? entry.name : basename;
switch (group.type) {
case CustomSortGroupType.ExactPrefix:
if (group.exactPrefix) {
if (nameForMatching.startsWith(group.exactPrefix)) {
determined = true;
}
} else { // regexp is involved
const match: RegExpMatchArray = group.regexSpec.regex.exec(nameForMatching);
if (match) {
determined = true
matchedGroup = group.regexSpec.normalizerFn(match[1]);
}
}
break;
case CustomSortGroupType.ExactSuffix:
if (group.exactSuffix) {
if (nameForMatching.endsWith(group.exactSuffix)) {
determined = true;
}
} else { // regexp is involved
const match: RegExpMatchArray = group.regexSpec.regex.exec(nameForMatching);
if (match) {
determined = true
matchedGroup = group.regexSpec.normalizerFn(match[1]);
}
}
break;
case CustomSortGroupType.ExactHeadAndTail:
if (group.exactPrefix && group.exactSuffix) {
if (nameForMatching.length >= group.exactPrefix.length + group.exactSuffix.length) {
if (nameForMatching.startsWith(group.exactPrefix) && nameForMatching.endsWith(group.exactSuffix)) {
determined = true;
}
}
} else { // regexp is involved as the prefix or as the suffix
if ((group.exactPrefix && nameForMatching.startsWith(group.exactPrefix)) ||
(group.exactSuffix && nameForMatching.endsWith(group.exactSuffix))) {
const match: RegExpMatchArray = group.regexSpec.regex.exec(nameForMatching);
if (match) {
const fullMatch: string = match[0]
matchedGroup = group.regexSpec.normalizerFn(match[1]);
// check for overlapping of prefix and suffix match (not allowed)
if ((fullMatch.length + (group.exactPrefix?.length ?? 0) + (group.exactSuffix?.length ?? 0)) <= nameForMatching.length) {
determined = true
} else {
matchedGroup = null // if it falls into Outsiders group, let it use title to sort
}
}
}
}
break;
case CustomSortGroupType.ExactName:
if (group.exactText) {
if (nameForMatching === group.exactText) {
determined = true;
}
} else { // regexp is involved
const match: RegExpMatchArray = group.regexSpec.regex.exec(nameForMatching);
if (match) {
determined = true
matchedGroup = group.regexSpec.normalizerFn(match[1]);
}
}
break;
case CustomSortGroupType.MatchAll:
determined = true;
break;
}
if (determined) {
break;
}
}
// the final groupIdx for undetermined folder entry is either the last+1 groupIdx or idx of explicitly defined outsiders group
let determinedGroupIdx = groupIdx;
if (!determined) {
// Automatically assign the index to outsiders group, if relevant was configured
if (isDefined(spec.outsidersFilesGroupIdx) && aFile) {
determinedGroupIdx = spec.outsidersFilesGroupIdx;
} else if (isDefined(spec.outsidersFoldersGroupIdx) && aFolder) {
determinedGroupIdx = spec.outsidersFoldersGroupIdx;
} else if (isDefined(spec.outsidersGroupIdx)) {
determinedGroupIdx = spec.outsidersGroupIdx;
}
}
return {
// idx of the matched group or idx of Outsiders group or the largest index (= groups count+1)
groupIdx: determinedGroupIdx,
sortString: matchedGroup ? (matchedGroup + '//' + entry.name) : entry.name,
matchGroup: matchedGroup ?? undefined,
isFolder: aFolder,
path: entry.path,
ctime: aFile ? entryAsTFile.stat.ctime : 0,
mtime: aFile ? entryAsTFile.stat.mtime : 0
}
}
export const folderSort = function (sortingSpec: CustomSortSpec, order: string[]) {
let fileExplorer = this.fileExplorer
const thisFolderPath: string = this.file.path;
const folderItems: Array<FolderItemForSorting> = (sortingSpec.itemsToHide ?
this.file.children.filter((entry: TFile | TFolder) => {
return !sortingSpec.itemsToHide.has(entry.name)
})
:
this.file.children)
.map((entry: TFile | TFolder) =>
determineSortingGroup(entry, sortingSpec)
)
folderItems.sort(function (itA: FolderItemForSorting, itB: FolderItemForSorting) {
return compareTwoItems(itA, itB, sortingSpec);
});
const items = folderItems
.map((item: FolderItemForSorting) => fileExplorer.fileItems[item.path])
if (requireApiVersion && requireApiVersion("0.15.0")) {
this.vChildren.setChildren(items);
} else {
this.children = items;
}
};

35
src/custom-sort/icons.ts Normal file
View File

@ -0,0 +1,35 @@
import {addIcon} from "obsidian";
export const ICON_SORT_ENABLED_ACTIVE: string = 'custom-sort-icon-active'
export const ICON_SORT_SUSPENDED: string = 'custom-sort-icon-suspended'
export const ICON_SORT_ENABLED_NOT_APPLIED: string = 'custom-sort-icon-enabled-not-applied'
export const ICON_SORT_SUSPENDED_SYNTAX_ERROR: string = 'custom-sort-icon-syntax-error'
export function addIcons() {
addIcon(ICON_SORT_ENABLED_ACTIVE,
`<path d="M 93.54751 9.983795 L 79.21469 31.556912 C 78.297815 32.93695 76.4358 33.31242 75.05576 32.395544 C 74.72319 32.174593 74.43808 31.88948 74.21713 31.556912 L 59.8843 9.983795 C 58.96743 8.603756 59.3429 6.74174 60.722935 5.824865 C 61.21491 5.4980047 61.792426 5.3236456 62.383084 5.3236456 L 91.04873 5.3236456 C 92.70559 5.3236456 94.04873 6.666791 94.04873 8.323646 C 94.04873 8.914304 93.87437 9.49182 93.54751 9.983795 Z" fill="currentColor"/>
<path d="M 11.096126 32.678017 L 20.217128 18.949499 C 21.134003 17.56946 22.99602 17.193992 24.376058 18.110867 C 24.708624 18.331818 24.99374 18.616933 25.21469 18.949499 L 34.33569 32.678017 C 35.252567 34.058055 34.8771 35.92007 33.49706 36.836947 C 33.005085 37.163807 32.42757 37.338166 31.83691 37.338166 L 13.594907 37.338166 C 11.938053 37.338166 10.594907 35.99502 10.594907 34.338166 C 10.594907 33.747508 10.769266 33.16999 11.096126 32.678017 Z" fill="currentColor"/>
<path d="M 11.096126 55.71973 L 20.217128 41.991214 C 21.134003 40.611175 22.99602 40.235707 24.376058 41.15258 C 24.708624 41.373533 24.99374 41.65865 25.21469 41.991214 L 34.33569 55.71973 C 35.252567 57.09977 34.8771 58.96179 33.49706 59.87866 C 33.005085 60.20552 32.42757 60.37988 31.83691 60.37988 L 13.594907 60.37988 C 11.938053 60.37988 10.594907 59.036736 10.594907 57.37988 C 10.594907 56.78922 10.769266 56.21171 11.096126 55.71973 Z" fill="currentColor"/>
<path d="M 2.5382185 90.37054 L 20.217128 63.76105 C 21.134003 62.38101 22.99602 62.005545 24.376058 62.92242 C 24.708624 63.14337 24.99374 63.428486 25.21469 63.76105 L 42.8936 90.37054 C 43.810475 91.75058 43.435006 93.6126 42.05497 94.52947 C 41.562993 94.85633 40.985477 95.03069 40.39482 95.03069 L 5.0369993 95.03069 C 3.380145 95.03069 2.0369993 93.68755 2.0369993 92.03069 C 2.0369993 91.44004 2.2113584 90.86252 2.5382185 90.37054 Z" fill="currentColor"/>
<path d="M 88.33569 46.24901 L 79.21469 59.97753 C 78.297815 61.35757 76.4358 61.73304 75.05576 60.81616 C 74.72319 60.59521 74.43808 60.310096 74.21713 59.97753 L 65.09613 46.24901 C 64.17925 44.868973 64.55472 43.006957 65.93476 42.09008 C 66.42673 41.76322 67.00425 41.588863 67.59491 41.588863 L 85.83691 41.588863 C 87.49377 41.588863 88.83691 42.93201 88.83691 44.58886 C 88.83691 45.17952 88.66255 45.757036 88.33569 46.24901 Z" fill="currentColor"/>
<path d="M 88.33569 77.48964 L 79.21469 91.21816 C 78.297815 92.5982 76.4358 92.97366 75.05576 92.05679 C 74.72319 91.83584 74.43808 91.55072 74.21713 91.21816 L 65.09613 77.48964 C 64.17925 76.1096 64.55472 74.247585 65.93476 73.33071 C 66.42673 73.00385 67.00425 72.82949 67.59491 72.82949 L 85.83691 72.82949 C 87.49377 72.82949 88.83691 74.17264 88.83691 75.82949 C 88.83691 76.42015 88.66255 76.99766 88.33569 77.48964 Z" fill="currentColor"/>`
)
addIcon(ICON_SORT_SUSPENDED,
`<path d="M 93.54751 9.983795 L 79.21469 31.556912 C 78.297815 32.93695 76.4358 33.31242 75.05576 32.395544 C 74.72319 32.174593 74.43808 31.88948 74.21713 31.556912 L 59.8843 9.983795 C 58.96743 8.603756 59.3429 6.74174 60.722935 5.824865 C 61.21491 5.4980047 61.792426 5.3236456 62.383084 5.3236456 L 91.04873 5.3236456 C 92.70559 5.3236456 94.04873 6.666791 94.04873 8.323646 C 94.04873 8.914304 93.87437 9.49182 93.54751 9.983795 Z" stroke="currentColor" stroke-width="2" fill="none"/>
<path d="M 2.5382185 90.37054 L 20.217128 63.76105 C 21.134003 62.38101 22.99602 62.005545 24.376058 62.92242 C 24.708624 63.14337 24.99374 63.428486 25.21469 63.76105 L 42.8936 90.37054 C 43.810475 91.75058 43.435006 93.6126 42.05497 94.52947 C 41.562993 94.85633 40.985477 95.03069 40.39482 95.03069 L 5.0369993 95.03069 C 3.380145 95.03069 2.0369993 93.68755 2.0369993 92.03069 C 2.0369993 91.44004 2.2113584 90.86252 2.5382185 90.37054 Z" stroke="currentColor" stroke-width="2" fill="none"/>`
)
addIcon(ICON_SORT_SUSPENDED_SYNTAX_ERROR,
`<path d="M 93.54751 9.983795 L 79.21469 31.556912 C 78.297815 32.93695 76.4358 33.31242 75.05576 32.395544 C 74.72319 32.174593 74.43808 31.88948 74.21713 31.556912 L 59.8843 9.983795 C 58.96743 8.603756 59.3429 6.74174 60.722935 5.824865 C 61.21491 5.4980047 61.792426 5.3236456 62.383084 5.3236456 L 91.04873 5.3236456 C 92.70559 5.3236456 94.04873 6.666791 94.04873 8.323646 C 94.04873 8.914304 93.87437 9.49182 93.54751 9.983795 Z" fill="red"/>
<path d="M 11.096126 32.678017 L 20.217128 18.949499 C 21.134003 17.56946 22.99602 17.193992 24.376058 18.110867 C 24.708624 18.331818 24.99374 18.616933 25.21469 18.949499 L 34.33569 32.678017 C 35.252567 34.058055 34.8771 35.92007 33.49706 36.836947 C 33.005085 37.163807 32.42757 37.338166 31.83691 37.338166 L 13.594907 37.338166 C 11.938053 37.338166 10.594907 35.99502 10.594907 34.338166 C 10.594907 33.747508 10.769266 33.16999 11.096126 32.678017 Z" stroke="red" stroke-width="2" fill="none"/>
<path d="M 11.096126 55.71973 L 20.217128 41.991214 C 21.134003 40.611175 22.99602 40.235707 24.376058 41.15258 C 24.708624 41.373533 24.99374 41.65865 25.21469 41.991214 L 34.33569 55.71973 C 35.252567 57.09977 34.8771 58.96179 33.49706 59.87866 C 33.005085 60.20552 32.42757 60.37988 31.83691 60.37988 L 13.594907 60.37988 C 11.938053 60.37988 10.594907 59.036736 10.594907 57.37988 C 10.594907 56.78922 10.769266 56.21171 11.096126 55.71973 Z" stroke="red" stroke-width="2" fill="none"/>
<path d="M 2.5382185 90.37054 L 20.217128 63.76105 C 21.134003 62.38101 22.99602 62.005545 24.376058 62.92242 C 24.708624 63.14337 24.99374 63.428486 25.21469 63.76105 L 42.8936 90.37054 C 43.810475 91.75058 43.435006 93.6126 42.05497 94.52947 C 41.562993 94.85633 40.985477 95.03069 40.39482 95.03069 L 5.0369993 95.03069 C 3.380145 95.03069 2.0369993 93.68755 2.0369993 92.03069 C 2.0369993 91.44004 2.2113584 90.86252 2.5382185 90.37054 Z" stroke="red" stroke-width="2" fill="none"/>
<path d="M 88.33569 46.24901 L 79.21469 59.97753 C 78.297815 61.35757 76.4358 61.73304 75.05576 60.81616 C 74.72319 60.59521 74.43808 60.310096 74.21713 59.97753 L 65.09613 46.24901 C 64.17925 44.868973 64.55472 43.006957 65.93476 42.09008 C 66.42673 41.76322 67.00425 41.588863 67.59491 41.588863 L 85.83691 41.588863 C 87.49377 41.588863 88.83691 42.93201 88.83691 44.58886 C 88.83691 45.17952 88.66255 45.757036 88.33569 46.24901 Z" fill="red"/>
<path d="M 88.33569 77.48964 L 79.21469 91.21816 C 78.297815 92.5982 76.4358 92.97366 75.05576 92.05679 C 74.72319 91.83584 74.43808 91.55072 74.21713 91.21816 L 65.09613 77.48964 C 64.17925 76.1096 64.55472 74.247585 65.93476 73.33071 C 66.42673 73.00385 67.00425 72.82949 67.59491 72.82949 L 85.83691 72.82949 C 87.49377 72.82949 88.83691 74.17264 88.83691 75.82949 C 88.83691 76.42015 88.66255 76.99766 88.33569 77.48964 Z" fill="red"/>`
)
addIcon(ICON_SORT_ENABLED_NOT_APPLIED,
`<path d="M 93.54751 9.983795 L 79.21469 31.556912 C 78.297815 32.93695 76.4358 33.31242 75.05576 32.395544 C 74.72319 32.174593 74.43808 31.88948 74.21713 31.556912 L 59.8843 9.983795 C 58.96743 8.603756 59.3429 6.74174 60.722935 5.824865 C 61.21491 5.4980047 61.792426 5.3236456 62.383084 5.3236456 L 91.04873 5.3236456 C 92.70559 5.3236456 94.04873 6.666791 94.04873 8.323646 C 94.04873 8.914304 93.87437 9.49182 93.54751 9.983795 Z" stroke="orange" stroke-width="2" fill="none"/>
<path d="M 11.096126 55.71973 L 20.217128 41.991214 C 21.134003 40.611175 22.99602 40.235707 24.376058 41.15258 C 24.708624 41.373533 24.99374 41.65865 25.21469 41.991214 L 34.33569 55.71973 C 35.252567 57.09977 34.8771 58.96179 33.49706 59.87866 C 33.005085 60.20552 32.42757 60.37988 31.83691 60.37988 L 13.594907 60.37988 C 11.938053 60.37988 10.594907 59.036736 10.594907 57.37988 C 10.594907 56.78922 10.769266 56.21171 11.096126 55.71973 Z" stroke="orange" stroke-width="2" fill="none"/>
<path d="M 2.5382185 90.37054 L 20.217128 63.76105 C 21.134003 62.38101 22.99602 62.005545 24.376058 62.92242 C 24.708624 63.14337 24.99374 63.428486 25.21469 63.76105 L 42.8936 90.37054 C 43.810475 91.75058 43.435006 93.6126 42.05497 94.52947 C 41.562993 94.85633 40.985477 95.03069 40.39482 95.03069 L 5.0369993 95.03069 C 3.380145 95.03069 2.0369993 93.68755 2.0369993 92.03069 C 2.0369993 91.44004 2.2113584 90.86252 2.5382185 90.37054 Z" stroke="orange" stroke-width="2" fill="none"/>
<path d="M 88.33569 46.24901 L 79.21469 59.97753 C 78.297815 61.35757 76.4358 61.73304 75.05576 60.81616 C 74.72319 60.59521 74.43808 60.310096 74.21713 59.97753 L 65.09613 46.24901 C 64.17925 44.868973 64.55472 43.006957 65.93476 42.09008 C 66.42673 41.76322 67.00425 41.588863 67.59491 41.588863 L 85.83691 41.588863 C 87.49377 41.588863 88.83691 42.93201 88.83691 44.58886 C 88.83691 45.17952 88.66255 45.757036 88.33569 46.24901 Z" stroke="orange" stroke-width="2" fill="none"/>`
)
}

View File

@ -0,0 +1,323 @@
import {
getNormalizedNumber,
getNormalizedRomanNumber,
prependWithZeros,
romanToIntStr,
NumberRegex,
CompoundNumberDotRegex,
CompoundNumberDashRegex,
RomanNumberRegex,
CompoundRomanNumberDotRegex,
CompoundRomanNumberDashRegex
} from "./matchers";
describe('Plain numbers regexp', () => {
it.each([
['', null],
[' ', null],
[' 1', '1'], // leading spaces are swallowed
['-1', null],
['1', '1'],
['1a', '1'],
['3580', '3580'],
['9', '9'],
['7328964783268794325496783', '7328964783268794325496783']
])('%s => %s', (s: string, out: string | null) => {
const match: RegExpMatchArray | null = s.match(NumberRegex)
if (out) {
expect(match).not.toBeNull()
expect(match[1]).toBe(out)
} else {
expect(match).toBeNull()
}
})
})
describe('Plain compound numbers regexp (dot)', () => {
it.each([
['', null],
[' ', null],
[' 1', '1'], // leading spaces are swallowed
['-1', null],
['1', '1'],
['1a', '1'],
['3580', '3580'],
['9', '9'],
['7328964783268794325496783', '7328964783268794325496783'],
['9.', '9'],
['5.2', '5.2'],
['5.-2', '5'],
['12. 34', '12'],
['.12 .34', null],
['56.78.000.1', '56.78.000.1'],
['56.78.000.1 ', '56.78.000.1'],
['56.78.000.1abc', '56.78.000.1'],
['56.78.-.1abc', '56.78'],
['56.78-.1abc', '56.78'],
])('%s => %s', (s: string, out: string | null) => {
const match: RegExpMatchArray | null = s.match(CompoundNumberDotRegex)
if (out) {
expect(match).not.toBeNull()
expect(match[1]).toBe(out)
} else {
expect(match).toBeNull()
}
})
})
describe('Plain compound numbers regexp (dash)', () => {
it.each([
['', null],
[' ', null],
[' 1', '1'], // leading spaces are swallowed
['.1', null],
['1', '1'],
['1a', '1'],
['3580', '3580'],
['9', '9'],
['7328964783268794325496783', '7328964783268794325496783'],
['9-', '9'],
['5-2', '5-2'],
['5-.2', '5'],
['12- 34', '12'],
['-12 -34', null],
['56-78-000-1', '56-78-000-1'],
['56-78-000-1 ', '56-78-000-1'],
['56-78-000-1abc', '56-78-000-1'],
['56-78-.-1abc', '56-78'],
['56-78.-1abc', '56-78'],
])('%s => %s', (s: string, out: string | null) => {
const match: RegExpMatchArray | null = s.match(CompoundNumberDashRegex)
if (out) {
expect(match).not.toBeNull()
expect(match[1]).toBe(out)
} else {
expect(match).toBeNull()
}
})
})
describe('Plain Roman numbers regexp', () => {
it.each([
['', null],
[' ', null],
[' i', 'i'], // leading spaces are swallowed
['-i', null],
['i', 'i'],
['ia', 'i'],
['mdclxv', 'mdclxv'],
['iiiii', 'iiiii'],
['viviviv794325496783', 'viviviv']
])('%s => %s', (s: string, out: string | null) => {
const match: RegExpMatchArray | null = s.match(RomanNumberRegex)
if (out) {
expect(match).not.toBeNull()
expect(match[1]).toBe(out)
} else {
expect(match).toBeNull()
}
})
})
describe('Roman compound numbers regexp (dot)', () => {
it.each([
['', null],
[' ', null],
[' I', 'I'], // leading spaces are swallowed
['.I', null],
['v', 'v'],
['va', 'v'],
['vava ', 'v'],
['ix', 'ix'],
['mclv96783', 'mclv'],
['C.', 'C'],
['v.ii', 'v.ii'],
['xx.-x', 'xx'],
['xx.x', 'xx.x'],
['iv- v', 'iv'],
['.12 .34', null],
['vv.mc.lx.i', 'vv.mc.lx.i'],
['mcm.m.mmm.l ', 'mcm.m.mmm.l'],
['iv.I.DDD.Iabc', 'iv.I.DDD.I'],
['xiii.viii.-.1abc', 'xiii.viii'],
['xvx.d-.iabc', 'xvx.d'],
['xvx.d..iabc', 'xvx.d'],
])('%s => %s', (s: string, out: string | null) => {
const match: RegExpMatchArray | null = s.match(CompoundRomanNumberDotRegex)
if (out) {
expect(match).not.toBeNull()
expect(match[1]).toBe(out)
} else {
expect(match).toBeNull()
}
})
})
describe('Roman compound numbers regexp (dash)', () => {
it.each([
['', null],
[' ', null],
[' I', 'I'], // leading spaces are swallowed
['.I', null],
['v', 'v'],
['va', 'v'],
['vava ', 'v'],
['ix', 'ix'],
['mclv96783', 'mclv'],
['C-', 'C'],
['v-ii', 'v-ii'],
['xx.-x', 'xx'],
['xx.x', 'xx'],
['iv- v', 'iv'],
['-12 -34', null],
['vv-mc-lx-i', 'vv-mc-lx-i'],
['mcm-m-mmm-l ', 'mcm-m-mmm-l'],
['iv-I-DDD-Iabc', 'iv-I-DDD-I'],
['xiii-viii-.-1abc', 'xiii-viii'],
['xvx-d.-iabc', 'xvx-d'],
['xvx-d--iabc', 'xvx-d']
])('%s => %s', (s: string, out: string | null) => {
const match: RegExpMatchArray | null = s.match(CompoundRomanNumberDashRegex)
if (out) {
expect(match).not.toBeNull()
expect(match[1]).toBe(out)
} else {
expect(match).toBeNull()
}
})
})
describe('prependWithZeros', () => {
const Length = 5;
it('should add leading zeros to empty string', () => {
const s = prependWithZeros('', Length);
expect(s).toBe('00000')
})
it('should add leading zeros to shorter string', () => {
const s = prependWithZeros('12', Length);
expect(s).toBe('00012')
})
it('should not add leading zeros to sufficient string', () => {
const s = prependWithZeros('12345', Length);
expect(s).toBe('12345')
})
it('should not add leading zeros to longer string', () => {
const s = prependWithZeros('12345678', Length);
expect(s).toBe('12345678')
})
})
describe('getNormalizedNumber', () => {
const LEN = 5;
const params = [
['', '00000000//', null],
['1', '00000001//', undefined],
['000', '00000000//', ' '],
['000', '00000000//', ''],
['1234567890123', '1234567890123//', ''],
['1234567890123456789012345', '1234567890123456789012345//', ''], // No conversion to number should happen
// Compound numbers - dot
['1', '00000001//', '.'],
['1 .1', '0000001 |00000001//', '.'], // Invalid case, Regexp on matcher in the caller should guard against this
['1. 1', '00000001|000000 1//', '.'], // Invalid case, Regexp on matcher in the caller should guard against this
['1..1', '00000001|00000001//', '.'], // Edge case, consecutive separators treated as one
['1.2', '00000001|00000002//', '.'],
['1.2.', '00000001|00000002//', '.'], // Edge case, trailing dangling separator is swallowed
['1.2.3', '00000001|00000002|00000003//', '.'],
['44.2314325423432.4', '00000044|2314325423432|00000004//', '.'],
['0.0.0.0.', '00000000|00000000|00000000|00000000//', '.'], // Edge case, trailing dangling separator is swallowed
['0.0.0.0', '00000000|00000000|00000000|00000000//', '.'],
['0.0-0.0', '00000000|000000-0|00000000//', '.'], // Invalid case, Regexp on matcher in the caller should guard against this
// Compound numbers - dash
['1', '00000001//', '-'],
['1 -1', '0000001 |00000001//', '-'], // Invalid case, Regexp on matcher in the caller should guard against this
['1- 1', '00000001|000000 1//', '-'], // Invalid case, Regexp on matcher in the caller should guard against this
['1--1', '00000001|00000001//', '-'], // Edge case, consecutive separators treated as one
['1-2', '00000001|00000002//', '-'],
['1-2-', '00000001|00000002//', '-'], // Edge case, trailing dangling separator is swallowed
['1-2-3', '00000001|00000002|00000003//', '-'],
['44-2314325423432-4', '00044|2314325423432|00004//', '-5'],
['0-7-0-0-', '00000000|00000007|00000000|00000000//', '-'], // // Edge case, trailing dangling separator is swallowed
['0-0.3-0', '00000|000.3|00000//', '-5'],
];
it.each(params)('>%s< should become %s (sep >%s<)', (s: string, out: string, separator?: string) => {
if (separator === '-5') {
expect(getNormalizedNumber(s, '-', LEN)).toBe(out)
} else {
expect(getNormalizedNumber(s, separator)).toBe(out)
}
})
})
describe('romanToIntStr', () => {
const params = [
['', '0'],
['I', '1'],
['II', '2'],
['III', '3'],
['IIII', '4'],
['IIIII', '5'],
['iv', '4'],
['v', '5'],
['vi', '6'],
['vii', '7'],
['viii', '8'],
['iX', '9'],
['x', '10'],
['XI', '11'],
['L', '50'],
['C', '100'],
['d', '500'],
['M', '1000'],
// formally correct, unused
['iv', '4'],
['iiv', '5'],
['iiiv', '6'],
['iiiiv', '7'],
['iiiiiv', '8'],
['12345', '0'],
];
it.each(params)('>%s< should be %s', (s: string, out: string) => {
expect(romanToIntStr(s)).toBe(out)
})
})
describe('getNormalizedRomanNumber', () => {
const LEN = 5
const params = [
['', '00000//', null],
['1', '00000//', undefined],
['000', '00000//', ' '],
['000', '00000//', ''],
['1234567890123//', '00000//', ''],
['00123', '00000//', ''],
['1234567890123456789012345//', '00000//', ''], // No conversion to number should happen
// Compound numbers - dot
['i', '00001//', '.'],
['ii .ii', '00002|00002//', '.'], // Invalid case, Regexp on matcher in the caller should guard against this
['iv. vi', '00004|00006//', '.'], // Invalid case, Regexp on matcher in the caller should guard against this
['X..C', '00010|00100//', '.'], // Edge case, consecutive separators treated as one
['C.M', '00100|01000//', '.'],
['I.II.', '00001|00002//', '.'], // Edge case, trailing dangling separator is swallowed
['i.i.iv', '00001|00001|00004//', '.'],
['MCMLXX.2314325423432.CM', '01970|00000|00900//', '.'],
['0.0.0.0.', '00000|00000|00000|00000//', '.'], // Edge case, trailing dangling separator is swallowed
['L.l.M.iiii', '00050|00050|01000|00004//', '.'],
['v.v-v.v', '00005|00010|00005//', '.'], // Invalid case, Regexp on matcher in the caller should guard against this
// Compound numbers - dash
['i', '00001//', '-'],
['ii -ii', '00002|00002//', '-'], // Invalid case, Regexp on matcher in the caller should guard against this
['iv- vi', '00004|00006//', '-'], // Invalid case, Regexp on matcher in the caller should guard against this
['X--C', '00010|00100//', '-'], // Edge case, consecutive separators treated as one
['C-M', '00100|01000//', '-'],
['I-II-', '00001|00002//', '-'], // Edge case, trailing dangling separator is swallowed
['i-i-iv', '00001|00001|00004//', '-'],
['MCMLXX-2314325423432-CM', '01970|00000|00900//', '-'],
['0-0-0-0-', '00000|00000|00000|00000//', '-'], // Edge case, trailing dangling separator is swallowed
['L-l-M-iiii', '00050|00050|01000|00004//', '-'],
['v-v.v-v', '00005|00010|00005//', '-'], // Invalid case, Regexp on matcher in the caller should guard against this
];
it.each(params)('>%s< should become %s (sep >%s<)', (s: string, out: string, separator?: string) => {
expect(getNormalizedRomanNumber(s, separator, LEN)).toBe(out)
})
})

View File

@ -0,0 +1,94 @@
export const RomanNumberRegex: RegExp = /^ *([MDCLXVI]+)/i; // Roman number
export const RomanNumberRegexStr: string = ' *([MDCLXVI]+)';
export const CompoundRomanNumberDotRegex: RegExp = /^ *([MDCLXVI]+(?:\.[MDCLXVI]+)*)/i; // Compound Roman number with dot as separator
export const CompoundRomanNumberDotRegexStr: string = ' *([MDCLXVI]+(?:\\.[MDCLXVI]+)*)';
export const CompoundRomanNumberDashRegex: RegExp = /^ *([MDCLXVI]+(?:-[MDCLXVI]+)*)/i; // Compound Roman number with dash as separator
export const CompoundRomanNumberDashRegexStr: string = ' *([MDCLXVI]+(?:-[MDCLXVI]+)*)';
export const NumberRegex: RegExp = /^ *(\d+)/; // Plain number
export const NumberRegexStr: string = ' *(\\d+)';
export const CompoundNumberDotRegex: RegExp = /^ *(\d+(?:\.\d+)*)/; // Compound number with dot as separator
export const CompoundNumberDotRegexStr: string = ' *(\\d+(?:\\.\\d+)*)';
export const CompoundNumberDashRegex: RegExp = /^ *(\d+(?:-\d+)*)/; // Compound number with dash as separator
export const CompoundNumberDashRegexStr: string = ' *(\\d+(?:-\\d+)*)';
export const DOT_SEPARATOR = '.'
export const DASH_SEPARATOR = '-'
const SLASH_SEPARATOR = '/' // ASCII 47
const PIPE_SEPARATOR = '|' // ASCII 124
export const DEFAULT_NORMALIZATION_PLACES = 8; // Fixed width of a normalized number (with leading zeros)
export function prependWithZeros(s: string, minLength: number) {
if (s.length < minLength) {
const delta: number = minLength - s.length;
return '000000000000000000000000000'.substr(0, delta) + s;
} else {
return s;
}
}
// Accepts trimmed number (compound or not) as parameter. No internal verification!!!
export function getNormalizedNumber(s: string = '', separator?: string, places?: number): string | null {
// The strange PIPE_SEPARATOR and trailing // are to allow correct sorting of compound numbers:
// 1-1 should go before 1-1-1 and 1 should go yet earlier.
// That's why the conversion to:
// 1//
// 1|1//
// 1|1|1//
// guarantees correct order (/ = ASCII 47, | = ASCII 124)
if (separator) {
const components: Array<string> = s.split(separator).filter(s => s)
return `${components.map((c) => prependWithZeros(c, places ?? DEFAULT_NORMALIZATION_PLACES)).join(PIPE_SEPARATOR)}//`
} else {
return `${prependWithZeros(s, places ?? DEFAULT_NORMALIZATION_PLACES)}//`
}
}
function RomanCharToInt(c: string): number {
const Roman: string = '0iIvVxXlLcCdDmM';
const RomanValues: Array<number> = [0, 1, 1, 5, 5, 10, 10, 50, 50, 100, 100, 500, 500, 1000, 1000];
if (c) {
const idx: number = Roman.indexOf(c[0])
return idx > 0 ? RomanValues[idx] : 0;
} else {
return 0;
}
}
export function romanToIntStr(rs: string): string {
if (rs == null) return '0';
let num = RomanCharToInt(rs.charAt(0));
let prev, curr;
for (let i = 1; i < rs.length; i++) {
curr = RomanCharToInt(rs.charAt(i));
prev = RomanCharToInt(rs.charAt(i - 1));
if (curr <= prev) {
num += curr;
} else {
num = num - prev * 2 + curr;
}
}
return `${num}`;
}
export function getNormalizedRomanNumber(s: string, separator?: string, places?: number): string | null {
// The strange PIPE_SEPARATOR and trailing // are to allow correct sorting of compound numbers:
// 1-1 should go before 1-1-1 and 1 should go yet earlier.
// That's why the conversion to:
// 1//
// 1|1//
// 1|1|1//
// guarantees correct order (/ = ASCII 47, | = ASCII 124)
if (separator) {
const components: Array<string> = s.split(separator).filter(s => s)
return `${components.map((c) => prependWithZeros(romanToIntStr(c), places ?? DEFAULT_NORMALIZATION_PLACES)).join(PIPE_SEPARATOR)}//`
} else {
return `${prependWithZeros(romanToIntStr(s), places ?? DEFAULT_NORMALIZATION_PLACES)}//`
}
}

View File

@ -0,0 +1,904 @@
import {
CompoundDashNumberNormalizerFn, CompoundDashRomanNumberNormalizerFn,
CompoundDotNumberNormalizerFn,
convertPlainStringWithNumericSortingSymbolToRegex,
detectNumericSortingSymbols,
escapeRegexUnsafeCharacters,
extractNumericSortingSymbol,
hasMoreThanOneNumericSortingSymbol,
NumberNormalizerFn,
RegexpUsedAs,
RomanNumberNormalizerFn,
SortingSpecProcessor
} from "./sorting-spec-processor"
import {CustomSortGroupType, CustomSortOrder, CustomSortSpec} from "./custom-sort-types";
const txtInputExampleA: string = `
order-asc: a-z
/ ...
/: ...
target-folder: tricky folder
/
/:
:::: Conceptual model
/: Entities
%
target-folder: /
/: Con...
/
> modified
/:
< modified
/: Ref...
/: Att...ch
sort...spec
/:. sortspec.md
target-folder: Sandbox
> modified
/: adfsasda
/ sdsadasdsa
> a-z
/folders fdsfdsfdsfs
> created
target-folder: Abcd efgh ijk
> a-z
Plain text spec bla bla bla (matches files and folders)...
/: files only matching
> a-z
/ folders only matching
< a-z
some-file (or folder)
/:. sort....md
Trailer item
:::: References
:::: Same rules as for References
Recently...
`;
const txtInputExampleAVerbose: string = `
order-asc: a-z
/folders ...
/:files ...
target-folder: tricky folder
/folders
/:files
:::: Conceptual model
/:files Entities
%
target-folder: /
/:files Con...
/folders
> modified
/:files
< modified
/:files Ref...
/:files Att...ch
% sort...spec
/:files. sortspec.md
target-folder: Sandbox
> modified
/:files adfsasda
/folders sdsadasdsa
> a-z
/ fdsfdsfdsfs
> created
target-folder: Abcd efgh ijk
> a-z
Plain text spec bla bla bla (matches files and folders)...
/:files files only matching
> a-z
/folders folders only matching
< a-z
% some-file (or folder)
/:files. sort....md
% Trailer item
target-folder: References
target-folder: Same rules as for References
% Recently...
`;
const expectedSortSpecsExampleA: { [key: string]: CustomSortSpec } = {
"mock-folder": {
defaultOrder: CustomSortOrder.alphabetical,
groups: [{
foldersOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.MatchAll
}, {
filesOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.MatchAll
}, {
type: CustomSortGroupType.Outsiders,
order: CustomSortOrder.alphabetical,
}],
targetFoldersPaths: ['mock-folder'],
outsidersGroupIdx: 2
},
"tricky folder": {
groups: [{
foldersOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}, {
filesOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersFilesGroupIdx: 1,
outsidersFoldersGroupIdx: 0,
targetFoldersPaths: ['tricky folder']
},
"Conceptual model": {
groups: [{
exactText: "Entities",
filesOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactName
}, {
type: CustomSortGroupType.Outsiders,
order: CustomSortOrder.alphabetical,
}],
outsidersGroupIdx: 1,
targetFoldersPaths: ['Conceptual model']
},
"/": {
groups: [{
exactPrefix: "Con",
filesOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactPrefix
}, {
foldersOnly: true,
order: CustomSortOrder.byModifiedTimeReverse,
type: CustomSortGroupType.Outsiders
}, {
filesOnly: true,
order: CustomSortOrder.byModifiedTime,
type: CustomSortGroupType.Outsiders
}, {
exactPrefix: "Ref",
filesOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactPrefix
}, {
exactPrefix: "Att",
exactSuffix: "ch",
filesOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactHeadAndTail
}, {
exactPrefix: "sort",
exactSuffix: "spec",
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactHeadAndTail
}, {
exactText: "sortspec.md",
filesOnly: true,
matchFilenameWithExt: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactName
}],
outsidersFilesGroupIdx: 2,
outsidersFoldersGroupIdx: 1,
targetFoldersPaths: ['/']
},
"Sandbox": {
defaultOrder: CustomSortOrder.byModifiedTimeReverse,
groups: [{
exactText: "adfsasda",
filesOnly: true,
order: CustomSortOrder.byModifiedTimeReverse,
type: CustomSortGroupType.ExactName
}, {
exactText: "sdsadasdsa",
foldersOnly: true,
order: CustomSortOrder.alphabeticalReverse,
type: CustomSortGroupType.ExactName
}, {
exactText: "fdsfdsfdsfs",
foldersOnly: true,
order: CustomSortOrder.byCreatedTimeReverse,
type: CustomSortGroupType.ExactName
}, {
order: CustomSortOrder.byModifiedTimeReverse,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 3,
targetFoldersPaths: ['Sandbox']
},
"Abcd efgh ijk": {
defaultOrder: CustomSortOrder.alphabeticalReverse,
groups: [{
exactPrefix: "Plain text spec bla bla bla (matches files and folders)",
order: CustomSortOrder.alphabeticalReverse,
type: CustomSortGroupType.ExactPrefix
}, {
exactText: "files only matching",
filesOnly: true,
order: CustomSortOrder.alphabeticalReverse,
type: CustomSortGroupType.ExactName
}, {
exactText: "folders only matching",
foldersOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactName
}, {
exactText: "some-file (or folder)",
order: CustomSortOrder.alphabeticalReverse,
type: CustomSortGroupType.ExactName
}, {
exactPrefix: "sort",
exactSuffix: ".md",
filesOnly: true,
matchFilenameWithExt: true,
order: CustomSortOrder.alphabeticalReverse,
type: CustomSortGroupType.ExactHeadAndTail
}, {
exactText: "Trailer item",
order: CustomSortOrder.alphabeticalReverse,
type: CustomSortGroupType.ExactName
}, {
order: CustomSortOrder.alphabeticalReverse,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 6,
targetFoldersPaths: ['Abcd efgh ijk']
},
"References": {
groups: [{
exactPrefix: "Recently",
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactPrefix
}, {
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 1,
targetFoldersPaths: ['References', 'Same rules as for References']
},
"Same rules as for References": {
groups: [{
exactPrefix: "Recently",
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactPrefix
}, {
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 1,
targetFoldersPaths: ['References', 'Same rules as for References']
}
}
const expectedSortSpecsExampleNumericSortingSymbols: { [key: string]: CustomSortSpec } = {
"mock-folder": {
groups: [{
foldersOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactPrefix,
regexSpec: {
regex: /^Chapter *(\d+(?:\.\d+)*) /i,
normalizerFn: CompoundDotNumberNormalizerFn
}
}, {
filesOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactSuffix,
regexSpec: {
regex: /section *([MDCLXVI]+(?:-[MDCLXVI]+)*)\.$/i,
normalizerFn: CompoundDashRomanNumberNormalizerFn
}
}, {
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactName,
regexSpec: {
regex: /^Appendix *(\d+(?:-\d+)*) \(attachments\)$/i,
normalizerFn: CompoundDashNumberNormalizerFn
}
}, {
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactHeadAndTail,
exactSuffix: ' works?',
regexSpec: {
regex: /^Plain syntax *([MDCLXVI]+) /i,
normalizerFn: RomanNumberNormalizerFn
}
}, {
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactHeadAndTail,
exactPrefix: 'And this kind of',
regexSpec: {
regex: / *(\d+)plain syntax\?\?\?$/i,
normalizerFn: NumberNormalizerFn
}
}, {
type: CustomSortGroupType.Outsiders,
order: CustomSortOrder.alphabetical,
}],
targetFoldersPaths: ['mock-folder'],
outsidersGroupIdx: 5
}
}
const txtInputExampleNumericSortingSymbols: string = `
/folders Chapter \\.d+ ...
/:files ...section \\-r+.
% Appendix \\-d+ (attachments)
Plain syntax\\R+ ... works?
And this kind of... \\D+plain syntax???
`
describe('SortingSpecProcessor', () => {
let processor: SortingSpecProcessor;
beforeEach(() => {
processor = new SortingSpecProcessor();
});
it('should generate correct SortSpecs (complex example A)', () => {
const inputTxtArr: Array<string> = txtInputExampleA.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toEqual(expectedSortSpecsExampleA)
})
it('should generate correct SortSpecs (complex example A verbose)', () => {
const inputTxtArr: Array<string> = txtInputExampleAVerbose.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toEqual(expectedSortSpecsExampleA)
})
it('should generate correct SortSpecs (example with numerical sorting symbols)', () => {
const inputTxtArr: Array<string> = txtInputExampleNumericSortingSymbols.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toEqual(expectedSortSpecsExampleNumericSortingSymbols)
})
})
const txtInputNotDuplicatedSortSpec: string = `
target-folder: AAA
> A-Z
target-folder: BBB
% Whatever ...
`
const expectedSortSpecsNotDuplicatedSortSpec: { [key: string]: CustomSortSpec } = {
"AAA": {
defaultOrder: CustomSortOrder.alphabeticalReverse,
groups: [{
order: CustomSortOrder.alphabeticalReverse,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 0,
targetFoldersPaths: ['AAA']
},
"BBB": {
groups: [{
exactPrefix: "Whatever ",
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactPrefix
}, {
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 1,
targetFoldersPaths: ['BBB']
}
}
describe('SortingSpecProcessor', () => {
let processor: SortingSpecProcessor;
beforeEach(() => {
processor = new SortingSpecProcessor();
});
it('should not duplicate spec if former target-folder had some attribute specified', () => {
const inputTxtArr: Array<string> = txtInputNotDuplicatedSortSpec.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toEqual(expectedSortSpecsNotDuplicatedSortSpec)
})
})
const txtInputItemsToHideWithDupsSortSpec: string = `
target-folder: AAA
/--hide: SomeFileToHide.md
--% SomeFileToHide.md
--% SomeFolderToHide
/--hide: SomeFolderToHide
--% HideItRegardlessFileOrFolder
`
const expectedHiddenItemsSortSpec: { [key: string]: CustomSortSpec } = {
"AAA": {
groups: [{
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
itemsToHide: new Set(['SomeFileToHide.md', 'SomeFolderToHide', 'HideItRegardlessFileOrFolder']),
outsidersGroupIdx: 0,
targetFoldersPaths: ['AAA']
}
}
describe('SortingSpecProcessor bonus experimental feature', () => {
let processor: SortingSpecProcessor;
beforeEach(() => {
processor = new SortingSpecProcessor();
});
it('should correctly parse list of items to hide', () => {
const inputTxtArr: Array<string> = txtInputItemsToHideWithDupsSortSpec.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
// REMARK: be careful with examining Set object
expect(result).toEqual(expectedHiddenItemsSortSpec)
})
})
const txtInputItemsReadmeExample1Spec: string = `
// Less verbose versions of the spec,
// I know that at root level there will only folders matching
// the below names, so I can skip the /folders prefix
// I know there are no other root level folders and files
// so no need to specify order for them
target-folder: /
Projects
Areas
Responsibilities
Archive
/--hide: sortspec.md
`
const expectedReadmeExample1SortSpec: { [key: string]: CustomSortSpec } = {
"/": {
groups: [{
exactText: 'Projects',
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactName
}, {
exactText: 'Areas',
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactName
}, {
exactText: 'Responsibilities',
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactName
}, {
exactText: 'Archive',
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactName
}, {
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
itemsToHide: new Set(['sortspec.md']),
outsidersGroupIdx: 4,
targetFoldersPaths: ['/']
}
}
describe('SortingSpecProcessor - README.md examples', () => {
let processor: SortingSpecProcessor;
beforeEach(() => {
processor = new SortingSpecProcessor();
});
it('should correctly parse example 1', () => {
const inputTxtArr: Array<string> = txtInputItemsReadmeExample1Spec.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
// REMARK: be careful with examining Set object
expect(result).toEqual(expectedReadmeExample1SortSpec)
})
})
const txtInputEmptySpecOnlyTargetFolder: string = `
target-folder: BBB
`
const expectedSortSpecsOnlyTargetFolder: { [key: string]: CustomSortSpec } = {
"BBB": {
groups: [{
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 0,
targetFoldersPaths: ['BBB']
}
}
const txtInputTargetFolderAsDot: string = `
// Let me introduce a comment here ;-) to ensure it is ignored
target-folder: .
target-folder: CCC
// This comment should be ignored as well
`
const expectedSortSpecsTargetFolderAsDot: { [key: string]: CustomSortSpec } = {
'mock-folder': {
groups: [{
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 0,
targetFoldersPaths: ['mock-folder', 'CCC']
},
'CCC': {
groups: [{
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 0,
targetFoldersPaths: ['mock-folder', 'CCC']
}
}
describe('SortingSpecProcessor edge case', () => {
let processor: SortingSpecProcessor;
beforeEach(() => {
processor = new SortingSpecProcessor();
});
it('should not recognize empty spec containing only target folder', () => {
const inputTxtArr: Array<string> = txtInputEmptySpecOnlyTargetFolder.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toEqual(expectedSortSpecsOnlyTargetFolder)
})
it('should not recognize and correctly replace dot as the target folder', () => {
const inputTxtArr: Array<string> = txtInputTargetFolderAsDot.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toEqual(expectedSortSpecsTargetFolderAsDot)
})
})
const errorsLogger = jest.fn();
const ERR_PREFIX = 'Sorting specification problem:'
const ERR_SUFFIX = '---encountered in sorting spec in file mock-folder/custom-name-note.md'
const ERR_SUFFIX_IN_LINE = (n: number) => `---encountered in line ${n} of sorting spec in file mock-folder/custom-name-note.md`
const ERR_LINE_TXT = (txt: string) => `Content of problematic line: "${txt}"`
const txtInputErrorDupTargetFolder: string = `
target-folder: AAA
:::: AAA
> Modified
`
const txtInputErrorMissingSpaceTargetFolderAttr: string = `
target-folder:AAA
:::: AAA
> Modified
`
const txtInputErrorEmptyValueOfTargetFolderAttr: string = `
target-folder:
:::: AAA
> Modified
`
// There is a trailing space character in the first line
const txtInputErrorSpaceAsValueOfTargetFolderAttr: string = `
TARGET-FOLDER:
:::: AAA
> Modified
`
const txtInputErrorSpaceAsValueOfAscendingAttr: string = `
ORDER-ASC:
`
const txtInputErrorInvalidValueOfDescendingAttr: string = `
/Folders:
> definitely not correct
`
const txtInputErrorNoSpaceDescendingAttr: string = `
/files: Chapter ...
Order-DESC:MODIFIED
`
const txtInputErrorItemToHideWithNoValue: string = `
target-folder: AAA
--%
`
const txtInputErrorTooManyNumericSortSymbols: string = `
% Chapter\\R+ ... page\\d+
`
const txtInputEmptySpec: string = ``
describe('SortingSpecProcessor error detection and reporting', () => {
let processor: SortingSpecProcessor;
beforeEach(() => {
processor = new SortingSpecProcessor(errorsLogger);
errorsLogger.mockReset()
});
it('should recognize error: target folder name duplicated (edge case)', () => {
const inputTxtArr: Array<string> = txtInputErrorDupTargetFolder.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(1)
expect(errorsLogger).toHaveBeenCalledWith(`${ERR_PREFIX} 2:DuplicateSortSpecForSameFolder Duplicate sorting spec for folder AAA ${ERR_SUFFIX}`)
})
it('should recognize error: no space before target folder name ', () => {
const inputTxtArr: Array<string> = txtInputErrorMissingSpaceTargetFolderAttr.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1, `${ERR_PREFIX} 6:NoSpaceBetweenAttributeAndValue Space required after attribute name "target-folder:" ${ERR_SUFFIX_IN_LINE(2)}`)
expect(errorsLogger
).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('target-folder:AAA'))
})
it('should recognize error: no value for target folder attr (immediate endline)', () => {
const inputTxtArr: Array<string> = txtInputErrorEmptyValueOfTargetFolderAttr.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 5:MissingAttributeValue Attribute "target-folder:" requires a value to follow ${ERR_SUFFIX_IN_LINE(2)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('target-folder:'))
})
it('should recognize error: no value for target folder attr (space only)', () => {
const inputTxtArr: Array<string> = txtInputErrorSpaceAsValueOfTargetFolderAttr.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 5:MissingAttributeValue Attribute "TARGET-FOLDER:" requires a value to follow ${ERR_SUFFIX_IN_LINE(2)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('TARGET-FOLDER: '))
})
it('should recognize error: no value for ascending sorting attr (space only)', () => {
const inputTxtArr: Array<string> = txtInputErrorSpaceAsValueOfAscendingAttr.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 5:MissingAttributeValue Attribute "ORDER-ASC:" requires a value to follow ${ERR_SUFFIX_IN_LINE(2)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('ORDER-ASC: '))
})
it('should recognize error: invalid value for descending sorting attr (space only)', () => {
const inputTxtArr: Array<string> = txtInputErrorInvalidValueOfDescendingAttr.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 7:InvalidAttributeValue Invalid value of the attribute ">" ${ERR_SUFFIX_IN_LINE(3)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT(' > definitely not correct'))
})
it('should recognize error: no space before value for descending sorting attr (space only)', () => {
const inputTxtArr: Array<string> = txtInputErrorNoSpaceDescendingAttr.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 6:NoSpaceBetweenAttributeAndValue Space required after attribute name "Order-DESC:" ${ERR_SUFFIX_IN_LINE(3)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('Order-DESC:MODIFIED'))
})
it('should recognize error: item to hide requires exact name with ext', () => {
const inputTxtArr: Array<string> = txtInputErrorItemToHideWithNoValue.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 11:ItemToHideExactNameWithExtRequired Exact name with ext of file or folders to hide is required ${ERR_SUFFIX_IN_LINE(3)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('--%'))
})
it('should recognize error: too many numeric sorting indicators in a line', () => {
const inputTxtArr: Array<string> = txtInputErrorTooManyNumericSortSymbols.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 9:TooManyNumericSortingSymbols Maximum one numeric sorting indicator allowed per line ${ERR_SUFFIX_IN_LINE(2)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('% Chapter\\R+ ... page\\d+ '))
})
it('should recognize empty spec', () => {
const inputTxtArr: Array<string> = txtInputEmptySpec.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(0)
})
it.each([
'% \\.d+...',
'% ...\\d+',
'% Chapter\\R+... page',
'% Section ...\\-r+page'
])('should recognize error: numeric sorting symbol adjacent to wildcard in >%s<', (s: string) => {
const inputTxtArr: Array<string> = s.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 10:NumericalSymbolAdjacentToWildcard Numerical sorting symbol must not be directly adjacent to a wildcard because of potential performance problem. An additional explicit separator helps in such case. ${ERR_SUFFIX_IN_LINE(1)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT(s))
})
})
describe('convertPlainStringSortingGroupSpecToArraySpec', () => {
let processor: SortingSpecProcessor;
beforeEach(() => {
processor = new SortingSpecProcessor();
});
it('should recognize infix', () => {
const s = 'Advanced adv...ed, etc. and so on'
expect(processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)).toEqual([
'Advanced adv', '...', 'ed, etc. and so on'
])
})
it('should recognize suffix', () => {
const s = 'Advanced... '
expect(processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)).toEqual([
'Advanced', '...'
])
})
it('should recognize prefix', () => {
const s = ' ...tion. !!!'
expect(processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)).toEqual([
'...', 'tion. !!!'
])
})
it('should recognize some edge case', () => {
const s = 'Edge...... ... ..... ... eee?'
expect(processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)).toEqual([
'Edge', '...', '... ... ..... ... eee?'
])
})
it('should recognize some other edge case', () => {
const s = 'Edge... ... ... ..... ... eee?'
expect(processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)).toEqual([
'Edge', '...', ' ... ... ..... ... eee?'
])
})
it('should recognize another edge case', () => {
const s = '...Edge ... ... ... ..... ... eee? ...'
expect(processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)).toEqual([
'...', 'Edge ... ... ... ..... ... eee? ...'
])
})
it('should recognize yet another edge case', () => {
const s = '. .. ... ...'
const result = processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)
expect(result).toEqual([
'. .. ... ', '...' // Edge case -> splitting here is neutral, syntax error should be raised later on
])
})
it('should recognize tricky edge case', () => {
const s = '... ...'
const result = processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)
expect(result).toEqual([
'...', ' ...' // Edge case -> splitting here is neutral, syntax error should be raised later on
])
})
it('should recognize a variant of tricky edge case', () => {
const s = '......'
const result = processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)
expect(result).toEqual([
'...', '...' // Edge case -> splitting here is neutral, syntax error should be raised later on
])
})
it('edge case behavior', () => {
const s = ' ...... .......... '
const result = processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)
expect(result).toEqual([
'...', '... ..........' // Edge case -> splitting here is neutral, syntax error should be raised later on
])
})
it('intentional edge case parsing', () => {
const s = ' Abc......def..........ghi '
const result = processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)
expect(result).toEqual([
'Abc', '...', '...def..........ghi' // Edge case -> splitting here is neutral, syntax error should be raised later on
])
})
it.each([
' ... ',
'... ',
' ...'
])('should not split >%s< and only trim', (s: string) => {
const result = processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)
expect(result).toEqual([s.trim()])
})
})
describe('escapeRegexUnsafeCharacters', () => {
it.each([
['^', '\\^'],
['...', '\\.\\.\\.'],
[' \\ ', ' \\\\ '],
['^$.-+[]{}()|\\*?=!', '\\^\\$\\.\\-\\+\\[\\]\\{\\}\\(\\)\\|\\\\\\*\\?\\=\\!'],
['^Chapter \\.d+ -', '\\^Chapter \\\\\\.d\\+ \\-']
])('should correctly escape >%s< to >%s<', (s: string, ss: string) => {
// const UnsafeRegexChars: string = '^$.-+[]{}()|\\*?=!';
const result = escapeRegexUnsafeCharacters(s)
expect(result).toBe(ss);
})
})
describe('detectNumericSortingSymbols', () => {
it.each([
['', false],
['d+', false],
['\\d +', false],
['\\ d +', false],
['\\D+', true], [' \\D+ ', true],
['\\.D+', true], [' \\.D+ ', true],
['\\-D+', true], [' \\-D+ ', true],
['\\r+', true], [' \\r+ ', true],
['\\.r+', true], [' \\.r+ ', true],
['\\-r+', true], [' \\-r+ ', true],
['\\d+abcd\\d+efgh', true],
['\\d+\\.D+\\-d+\\R+\\.r+\\-R+ \\d+', true]
])('should correctly detect in >%s< (%s) sorting regex symbols', (s: string, b: boolean) => {
const result = detectNumericSortingSymbols(s)
expect(result).toBe(b)
})
})
describe('hasMoreThanOneNumericSortingSymbol', () => {
it.each([
['', false],
[' d+', false],
['\\d +', false],
['\\ d +', false],
[' \\D+', false], [' \\D+ \\R+ ', true],
[' \\.D+', false], ['\\.D+ \\.R+', true],
[' \\-D+ ', false], [' \\-D+\\-R+', true],
['\\r+', false], [' \\r+ \\D+ ', true],
['\\.r+', false], ['ab\\.r+de\\.D+fg', true],
['\\-r+', false], ['--\\-r+--\\-D+++', true],
['\\d+abcd\\d+efgh', true],
['\\R+abcd\\.R+efgh', true],
['\\d+\\.D+\\-d+\\R+\\.r+\\-R+ \\d+', true]
])('should correctly detect in >%s< (%s) sorting regex symbols', (s: string, b: boolean) => {
const result = hasMoreThanOneNumericSortingSymbol(s)
expect(result).toBe(b)
})
})
describe('extractNumericSortingSymbol', () => {
it.each([
['', null],
['d+', null],
[' \\d +', null],
['\\ d +', null],
[' \\d+', '\\d+'],
['--\\.D+\\d+', '\\.D+'],
['wdwqwqe\\d+\\.D+\\-d+\\R+\\.r+\\-R+ \\d+', '\\d+']
])('should correctly extract from >%s< the numeric sorting symbol (%s)', (s: string, ss: string) => {
const result = extractNumericSortingSymbol(s)
expect(result).toBe(ss)
})
})
describe('convertPlainStringWithNumericSortingSymbolToRegex', () => {
it.each([
[' \\d+ ', / *(\d+) /i],
['--\\.D+\\d+', /\-\- *(\d+(?:\.\d+)*)\\d\+/i],
['Chapter \\D+:', /Chapter *(\d+):/i],
['Section \\.D+ of', /Section *(\d+(?:\.\d+)*) of/i],
['Part\\-D+:', /Part *(\d+(?:-\d+)*):/i],
['Lorem ipsum\\r+:', /Lorem ipsum *([MDCLXVI]+):/i],
['\\.r+', / *([MDCLXVI]+(?:\.[MDCLXVI]+)*)/i],
['\\-r+:Lorem', / *([MDCLXVI]+(?:-[MDCLXVI]+)*):Lorem/i],
['abc\\d+efg\\d+hij', /abc *(\d+)efg/i], // Double numerical sorting symbol, error case, covered for clarity of implementation detail
])('should correctly extract from >%s< the numeric sorting symbol (%s)', (s: string, regex: RegExp) => {
const result = convertPlainStringWithNumericSortingSymbolToRegex(s, RegexpUsedAs.InUnitTest)
expect(result.regexpSpec.regex).toEqual(regex)
// No need to examine prefix and suffix fields of result, they are secondary and derived from the returned regexp
})
it('should not process string not containing numeric sorting symbol', () => {
const input = 'abc'
const result = convertPlainStringWithNumericSortingSymbolToRegex(input, RegexpUsedAs.InUnitTest)
expect(result).toBeNull()
})
it('should correctly include regex token for string begin', () => {
const input = 'Part\\-D+:'
const result = convertPlainStringWithNumericSortingSymbolToRegex(input, RegexpUsedAs.Prefix)
expect(result.regexpSpec.regex).toEqual(/^Part *(\d+(?:-\d+)*):/i)
})
it('should correctly include regex token for string end', () => {
const input = 'Part\\-D+:'
const result = convertPlainStringWithNumericSortingSymbolToRegex(input, RegexpUsedAs.Suffix)
expect(result.regexpSpec.regex).toEqual(/Part *(\d+(?:-\d+)*):$/i)
})
it('should correctly include regex token for string begin and end', () => {
const input = 'Part\\.D+:'
const result = convertPlainStringWithNumericSortingSymbolToRegex(input, RegexpUsedAs.FullMatch)
expect(result.regexpSpec.regex).toEqual(/^Part *(\d+(?:\.\d+)*):$/i)
})
})

View File

@ -0,0 +1,877 @@
import {
CustomSortGroup,
CustomSortGroupType,
CustomSortOrder,
CustomSortSpec,
NormalizerFn, RecognizedOrderValue,
RegExpSpec,
SortSpecsCollection
} from "./custom-sort-types";
import {isDefined, last} from "../utils/utils";
import {
CompoundNumberDashRegexStr,
CompoundNumberDotRegexStr,
CompoundRomanNumberDashRegexStr,
CompoundRomanNumberDotRegexStr,
DASH_SEPARATOR,
DOT_SEPARATOR,
getNormalizedNumber,
getNormalizedRomanNumber,
NumberRegexStr,
RomanNumberRegexStr
} from "./matchers";
interface ProcessingContext {
folderPath: string
specs: Array<CustomSortSpec>
currentSpec?: CustomSortSpec
currentSpecGroup?: CustomSortGroup
// Support for specific conditions (intentionally not generic approach)
previousValidEntryWasTargetFolderAttr?: boolean // Entry in previous non-empty valid line
}
interface ParsedSortingGroup {
filesOnly?: boolean
matchFilenameWithExt?: boolean
foldersOnly?: boolean
plainSpec?: string
arraySpec?: Array<string>
outsidersGroup?: boolean // Mutually exclusive with plainSpec and arraySpec
itemToHide?: boolean
}
export enum ProblemCode {
SyntaxError,
SyntaxErrorInGroupSpec,
DuplicateSortSpecForSameFolder,
DuplicateOrderAttr,
DanglingOrderAttr,
MissingAttributeValue,
NoSpaceBetweenAttributeAndValue,
InvalidAttributeValue,
TargetFolderNestedSpec,
TooManyNumericSortingSymbols,
NumericalSymbolAdjacentToWildcard,
ItemToHideExactNameWithExtRequired,
ItemToHideNoSupportForThreeDots
}
const ContextFreeProblems = new Set<ProblemCode>([
ProblemCode.DuplicateSortSpecForSameFolder
])
const ThreeDots = '...';
const ThreeDotsLength = ThreeDots.length;
const DEFAULT_SORT_ORDER = CustomSortOrder.alphabetical
interface CustomSortOrderAscDescPair {
asc: CustomSortOrder,
desc: CustomSortOrder,
secondary?: CustomSortOrder
}
// remember about .toLowerCase() before comparison!
const OrderLiterals: { [key: string]: CustomSortOrderAscDescPair } = {
'a-z': {asc: CustomSortOrder.alphabetical, desc: CustomSortOrder.alphabeticalReverse},
'created': {asc: CustomSortOrder.byCreatedTime, desc: CustomSortOrder.byCreatedTimeReverse},
'modified': {asc: CustomSortOrder.byModifiedTime, desc: CustomSortOrder.byModifiedTimeReverse},
// Advanced, for edge cases of secondary sorting, when if regexp match is the same, override the alphabetical sorting by full name
'a-z, created': {
asc: CustomSortOrder.alphabetical,
desc: CustomSortOrder.alphabeticalReverse,
secondary: CustomSortOrder.byCreatedTime
},
'a-z, created desc': {
asc: CustomSortOrder.alphabetical,
desc: CustomSortOrder.alphabeticalReverse,
secondary: CustomSortOrder.byCreatedTimeReverse
},
'a-z, modified': {
asc: CustomSortOrder.alphabetical,
desc: CustomSortOrder.alphabeticalReverse,
secondary: CustomSortOrder.byModifiedTime
},
'a-z, modified desc': {
asc: CustomSortOrder.alphabetical,
desc: CustomSortOrder.alphabeticalReverse,
secondary: CustomSortOrder.byModifiedTimeReverse
}
}
enum Attribute {
TargetFolder = 1, // Starting from 1 to allow: if (attribute) { ...
OrderAsc,
OrderDesc
}
const AttrLexems: { [key: string]: Attribute } = {
// Verbose attr names
'target-folder:': Attribute.TargetFolder,
'order-asc:': Attribute.OrderAsc,
'order-desc:': Attribute.OrderDesc,
// Concise abbreviated equivalents
'::::': Attribute.TargetFolder,
'<': Attribute.OrderAsc,
'>': Attribute.OrderDesc
}
const CURRENT_FOLDER_SYMBOL: string = '.'
interface ParsedSortingAttribute {
nesting: number // nesting level, 0 (default), 1+
attribute: Attribute
value?: any
}
type AttrValueValidatorFn = (v: string) => any | null;
const FilesGroupVerboseLexeme: string = '/:files'
const FilesGroupShortLexeme: string = '/:'
const FilesWithExtGroupVerboseLexeme: string = '/:files.'
const FilesWithExtGroupShortLexeme: string = '/:.'
const FoldersGroupVerboseLexeme: string = '/folders'
const FoldersGroupShortLexeme: string = '/'
const AnyTypeGroupLexeme: string = '%' // See % as a combination of / and :
const HideItemShortLexeme: string = '--%' // See % as a combination of / and :
const HideItemVerboseLexeme: string = '/--hide:'
const CommentPrefix: string = '//'
interface SortingGroupType {
filesOnly?: boolean
filenameWithExt?: boolean // The text matching criteria should apply to filename + extension
foldersOnly?: boolean
itemToHide?: boolean
}
const SortingGroupPrefixes: { [key: string]: SortingGroupType } = {
[FilesGroupShortLexeme]: {filesOnly: true},
[FilesGroupVerboseLexeme]: {filesOnly: true},
[FilesWithExtGroupShortLexeme]: {filesOnly: true, filenameWithExt: true},
[FilesWithExtGroupVerboseLexeme]: {filesOnly: true, filenameWithExt: true},
[FoldersGroupShortLexeme]: {foldersOnly: true},
[FoldersGroupVerboseLexeme]: {foldersOnly: true},
[AnyTypeGroupLexeme]: {},
[HideItemShortLexeme]: {itemToHide: true},
[HideItemVerboseLexeme]: {itemToHide: true}
}
const isThreeDots = (s: string): boolean => {
return s === ThreeDots
}
const containsThreeDots = (s: string): boolean => {
return s.indexOf(ThreeDots) !== -1
}
const RomanNumberRegexSymbol: string = '\\R+' // Roman number
const CompoundRomanNumberDotRegexSymbol: string = '\\.R+' // Compound Roman number with dot as separator
const CompoundRomanNumberDashRegexSymbol: string = '\\-R+' // Compound Roman number with dash as separator
const NumberRegexSymbol: string = '\\d+' // Plain number
const CompoundNumberDotRegexSymbol: string = '\\.d+' // Compound number with dot as separator
const CompoundNumberDashRegexSymbol: string = '\\-d+' // Compound number with dash as separator
const UnsafeRegexCharsRegex: RegExp = /[\^$.\-+\[\]{}()|*?=!\\]/g
export const escapeRegexUnsafeCharacters = (s: string): string => {
return s.replace(UnsafeRegexCharsRegex, '\\$&')
}
const numericSortingSymbolsArr: Array<string> = [
escapeRegexUnsafeCharacters(NumberRegexSymbol),
escapeRegexUnsafeCharacters(RomanNumberRegexSymbol),
escapeRegexUnsafeCharacters(CompoundNumberDotRegexSymbol),
escapeRegexUnsafeCharacters(CompoundNumberDashRegexSymbol),
escapeRegexUnsafeCharacters(CompoundRomanNumberDotRegexSymbol),
escapeRegexUnsafeCharacters(CompoundRomanNumberDashRegexSymbol),
]
const numericSortingSymbolsRegex = new RegExp(numericSortingSymbolsArr.join('|'), 'gi')
export const hasMoreThanOneNumericSortingSymbol = (s: string): boolean => {
numericSortingSymbolsRegex.lastIndex = 0
return numericSortingSymbolsRegex.test(s) && numericSortingSymbolsRegex.test(s)
}
export const detectNumericSortingSymbols = (s: string): boolean => {
numericSortingSymbolsRegex.lastIndex = 0
return numericSortingSymbolsRegex.test(s)
}
export const extractNumericSortingSymbol = (s: string): string => {
numericSortingSymbolsRegex.lastIndex = 0
const matches: RegExpMatchArray = numericSortingSymbolsRegex.exec(s)
return matches ? matches[0] : null
}
export interface RegExpSpecStr {
regexpStr: string
normalizerFn: NormalizerFn
}
// Exposed as named exports to allow unit testing
export const RomanNumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedRomanNumber(s)
export const CompoundDotRomanNumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedRomanNumber(s, DOT_SEPARATOR)
export const CompoundDashRomanNumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedRomanNumber(s, DASH_SEPARATOR)
export const NumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedNumber(s)
export const CompoundDotNumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedNumber(s, DOT_SEPARATOR)
export const CompoundDashNumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedNumber(s, DASH_SEPARATOR)
const numericSortingSymbolToRegexpStr: { [key: string]: RegExpSpecStr } = {
[RomanNumberRegexSymbol.toLowerCase()]: {
regexpStr: RomanNumberRegexStr,
normalizerFn: RomanNumberNormalizerFn
},
[CompoundRomanNumberDotRegexSymbol.toLowerCase()]: {
regexpStr: CompoundRomanNumberDotRegexStr,
normalizerFn: CompoundDotRomanNumberNormalizerFn
},
[CompoundRomanNumberDashRegexSymbol.toLowerCase()]: {
regexpStr: CompoundRomanNumberDashRegexStr,
normalizerFn: CompoundDashRomanNumberNormalizerFn
},
[NumberRegexSymbol.toLowerCase()]: {
regexpStr: NumberRegexStr,
normalizerFn: NumberNormalizerFn
},
[CompoundNumberDotRegexSymbol.toLowerCase()]: {
regexpStr: CompoundNumberDotRegexStr,
normalizerFn: CompoundDotNumberNormalizerFn
},
[CompoundNumberDashRegexSymbol.toLowerCase()]: {
regexpStr: CompoundNumberDashRegexStr,
normalizerFn: CompoundDashNumberNormalizerFn
}
}
export interface ExtractedNumericSortingSymbolInfo {
regexpSpec: RegExpSpec
prefix: string
suffix: string
}
export enum RegexpUsedAs {
InUnitTest,
Prefix,
Suffix,
FullMatch
}
export const convertPlainStringWithNumericSortingSymbolToRegex = (s: string, actAs: RegexpUsedAs): ExtractedNumericSortingSymbolInfo => {
const detectedSymbol: string = extractNumericSortingSymbol(s)
if (detectedSymbol) {
const replacement: RegExpSpecStr = numericSortingSymbolToRegexpStr[detectedSymbol.toLowerCase()]
const [extractedPrefix, extractedSuffix] = s.split(detectedSymbol)
const regexPrefix: string = actAs === RegexpUsedAs.Prefix || actAs === RegexpUsedAs.FullMatch ? '^' : ''
const regexSuffix: string = actAs === RegexpUsedAs.Suffix || actAs === RegexpUsedAs.FullMatch ? '$' : ''
return {
regexpSpec: {
regex: new RegExp(`${regexPrefix}${escapeRegexUnsafeCharacters(extractedPrefix)}${replacement.regexpStr}${escapeRegexUnsafeCharacters(extractedSuffix)}${regexSuffix}`, 'i'),
normalizerFn: replacement.normalizerFn
},
prefix: extractedPrefix,
suffix: extractedSuffix
}
} else {
return null
}
}
interface AdjacencyInfo {
noPrefix: boolean,
noSuffix: boolean
}
const checkAdjacency = (sortingSymbolInfo: ExtractedNumericSortingSymbolInfo): AdjacencyInfo => {
return {
noPrefix: sortingSymbolInfo.prefix.length === 0,
noSuffix: sortingSymbolInfo.suffix.length === 0
}
}
const ADJACENCY_ERROR: string = "Numerical sorting symbol must not be directly adjacent to a wildcard because of potential performance problem. An additional explicit separator helps in such case."
export class SortingSpecProcessor {
ctx: ProcessingContext
currentEntryLine: string
currentEntryLineIdx: number
currentSortingSpecContainerFilePath: string
problemAlreadyReportedForCurrentLine: boolean
recentErrorMessage: string
// Logger parameter exposed to support unit testing of error cases as well as capturing error messages
// for in-app presentation
constructor(private errorLogger?: typeof console.log) {
}
// root level parser function
parseSortSpecFromText(text: Array<string>,
folderPath: string,
sortingSpecFileName: string,
collection?: SortSpecsCollection
): SortSpecsCollection {
this.ctx = {
folderPath: folderPath, // location of the sorting spec file
specs: []
};
let success: boolean = false;
let lineIdx: number = 0;
for (let entryLine of text) {
lineIdx++
this.currentEntryLine = entryLine
this.currentEntryLineIdx = lineIdx
this.currentSortingSpecContainerFilePath = `${folderPath}/${sortingSpecFileName}`
this.problemAlreadyReportedForCurrentLine = false
const trimmedEntryLine: string = entryLine.trim()
if (trimmedEntryLine === '') continue
if (trimmedEntryLine.startsWith(CommentPrefix)) continue
success = false // Empty lines and comments are OK, that's why setting so late
const attr: ParsedSortingAttribute = this._l1s1_parseAttribute(entryLine);
if (attr) {
success = this._l1s2_processParsedSortingAttribute(attr);
this.ctx.previousValidEntryWasTargetFolderAttr = success && (attr.attribute === Attribute.TargetFolder)
} else if (!this.problemAlreadyReportedForCurrentLine && !this._l1s3_checkForRiskyAttrSyntaxError(entryLine)) {
let group: ParsedSortingGroup = this._l1s4_parseSortingGroupSpec(entryLine);
if (!this.problemAlreadyReportedForCurrentLine && !group) {
// Default for unrecognized syntax: treat the line as exact name (of file or folder)
group = {plainSpec: trimmedEntryLine}
}
if (group) {
success = this._l1s5_processParsedSortGroupSpec(group);
}
this.ctx.previousValidEntryWasTargetFolderAttr = undefined
}
if (!success) {
if (!this.problemAlreadyReportedForCurrentLine) {
this.problem(ProblemCode.SyntaxError, "Sorting specification line doesn't match any supported syntax")
}
break;
}
}
if (success) {
if (this.ctx.specs.length > 0) {
for (let spec of this.ctx.specs) {
this._l1s6_postprocessSortSpec(spec)
for (let folderPath of spec.targetFoldersPaths) {
collection = collection ?? {}
if (collection[folderPath]) {
this.problem(ProblemCode.DuplicateSortSpecForSameFolder, `Duplicate sorting spec for folder ${folderPath}`)
return null // Failure - not allow duplicate specs for the same folder
}
collection[folderPath] = spec
}
}
}
return collection
} else {
return null
}
}
problem = (code: ProblemCode, details: string): void => {
const problemLabel = ProblemCode[code]
let logger: typeof console.log = this.errorLogger ?? console.error
const hasLineContext: boolean = !ContextFreeProblems.has(code)
const lineContext = (hasLineContext) ? ` line ${this.currentEntryLineIdx} of` : ''
logger(`Sorting specification problem: ${code}:${problemLabel} ${details} ---` +
`encountered in${lineContext} sorting spec in file ${this.currentSortingSpecContainerFilePath}`)
if (lineContext) {
logger(`Content of problematic line: "${this.currentEntryLine}"`)
}
this.recentErrorMessage =
`File: ${this.currentSortingSpecContainerFilePath}\n` +
(hasLineContext ? `Line #${this.currentEntryLineIdx}: "${this.currentEntryLine}"\n` : '') +
`Problem: ${code}:${problemLabel}\n` +
`Details: ${details}`
this.problemAlreadyReportedForCurrentLine = true
}
// level 1 parser functions defined in order of occurrence and dependency
private _l1s1_parseAttribute = (line: string): ParsedSortingAttribute | null => {
const lineTrimmedStart: string = line.trimStart()
const nestingLevel: number = line.length - lineTrimmedStart.length
// Attribute lexeme (name or alias) requires trailing space separator
const indexOfSpace: number = lineTrimmedStart.indexOf(' ')
if (indexOfSpace === -1) {
return null; // Seemingly not an attribute or a syntax error, to be checked separately
}
const firstLexeme: string = lineTrimmedStart.substring(0, indexOfSpace)
const firstLexemeLowerCase: string = firstLexeme.toLowerCase()
const recognizedAttr: Attribute = AttrLexems[firstLexemeLowerCase]
if (recognizedAttr) {
const attrValue: string = lineTrimmedStart.substring(indexOfSpace).trim()
if (attrValue) {
const validator: AttrValueValidatorFn = this.attrValueValidators[recognizedAttr]
if (validator) {
const validValue = validator(attrValue);
if (validValue) {
return {
nesting: nestingLevel,
attribute: recognizedAttr,
value: validValue
}
} else {
this.problem(ProblemCode.InvalidAttributeValue, `Invalid value of the attribute "${firstLexeme}"`)
}
} else {
return {
nesting: nestingLevel,
attribute: recognizedAttr,
value: attrValue
}
}
} else {
this.problem(ProblemCode.MissingAttributeValue, `Attribute "${firstLexeme}" requires a value to follow`)
}
}
return null; // Seemingly not an attribute or not a valid attribute expression (respective syntax error could have been logged)
}
private _l1s2_processParsedSortingAttribute(attr: ParsedSortingAttribute): boolean {
if (attr.attribute === Attribute.TargetFolder) {
if (attr.nesting === 0) { // root-level attribute causing creation of new spec or decoration of a previous one
if (this.ctx.previousValidEntryWasTargetFolderAttr) {
this.ctx.currentSpec.targetFoldersPaths.push(attr.value)
} else {
this._l2s2_putNewSpecForNewTargetFolder(attr.value)
}
return true
} else {
this.problem(ProblemCode.TargetFolderNestedSpec, `Nested (indented) specification of target folder is not allowed`)
return false
}
} else if (attr.attribute === Attribute.OrderAsc || attr.attribute === Attribute.OrderDesc) {
if (attr.nesting === 0) {
if (!this.ctx.currentSpec) {
this._l2s2_putNewSpecForNewTargetFolder()
}
if (this.ctx.currentSpec.defaultOrder) {
const folderPathsForProblemMsg: string = this.ctx.currentSpec.targetFoldersPaths.join(' :: ');
this.problem(ProblemCode.DuplicateOrderAttr, `Duplicate order specification for folder(s) ${folderPathsForProblemMsg}`)
return false;
}
this.ctx.currentSpec.defaultOrder = (attr.value as RecognizedOrderValue).order
return true;
} else if (attr.nesting > 0) { // For now only distinguishing nested (indented) and not-nested (not-indented), the depth doesn't matter
if (!this.ctx.currentSpec || !this.ctx.currentSpecGroup) {
this.problem(ProblemCode.DanglingOrderAttr, `Nested (indented) attribute requires prior sorting group definition`)
return false;
}
if (this.ctx.currentSpecGroup.order) {
const folderPathsForProblemMsg: string = this.ctx.currentSpec.targetFoldersPaths.join(' :: ');
this.problem(ProblemCode.DuplicateOrderAttr, `Duplicate order specification for a sorting rule of folder ${folderPathsForProblemMsg}`)
return false;
}
this.ctx.currentSpecGroup.order = (attr.value as RecognizedOrderValue).order
this.ctx.currentSpecGroup.secondaryOrder = (attr.value as RecognizedOrderValue).secondaryOrder
return true;
}
}
return false;
}
private _l1s3_checkForRiskyAttrSyntaxError = (line: string): boolean => {
const lineTrimmedStart: string = line.trimStart()
const lineTrimmedStartLowerCase: string = lineTrimmedStart.toLowerCase()
// no space present, check for potential syntax errors
for (let attrLexeme of Object.keys(AttrLexems)) {
if (lineTrimmedStartLowerCase.startsWith(attrLexeme)) {
const originalAttrLexeme: string = lineTrimmedStart.substr(0, attrLexeme.length)
if (lineTrimmedStartLowerCase.length === attrLexeme.length) {
this.problem(ProblemCode.MissingAttributeValue, `Attribute "${originalAttrLexeme}" requires a value to follow`)
return true
} else {
this.problem(ProblemCode.NoSpaceBetweenAttributeAndValue, `Space required after attribute name "${originalAttrLexeme}"`)
return true
}
}
}
return false
}
private _l1s4_parseSortingGroupSpec = (line: string): ParsedSortingGroup | null => {
const s: string = line.trim()
if (hasMoreThanOneNumericSortingSymbol(s)) {
this.problem(ProblemCode.TooManyNumericSortingSymbols, 'Maximum one numeric sorting indicator allowed per line')
return null
}
const prefixAlone: SortingGroupType = SortingGroupPrefixes[s]
if (prefixAlone) {
if (prefixAlone.itemToHide) {
this.problem(ProblemCode.ItemToHideExactNameWithExtRequired, 'Exact name with ext of file or folders to hide is required')
return null
} else { // !prefixAlone.itemToHide
return {
outsidersGroup: true,
filesOnly: prefixAlone.filesOnly,
foldersOnly: prefixAlone.foldersOnly
}
}
}
for (const prefix of Object.keys(SortingGroupPrefixes)) {
if (s.startsWith(prefix + ' ')) {
const sortingGroupType: SortingGroupType = SortingGroupPrefixes[prefix]
if (sortingGroupType.itemToHide) {
return {
itemToHide: true,
plainSpec: s.substring(prefix.length + 1),
filesOnly: sortingGroupType.filesOnly,
foldersOnly: sortingGroupType.foldersOnly
}
} else { // !sortingGroupType.itemToHide
return {
plainSpec: s.substring(prefix.length + 1),
filesOnly: sortingGroupType.filesOnly,
foldersOnly: sortingGroupType.foldersOnly,
matchFilenameWithExt: sortingGroupType.filenameWithExt
}
}
}
}
return null;
}
private _l1s5_processParsedSortGroupSpec(group: ParsedSortingGroup): boolean {
if (!this.ctx.currentSpec) {
this._l2s2_putNewSpecForNewTargetFolder()
}
if (group.plainSpec) {
group.arraySpec = this._l2s2_convertPlainStringSortingGroupSpecToArraySpec(group.plainSpec)
delete group.plainSpec
}
if (group.itemToHide) {
if (!this._l2s3_consumeParsedItemToHide(group)) {
this.problem(ProblemCode.ItemToHideNoSupportForThreeDots, 'For hiding of file or folder, the exact name with ext is required and no numeric sorting indicator allowed')
return false
} else {
return true
}
} else { // !group.itemToHide
const newGroup: CustomSortGroup = this._l2s4_consumeParsedSortingGroupSpec(group)
if (newGroup) {
if (this._l2s5_adjustSortingGroupForNumericSortingSymbol(newGroup)) {
this.ctx.currentSpec.groups.push(newGroup)
this.ctx.currentSpecGroup = newGroup
return true;
} else {
return false;
}
} else {
return false;
}
}
}
private _l1s6_postprocessSortSpec(spec: CustomSortSpec): void {
// clean up to prevent false warnings in console
spec.outsidersGroupIdx = undefined
spec.outsidersFilesGroupIdx = undefined
spec.outsidersFoldersGroupIdx = undefined
let outsidersGroupForFolders: boolean
let outsidersGroupForFiles: boolean
// process all defined sorting groups
for (let groupIdx = 0; groupIdx < spec.groups.length; groupIdx++) {
const group: CustomSortGroup = spec.groups[groupIdx];
if (group.type === CustomSortGroupType.Outsiders) {
if (group.filesOnly) {
if (isDefined(spec.outsidersFilesGroupIdx)) {
console.warn(`Ignoring duplicate Outsiders-files sorting group definition in sort spec for folder '${last(spec.targetFoldersPaths)}'`)
} else {
spec.outsidersFilesGroupIdx = groupIdx
outsidersGroupForFiles = true
}
} else if (group.foldersOnly) {
if (isDefined(spec.outsidersFoldersGroupIdx)) {
console.warn(`Ignoring duplicate Outsiders-folders sorting group definition in sort spec for folder '${last(spec.targetFoldersPaths)}'`)
} else {
spec.outsidersFoldersGroupIdx = groupIdx
outsidersGroupForFolders = true
}
} else {
if (isDefined(spec.outsidersGroupIdx)) {
console.warn(`Ignoring duplicate Outsiders sorting group definition in sort spec for folder '${last(spec.targetFoldersPaths)}'`)
} else {
spec.outsidersGroupIdx = groupIdx
outsidersGroupForFolders = true
outsidersGroupForFiles = true
}
}
}
}
if (isDefined(spec.outsidersGroupIdx) && (isDefined(spec.outsidersFilesGroupIdx) || isDefined(spec.outsidersFoldersGroupIdx))) {
console.warn(`Inconsistent Outsiders sorting group definition in sort spec for folder '${last(spec.targetFoldersPaths)}'`)
}
// For consistency and to simplify sorting code later on, implicitly append a single catch-all Outsiders group
if (!outsidersGroupForFiles && !outsidersGroupForFolders) {
spec.outsidersGroupIdx = spec.groups.length
spec.groups.push({
type: CustomSortGroupType.Outsiders
})
}
// Populate sorting order for a bit more efficient sorting later on
for (let group of spec.groups) {
if (!group.order) {
group.order = spec.defaultOrder ?? DEFAULT_SORT_ORDER
}
}
// Replace the dot-folder names (coming from: 'target-folder: .') with actual folder names
spec.targetFoldersPaths.forEach((path, idx) => {
if (path === CURRENT_FOLDER_SYMBOL) {
spec.targetFoldersPaths[idx] = this.ctx.folderPath
}
});
}
// level 2 parser functions defined in order of occurrence and dependency
private _l2s1_validateTargetFolderAttrValue = (v: string): string | null => {
if (v) {
const trimmed: string = v.trim();
return trimmed ? trimmed : null; // Can't use ?? - it treats '' as a valid value
} else {
return null;
}
}
private _l2s1_internal_validateOrderAttrValue = (v: string): CustomSortOrderAscDescPair | null => {
v = v.trim();
return v ? OrderLiterals[v.toLowerCase()] : null
}
private _l2s1_validateOrderAscAttrValue = (v: string): RecognizedOrderValue | null => {
const recognized: CustomSortOrderAscDescPair = this._l2s1_internal_validateOrderAttrValue(v)
return recognized ? {
order: recognized.asc,
secondaryOrder: recognized.secondary
} : null;
}
private _l2s1_validateOrderDescAttrValue = (v: string): RecognizedOrderValue | null => {
const recognized: CustomSortOrderAscDescPair = this._l2s1_internal_validateOrderAttrValue(v)
return recognized ? {
order: recognized.desc,
secondaryOrder: recognized.secondary
} : null;
}
attrValueValidators: { [key in Attribute]: AttrValueValidatorFn } = {
[Attribute.TargetFolder]: this._l2s1_validateTargetFolderAttrValue.bind(this),
[Attribute.OrderAsc]: this._l2s1_validateOrderAscAttrValue.bind(this),
[Attribute.OrderDesc]: this._l2s1_validateOrderDescAttrValue.bind(this),
}
_l2s2_convertPlainStringSortingGroupSpecToArraySpec = (spec: string): Array<string> => {
spec = spec.trim()
if (isThreeDots(spec)) {
return [ThreeDots]
}
if (spec.startsWith(ThreeDots)) {
return [ThreeDots, spec.substr(ThreeDotsLength)];
}
if (spec.endsWith(ThreeDots)) {
return [spec.substring(0, spec.length - ThreeDotsLength), ThreeDots];
}
const idx = spec.indexOf(ThreeDots);
if (idx > 0) {
return [
spec.substring(0, idx),
ThreeDots,
spec.substr(idx + ThreeDotsLength)
];
}
// Unrecognized, treat as exact match
return [spec];
}
private _l2s2_putNewSpecForNewTargetFolder(folderPath?: string) {
const newSpec: CustomSortSpec = {
targetFoldersPaths: [folderPath ?? this.ctx.folderPath],
groups: []
}
this.ctx.specs.push(newSpec);
this.ctx.currentSpec = newSpec;
this.ctx.currentSpecGroup = undefined;
}
// Detection of slippery syntax errors which can confuse user due to false positive parsing with an unexpected sorting result
private _l2s3_consumeParsedItemToHide(spec: ParsedSortingGroup): boolean {
if (spec.arraySpec.length === 1) {
const theOnly: string = spec.arraySpec[0]
if (!isThreeDots(theOnly)) {
const nameWithExt: string = theOnly.trim()
if (nameWithExt) { // Sanity check
if (!detectNumericSortingSymbols(nameWithExt)) {
const itemsToHide: Set<string> = this.ctx.currentSpec.itemsToHide ?? new Set<string>()
itemsToHide.add(nameWithExt)
this.ctx.currentSpec.itemsToHide = itemsToHide
return true
}
}
}
}
return false
}
private _l2s4_consumeParsedSortingGroupSpec = (spec: ParsedSortingGroup): CustomSortGroup => {
if (spec.outsidersGroup) {
return {
type: CustomSortGroupType.Outsiders,
filesOnly: spec.filesOnly,
foldersOnly: spec.foldersOnly,
matchFilenameWithExt: spec.matchFilenameWithExt // Doesn't make sense for matching, yet for multi-match
} // theoretically could match the sorting of matched files
}
if (spec.arraySpec.length === 1) {
const theOnly: string = spec.arraySpec[0]
if (isThreeDots(theOnly)) {
return {
type: CustomSortGroupType.MatchAll,
filesOnly: spec.filesOnly,
foldersOnly: spec.foldersOnly,
matchFilenameWithExt: spec.matchFilenameWithExt // Doesn't make sense for matching, yet for multi-match
} // theoretically could match the sorting of matched files
} else {
// For non-three dots single text line assume exact match group
return {
type: CustomSortGroupType.ExactName,
exactText: spec.arraySpec[0],
filesOnly: spec.filesOnly,
foldersOnly: spec.foldersOnly,
matchFilenameWithExt: spec.matchFilenameWithExt
}
}
}
if (spec.arraySpec.length === 2) {
const theFirst: string = spec.arraySpec[0]
const theSecond: string = spec.arraySpec[1]
if (isThreeDots(theFirst) && !isThreeDots(theSecond) && !containsThreeDots(theSecond)) {
return {
type: CustomSortGroupType.ExactSuffix,
exactSuffix: theSecond,
filesOnly: spec.filesOnly,
foldersOnly: spec.foldersOnly,
matchFilenameWithExt: spec.matchFilenameWithExt
}
} else if (!isThreeDots(theFirst) && isThreeDots(theSecond) && !containsThreeDots(theFirst)) {
return {
type: CustomSortGroupType.ExactPrefix,
exactPrefix: theFirst,
filesOnly: spec.filesOnly,
foldersOnly: spec.foldersOnly,
matchFilenameWithExt: spec.matchFilenameWithExt
}
} else {
// both are three dots or contain three dots or
this.problem(ProblemCode.SyntaxErrorInGroupSpec, "three dots occurring more than once and no more text specified")
return null;
}
}
if (spec.arraySpec.length === 3) {
const theFirst: string = spec.arraySpec[0]
const theMiddle: string = spec.arraySpec[1]
const theLast: string = spec.arraySpec[2]
if (isThreeDots(theMiddle)
&& !isThreeDots(theFirst)
&& !isThreeDots(theLast)
&& !containsThreeDots(theLast)) {
return {
type: CustomSortGroupType.ExactHeadAndTail,
exactPrefix: theFirst,
exactSuffix: theLast,
filesOnly: spec.filesOnly,
foldersOnly: spec.foldersOnly,
matchFilenameWithExt: spec.matchFilenameWithExt
}
} else {
// both are three dots or three dots occurring more times
this.problem(ProblemCode.SyntaxErrorInGroupSpec, "three dots occurring more than once or unrecognized specification of sorting rule")
return null;
}
}
this.problem(ProblemCode.SyntaxErrorInGroupSpec, "Unrecognized specification of sorting rule")
return null;
}
// Returns true if no numeric sorting symbol (hence no adjustment) or if correctly adjusted with regex
private _l2s5_adjustSortingGroupForNumericSortingSymbol = (group: CustomSortGroup) => {
switch (group.type) {
case CustomSortGroupType.ExactPrefix:
const numSymbolInPrefix = convertPlainStringWithNumericSortingSymbolToRegex(group.exactPrefix, RegexpUsedAs.Prefix)
if (numSymbolInPrefix) {
if (checkAdjacency(numSymbolInPrefix).noSuffix) {
this.problem(ProblemCode.NumericalSymbolAdjacentToWildcard, ADJACENCY_ERROR)
return false;
}
delete group.exactPrefix
group.regexSpec = numSymbolInPrefix.regexpSpec
}
break;
case CustomSortGroupType.ExactSuffix:
const numSymbolInSuffix = convertPlainStringWithNumericSortingSymbolToRegex(group.exactSuffix, RegexpUsedAs.Suffix)
if (numSymbolInSuffix) {
if (checkAdjacency(numSymbolInSuffix).noPrefix) {
this.problem(ProblemCode.NumericalSymbolAdjacentToWildcard, ADJACENCY_ERROR)
return false;
}
delete group.exactSuffix
group.regexSpec = numSymbolInSuffix.regexpSpec
}
break;
case CustomSortGroupType.ExactHeadAndTail:
const numSymbolInHead = convertPlainStringWithNumericSortingSymbolToRegex(group.exactPrefix, RegexpUsedAs.Prefix)
if (numSymbolInHead) {
if (checkAdjacency(numSymbolInHead).noSuffix) {
this.problem(ProblemCode.NumericalSymbolAdjacentToWildcard, ADJACENCY_ERROR)
return false;
}
delete group.exactPrefix
group.regexSpec = numSymbolInHead.regexpSpec
} else {
const numSymbolInTail = convertPlainStringWithNumericSortingSymbolToRegex(group.exactSuffix, RegexpUsedAs.Suffix)
if (numSymbolInTail) {
if (checkAdjacency(numSymbolInTail).noPrefix) {
this.problem(ProblemCode.NumericalSymbolAdjacentToWildcard, ADJACENCY_ERROR)
return false;
}
delete group.exactSuffix
group.regexSpec = numSymbolInTail.regexpSpec
}
}
break;
case CustomSortGroupType.ExactName:
const numSymbolInExactMatch = convertPlainStringWithNumericSortingSymbolToRegex(group.exactText, RegexpUsedAs.FullMatch)
if (numSymbolInExactMatch) {
delete group.exactText
group.regexSpec = numSymbolInExactMatch.regexpSpec
}
break;
}
return true
}
}

269
src/main.ts Normal file
View File

@ -0,0 +1,269 @@
import {
App,
FileExplorerView,
MetadataCache,
Notice,
Plugin,
PluginSettingTab,
setIcon,
Setting,
TAbstractFile,
TFile,
TFolder,
Vault
} from 'obsidian';
import {around} from 'monkey-around';
import {folderSort} from './custom-sort/custom-sort';
import {SortingSpecProcessor} from './custom-sort/sorting-spec-processor';
import {CustomSortSpec, SortSpecsCollection} from './custom-sort/custom-sort-types';
import {
addIcons,
ICON_SORT_ENABLED_ACTIVE,
ICON_SORT_SUSPENDED,
ICON_SORT_SUSPENDED_SYNTAX_ERROR,
ICON_SORT_ENABLED_NOT_APPLIED
} from "./custom-sort/icons";
interface CustomSortPluginSettings {
additionalSortspecFile: string
suspended: boolean
}
const DEFAULT_SETTINGS: CustomSortPluginSettings = {
additionalSortspecFile: 'Inbox/Inbox.md',
suspended: true // if false by default, it would be hard to handle the auto-parse after plugin install
}
const SORTSPEC_FILE_NAME: string = 'sortspec.md'
const SORTINGSPEC_YAML_KEY: string = 'sorting-spec'
const ERROR_NOTICE_TIMEOUT: number = 10000
export default class CustomSortPlugin extends Plugin {
settings: CustomSortPluginSettings
statusBarItemEl: HTMLElement
ribbonIconEl: HTMLElement
sortSpecCache: SortSpecsCollection
initialAutoOrManualSortingTriggered: boolean
readAndParseSortingSpec() {
const mCache: MetadataCache = this.app.metadataCache
let failed: boolean = false
let anySortingSpecFound: boolean = false
let errorMessage: string
// reset cache
this.sortSpecCache = null
Vault.recurseChildren(this.app.vault.getRoot(), (file: TAbstractFile) => {
if (failed) return
if (file instanceof TFile) {
const aFile: TFile = file as TFile
const parent: TFolder = aFile.parent
// Read sorting spec from three sources of equal priority:
// - files with designated name (sortspec.md by default)
// - files with the same name as parent folders (aka folder notes): References/References.md
// - the file explicitly indicated in documentation, by default Inbox/Inbox.md
if (aFile.name === SORTSPEC_FILE_NAME || aFile.basename === parent.name || aFile.path === DEFAULT_SETTINGS.additionalSortspecFile) {
const sortingSpecTxt: string = mCache.getCache(aFile.path)?.frontmatter?.[SORTINGSPEC_YAML_KEY]
if (sortingSpecTxt) {
anySortingSpecFound = true
const processor: SortingSpecProcessor = new SortingSpecProcessor()
this.sortSpecCache = processor.parseSortSpecFromText(
sortingSpecTxt.split('\n'),
parent.path,
aFile.name,
this.sortSpecCache
)
if (this.sortSpecCache === null) {
failed = true
errorMessage = processor.recentErrorMessage ?? ''
}
}
}
}
})
if (this.sortSpecCache) {
new Notice(`Parsing custom sorting specification SUCCEEDED!`)
} else {
if (anySortingSpecFound) {
errorMessage = errorMessage ? errorMessage : `No custom sorting specification found or only empty specification(s)`
} else {
errorMessage = `No valid '${SORTINGSPEC_YAML_KEY}:' key(s) in YAML front matter or multiline YAML indentation error or general YAML syntax error`
}
new Notice(`Parsing custom sorting specification FAILED. Suspending the plugin.\n${errorMessage}`, ERROR_NOTICE_TIMEOUT)
this.settings.suspended = true
this.saveSettings()
}
}
async onload() {
console.log("loading custom-sort");
await this.loadSettings();
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
this.statusBarItemEl = this.addStatusBarItem();
this.updateStatusBar()
addIcons();
// Create an icon button in the left ribbon.
this.ribbonIconEl = this.addRibbonIcon(
this.settings.suspended ? ICON_SORT_SUSPENDED : ICON_SORT_ENABLED_NOT_APPLIED,
'Toggle custom sorting', (evt: MouseEvent) => {
// Clicking the icon toggles between the states of custom sort plugin
this.settings.suspended = !this.settings.suspended;
this.saveSettings()
let iconToSet: string
if (this.settings.suspended) {
new Notice('Custom sort OFF');
this.sortSpecCache = null
iconToSet = ICON_SORT_SUSPENDED
} else {
this.readAndParseSortingSpec();
if (this.sortSpecCache) {
new Notice('Custom sort ON');
this.initialAutoOrManualSortingTriggered = true
iconToSet = ICON_SORT_ENABLED_ACTIVE
} else {
iconToSet = ICON_SORT_SUSPENDED_SYNTAX_ERROR
this.settings.suspended = true
this.saveSettings()
}
}
const fileExplorerView: FileExplorerView = this.getFileExplorer()
if (fileExplorerView) {
fileExplorerView.requestSort();
} else {
if (iconToSet === ICON_SORT_ENABLED_ACTIVE) {
iconToSet = ICON_SORT_ENABLED_NOT_APPLIED
}
}
setIcon(this.ribbonIconEl, iconToSet)
this.updateStatusBar();
});
this.addSettingTab(new CustomSortSettingTab(this.app, this));
this.registerEventHandlers()
this.initialize();
}
registerEventHandlers() {
this.registerEvent(
// Keep in mind: this event is triggered once after app starts and then after each modification of _any_ metadata
this.app.metadataCache.on("resolved", () => {
if (!this.settings.suspended) {
if (!this.initialAutoOrManualSortingTriggered) {
this.readAndParseSortingSpec()
this.initialAutoOrManualSortingTriggered = true
if (this.sortSpecCache) { // successful read of sorting specifications?
new Notice('Custom sort ON')
const fileExplorerView: FileExplorerView = this.getFileExplorer()
if (fileExplorerView) {
setIcon(this.ribbonIconEl, ICON_SORT_ENABLED_ACTIVE)
fileExplorerView.requestSort()
} else {
setIcon(this.ribbonIconEl, ICON_SORT_ENABLED_NOT_APPLIED)
}
this.updateStatusBar()
} else {
this.settings.suspended = true
setIcon(this.ribbonIconEl, ICON_SORT_SUSPENDED_SYNTAX_ERROR)
this.saveSettings()
}
}
}
})
);
}
initialize() {
this.app.workspace.onLayoutReady(() => {
this.patchFileExplorerFolder();
})
}
// For the idea of monkey-patching credits go to https://github.com/nothingislost/obsidian-bartender
patchFileExplorerFolder() {
let plugin = this;
let leaf = this.app.workspace.getLeaf();
const fileExplorer = this.app.viewRegistry.viewByType["file-explorer"](leaf) as FileExplorerView;
// @ts-ignore
let tmpFolder = new TFolder(Vault, "");
let Folder = fileExplorer.createFolderDom(tmpFolder).constructor;
this.register(
around(Folder.prototype, {
sort(old: any) {
return function (...args: any[]) {
// if custom sort is not specified, use the UI-selected
const folder: TFolder = this.file
const sortSpec: CustomSortSpec = plugin.sortSpecCache?.[folder.path]
if (!plugin.settings.suspended && sortSpec) {
return folderSort.call(this, sortSpec, ...args);
} else {
return old.call(this, ...args);
}
};
},
})
);
leaf.detach()
}
// Credits go to https://github.com/nothingislost/obsidian-bartender
getFileExplorer() {
let fileExplorer: FileExplorerView | undefined = this.app.workspace.getLeavesOfType("file-explorer")?.first()
?.view as unknown as FileExplorerView;
return fileExplorer;
}
onunload() {
}
updateStatusBar() {
if (this.statusBarItemEl) {
this.statusBarItemEl.setText(`Custom sort:${this.settings.suspended ? 'OFF' : 'ON'}`)
}
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class CustomSortSettingTab extends PluginSettingTab {
plugin: CustomSortPlugin;
constructor(app: App, plugin: CustomSortPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
containerEl.createEl('h2', {text: 'Settings for custom sorting plugin'});
new Setting(containerEl)
.setName('Path to the designated note containing sorting specification')
.setDesc('The YAML front matter of this note will be scanned for sorting specification, in addition to the sortspec.md notes and folder notes')
.addText(text => text
.setPlaceholder('e.g. note.md')
.setValue(this.plugin.settings.additionalSortspecFile)
.onChange(async (value) => {
this.plugin.settings.additionalSortspecFile = value;
await this.plugin.saveSettings();
}));
}
}

20
src/types/types.d.ts vendored Normal file
View File

@ -0,0 +1,20 @@
import {TFolder, WorkspaceLeaf} from "obsidian";
declare module 'obsidian' {
export interface ViewRegistry {
viewByType: Record<string, (leaf: WorkspaceLeaf) => unknown>;
}
export interface App {
viewRegistry: ViewRegistry;
}
interface FileExplorerFolder {
}
export interface FileExplorerView extends View {
createFolderDom(folder: TFolder): FileExplorerFolder;
requestSort(): void;
}
}

8
src/utils/utils.ts Normal file
View File

@ -0,0 +1,8 @@
// syntax sugar for checking for optional numbers equal to zero (and other similar cases)
export function isDefined(o: any): boolean {
return o !== undefined && o !== null;
}
export function last<T>(o: Array<T>): T | undefined {
return o?.length > 0 ? o[o.length - 1] : undefined
}

View File

@ -1,4 +1,2 @@
/* Sets all the text color to red! */
body {
color: red;
}
/* Unsure if this file of plugin is required (even empty) or not */

View File

@ -1,21 +1,22 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"target": "ES6",
"allowJs": false,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"