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 +![Simplest example](./docs/svg/simplest-example.svg) -- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. +### Simple case 2: impose manual order of some items in root folder -## Improve code quality with eslint (optional) -- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code. -- To use eslint with this project, make sure to install eslint from terminal: - - `npm install -g eslint` -- To use eslint to analyze this project use this command: - - `eslint main.ts` - - eslint will then create a report with suggestions for code improvement by file and line number. -- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: - - `eslint .\src\` +The specification here lists items (files and folders) by name in the desired order +Notice, that only a subset of items was listed. Unlisted items go after the specified ones, if the specification +doesn't say otherwise -## API Documentation +```yaml +--- +sorting-spec: | + target-folder: / + Note 1 + Z Archive + Some note + Some folder +--- +``` + +produces: + +![Simplest example](./docs/svg/simplest-example-2.svg) + +### Example 3: In root folder, let files go first and folders get pushed to the bottom + +Files go first, sorted by modification date descending (newest note in the top) + +Then go folders, sorted in reverse alphabetical order + +> IMPORTANT: Again, indentation matters in all of the examples. Notice that the order specification `< modified` for +> the `/:files` and the order `> a-z` for `/folders` are indented by one more space. The indentation says the order +> applies +> to the group and not to the 'target-folder' directly. +> +> And yes, each group can have a different order in the same parent folder + +```yaml +--- +sorting-spec: | + target-folder: / + /:files + < modified + /folders + > a-z +--- +``` + +will order items as: + +![Files go first example](./docs/svg/files-go-first.svg) + +### Example 4: In root folder, pin a focus note, then Inbox folder, and push archive to the bottom + +The specification below says: + +- first go items which name starts with 'Focus' (e.g. the notes to pin to the top) + - notice the usage of '...' wildcard +- then goes an item named 'Inbox' (my Inbox folder) +- then go all items not matching any of the above or below rules/names/patterns + - the special symbol `%` has that meaning +- then, second to the bottom goes the 'Archive' (a folder which doesn't need focus) +- and finally, in the very bottom, the `sortspec.md` file, which probably contains this sorting specification ;-) + +```yaml +--- +sorting-spec: | + target-folder: . + Focus... + Inbox + % + Archive + sortspec +--- +``` + +and the result will be: + +![Result of the example](./docs/svg/pin-focus-note.svg) + +> Remarks for the `target-folder:` +> +> In this example the dot '.' symbol was used `target-folder: .` which means _apply the sorting specification to the +folder which contains the note with the specification_. +> +> If the `target-folder:` line is omitted, the specification will be applied to the parent folder of the note, which has +> the same effect as `target-folder: .` + +### Example 5: P.A.R.A. method example + +The P.A.R.A. system for organizing digital information is based on the four specifically named folders ordered as in the +acronym: Projects — Areas — Resources — Archives + +To put folders in the desired order you can simply list them by name in the needed sequence: + +```yaml +--- +sorting-spec: | + target-folder: / + Projects + Areas + Responsibilities + Archive +--- +``` + +which will have the effect of: + +![Result of the example](./docs/svg/p_a_r_a.svg) + +### Example 6: P.A.R.A. example with smart syntax + +Instead of listing full names of folders or notes, you can use the prefix or suffix of prefix+suffix notation with the +special syntax of '...' which acts as a wildcard here, matching any sequence of characters: + +```yaml +--- +sorting-spec: | + target-folder: / + Pro... + A...s + Res...es + ...ive +--- +``` + +It will give exactly the same order as in previous example: + +![Result of the example](./docs/svg/p_a_r_a.svg) + +``` +REMARK: the wildcard expression '...' can be used only once per line +``` + +### Example 7: Apply the same sorting rules to two folders + +Let's tell a few folders to sort their child notes and child folders by created date reverse order (newer go first) + +```yaml +--- +sorting-spec: | + target-folder: Some subfolder + target-folder: Archive + target-folder: Archive/2021/Completed projects + > created +--- +``` + +No visualization for this example needed + +### Example 8: Specify rules for multiple folders + +The specification can contain rules and orders for more than one folder + +Personally I find convenient to keep sorting specification of all folders in a vault in a single place, e.g. in a +dedicated note Inbox/Inbox.md + +```yaml +--- +sorting-spec: | + target-folder: / + Pro... + Archive + + target-folder: Projects + Top Secret + + target-folder: Archive + > a-z +--- +``` + +will have the effect of: + +![Result of the example](./docs/svg/multi-folder.svg) + +### Example 9: Sort by numerical suffix + +This is interesting. + +Sorting by numerical prefix is easy and doesn't require any additional plugin in Obsidian. +At the same time sorting by numerical suffix is not feasible without a plugin like this one. + +Use the specification like below to order notes in 'Inbox' subfolder of 'Data' folder by the numerical suffix indicated +by the 'part' token (an arbitrary example) + +```yaml +--- +sorting-spec: | + target-folder: Data/Inbox + ... part \d+ + < a-z +--- +``` + +the line `... part \d+` says: group all notes and folders with name ending with 'part' followed by a number. Then order +them by the number. And for clarity the subsequent (indented) line is added ` < a-z` which sets the order to +alphanumerical ascending. + +The effect is: + +![Order by numerical suffix](./docs/svg/by-suffix.svg) + +## Location of sorting specification YAML entry + +You can keep the custom sorting specifications in any of the following locations (or in all of them): + +- in the front matter of the `sortspec` note (which is the `sortspec.md` file under the hood) + - you can keep one global `sortspec` note or one `sortspec` in each folder for which you set up a custom sorting + - YAML in front matter of all existing `sortspec` notes is scanned, so feel free to choose your preferred approach +- in the front matter of the - so called - _folder note_. For instance '/References/References.md' + - the 'folder note' is a concept of note named exactly as its parent folder, e.g. `references` note ( + actually `references.md` file) residing inside the `/references/` folder + - there are popular Obsidian plugins which allow convenient access and editing of folder note, plus hiding it in the + notes list +- in the front matter of a **designated note** configured in setting + - in settings page of the plugin in obsidian you can set the exact path to the designated note + - by default, it is `Inbox/Inbox.md` + - feel free to adjust it to your preferences + - primary intention is to use this setting as the reminder note to yourself, to easily locate the note containing + sorting specifications for the vault + +A sorting specification for a folder has to reside in a single YAML entry in one of the listed locations. +At the same time, you can put specifications for different target folders into different notes, according to your +preference. +My personal approach is to keep the sorting specification for all desired folders in a single note ( +e.g. `Inbox/Inbox.md`). And for clarity, I keep the name of that designated note in the plugin settings, for easy +reference. + + + +## Ribbon icon + +Click the ribbon icon to toggle the plugin between enabled and suspended states. + +States of the ribbon icon: + +- ![Inactive](./docs/icons/icon-inactive.png) Plugin suspended. Custom sorting NOT applied. + - Click to enable and apply custom sorting. + - Note: parsing of the custom sorting specification happens after clicking the icon. If the specification contains + errors, they will show up in the notice baloon and also in developer console. +- ![Active](./docs/icons/icon-active.png) Plugin active, custom sorting applied. + - Click to suspend and return to the standard Obsidian sorting in File Explorer. +- ![Error](./docs/icons/icon-error.png) Syntax error in custom sorting configuration. + - Fix the problem in specification and click the ribbon icon to re-enable custom sorting. + - If syntax error is not fixed, the notice baloon with show error details. Syntax error details are also visible in + the developer console +- ![Sorting not applied](./docs/icons/icon-not-applied.png) Plugin enabled but the custom sorting was not applied. + - This can happen when reinstalling the plugin and in similar cases + - Click the ribbon icon twice to re-enable the custom sorting. + +## Installing the plugin + +As for now, the plugin can be installed manually or via the BRAT plugin + +### Installing the plugin using BRAT + +1. Install the BRAT plugin + 1. Open `Settings` -> `Community Plugins` + 2. Disable restricted (formerly 'safe') mode, if enabled + 3. *Browse*, and search for "BRAT" + 4. Install the latest version of **Obsidian 42 - BRAT** +2. Open BRAT settings (`Settings` -> `Obsidian 42 - BRAT`) + 1. Scroll to the `Beta Plugin List` section + 2. `Add Beta Plugin` + 3. Specify this repository: `SebastianMC/obsidian-custom-sort` +3. Enable the `Custom File Explorer sorting` plugin (`Settings` -> `Community Plugins`) + +### Manually installing the plugin + +1. Go to Github for releases: https://github.com/SebastianMC/obsidian-custom-sort/releases +2. Download the Latest Release from the Releases section of the GitHub Repository +3. Copy the downloaded files `main.js`, `styles.css`, `manifest.json` over to your + vault `VaultFolder/.obsidian/plugins/custom-sort/`. + - you might need to manually create the `/custom-sort/` folder under `VaultFolder/.obsidian/plugins/` +4. Reload Obsidian +5. If prompted about Restricted (formerly 'Safe') Mode, you can disable restricted mode and enable the plugin. + -Otherwise, go to `Settings` -> `Community plugins`, make sure restricted mode is off and enable the plugin from + there. + +> Note: The `.obsidian` folder may be hidden. +> On macOS, you should be able to press Command+Shift+Dot to show the folder in Finder. + +## Credits + +Thanks to [Nothingislost](https://github.com/nothingislost) for the monkey-patching ideas of File Explorer +in [obsidian-bartender](https://github.com/nothingislost/obsidian-bartender) -See https://github.com/obsidianmd/obsidian-api 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 @@ + + + + + Produced by OmniGraffle 7.20\n2022-08-06 00:43:11 +0000 + + by suffix + + Layer 1 + + + + + + Data + + + + + Inbox + + + + + Mhmmm part 1 + + + + + Interesting part 20 + + + + + + Interim part 333 + + + + + + + + My + Vault + + + + + + Final part 401 + + + + + + + + + + + Note with no suffix + + + + + + 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 @@ + + + + + Produced by OmniGraffle 7.20\n2022-08-05 22:37:20 +0000 + + Files go first + + Layer 1 + + + + + + Note (oldest) + + + + + Note (old) + + + + + Note (newest) + + + + + Subfolder B + + + + + Subfolder A + + + + + + + My + Vault + + + + + + + + + Subfolder + + + + + + + + + + + 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 @@ + + + + + Produced by OmniGraffle 7.20\n2022-08-05 22:01:57 +0000 + + Multi-folder spec + + Layer 1 + + + + + + Projects + + + + + Top Secret + + + + + Experiment A.5-3 + + + + + + Archive + + + + + Something newer + + + + + + + My + Vault + + + + + + + + + Oooold + + + + + + + + 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 @@ + + + + + Produced by OmniGraffle 7.20\n2022-08-05 16:57:13 +0000 + + P.A.R.A + + Layer 1 + + + + + + Projects + + + + + Top Secret + + + + + Experiment A.5-3 + + + + + + Areas + + + + + Responsibilities + + + + + + + + My + Vault + + + + + + + + + Archive + + + + + + + + + + + + + + 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 @@ + + + + + Produced by OmniGraffle 7.20\n2022-08-05 23:01:43 +0000 + + Pin focus note + + Layer 1 + + + + + + Focus note XYZ + + + + + Inbox + + + + + Some note + + + + + Yet another note + + + + + + Archive + + + + + + + My + Vault + + + + + + sortspec + + + + + + + + + + + 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 @@ + + + + + Produced by OmniGraffle 7.20\n2022-08-05 22:12:33 +0000 + + Simplest 2 + + Layer 1 + + + + + + Note 1 + + + + + Z Archive + + + + + Some note + + + + + Some folder + + + + + A folder + + + + + + + My + Vault + + + + + + + + + Note 2 + + + + + + + + + + + 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 @@ + + + + + Produced by OmniGraffle 7.20\n2022-08-05 19:48:11 +0000 + + Simplest + + Layer 1 + + + + + + A folder + + + + + Note 1 + + + + + Note 2 + + + + + Some folder + + + + + Some note + + + + + + + My + Vault + + + + + + + + + Z Archive folder + + + + + + + + + + + + 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"