First public release of the Obsidian custom sort plugin
|
@ -20,3 +20,4 @@ data.json
|
||||||
|
|
||||||
# Exclude macOS Finder (System Explorer) View States
|
# Exclude macOS Finder (System Explorer) View States
|
||||||
.DS_Store
|
.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.
|
- folder-level configuration
|
||||||
The repo depends on the latest plugin API (obsidian.d.ts) in Typescript Definition format, which contains TSDoc comments describing what it does.
|
- 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.
|
For full version of the manual go to [./docs/manual.md]() and [./docs/syntax-reference.md]()
|
||||||
- Changes the default font color to red using `styles.css`.
|
> REMARK: as of this version of documentation, the manual and syntax reference are empty :-)
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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.
|
Each time after creating or updating the sorting specification click the [ribbon icon](#ribbon_icon) to parse the
|
||||||
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
|
specification and actually apply the custom sorting in File Explorer
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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.
|
The [ribbon icon](#ribbon_icon) acts also as the visual indicator of the current state of the plugin - see
|
||||||
- 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.
|
the [ribbon icon](#ribbon_icon) section for details
|
||||||
- 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.
|
|
||||||
|
|
||||||
> 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`.
|
### Simple case 1: in root folder sort items by a rule, intermixing folders and files
|
||||||
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
|
|
||||||
|
|
||||||
## 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
|
> IMPORTANT: indentation matters in all of the examples
|
||||||
- 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.
|
|
||||||
|
|
||||||
## How to use
|
```yaml
|
||||||
|
---
|
||||||
|
sorting-spec: |
|
||||||
|
target-folder: /
|
||||||
|
< a-z
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
- Clone this repo.
|
which can result in:
|
||||||
- `npm i` or `yarn` to install dependencies
|
|
||||||
- `npm run dev` to start compilation in watch mode.
|
|
||||||
|
|
||||||
## 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)
|
The specification here lists items (files and folders) by name in the desired order
|
||||||
- [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\`
|
|
||||||
|
|
||||||
|
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: {
|
banner: {
|
||||||
js: banner,
|
js: banner,
|
||||||
},
|
},
|
||||||
entryPoints: ['main.ts'],
|
entryPoints: ['src/main.ts'],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
external: [
|
external: [
|
||||||
'obsidian',
|
'obsidian',
|
||||||
|
@ -47,6 +47,7 @@ esbuild.build({
|
||||||
target: 'es2016',
|
target: 'es2016',
|
||||||
logLevel: "info",
|
logLevel: "info",
|
||||||
sourcemap: prod ? false : 'inline',
|
sourcemap: prod ? false : 'inline',
|
||||||
|
minify: prod,
|
||||||
treeShaking: true,
|
treeShaking: true,
|
||||||
outfile: 'main.js',
|
outfile: 'dist/main.js',
|
||||||
}).catch(() => process.exit(1));
|
}).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",
|
"id": "custom-sort",
|
||||||
"name": "Sample Plugin",
|
"name": "Custom File Explorer sorting",
|
||||||
"version": "1.0.1",
|
"version": "0.5.188",
|
||||||
"minAppVersion": "0.12.0",
|
"minAppVersion": "0.12.0",
|
||||||
"description": "This is a sample plugin for Obsidian. This plugin demonstrates some of the capabilities of the Obsidian API.",
|
"description": "Allows for manual and automatic, config-driven reordering and sorting of files and folders in File Explorer",
|
||||||
"author": "Obsidian",
|
"author": "SebastianMC <SebastianMC.github@gmail.com>",
|
||||||
"authorUrl": "https://obsidian.md",
|
"authorUrl": "https://github.com/SebastianMC",
|
||||||
"isDesktopOnly": false
|
"isDesktopOnly": false
|
||||||
}
|
}
|
||||||
|
|
22
package.json
|
@ -1,23 +1,31 @@
|
||||||
{
|
{
|
||||||
"name": "obsidian-sample-plugin",
|
"name": "obsidian-custom-sort",
|
||||||
"version": "1.0.1",
|
"version": "0.5.188",
|
||||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
"description": "Custom Sort plugin for Obsidian (https://obsidian.md)",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node esbuild.config.mjs",
|
"dev": "node esbuild.config.mjs",
|
||||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
"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": [],
|
"keywords": [
|
||||||
"author": "",
|
"obsidian",
|
||||||
|
"custom sorting"
|
||||||
|
],
|
||||||
|
"author": "SebastianMC <SebastianMC.github@gmail.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jest": "^28.1.2",
|
||||||
"@types/node": "^16.11.6",
|
"@types/node": "^16.11.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.2.0",
|
"@typescript-eslint/eslint-plugin": "^5.2.0",
|
||||||
"@typescript-eslint/parser": "^5.2.0",
|
"@typescript-eslint/parser": "^5.2.0",
|
||||||
"builtin-modules": "^3.2.0",
|
"builtin-modules": "^3.2.0",
|
||||||
"esbuild": "0.13.12",
|
"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",
|
"tslib": "2.3.1",
|
||||||
"typescript": "4.4.4"
|
"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! */
|
/* Unsure if this file of plugin is required (even empty) or not */
|
||||||
body {
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
|
|
|
@ -3,9 +3,10 @@
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"inlineSourceMap": true,
|
"inlineSourceMap": true,
|
||||||
"inlineSources": true,
|
"inlineSources": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"target": "ES6",
|
"target": "ES6",
|
||||||
"allowJs": true,
|
"allowJs": false,
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
|
|