First public release of the Obsidian custom sort plugin
|
@ -20,3 +20,4 @@ data.json
|
|||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
/yarn.lock
|
||||
|
|
367
README.md
|
@ -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
|
||||

|
||||
|
||||
- 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:
|
||||
|
||||

|
||||
|
||||
### 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:
|
||||
|
||||

|
||||
|
||||
### 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:
|
||||
|
||||

|
||||
|
||||
> 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:
|
||||
|
||||

|
||||
|
||||
### 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:
|
||||
|
||||

|
||||
|
||||
```
|
||||
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:
|
||||
|
||||

|
||||
|
||||
### 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:
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||
-  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.
|
||||
-  Plugin active, custom sorting applied.
|
||||
- Click to suspend and return to the standard Obsidian sorting in File Explorer.
|
||||
-  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
|
||||
-  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
|
||||
|
|
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 937 B |
After Width: | Height: | Size: 1.2 KiB |
|
@ -0,0 +1,3 @@
|
|||
Yet to be filled with content ;-)
|
||||
|
||||
See [syntax-reference.md](), maybe that file has already some content?
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -0,0 +1,3 @@
|
|||
Yet to be filled with content ;-)
|
||||
|
||||
Check [manual.md](), maybe that file has already some content?
|
|
@ -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));
|
||||
|
|
|
@ -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
|
@ -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();
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
22
package.json
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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'
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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"/>`
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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)}//`
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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 */
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|