diff --git a/.gitignore b/.gitignore
index e09a007..5a247e1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,22 +1,23 @@
-# vscode
-.vscode
-
-# Intellij
-*.iml
-.idea
-
-# npm
-node_modules
-
-# Don't include the compiled main.js file in the repo.
-# They should be uploaded to GitHub releases instead.
-main.js
-
-# Exclude sourcemaps
-*.map
-
-# obsidian
-data.json
-
-# Exclude macOS Finder (System Explorer) View States
-.DS_Store
+# vscode
+.vscode
+
+# Intellij
+*.iml
+.idea
+
+# npm
+node_modules
+
+# Don't include the compiled main.js file in the repo.
+# They should be uploaded to GitHub releases instead.
+main.js
+
+# Exclude sourcemaps
+*.map
+
+# obsidian
+data.json
+
+# Exclude macOS Finder (System Explorer) View States
+.DS_Store
+/yarn.lock
diff --git a/README.md b/README.md
index b8f865d..c4f7f96 100644
--- a/README.md
+++ b/README.md
@@ -1,73 +1,334 @@
-# Obsidian Sample Plugin
+## Custom Sorting Order in File Explorer (https://obsidian.md plugin)
-This is a sample plugin for Obsidian (https://obsidian.md).
+Take full control of the order of your notes and folders in File Explorer
-This project uses Typescript to provide type checking and documentation.
-The repo depends on the latest plugin API (obsidian.d.ts) in Typescript Definition format, which contains TSDoc comments describing what it does.
+- folder-level configuration
+ - each folder can have its own order or use the global Obsidian setting
+- use sorting and grouping rules or direct order specification or mixed
+- versatile configuration options
+- order configuration stored directly in your note(s) front matter
+ - use a dedicated key in YAML
-**Note:** The Obsidian API is still in early alpha and is subject to change at any time!
+## TL;DR Usage
-This sample plugin demonstrates some of the basic functionality the plugin API can do.
-- Changes the default font color to red using `styles.css`.
-- Adds a ribbon icon, which shows a Notice when clicked.
-- Adds a command "Open Sample Modal" which opens a Modal.
-- Adds a plugin setting tab to the settings page.
-- Registers a global click event and output 'click' to the console.
-- Registers a global interval which logs 'setInterval' to the console.
+For full version of the manual go to [./docs/manual.md]() and [./docs/syntax-reference.md]()
+> REMARK: as of this version of documentation, the manual and syntax reference are empty :-)
-## First time developing plugins?
+Below go examples of (some of) the key features, ready to copy & paste to your vault.
-Quick starting guide for new plugin devs:
+For simplicity (if you are examining the plugin for the first time) copy and paste the below YAML snippets to the front
+matter of the `sortspec` note (which is `sortspec.md` file under the hood). Create such note at any location in your
+vault if you don't have one.
-- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
-- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
-- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
-- Install NodeJS, then run `npm i` in the command line under your repo folder.
-- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
-- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
-- Reload Obsidian to load the new version of your plugin.
-- Enable plugin in settings window.
-- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
+Each time after creating or updating the sorting specification click the [ribbon icon](#ribbon_icon) to parse the
+specification and actually apply the custom sorting in File Explorer
-## Releasing new releases
+Click the [ribbon icon](#ribbon_icon) again to disable custom sorting and switch back to the standard Obsidian sorting.
-- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
-- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
-- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
-- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
-- Publish the release.
+The [ribbon icon](#ribbon_icon) acts also as the visual indicator of the current state of the plugin - see
+the [ribbon icon](#ribbon_icon) section for details
-> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
-> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
+### Simple case 1: in root folder sort items by a rule, intermixing folders and files
-## Adding your plugin to the community plugin list
+The specified rule is to sort items alphabetically
-- Check https://github.com/obsidianmd/obsidian-releases/blob/master/plugin-review.md
-- Publish an initial version.
-- Make sure you have a `README.md` file in the root of your repo.
-- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
+> IMPORTANT: indentation matters in all of the examples
-## How to use
+```yaml
+---
+sorting-spec: |
+ target-folder: /
+ < a-z
+---
+```
-- Clone this repo.
-- `npm i` or `yarn` to install dependencies
-- `npm run dev` to start compilation in watch mode.
+which can result in:
-## Manually installing the plugin
+
-- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
+### Simple case 2: impose manual order of some items in root folder
-## Improve code quality with eslint (optional)
-- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
-- To use eslint with this project, make sure to install eslint from terminal:
- - `npm install -g eslint`
-- To use eslint to analyze this project use this command:
- - `eslint main.ts`
- - eslint will then create a report with suggestions for code improvement by file and line number.
-- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder:
- - `eslint .\src\`
+The specification here lists items (files and folders) by name in the desired order
+Notice, that only a subset of items was listed. Unlisted items go after the specified ones, if the specification
+doesn't say otherwise
-## API Documentation
+```yaml
+---
+sorting-spec: |
+ target-folder: /
+ Note 1
+ Z Archive
+ Some note
+ Some folder
+---
+```
+
+produces:
+
+
+
+### Example 3: In root folder, let files go first and folders get pushed to the bottom
+
+Files go first, sorted by modification date descending (newest note in the top)
+
+Then go folders, sorted in reverse alphabetical order
+
+> IMPORTANT: Again, indentation matters in all of the examples. Notice that the order specification `< modified` for
+> the `/:files` and the order `> a-z` for `/folders` are indented by one more space. The indentation says the order
+> applies
+> to the group and not to the 'target-folder' directly.
+>
+> And yes, each group can have a different order in the same parent folder
+
+```yaml
+---
+sorting-spec: |
+ target-folder: /
+ /:files
+ < modified
+ /folders
+ > a-z
+---
+```
+
+will order items as:
+
+
+
+### Example 4: In root folder, pin a focus note, then Inbox folder, and push archive to the bottom
+
+The specification below says:
+
+- first go items which name starts with 'Focus' (e.g. the notes to pin to the top)
+ - notice the usage of '...' wildcard
+- then goes an item named 'Inbox' (my Inbox folder)
+- then go all items not matching any of the above or below rules/names/patterns
+ - the special symbol `%` has that meaning
+- then, second to the bottom goes the 'Archive' (a folder which doesn't need focus)
+- and finally, in the very bottom, the `sortspec.md` file, which probably contains this sorting specification ;-)
+
+```yaml
+---
+sorting-spec: |
+ target-folder: .
+ Focus...
+ Inbox
+ %
+ Archive
+ sortspec
+---
+```
+
+and the result will be:
+
+
+
+> Remarks for the `target-folder:`
+>
+> In this example the dot '.' symbol was used `target-folder: .` which means _apply the sorting specification to the
+folder which contains the note with the specification_.
+>
+> If the `target-folder:` line is omitted, the specification will be applied to the parent folder of the note, which has
+> the same effect as `target-folder: .`
+
+### Example 5: P.A.R.A. method example
+
+The P.A.R.A. system for organizing digital information is based on the four specifically named folders ordered as in the
+acronym: Projects — Areas — Resources — Archives
+
+To put folders in the desired order you can simply list them by name in the needed sequence:
+
+```yaml
+---
+sorting-spec: |
+ target-folder: /
+ Projects
+ Areas
+ Responsibilities
+ Archive
+---
+```
+
+which will have the effect of:
+
+
+
+### Example 6: P.A.R.A. example with smart syntax
+
+Instead of listing full names of folders or notes, you can use the prefix or suffix of prefix+suffix notation with the
+special syntax of '...' which acts as a wildcard here, matching any sequence of characters:
+
+```yaml
+---
+sorting-spec: |
+ target-folder: /
+ Pro...
+ A...s
+ Res...es
+ ...ive
+---
+```
+
+It will give exactly the same order as in previous example:
+
+
+
+```
+REMARK: the wildcard expression '...' can be used only once per line
+```
+
+### Example 7: Apply the same sorting rules to two folders
+
+Let's tell a few folders to sort their child notes and child folders by created date reverse order (newer go first)
+
+```yaml
+---
+sorting-spec: |
+ target-folder: Some subfolder
+ target-folder: Archive
+ target-folder: Archive/2021/Completed projects
+ > created
+---
+```
+
+No visualization for this example needed
+
+### Example 8: Specify rules for multiple folders
+
+The specification can contain rules and orders for more than one folder
+
+Personally I find convenient to keep sorting specification of all folders in a vault in a single place, e.g. in a
+dedicated note Inbox/Inbox.md
+
+```yaml
+---
+sorting-spec: |
+ target-folder: /
+ Pro...
+ Archive
+
+ target-folder: Projects
+ Top Secret
+
+ target-folder: Archive
+ > a-z
+---
+```
+
+will have the effect of:
+
+
+
+### Example 9: Sort by numerical suffix
+
+This is interesting.
+
+Sorting by numerical prefix is easy and doesn't require any additional plugin in Obsidian.
+At the same time sorting by numerical suffix is not feasible without a plugin like this one.
+
+Use the specification like below to order notes in 'Inbox' subfolder of 'Data' folder by the numerical suffix indicated
+by the 'part' token (an arbitrary example)
+
+```yaml
+---
+sorting-spec: |
+ target-folder: Data/Inbox
+ ... part \d+
+ < a-z
+---
+```
+
+the line `... part \d+` says: group all notes and folders with name ending with 'part' followed by a number. Then order
+them by the number. And for clarity the subsequent (indented) line is added ` < a-z` which sets the order to
+alphanumerical ascending.
+
+The effect is:
+
+
+
+## Location of sorting specification YAML entry
+
+You can keep the custom sorting specifications in any of the following locations (or in all of them):
+
+- in the front matter of the `sortspec` note (which is the `sortspec.md` file under the hood)
+ - you can keep one global `sortspec` note or one `sortspec` in each folder for which you set up a custom sorting
+ - YAML in front matter of all existing `sortspec` notes is scanned, so feel free to choose your preferred approach
+- in the front matter of the - so called - _folder note_. For instance '/References/References.md'
+ - the 'folder note' is a concept of note named exactly as its parent folder, e.g. `references` note (
+ actually `references.md` file) residing inside the `/references/` folder
+ - there are popular Obsidian plugins which allow convenient access and editing of folder note, plus hiding it in the
+ notes list
+- in the front matter of a **designated note** configured in setting
+ - in settings page of the plugin in obsidian you can set the exact path to the designated note
+ - by default, it is `Inbox/Inbox.md`
+ - feel free to adjust it to your preferences
+ - primary intention is to use this setting as the reminder note to yourself, to easily locate the note containing
+ sorting specifications for the vault
+
+A sorting specification for a folder has to reside in a single YAML entry in one of the listed locations.
+At the same time, you can put specifications for different target folders into different notes, according to your
+preference.
+My personal approach is to keep the sorting specification for all desired folders in a single note (
+e.g. `Inbox/Inbox.md`). And for clarity, I keep the name of that designated note in the plugin settings, for easy
+reference.
+
+
+
+## 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
diff --git a/docs/icons/icon-active.png b/docs/icons/icon-active.png
new file mode 100644
index 0000000..11b3e12
Binary files /dev/null and b/docs/icons/icon-active.png differ
diff --git a/docs/icons/icon-error.png b/docs/icons/icon-error.png
new file mode 100644
index 0000000..070ed6b
Binary files /dev/null and b/docs/icons/icon-error.png differ
diff --git a/docs/icons/icon-inactive.png b/docs/icons/icon-inactive.png
new file mode 100644
index 0000000..916171e
Binary files /dev/null and b/docs/icons/icon-inactive.png differ
diff --git a/docs/icons/icon-not-applied.png b/docs/icons/icon-not-applied.png
new file mode 100644
index 0000000..e76a748
Binary files /dev/null and b/docs/icons/icon-not-applied.png differ
diff --git a/docs/manual.md b/docs/manual.md
new file mode 100644
index 0000000..6304860
--- /dev/null
+++ b/docs/manual.md
@@ -0,0 +1,3 @@
+Yet to be filled with content ;-)
+
+See [syntax-reference.md](), maybe that file has already some content?
diff --git a/docs/svg/by-suffix.svg b/docs/svg/by-suffix.svg
new file mode 100644
index 0000000..6910118
--- /dev/null
+++ b/docs/svg/by-suffix.svg
@@ -0,0 +1,70 @@
+
+
+
diff --git a/docs/svg/files-go-first.svg b/docs/svg/files-go-first.svg
new file mode 100644
index 0000000..a660005
--- /dev/null
+++ b/docs/svg/files-go-first.svg
@@ -0,0 +1,65 @@
+
+
+
diff --git a/docs/svg/multi-folder.svg b/docs/svg/multi-folder.svg
new file mode 100644
index 0000000..1ddc82b
--- /dev/null
+++ b/docs/svg/multi-folder.svg
@@ -0,0 +1,63 @@
+
+
+
diff --git a/docs/svg/p_a_r_a.svg b/docs/svg/p_a_r_a.svg
new file mode 100644
index 0000000..5d3a18b
--- /dev/null
+++ b/docs/svg/p_a_r_a.svg
@@ -0,0 +1,70 @@
+
+
+
diff --git a/docs/svg/pin-focus-note.svg b/docs/svg/pin-focus-note.svg
new file mode 100644
index 0000000..0343c2f
--- /dev/null
+++ b/docs/svg/pin-focus-note.svg
@@ -0,0 +1,63 @@
+
+
+
diff --git a/docs/svg/simplest-example-2.svg b/docs/svg/simplest-example-2.svg
new file mode 100644
index 0000000..c6adcc8
--- /dev/null
+++ b/docs/svg/simplest-example-2.svg
@@ -0,0 +1,65 @@
+
+
+
diff --git a/docs/svg/simplest-example.svg b/docs/svg/simplest-example.svg
new file mode 100644
index 0000000..3bf0cc1
--- /dev/null
+++ b/docs/svg/simplest-example.svg
@@ -0,0 +1,66 @@
+
+
+
diff --git a/docs/syntax-reference.md b/docs/syntax-reference.md
new file mode 100644
index 0000000..da2dd93
--- /dev/null
+++ b/docs/syntax-reference.md
@@ -0,0 +1,3 @@
+Yet to be filled with content ;-)
+
+Check [manual.md](), maybe that file has already some content?
diff --git a/esbuild.config.mjs b/esbuild.config.mjs
index f1fe201..fca6656 100644
--- a/esbuild.config.mjs
+++ b/esbuild.config.mjs
@@ -15,7 +15,7 @@ esbuild.build({
banner: {
js: banner,
},
- entryPoints: ['main.ts'],
+ entryPoints: ['src/main.ts'],
bundle: true,
external: [
'obsidian',
@@ -47,6 +47,7 @@ esbuild.build({
target: 'es2016',
logLevel: "info",
sourcemap: prod ? false : 'inline',
+ minify: prod,
treeShaking: true,
- outfile: 'main.js',
+ outfile: 'dist/main.js',
}).catch(() => process.exit(1));
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 0000000..9125e68
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,12 @@
+/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'node',
+ roots: [""],
+ moduleNameMapper: {
+ "obsidian": "/node_modules/obsidian/obsidian.d.ts"
+ },
+ transformIgnorePatterns: [
+ 'node_modules/(?!obsidian/.*)'
+ ]
+};
diff --git a/main.ts b/main.ts
deleted file mode 100644
index 50b75f3..0000000
--- a/main.ts
+++ /dev/null
@@ -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();
- }));
- }
-}
diff --git a/manifest.json b/manifest.json
index 60626c5..8083d65 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,10 +1,10 @@
{
- "id": "obsidian-sample-plugin",
- "name": "Sample Plugin",
- "version": "1.0.1",
+ "id": "custom-sort",
+ "name": "Custom File Explorer sorting",
+ "version": "0.5.188",
"minAppVersion": "0.12.0",
- "description": "This is a sample plugin for Obsidian. This plugin demonstrates some of the capabilities of the Obsidian API.",
- "author": "Obsidian",
- "authorUrl": "https://obsidian.md",
+ "description": "Allows for manual and automatic, config-driven reordering and sorting of files and folders in File Explorer",
+ "author": "SebastianMC ",
+ "authorUrl": "https://github.com/SebastianMC",
"isDesktopOnly": false
}
diff --git a/package.json b/package.json
index fd44f15..3da91fc 100644
--- a/package.json
+++ b/package.json
@@ -1,23 +1,31 @@
{
- "name": "obsidian-sample-plugin",
- "version": "1.0.1",
- "description": "This is a sample plugin for Obsidian (https://obsidian.md)",
+ "name": "obsidian-custom-sort",
+ "version": "0.5.188",
+ "description": "Custom Sort plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
- "version": "node version-bump.mjs && git add manifest.json versions.json"
+ "version": "node version-bump.mjs && git add manifest.json versions.json",
+ "test": "jest"
},
- "keywords": [],
- "author": "",
+ "keywords": [
+ "obsidian",
+ "custom sorting"
+ ],
+ "author": "SebastianMC ",
"license": "MIT",
"devDependencies": {
+ "@types/jest": "^28.1.2",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "^5.2.0",
"@typescript-eslint/parser": "^5.2.0",
"builtin-modules": "^3.2.0",
"esbuild": "0.13.12",
- "obsidian": "latest",
+ "jest": "^28.1.1",
+ "monkey-around": "^2.3.0",
+ "obsidian": "^0.15.4",
+ "ts-jest": "^28.0.5",
"tslib": "2.3.1",
"typescript": "4.4.4"
}
diff --git a/src/custom-sort/custom-sort-types.ts b/src/custom-sort/custom-sort-types.ts
new file mode 100644
index 0000000..8b293ef
--- /dev/null
+++ b/src/custom-sort/custom-sort-types.ts
@@ -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 // For root use '/'
+ defaultOrder?: CustomSortOrder
+ groups: Array
+ outsidersGroupIdx?: number
+ outsidersFilesGroupIdx?: number
+ outsidersFoldersGroupIdx?: number
+ itemsToHide?: Set
+}
+
+export interface FolderPathToSortSpecMap {
+ [key: string]: CustomSortSpec
+}
+
+export type SortSpecsCollection = FolderPathToSortSpecMap
diff --git a/src/custom-sort/custom-sort.spec.ts b/src/custom-sort/custom-sort.spec.ts
new file mode 100644
index 0000000..2d53d60
--- /dev/null
+++ b/src/custom-sort/custom-sort.spec.ts
@@ -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'
+ });
+ })
+ })
+})
diff --git a/src/custom-sort/custom-sort.ts b/src/custom-sort/custom-sort.ts
new file mode 100644
index 0000000..564b086
--- /dev/null
+++ b/src/custom-sort/custom-sort.ts
@@ -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 = (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;
+ }
+};
diff --git a/src/custom-sort/icons.ts b/src/custom-sort/icons.ts
new file mode 100644
index 0000000..2547c03
--- /dev/null
+++ b/src/custom-sort/icons.ts
@@ -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,
+ `
+
+
+
+
+`
+ )
+ addIcon(ICON_SORT_SUSPENDED,
+ `
+`
+ )
+ addIcon(ICON_SORT_SUSPENDED_SYNTAX_ERROR,
+ `
+
+
+
+
+`
+ )
+ addIcon(ICON_SORT_ENABLED_NOT_APPLIED,
+ `
+
+
+`
+ )
+}
diff --git a/src/custom-sort/matchers.spec.ts b/src/custom-sort/matchers.spec.ts
new file mode 100644
index 0000000..41cbdb9
--- /dev/null
+++ b/src/custom-sort/matchers.spec.ts
@@ -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)
+ })
+})
diff --git a/src/custom-sort/matchers.ts b/src/custom-sort/matchers.ts
new file mode 100644
index 0000000..b296722
--- /dev/null
+++ b/src/custom-sort/matchers.ts
@@ -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 = 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 = [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 = 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)}//`
+ }
+}
diff --git a/src/custom-sort/sorting-spec-processor.spec.ts b/src/custom-sort/sorting-spec-processor.spec.ts
new file mode 100644
index 0000000..c760ba1
--- /dev/null
+++ b/src/custom-sort/sorting-spec-processor.spec.ts
@@ -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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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)
+ })
+})
diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts
new file mode 100644
index 0000000..82d41f1
--- /dev/null
+++ b/src/custom-sort/sorting-spec-processor.ts
@@ -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
+ 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
+ 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.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 = [
+ 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,
+ 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 => {
+ 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 = this.ctx.currentSpec.itemsToHide ?? new Set()
+ 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
+ }
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..7bf6dd1
--- /dev/null
+++ b/src/main.ts
@@ -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();
+ }));
+ }
+}
diff --git a/src/types/types.d.ts b/src/types/types.d.ts
new file mode 100644
index 0000000..3fe852c
--- /dev/null
+++ b/src/types/types.d.ts
@@ -0,0 +1,20 @@
+import {TFolder, WorkspaceLeaf} from "obsidian";
+
+declare module 'obsidian' {
+ export interface ViewRegistry {
+ viewByType: Record unknown>;
+ }
+
+ export interface App {
+ viewRegistry: ViewRegistry;
+ }
+
+ interface FileExplorerFolder {
+ }
+
+ export interface FileExplorerView extends View {
+ createFolderDom(folder: TFolder): FileExplorerFolder;
+
+ requestSort(): void;
+ }
+}
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
new file mode 100644
index 0000000..db8a783
--- /dev/null
+++ b/src/utils/utils.ts
@@ -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(o: Array): T | undefined {
+ return o?.length > 0 ? o[o.length - 1] : undefined
+}
diff --git a/styles.css b/styles.css
index 586fdea..5a3bf60 100644
--- a/styles.css
+++ b/styles.css
@@ -1,4 +1,2 @@
-/* Sets all the text color to red! */
-body {
- color: red;
-}
+/* Unsure if this file of plugin is required (even empty) or not */
+
diff --git a/tsconfig.json b/tsconfig.json
index 1383e2f..ed4da99 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,21 +1,22 @@
{
"compilerOptions": {
- "baseUrl": ".",
- "inlineSourceMap": true,
- "inlineSources": true,
- "module": "ESNext",
- "target": "ES6",
- "allowJs": true,
- "noImplicitAny": true,
- "moduleResolution": "node",
- "importHelpers": true,
- "isolatedModules": true,
- "lib": [
- "DOM",
- "ES5",
- "ES6",
- "ES7"
- ]
+ "baseUrl": ".",
+ "inlineSourceMap": true,
+ "inlineSources": true,
+ "allowSyntheticDefaultImports": true,
+ "module": "ESNext",
+ "target": "ES6",
+ "allowJs": false,
+ "noImplicitAny": true,
+ "moduleResolution": "node",
+ "importHelpers": true,
+ "isolatedModules": true,
+ "lib": [
+ "DOM",
+ "ES5",
+ "ES6",
+ "ES7"
+ ]
},
"include": [
"**/*.ts"