code style(format and naming convetion) have been added

This commit is contained in:
Nick 2025-11-10 03:35:55 +01:00
parent 1db557519c
commit 32e689bbb1
17 changed files with 2121 additions and 1576 deletions

11
.prettierignore Normal file
View file

@ -0,0 +1,11 @@
node_modules
release
dist
build
coverage
.obsidian
*.min.js
*.min.css
package-lock.json
versions.json
*.md

11
.prettierrc Normal file
View file

@ -0,0 +1,11 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"bracketSpacing": true,
"bracketSameLine": false,
"endOfLine": "lf"
}

View file

@ -1,49 +1,49 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import esbuild from 'esbuild';
import process from 'process';
import builtins from 'builtin-modules';
const banner =
`/*
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const prod = process.argv[2] === 'production';
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
banner: {
js: banner,
},
entryPoints: ['src/main.ts'],
bundle: true,
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@lezer/common',
'@lezer/highlight',
'@lezer/lr',
...builtins,
],
format: 'cjs',
target: 'es2018',
logLevel: 'info',
sourcemap: prod ? false : 'inline',
treeShaking: true,
outfile: 'main.js',
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
await context.rebuild();
process.exit(0);
} else {
await context.watch();
await context.watch();
}

View file

@ -149,98 +149,162 @@ RULE CATALOG (what and why) — with tiny examples
import z from "z";
// blank line between builtin/external/internal, etc.
*/
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import obsidianmd from "eslint-plugin-obsidianmd";
import importPlugin from "eslint-plugin-import";
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import obsidianmd from 'eslint-plugin-obsidianmd';
import importPlugin from 'eslint-plugin-import';
import unicorn from 'eslint-plugin-unicorn';
import eslintConfigPrettier from 'eslint-config-prettier';
// Scope type-checked rules to src/**/*.ts(x)
const tsTypeChecked = tseslint.configs.recommendedTypeChecked.map((cfg) => ({
...cfg,
files: ["src/**/*.ts", "src/**/*.tsx"],
...cfg,
files: ['src/**/*.ts', 'src/**/*.tsx'],
}));
const tsStrictTypeChecked = tseslint.configs.strictTypeChecked.map((cfg) => ({
...cfg,
files: ["src/**/*.ts", "src/**/*.tsx"],
...cfg,
files: ['src/**/*.ts', 'src/**/*.tsx'],
}));
export default [
// Ignore build artifacts and non-source configs
{ ignores: ["node_modules/**", "release/**", "main.js", "eslint.config.js", "scripts/**"] },
// Ignore build artifacts and non-source configs
{ ignores: ['node_modules/**', 'release/**', 'main.js', 'eslint.config.js', 'scripts/**'] },
// Base JS rules
js.configs.recommended,
// Base JS rules
js.configs.recommended,
// Base TS rules (untyped) — syntax-level safety
...tseslint.configs.recommended,
// Base TS rules (untyped) — syntax-level safety
...tseslint.configs.recommended,
// Enable typed linting for src TS files (required by many rules below)
{
files: ["src/**/*.ts", "src/**/*.tsx"],
languageOptions: {
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: process.cwd(),
},
},
},
// Enable typed linting for src TS files (required by many rules below)
{
files: ['src/**/*.ts', 'src/**/*.tsx'],
languageOptions: {
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: process.cwd(),
},
},
},
// TS recommendedTypeChecked scoped to src
...tsTypeChecked,
// TS strictTypeChecked scoped to src
...tsStrictTypeChecked,
// TS recommendedTypeChecked scoped to src
...tsTypeChecked,
// TS strictTypeChecked scoped to src
...tsStrictTypeChecked,
// Obsidian rules for src TS
{
files: ["src/**/*.ts", "src/**/*.tsx"],
plugins: { obsidianmd, import: importPlugin },
rules: {
"obsidianmd/sample-names": "off",
"obsidianmd/prefer-file-manager-trash-file": "error",
"obsidianmd/commands/no-command-in-command-id": "error",
"obsidianmd/commands/no-command-in-command-name": "error",
"obsidianmd/commands/no-default-hotkeys": "error",
"obsidianmd/commands/no-plugin-id-in-command-id": "error",
"obsidianmd/commands/no-plugin-name-in-command-name": "error",
"obsidianmd/settings-tab/no-manual-html-headings": "error",
"obsidianmd/settings-tab/no-problematic-settings-headings": "error",
"obsidianmd/vault/iterate": "error",
"obsidianmd/detach-leaves": "error",
"obsidianmd/hardcoded-config-path": "error",
"obsidianmd/no-forbidden-elements": "error",
"obsidianmd/no-plugin-as-component": "error",
"obsidianmd/no-sample-code": "error",
"obsidianmd/no-tfile-tfolder-cast": "error",
"obsidianmd/no-view-references-in-plugin": "error",
"obsidianmd/no-static-styles-assignment": "error",
"obsidianmd/object-assign": "error",
"obsidianmd/platform": "error",
"obsidianmd/regex-lookbehind": "error",
"obsidianmd/validate-manifest": "error",
"obsidianmd/validate-license": "error",
"obsidianmd/ui/sentence-case": ["error", { enforceCamelCaseLower: true }],
// Obsidian rules for src TS
{
files: ['src/**/*.ts', 'src/**/*.tsx'],
plugins: { obsidianmd, import: importPlugin },
rules: {
'obsidianmd/sample-names': 'off',
'obsidianmd/prefer-file-manager-trash-file': 'error',
'obsidianmd/commands/no-command-in-command-id': 'error',
'obsidianmd/commands/no-command-in-command-name': 'error',
'obsidianmd/commands/no-default-hotkeys': 'error',
'obsidianmd/commands/no-plugin-id-in-command-id': 'error',
'obsidianmd/commands/no-plugin-name-in-command-name': 'error',
'obsidianmd/settings-tab/no-manual-html-headings': 'error',
'obsidianmd/settings-tab/no-problematic-settings-headings': 'error',
'obsidianmd/vault/iterate': 'error',
'obsidianmd/detach-leaves': 'error',
'obsidianmd/hardcoded-config-path': 'error',
'obsidianmd/no-forbidden-elements': 'error',
'obsidianmd/no-plugin-as-component': 'error',
'obsidianmd/no-sample-code': 'error',
'obsidianmd/no-tfile-tfolder-cast': 'error',
'obsidianmd/no-view-references-in-plugin': 'error',
'obsidianmd/no-static-styles-assignment': 'error',
'obsidianmd/object-assign': 'error',
'obsidianmd/platform': 'error',
'obsidianmd/regex-lookbehind': 'error',
'obsidianmd/validate-manifest': 'error',
'obsidianmd/validate-license': 'error',
'obsidianmd/ui/sentence-case': ['error', { enforceCamelCaseLower: true }],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
"@typescript-eslint/promise-function-async": "error",
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports", fixStyle: "separate-type-imports" }],
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/no-unsafe-argument": "error",
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true, allowBoolean: false, allowNullish: false }],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }],
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'separate-type-imports' },
],
'@typescript-eslint/explicit-function-return-type': ['error', { allowExpressions: true }],
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/restrict-template-expressions': [
'error',
{ allowNumber: true, allowBoolean: false, allowNullish: false },
],
"eqeqeq": ["error", "smart"],
"curly": ["error", "all"],
"no-console": ["error", { allow: ["warn", "error", "debug"] }],
// Naming convention (popular TS setup)
'@typescript-eslint/naming-convention': [
'error',
// Variables (includes imports): allow PascalCase for constructors/components, UPPER_CASE for consts
{ selector: 'variable', format: ['camelCase', 'UPPER_CASE', 'PascalCase'] },
// Parameters: allow leading underscore
{ selector: 'parameter', format: ['camelCase'], leadingUnderscore: 'allow' },
// Class members: default camelCase
{ selector: 'memberLike', format: ['camelCase'], leadingUnderscore: 'allow' },
// Static readonly members (constants) can be UPPER_CASE
{
selector: 'memberLike',
modifiers: ['static', 'readonly'],
format: ['UPPER_CASE', 'camelCase'],
leadingUnderscore: 'allow',
},
// Types, classes, interfaces, enums, type aliases: PascalCase
{ selector: 'typeLike', format: ['PascalCase'] },
// Enum members: PascalCase (common TS convention)
{ selector: 'enumMember', format: ['PascalCase'] },
// Type parameters: PascalCase with T-prefix
{ selector: 'typeParameter', format: ['PascalCase'], prefix: ['T'] },
// Interfaces should not be prefixed with I
{
selector: 'interface',
format: ['PascalCase'],
custom: { regex: '^I[A-Z]', match: false },
},
// Quoted properties are ignored
{ selector: 'property', modifiers: ['requiresQuotes'], format: null },
],
"import/order": ["error", {
"groups": ["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"],
"newlines-between": "always",
"alphabetize": { order: "asc", caseInsensitive: true }
}]
}
}
];
eqeqeq: ['error', 'smart'],
curly: ['error', 'all'],
'no-console': ['error', { allow: ['warn', 'error', 'debug'] }],
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'index',
'object',
'type',
],
'newlines-between': 'always',
alphabetize: { order: 'asc', caseInsensitive: true },
},
],
},
},
// Enforce file naming convention (kebab-case) for source files
{
files: ['src/**/*.{ts,tsx,js,jsx}'],
plugins: { unicorn },
rules: {
'unicorn/filename-case': ['error', { cases: { kebabCase: true } }],
},
},
// Disable stylistic rules that conflict with Prettier
eslintConfigPrettier,
];

View file

@ -1,10 +1,10 @@
{
"id": "daily-notes-sorter",
"name": "Daily notes sorter",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Automatically sort daily notes and files by date in the file explorer. Supports multiple date formats (YYYY-MM-DD, DD.MM.YYYY, DD.MM.YY, MM/DD/YYYY) and flexible folder configuration.",
"author": "Nick",
"authorUrl": "https://github.com/Reifat",
"isDesktopOnly": false
"id": "daily-notes-sorter",
"name": "Daily notes sorter",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Automatically sort daily notes and files by date in the file explorer. Supports multiple date formats (YYYY-MM-DD, DD.MM.YYYY, DD.MM.YY, MM/DD/YYYY) and flexible folder configuration.",
"author": "Nick",
"authorUrl": "https://github.com/Reifat",
"isDesktopOnly": false
}

385
package-lock.json generated
View file

@ -18,13 +18,26 @@
"builtin-modules": "3.3.0",
"esbuild": "^0.25.12",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-obsidianmd": "^0.1.8",
"eslint-plugin-unicorn": "^62.0.0",
"obsidian": "latest",
"prettier": "^3.6.2",
"typescript": "^5.3.0",
"typescript-eslint": "^8.46.3"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz",
@ -1529,6 +1542,16 @@
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz",
"integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@ -1552,6 +1575,40 @@
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.27.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz",
"integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
"electron-to-chromium": "^1.5.238",
"node-releases": "^2.0.26",
"update-browserslist-db": "^1.1.4"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@ -1660,6 +1717,27 @@
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001754",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz",
"integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -1677,6 +1755,52 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"dev": true,
"license": "MIT"
},
"node_modules/ci-info": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz",
"integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/clean-regexp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz",
"integrity": "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==",
"dev": true,
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^1.0.5"
},
"engines": {
"node": ">=4"
}
},
"node_modules/clean-regexp/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -1721,6 +1845,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js-compat": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz",
"integrity": "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==",
"dev": true,
"license": "MIT",
"dependencies": {
"browserslist": "^4.26.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@ -1920,6 +2058,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.249",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.249.tgz",
"integrity": "sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg==",
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@ -2160,6 +2305,16 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@ -2249,6 +2404,22 @@
"eslint": ">=6.0.0"
}
},
"node_modules/eslint-config-prettier": {
"version": "10.1.8",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"funding": {
"url": "https://opencollective.com/eslint-config-prettier"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
@ -2672,6 +2843,55 @@
"safe-regex": "^2.1.1"
}
},
"node_modules/eslint-plugin-unicorn": {
"version": "62.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-62.0.0.tgz",
"integrity": "sha512-HIlIkGLkvf29YEiS/ImuDZQbP12gWyx5i3C6XrRxMvVdqMroCI9qoVYCoIl17ChN+U89pn9sVwLxhIWj5nEc7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@eslint-community/eslint-utils": "^4.9.0",
"@eslint/plugin-kit": "^0.4.0",
"change-case": "^5.4.4",
"ci-info": "^4.3.1",
"clean-regexp": "^1.0.0",
"core-js-compat": "^3.46.0",
"esquery": "^1.6.0",
"find-up-simple": "^1.0.1",
"globals": "^16.4.0",
"indent-string": "^5.0.0",
"is-builtin-module": "^5.0.0",
"jsesc": "^3.1.0",
"pluralize": "^8.0.0",
"regexp-tree": "^0.1.27",
"regjsparser": "^0.13.0",
"semver": "^7.7.3",
"strip-indent": "^4.1.1"
},
"engines": {
"node": "^20.10.0 || >=21.0.0"
},
"funding": {
"url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1"
},
"peerDependencies": {
"eslint": ">=9.38.0"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/globals": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
"integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-scope": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
@ -2984,6 +3204,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-up-simple": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz",
"integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@ -3402,6 +3635,19 @@
"node": ">=0.8.19"
}
},
"node_modules/indent-string": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
"integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -3495,6 +3741,35 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-builtin-module": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-5.0.0.tgz",
"integrity": "sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"builtin-modules": "^5.0.0"
},
"engines": {
"node": ">=18.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-builtin-module/node_modules/builtin-modules": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz",
"integrity": "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@ -3907,6 +4182,19 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@ -4238,6 +4526,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -4528,6 +4823,13 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@ -4541,6 +4843,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -4561,6 +4873,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@ -4722,6 +5050,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regjsparser": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz",
"integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"jsesc": "~3.1.0"
},
"bin": {
"regjsparser": "bin/parser"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -5340,6 +5681,19 @@
"node": ">=4"
}
},
"node_modules/strip-indent": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz",
"integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -5670,6 +6024,37 @@
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
"integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",

View file

@ -1,39 +1,44 @@
{
"name": "daily-notes-sorter",
"version": "1.0.0",
"description": "Obsidian plugin for automatically sorting daily notes by date in the file explorer",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix",
"release": "npm run lint && npm run build && node scripts/release.js"
},
"keywords": [
"obsidian",
"obsidian-plugin",
"daily-notes",
"sorting",
"file-explorer"
],
"author": "Reifat <https://github.com/Reifat>",
"license": "MIT",
"type": "module",
"dependencies": {
"monkey-around": "^3.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^20.0.0",
"archiver": "^7.0.1",
"builtin-modules": "3.3.0",
"esbuild": "^0.25.12",
"eslint": "^9.39.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-obsidianmd": "^0.1.8",
"obsidian": "latest",
"typescript": "^5.3.0",
"typescript-eslint": "^8.46.3"
}
"name": "daily-notes-sorter",
"version": "1.0.0",
"description": "Obsidian plugin for automatically sorting daily notes by date in the file explorer",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"release": "npm run format && npm run lint && npm run build && node scripts/release.js"
},
"keywords": [
"obsidian",
"obsidian-plugin",
"daily-notes",
"sorting",
"file-explorer"
],
"author": "Reifat <https://github.com/Reifat>",
"license": "MIT",
"type": "module",
"dependencies": {
"monkey-around": "^3.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^20.0.0",
"archiver": "^7.0.1",
"builtin-modules": "3.3.0",
"esbuild": "^0.25.12",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-obsidianmd": "^0.1.8",
"eslint-plugin-unicorn": "^62.0.0",
"obsidian": "latest",
"prettier": "^3.6.2",
"typescript": "^5.3.0",
"typescript-eslint": "^8.46.3"
}
}

View file

@ -1,97 +1,97 @@
import fs from "node:fs";
import path from "node:path";
import archiver from "archiver";
import fs from 'node:fs';
import path from 'node:path';
import archiver from 'archiver';
function ensureDir(p) {
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
}
function copyFile(src, dst) {
fs.copyFileSync(src, dst);
fs.copyFileSync(src, dst);
}
function main() {
const cwd = process.cwd();
const manifestPath = path.join(cwd, "manifest.json");
const cwd = process.cwd();
const manifestPath = path.join(cwd, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
console.error("manifest.json not found in repository root");
process.exit(1);
}
if (!fs.existsSync(manifestPath)) {
console.error('manifest.json not found in repository root');
process.exit(1);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const pluginId = manifest.id;
const pluginVersion = manifest.version;
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const pluginId = manifest.id;
const pluginVersion = manifest.version;
if (!pluginId || !pluginVersion) {
console.error("Fields 'id' and/or 'version' are missing in manifest.json");
process.exit(1);
}
if (!pluginId || !pluginVersion) {
console.error("Fields 'id' and/or 'version' are missing in manifest.json");
process.exit(1);
}
const releaseDir = path.join(cwd, "release");
ensureDir(releaseDir);
const releaseDir = path.join(cwd, 'release');
ensureDir(releaseDir);
// Files to include. styles.css is optional.
const sourceFiles = [
{ name: "main.js", required: true },
{ name: "manifest.json", required: true },
{ name: "styles.css", required: false }
];
// Files to include. styles.css is optional.
const sourceFiles = [
{ name: 'main.js', required: true },
{ name: 'manifest.json', required: true },
{ name: 'styles.css', required: false },
];
const filesToZip = [];
for (const f of sourceFiles) {
const src = path.join(cwd, f.name);
if (!fs.existsSync(src)) {
if (f.required) {
console.error(`Required file is missing: ${f.name}`);
process.exit(1);
} else {
console.warn(`Optional file is missing and will be skipped: ${f.name}`);
continue;
}
}
const dst = path.join(releaseDir, path.basename(f.name));
copyFile(src, dst);
filesToZip.push(dst);
}
const filesToZip = [];
for (const f of sourceFiles) {
const src = path.join(cwd, f.name);
if (!fs.existsSync(src)) {
if (f.required) {
console.error(`Required file is missing: ${f.name}`);
process.exit(1);
} else {
console.warn(`Optional file is missing and will be skipped: ${f.name}`);
continue;
}
}
const dst = path.join(releaseDir, path.basename(f.name));
copyFile(src, dst);
filesToZip.push(dst);
}
if (filesToZip.length === 0) {
console.error("No files to pack");
process.exit(1);
}
if (filesToZip.length === 0) {
console.error('No files to pack');
process.exit(1);
}
// Archive name: <id>-<version>.zip
const zipName = `${pluginId}-${pluginVersion}.zip`;
const zipPath = path.join(releaseDir, zipName);
// Archive name: <id>-<version>.zip
const zipName = `${pluginId}-${pluginVersion}.zip`;
const zipPath = path.join(releaseDir, zipName);
// Remove existing archive if present
if (fs.existsSync(zipPath)) {
fs.unlinkSync(zipPath);
}
// Remove existing archive if present
if (fs.existsSync(zipPath)) {
fs.unlinkSync(zipPath);
}
const output = fs.createWriteStream(zipPath);
const archive = archiver("zip", { zlib: { level: 9 } });
const output = fs.createWriteStream(zipPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on("close", () => {
console.log(`Archive ready: ${zipPath} (${archive.pointer()} total bytes)`);
});
output.on("error", (err) => {
console.error(err);
process.exit(1);
});
archive.on("error", (err) => {
console.error(err);
process.exit(1);
});
output.on('close', () => {
console.log(`Archive ready: ${zipPath} (${archive.pointer()} total bytes)`);
});
output.on('error', (err) => {
console.error(err);
process.exit(1);
});
archive.on('error', (err) => {
console.error(err);
process.exit(1);
});
archive.pipe(output);
archive.pipe(output);
// Put files into the root of the archive
for (const filePath of filesToZip) {
archive.file(filePath, { name: path.basename(filePath) });
}
// Put files into the root of the archive
for (const filePath of filesToZip) {
archive.file(filePath, { name: path.basename(filePath) });
}
archive.finalize();
archive.finalize();
}
main();
main();

View file

@ -1,291 +1,323 @@
import { TFile, Notice } from 'obsidian';
import type { TFolder, App} from 'obsidian';
import type { TFolder, App } from 'obsidian';
class Data {
day: number
month: number
year: number
constructor(day: string, month: string, year: string) {
this.day = Number(day);
this.month = Number(month);
this.year = Number(year);
}
day: number;
month: number;
year: number;
constructor(day: string, month: string, year: string) {
this.day = Number(day);
this.month = Number(month);
this.year = Number(year);
}
}
class SortingItem {
data: Data | undefined
path: string
basename: string
data: Data | undefined;
path: string;
basename: string;
constructor(data: Data | undefined, path: string, basename: string) {
this.data = data;
this.path = path;
this.basename = basename;
}
constructor(data: Data | undefined, path: string, basename: string) {
this.data = data;
this.path = path;
this.basename = basename;
}
}
export class Sorter {
private app: App | null = null;
private app: App | null = null;
constructor(app?: App) {
this.app = app || null;
}
/**
* Maps textual month tokens to a month number (1-12).
* Supports English and Russian names and common abbreviations (with/without a trailing dot).
*/
private mapTextMonthToNumber(monthTokenRaw: string): number | undefined {
const token = monthTokenRaw.toLowerCase().replace(/\.$/, '');
const months: Record<string, number> = {
// English
'january': 1, 'jan': 1,
'february': 2, 'feb': 2,
'march': 3, 'mar': 3,
'april': 4, 'apr': 4,
'may': 5,
'june': 6, 'jun': 6,
'july': 7, 'jul': 7,
'august': 8, 'aug': 8,
'september': 9, 'sep': 9, 'sept': 9,
'october': 10, 'oct': 10,
'november': 11, 'nov': 11,
'december': 12, 'dec': 12,
constructor(app?: App) {
this.app = app || null;
}
/**
* Maps textual month tokens to a month number (1-12).
* Supports English and Russian names and common abbreviations (with/without a trailing dot).
*/
private mapTextMonthToNumber(monthTokenRaw: string): number | undefined {
const token = monthTokenRaw.toLowerCase().replace(/\.$/, '');
const months: Record<string, number> = {
// English
january: 1,
jan: 1,
february: 2,
feb: 2,
march: 3,
mar: 3,
april: 4,
apr: 4,
may: 5,
june: 6,
jun: 6,
july: 7,
jul: 7,
august: 8,
aug: 8,
september: 9,
sep: 9,
sept: 9,
october: 10,
oct: 10,
november: 11,
nov: 11,
december: 12,
dec: 12,
};
return months[token];
}
};
return months[token];
}
/**
* Attempts to parse a textual date at the beginning of the filename using spaces as separators
* and a specific component order.
*
* Example supported variants depending on order:
* - ['y','m','d'] "YYYY Month DD" (or "YY Month DD" for 2-digit year)
* - ['d','m','y'] "DD Month YYYY"
* - ['m','d','y'] "Month DD YYYY"
*
* Month can be in English or Russian, full or abbreviated (with/without dot).
* expectedYearDigits: 2 or 4. If 2, year will be expanded (00-49 -> 2000-2049, 50-99 -> 1950-1999).
*/
private parseTextualDate(
fileName: string,
order: Array<'y' | 'm' | 'd'>,
expectedYearDigits: 2 | 4,
): Data | undefined {
const monthWord = '([A-Za-zА-Яа-яЁё\\.]+)';
const dayNum = '(\\d{1,2})';
const yearPattern = expectedYearDigits === 2 ? '(\\d{2})' : '(\\d{4})';
/**
* Attempts to parse a textual date at the beginning of the filename using spaces as separators
* and a specific component order.
*
* Example supported variants depending on order:
* - ['y','m','d'] "YYYY Month DD" (or "YY Month DD" for 2-digit year)
* - ['d','m','y'] "DD Month YYYY"
* - ['m','d','y'] "Month DD YYYY"
*
* Month can be in English or Russian, full or abbreviated (with/without dot).
* expectedYearDigits: 2 or 4. If 2, year will be expanded (00-49 -> 2000-2049, 50-99 -> 1950-1999).
*/
private parseTextualDate(fileName: string, order: Array<'y' | 'm' | 'd'>, expectedYearDigits: 2 | 4): Data | undefined {
const monthWord = '([A-Za-zА-Яа-яЁё\\.]+)';
const dayNum = '(\\d{1,2})';
const yearPattern = expectedYearDigits === 2 ? '(\\d{2})' : '(\\d{4})';
// Build pattern according to order
const tokenToPattern: Record<'y' | 'm' | 'd', string> = {
y: yearPattern,
m: monthWord,
d: dayNum,
};
const parts = order.map((p) => tokenToPattern[p]);
const re = new RegExp(`^${parts[0]}\\s+${parts[1]}\\s+${parts[2]}`);
// Build pattern according to order
const tokenToPattern: Record<'y' | 'm' | 'd', string> = {
'y': yearPattern,
'm': monthWord,
'd': dayNum
};
const parts = order.map((p) => tokenToPattern[p]);
const re = new RegExp(`^${parts[0]}\\s+${parts[1]}\\s+${parts[2]}`);
const match = fileName.match(re);
if (!match) {
return undefined;
}
const match = fileName.match(re);
if (!match) {
return undefined;
}
// match groups depend on order; map them using order
let dayStr = '';
let monthStr = '';
let yearStr = '';
// match groups depend on order; map them using order
let dayStr = '';
let monthStr = '';
let yearStr = '';
// The first capturing group index is 1
const g1 = match[1];
const g2 = match[2];
const g3 = match[3];
// The first capturing group index is 1
const g1 = match[1];
const g2 = match[2];
const g3 = match[3];
order.forEach((part, idx) => {
const val = idx === 0 ? g1 : idx === 1 ? g2 : g3;
if (part === 'd') {
dayStr = val;
}
if (part === 'm') {
monthStr = val;
}
if (part === 'y') {
yearStr = val;
}
});
order.forEach((part, idx) => {
const val = idx === 0 ? g1 : idx === 1 ? g2 : g3;
if (part === 'd') {dayStr = val;}
if (part === 'm') {monthStr = val;}
if (part === 'y') {yearStr = val;}
});
// Convert textual month to number
const monthNum = this.mapTextMonthToNumber(monthStr);
if (!monthNum) {
return undefined;
}
// Convert textual month to number
const monthNum = this.mapTextMonthToNumber(monthStr);
if (!monthNum) {
return undefined;
}
// If expected 4 digits but got 2, or vice versa, do not match
if (
(expectedYearDigits === 4 && yearStr.length !== 4) ||
(expectedYearDigits === 2 && yearStr.length !== 2)
) {
return undefined;
}
// If expected 4 digits but got 2, or vice versa, do not match
if ((expectedYearDigits === 4 && yearStr.length !== 4) || (expectedYearDigits === 2 && yearStr.length !== 2)) {
return undefined;
}
// Expand 2-digit year if necessary
let yearNum = parseInt(yearStr, 10);
if (yearStr.length === 2) {
yearNum = yearNum < 50 ? 2000 + yearNum : 1900 + yearNum;
}
// Expand 2-digit year if necessary
let yearNum = parseInt(yearStr, 10);
if (yearStr.length === 2) {
yearNum = yearNum < 50 ? 2000 + yearNum : 1900 + yearNum;
}
// Basic sanity checks for day/month bounds (not strict calendar validation)
const dayNumInt = parseInt(dayStr, 10);
if (dayNumInt < 1 || dayNumInt > 31) {
return undefined;
}
if (monthNum < 1 || monthNum > 12) {
return undefined;
}
// Basic sanity checks for day/month bounds (not strict calendar validation)
const dayNumInt = parseInt(dayStr, 10);
if (dayNumInt < 1 || dayNumInt > 31) {
return undefined;
}
if (monthNum < 1 || monthNum > 12) {
return undefined;
}
return new Data(String(dayNumInt), String(monthNum), String(yearNum));
}
/**
* Extracts date from file name according to specified format
* Supported formats: YYYY-MM-DD, DD.MM.YYYY, DD.MM.YY, MM/DD/YYYY
* Parses date from the beginning of file name
*/
private extractDate(fileName: string, dateFormat: string): Data | undefined {
let day: string, month: string, year: string;
let match: RegExpMatchArray | null = null;
return new Data(String(dayNumInt), String(monthNum), String(yearNum));
}
/**
* Extracts date from file name according to specified format
* Supported formats: YYYY-MM-DD, DD.MM.YYYY, DD.MM.YY, MM/DD/YYYY
* Parses date from the beginning of file name
*/
private extractDate(fileName: string, dateFormat: string): Data | undefined {
let day: string, month: string, year: string;
let match: RegExpMatchArray | null = null;
switch (dateFormat) {
case 'YYYY-MM-DD': {
// Format: 2024-01-15 (search at the beginning of string)
match = fileName.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if (match) {
year = match[1];
month = match[2];
day = match[3];
} else {
// Try textual date fallback with the same order and spaces: "YYYY Month DD"
return this.parseTextualDate(fileName, ['y', 'm', 'd'], 4);
}
break;
}
case 'DD.MM.YYYY': {
// Format: 15.01.2024 (search at the beginning of string)
match = fileName.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/);
if (match) {
day = match[1];
month = match[2];
year = match[3];
} else {
// Try textual date fallback with the same order and spaces: "DD Month YYYY"
return this.parseTextualDate(fileName, ['d', 'm', 'y'], 4);
}
break;
}
case 'DD.MM.YY': {
// Format: 15.01.24 (search at the beginning of string)
match = fileName.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2})/);
if (match) {
day = match[1];
month = match[2];
const yy = parseInt(match[3], 10);
// Convert two-digit year to four-digit
// YY < 50 -> 20YY, otherwise 19YY
year = yy < 50 ? `20${match[3]}` : `19${match[3]}`;
} else {
// Try textual date fallback with the same order and spaces: "DD Month YY"
return this.parseTextualDate(fileName, ['d', 'm', 'y'], 2);
}
break;
}
case 'MM/DD/YYYY': {
// Format: 01/15/2024 (search at the beginning of string)
match = fileName.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);
if (match) {
month = match[1];
day = match[2];
year = match[3];
} else {
// Try textual date fallback with the same order and spaces: "Month DD YYYY"
return this.parseTextualDate(fileName, ['m', 'd', 'y'], 4);
}
break;
}
default:
console.warn(`[Sorter] Unsupported date format: ${dateFormat}`);
return undefined;
}
switch (dateFormat) {
case "YYYY-MM-DD": {
// Format: 2024-01-15 (search at the beginning of string)
match = fileName.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if (match) {
year = match[1];
month = match[2];
day = match[3];
} else {
// Try textual date fallback with the same order and spaces: "YYYY Month DD"
return this.parseTextualDate(fileName, ['y','m','d'], 4);
}
break;
}
case "DD.MM.YYYY": {
// Format: 15.01.2024 (search at the beginning of string)
match = fileName.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/);
if (match) {
day = match[1];
month = match[2];
year = match[3];
} else {
// Try textual date fallback with the same order and spaces: "DD Month YYYY"
return this.parseTextualDate(fileName, ['d','m','y'], 4);
}
break;
}
case "DD.MM.YY": {
// Format: 15.01.24 (search at the beginning of string)
match = fileName.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2})/);
if (match) {
day = match[1];
month = match[2];
const yy = parseInt(match[3], 10);
// Convert two-digit year to four-digit
// YY < 50 -> 20YY, otherwise 19YY
year = yy < 50 ? `20${match[3]}` : `19${match[3]}`;
} else {
// Try textual date fallback with the same order and spaces: "DD Month YY"
return this.parseTextualDate(fileName, ['d','m','y'], 2);
}
break;
}
case "MM/DD/YYYY": {
// Format: 01/15/2024 (search at the beginning of string)
match = fileName.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);
if (match) {
month = match[1];
day = match[2];
year = match[3];
} else {
// Try textual date fallback with the same order and spaces: "Month DD YYYY"
return this.parseTextualDate(fileName, ['m','d','y'], 4);
}
break;
}
default:
console.warn(`[Sorter] Unsupported date format: ${dateFormat}`);
return undefined;
}
return new Data(day, month, year);
}
return new Data(day, month, year);
}
/**
* Sorts files in folder by date (year, month, day)
* Files not matching the format are placed at the top (descending) or bottom (ascending)
* @param sortedFolder - folder to sort
* @param fileItems - collection of all file explorer items
* @param ascending - sort direction: true for ascending, false for descending
* @param dateFormat - date format for parsing (YYYY-MM-DD, DD.MM.YYYY, MM/DD/YYYY)
* @returns sorted array of items
*/
sortFolder(
sortedFolder: TFolder,
fileItems: Record<string, unknown>,
ascending: boolean = true,
dateFormat: string = 'YYYY-MM-DD',
): unknown[] {
const allFileItemsCollection = fileItems;
/**
* Sorts files in folder by date (year, month, day)
* Files not matching the format are placed at the top (descending) or bottom (ascending)
* @param sortedFolder - folder to sort
* @param fileItems - collection of all file explorer items
* @param ascending - sort direction: true for ascending, false for descending
* @param dateFormat - date format for parsing (YYYY-MM-DD, DD.MM.YYYY, MM/DD/YYYY)
* @returns sorted array of items
*/
sortFolder(sortedFolder: TFolder, fileItems: Record<string, unknown>, ascending: boolean = true, dateFormat: string = "YYYY-MM-DD"): unknown[] {
const allFileItemsCollection = fileItems;
// Separate files into matching and non-matching format
const filesWithDate: Array<SortingItem> = [];
const filesWithoutDate: Array<SortingItem> = [];
// Separate files into matching and non-matching format
const filesWithDate: Array<SortingItem> = [];
const filesWithoutDate: Array<SortingItem> = [];
sortedFolder.children
.filter((entry): entry is TFile => {
return entry instanceof TFile;
})
.forEach((item: TFile) => {
const dateData = this.extractDate(item.basename, dateFormat);
const sortingItem = new SortingItem(dateData, item.path, item.basename);
sortedFolder.children
.filter((entry): entry is TFile => {
return entry instanceof TFile;
})
.forEach((item: TFile) => {
const dateData = this.extractDate(item.basename, dateFormat);
const sortingItem = new SortingItem(dateData, item.path, item.basename);
if (dateData) {
filesWithDate.push(sortingItem);
} else {
filesWithoutDate.push(sortingItem);
}
});
if (dateData) {
filesWithDate.push(sortingItem);
} else {
filesWithoutDate.push(sortingItem);
}
});
// Show notification if there are files not matching the format
if (filesWithoutDate.length > 0 && this.app) {
const fileNames = filesWithoutDate.slice(0, 3).map(item => item.basename).join(", ");
const moreFiles = filesWithoutDate.length > 3 ? ` and ${filesWithoutDate.length - 3} more` : "";
const message = `Found ${filesWithoutDate.length} file(s) in folder "${sortedFolder.path}" that do not match format ${dateFormat}: ${fileNames}${moreFiles}`;
new Notice(message, 5000);
}
// Show notification if there are files not matching the format
if (filesWithoutDate.length > 0 && this.app) {
const fileNames = filesWithoutDate
.slice(0, 3)
.map((item) => item.basename)
.join(', ');
const moreFiles =
filesWithoutDate.length > 3 ? ` and ${filesWithoutDate.length - 3} more` : '';
const message = `Found ${filesWithoutDate.length} file(s) in folder "${sortedFolder.path}" that do not match format ${dateFormat}: ${fileNames}${moreFiles}`;
// Sort files with date
if (filesWithDate.length > 0) {
filesWithDate.sort((a: SortingItem, b: SortingItem) => {
if (!a.data || !b.data) {
return !a.data ? 1 : -1;
}
const diffYear = a.data.year - b.data.year;
if (diffYear === 0) {
const diffMonth = a.data.month - b.data.month;
if (diffMonth === 0) {
const diff = a.data.day - b.data.day;
return ascending ? diff : -diff;
}
return ascending ? diffMonth : -diffMonth;
}
return ascending ? diffYear : -diffYear;
});
}
new Notice(message, 5000);
}
// Sort files without date by name (for consistency)
filesWithoutDate.sort((a: SortingItem, b: SortingItem) => {
return a.basename.localeCompare(b.basename);
});
// Sort files with date
if (filesWithDate.length > 0) {
filesWithDate.sort((a: SortingItem, b: SortingItem) => {
if (!a.data || !b.data) {
return !a.data ? 1 : -1;
}
// Combine results: files without date at top (descending) or bottom (ascending)
let resultItems: Array<SortingItem>;
if (ascending) {
// Ascending: files with date first, then without date
resultItems = [...filesWithDate, ...filesWithoutDate];
} else {
// Descending: files without date first, then with date
resultItems = [...filesWithoutDate, ...filesWithDate];
}
const diffYear = a.data.year - b.data.year;
if (diffYear === 0) {
const diffMonth = a.data.month - b.data.month;
if (diffMonth === 0) {
const diff = a.data.day - b.data.day;
return ascending ? diff : -diff;
}
return ascending ? diffMonth : -diffMonth;
}
return ascending ? diffYear : -diffYear;
});
}
// Convert to FileExplorer items
const result = resultItems
.map((item: SortingItem) => allFileItemsCollection[item.path])
.filter(item => item !== undefined);
// Sort files without date by name (for consistency)
filesWithoutDate.sort((a: SortingItem, b: SortingItem) => {
return a.basename.localeCompare(b.basename);
});
return result;
}
// Combine results: files without date at top (descending) or bottom (ascending)
let resultItems: Array<SortingItem>;
if (ascending) {
// Ascending: files with date first, then without date
resultItems = [...filesWithDate, ...filesWithoutDate];
} else {
// Descending: files without date first, then with date
resultItems = [...filesWithoutDate, ...filesWithDate];
}
// Convert to FileExplorer items
const result = resultItems
.map((item: SortingItem) => allFileItemsCollection[item.path])
.filter((item) => item !== undefined);
return result;
}
}

View file

@ -9,227 +9,258 @@ import type { App, Plugin, FileExplorerView, TFolder } from 'obsidian';
type MonkeyAroundUninstaller = () => void;
export class FileExplorerUtils {
private app: App;
private plugin: Plugin;
private sorter: Sorter;
private settings: { items: FolderItem[]; sortAscending?: boolean };
private pluginInstance: { saveSettings: () => Promise<void> } | undefined; // Reference to plugin instance for saving settings
public sortAscending: boolean = true; // Public for access from closure
private app: App;
private plugin: Plugin;
private sorter: Sorter;
private settings: { items: FolderItem[]; sortAscending?: boolean };
private pluginInstance: { saveSettings: () => Promise<void> } | undefined; // Reference to plugin instance for saving settings
public sortAscending: boolean = true; // Public for access from closure
constructor(app: App, plugin: Plugin, sorter: Sorter, settings: { items: FolderItem[]; sortAscending?: boolean }, pluginInstance?: { saveSettings: () => Promise<void> }) {
this.app = app;
this.plugin = plugin;
this.sorter = sorter;
this.settings = settings;
this.pluginInstance = pluginInstance;
// Restore saved sort state
if (settings.sortAscending !== undefined) {
this.sortAscending = settings.sortAscending;
}
}
constructor(
app: App,
plugin: Plugin,
sorter: Sorter,
settings: { items: FolderItem[]; sortAscending?: boolean },
pluginInstance?: { saveSettings: () => Promise<void> },
) {
this.app = app;
this.plugin = plugin;
this.sorter = sorter;
this.settings = settings;
this.pluginInstance = pluginInstance;
// Restore saved sort state
if (settings.sortAscending !== undefined) {
this.sortAscending = settings.sortAscending;
}
}
/**
* Updates settings (called when plugin settings change)
*/
updateSettings(settings: { items: FolderItem[]; sortAscending?: boolean }): void {
this.settings = settings;
// Update sort state if it exists in settings
if (settings.sortAscending !== undefined) {
this.sortAscending = settings.sortAscending;
}
}
/**
* Updates settings (called when plugin settings change)
*/
updateSettings(settings: { items: FolderItem[]; sortAscending?: boolean }): void {
this.settings = settings;
// Update sort state if it exists in settings
if (settings.sortAscending !== undefined) {
this.sortAscending = settings.sortAscending;
}
}
/**
* Checks if sorting is enabled for specified path
* @param path - path to folder
* @returns object with folder settings information or undefined
*/
private getFolderSettings(path: string): FolderItem | undefined {
return this.settings.items.find(item => {
// Compare paths, accounting for possible variants (with "/" and without)
const normalizedItemPath = item.path.startsWith("/") ? item.path : `/${item.path}`;
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return item.path === path || normalizedItemPath === normalizedPath;
});
}
/**
* Checks if sorting is enabled for specified path
* @param path - path to folder
* @returns object with folder settings information or undefined
*/
private getFolderSettings(path: string): FolderItem | undefined {
return this.settings.items.find((item) => {
// Compare paths, accounting for possible variants (with "/" and without)
const normalizedItemPath = item.path.startsWith('/') ? item.path : `/${item.path}`;
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return item.path === path || normalizedItemPath === normalizedPath;
});
}
/**
* Sets sort direction and updates display
* @param ascending - true for ascending, false for descending
* @param saveSettings - whether to save settings (default true)
*/
setSortDirection(ascending: boolean, saveSettings: boolean = true): void {
this.sortAscending = ascending;
// Update plugin settings
this.settings.sortAscending = ascending;
// Save settings if required
if (saveSettings && this.pluginInstance) {
this.pluginInstance.saveSettings().catch((err: unknown) => {
console.error("[FileExplorerUtils] Error saving sort direction:", err);
});
}
// Update sorting after changing direction
this.applySort();
}
/**
* Sets sort direction and updates display
* @param ascending - true for ascending, false for descending
* @param saveSettings - whether to save settings (default true)
*/
setSortDirection(ascending: boolean, saveSettings: boolean = true): void {
this.sortAscending = ascending;
/**
* Applies sorting to FileExplorer
*/
applySort(): void {
const fileExplorerView = this.getFileExplorer();
if (fileExplorerView && typeof fileExplorerView.requestSort === 'function') {
fileExplorerView.requestSort();
}
}
// Update plugin settings
this.settings.sortAscending = ascending;
/**
* Gets current sort direction
*/
getSortDirection(): boolean {
return this.sortAscending;
}
// Save settings if required
if (saveSettings && this.pluginInstance) {
this.pluginInstance.saveSettings().catch((err: unknown) => {
console.error('[FileExplorerUtils] Error saving sort direction:', err);
});
}
/**
* Gets FileExplorerView instance from workspace
*/
getFileExplorer(): FileExplorerView | undefined {
const fileExplorer: FileExplorerView | undefined = this.app.workspace.getLeavesOfType("file-explorer").first()
?.view as unknown as FileExplorerView;
return fileExplorer;
}
// Update sorting after changing direction
this.applySort();
}
/**
* Waits for FileExplorer to load and executes callback
* @param callback - function that will be called when FileExplorer loads
* @param interval - check interval in milliseconds (default 100)
* @param timeout - wait timeout in milliseconds (default 5000)
*/
async waitForFileExplorer(
callback: (fileExplorer: FileExplorerView) => void,
interval: number = 100,
timeout: number = 5000
): Promise<void> {
return new Promise((resolve, reject) => {
const startTime = Date.now();
/**
* Applies sorting to FileExplorer
*/
applySort(): void {
const fileExplorerView = this.getFileExplorer();
if (fileExplorerView && typeof fileExplorerView.requestSort === 'function') {
fileExplorerView.requestSort();
}
}
const checkFileExplorer = (): void => {
const fileExplorer = this.getFileExplorer();
if (fileExplorer) {
clearInterval(timer);
callback(fileExplorer);
resolve();
} else if (Date.now() - startTime > timeout) {
clearInterval(timer);
reject(new Error("[FileExplorerUtils] FileExplorer was not loaded, reason is time up!"));
}
};
const timer = setInterval(checkFileExplorer, interval);
});
}
/**
* Gets current sort direction
*/
getSortDirection(): boolean {
return this.sortAscending;
}
/**
* Checks availability and patchability of FileExplorer
*/
checkFileExplorerIsAvailableAndPatchable(logWarning: boolean = true): FileExplorerView | undefined {
const fileExplorerView: FileExplorerView | undefined = this.getFileExplorer()
if (fileExplorerView && typeof fileExplorerView.requestSort === 'function') {
// The plugin integration points changed with Obsidian 1.6.0 hence the patchability-check should also be Obsidian version aware
if (requireApiVersion("1.6.0")) {
if (typeof fileExplorerView.getSortedFolderItems === 'function') {
return fileExplorerView
}
} else { // Obsidian versions prior to 1.6.0
if (typeof fileExplorerView.createFolderDom === 'function') {
return fileExplorerView
}
}
}
// Various scenarios when File Explorer was turned off (e.g. by some other plugin)
if (logWarning) {
const msg = `custom-sort v${this.plugin.manifest.version}: failed to locate File Explorer. The 'Files' core plugin can be disabled.\n`
+ `Some community plugins can also disable it.\n`
+ `See the example of MAKE.md plugin: https://github.com/Make-md/makemd/issues/25\n`
+ `You can find there instructions on how to re-enable the File Explorer in MAKE.md plugin`
console.warn(msg)
}
return undefined
}
/**
* Gets FileExplorerView instance from workspace
*/
getFileExplorer(): FileExplorerView | undefined {
const fileExplorer: FileExplorerView | undefined = this.app.workspace
.getLeavesOfType('file-explorer')
.first()?.view as unknown as FileExplorerView;
return fileExplorer;
}
/**
* Applies patch to FileExplorer for custom sorting
*/
patchFileExplorerFolder(patchableFileExplorer?: FileExplorerView): boolean {
// Capture methods and properties needed in closures
const getFolderSettings = (path: string): FolderItem | undefined => this.getFolderSettings(path);
const getSortAscending = (): boolean => this.sortAscending;
const sorter = this.sorter;
const checkFileExplorerIsAvailableAndPatchable = (logWarning: boolean): FileExplorerView | undefined =>
this.checkFileExplorerIsAvailableAndPatchable(logWarning);
/**
* Waits for FileExplorer to load and executes callback
* @param callback - function that will be called when FileExplorer loads
* @param interval - check interval in milliseconds (default 100)
* @param timeout - wait timeout in milliseconds (default 5000)
*/
async waitForFileExplorer(
callback: (fileExplorer: FileExplorerView) => void,
interval: number = 100,
timeout: number = 5000,
): Promise<void> {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const requestStandardObsidianSortAfter = (patchUninstaller: MonkeyAroundUninstaller|undefined): (() => void) => {
return (): void => {
if (patchUninstaller) {patchUninstaller()}
const checkFileExplorer = (): void => {
const fileExplorer = this.getFileExplorer();
if (fileExplorer) {
clearInterval(timer);
callback(fileExplorer);
resolve();
} else if (Date.now() - startTime > timeout) {
clearInterval(timer);
reject(new Error('[FileExplorerUtils] FileExplorer was not loaded, reason is time up!'));
}
};
const timer = setInterval(checkFileExplorer, interval);
});
}
const fileExplorerView: FileExplorerView | undefined = checkFileExplorerIsAvailableAndPatchable(false)
if (fileExplorerView) {
fileExplorerView.requestSort()
}
}
}
/**
* Checks availability and patchability of FileExplorer
*/
checkFileExplorerIsAvailableAndPatchable(
logWarning: boolean = true,
): FileExplorerView | undefined {
const fileExplorerView: FileExplorerView | undefined = this.getFileExplorer();
if (fileExplorerView && typeof fileExplorerView.requestSort === 'function') {
// The plugin integration points changed with Obsidian 1.6.0 hence the patchability-check should also be Obsidian version aware
if (requireApiVersion('1.6.0')) {
if (typeof fileExplorerView.getSortedFolderItems === 'function') {
return fileExplorerView;
}
} else {
// Obsidian versions prior to 1.6.0
if (typeof fileExplorerView.createFolderDom === 'function') {
return fileExplorerView;
}
}
}
// Various scenarios when File Explorer was turned off (e.g. by some other plugin)
if (logWarning) {
const msg =
`custom-sort v${this.plugin.manifest.version}: failed to locate File Explorer. The 'Files' core plugin can be disabled.\n` +
`Some community plugins can also disable it.\n` +
`See the example of MAKE.md plugin: https://github.com/Make-md/makemd/issues/25\n` +
`You can find there instructions on how to re-enable the File Explorer in MAKE.md plugin`;
console.warn(msg);
}
return undefined;
}
// patching file explorer might fail here because of various non-error reasons.
// That's why not showing and not logging error message here
patchableFileExplorer = patchableFileExplorer ?? this.checkFileExplorerIsAvailableAndPatchable(false)
if (patchableFileExplorer) {
if (requireApiVersion("1.6.0")) {
// Starting from Obsidian 1.6.0 the sorting mechanics has been significantly refactored internally in Obsidian
const uninstallerOfFolderSortFunctionWrapper: MonkeyAroundUninstaller = around(patchableFileExplorer.constructor.prototype, {
getSortedFolderItems: (old: (folder: TFolder) => unknown[]) => {
return function (this: FileExplorerView, folder: TFolder): unknown[] {
const folderPath = folder.path;
const folderSettings = getFolderSettings(folderPath);
if (folderSettings) {
const ascending = getSortAscending();
const dateFormat = folderSettings.dateFormat;
return sorter.sortFolder(folder, this.fileItems, ascending, dateFormat);
}
else { // default sort
return old.call(this, folder);
}
};
}
})
this.plugin.register(requestStandardObsidianSortAfter(uninstallerOfFolderSortFunctionWrapper))
return true
} else {
// Up to Obsidian 1.6.0
const tmpFolder = this.app.vault.getRoot();
const Folder = patchableFileExplorer.createFolderDom(tmpFolder).constructor;
const uninstallerOfFolderSortFunctionWrapper: MonkeyAroundUninstaller = around(Folder.prototype, {
sort: (old: (folder: TFolder) => unknown[]) => {
return function (this: { fileItems: Record<string, unknown> }, folder: TFolder): unknown[] {
const folderPath = folder.path;
const folderSettings = getFolderSettings(folderPath);
if (folderSettings) {
const ascending = getSortAscending();
const dateFormat = folderSettings.dateFormat;
return sorter.sortFolder(folder, this.fileItems, ascending, dateFormat);
}
else { // default sort
return old.call(this, folder);
}
};
}
})
this.plugin.register(requestStandardObsidianSortAfter(uninstallerOfFolderSortFunctionWrapper))
return true
}
} else {
return false
}
}
/**
* Applies patch to FileExplorer for custom sorting
*/
patchFileExplorerFolder(patchableFileExplorer?: FileExplorerView): boolean {
// Capture methods and properties needed in closures
const getFolderSettings = (path: string): FolderItem | undefined =>
this.getFolderSettings(path);
const getSortAscending = (): boolean => this.sortAscending;
const sorter = this.sorter;
const checkFileExplorerIsAvailableAndPatchable = (
logWarning: boolean,
): FileExplorerView | undefined => this.checkFileExplorerIsAvailableAndPatchable(logWarning);
const requestStandardObsidianSortAfter = (
patchUninstaller: MonkeyAroundUninstaller | undefined,
): (() => void) => {
return (): void => {
if (patchUninstaller) {
patchUninstaller();
}
const fileExplorerView: FileExplorerView | undefined =
checkFileExplorerIsAvailableAndPatchable(false);
if (fileExplorerView) {
fileExplorerView.requestSort();
}
};
};
// patching file explorer might fail here because of various non-error reasons.
// That's why not showing and not logging error message here
patchableFileExplorer =
patchableFileExplorer ?? this.checkFileExplorerIsAvailableAndPatchable(false);
if (patchableFileExplorer) {
if (requireApiVersion('1.6.0')) {
// Starting from Obsidian 1.6.0 the sorting mechanics has been significantly refactored internally in Obsidian
const uninstallerOfFolderSortFunctionWrapper: MonkeyAroundUninstaller = around(
patchableFileExplorer.constructor.prototype,
{
getSortedFolderItems: (old: (folder: TFolder) => unknown[]) => {
return function (this: FileExplorerView, folder: TFolder): unknown[] {
const folderPath = folder.path;
const folderSettings = getFolderSettings(folderPath);
if (folderSettings) {
const ascending = getSortAscending();
const dateFormat = folderSettings.dateFormat;
return sorter.sortFolder(folder, this.fileItems, ascending, dateFormat);
} else {
// default sort
return old.call(this, folder);
}
};
},
},
);
this.plugin.register(
requestStandardObsidianSortAfter(uninstallerOfFolderSortFunctionWrapper),
);
return true;
} else {
// Up to Obsidian 1.6.0
const tmpFolder = this.app.vault.getRoot();
const Folder = patchableFileExplorer.createFolderDom(tmpFolder).constructor;
const uninstallerOfFolderSortFunctionWrapper: MonkeyAroundUninstaller = around(
Folder.prototype,
{
sort: (old: (folder: TFolder) => unknown[]) => {
return function (
this: { fileItems: Record<string, unknown> },
folder: TFolder,
): unknown[] {
const folderPath = folder.path;
const folderSettings = getFolderSettings(folderPath);
if (folderSettings) {
const ascending = getSortAscending();
const dateFormat = folderSettings.dateFormat;
return sorter.sortFolder(folder, this.fileItems, ascending, dateFormat);
} else {
// default sort
return old.call(this, folder);
}
};
},
},
);
this.plugin.register(
requestStandardObsidianSortAfter(uninstallerOfFolderSortFunctionWrapper),
);
return true;
}
} else {
return false;
}
}
}

View file

@ -5,127 +5,129 @@ import { FileExplorerUtils } from './file-explorer-utils';
import { ExplorerUI } from './ui/explorer-ui';
import { SorterSettings } from './ui/settings';
const DEFAULT_DAILY_NOTE_FORMAT = "YYYY-MM-DD";
const DEFAULT_DAILY_NOTE_FORMAT = 'YYYY-MM-DD';
export interface FolderItem {
path: string;
dateFormat: string;
path: string;
dateFormat: string;
}
interface DailyNotesSorterSettings {
items: FolderItem[];
sortAscending: boolean;
items: FolderItem[];
sortAscending: boolean;
}
const DEFAULT_SETTINGS: DailyNotesSorterSettings = {
items: [],
sortAscending: true
items: [],
sortAscending: true,
};
export default class DailyNotesSorter extends Plugin {
settings!: DailyNotesSorterSettings;
private sorter!: Sorter;
private fileExplorerUtils!: FileExplorerUtils;
private explorerUI!: ExplorerUI;
settings!: DailyNotesSorterSettings;
private sorter!: Sorter;
private fileExplorerUtils!: FileExplorerUtils;
private explorerUI!: ExplorerUI;
async onload(): Promise<void> {
await this.loadSettings();
async onload(): Promise<void> {
await this.loadSettings();
// Initialize sorter with App for showing notifications
this.sorter = new Sorter(this.app);
// Initialize utilities for working with FileExplorer
this.fileExplorerUtils = new FileExplorerUtils(this.app, this, this.sorter, this.settings, this);
// Initialize UI for FileExplorer
this.explorerUI = new ExplorerUI(this.app, this.fileExplorerUtils);
// Initialize sorter with App for showing notifications
this.sorter = new Sorter(this.app);
this.fileExplorerUtils.waitForFileExplorer((fileExplorer) => {
this.fileExplorerUtils.patchFileExplorerFolder(fileExplorer);
this.explorerUI.initialize(fileExplorer);
// Apply sorting after plugin load
// Use a small delay to ensure the patch is applied
setTimeout(() => {
this.fileExplorerUtils.applySort();
}, 100);
})
.catch((err: unknown) => {
console.error("[DailyNotesSorter] Error initializing FileExplorer:", err);
// Note: We don't show a Notice here as the plugin might still work
// if FileExplorer loads later
});
// Initialize utilities for working with FileExplorer
this.fileExplorerUtils = new FileExplorerUtils(
this.app,
this,
this.sorter,
this.settings,
this,
);
// Initialize UI for FileExplorer
this.explorerUI = new ExplorerUI(this.app, this.fileExplorerUtils);
this.addSettingTab(new SorterSettings(this.app, this));
}
this.fileExplorerUtils
.waitForFileExplorer((fileExplorer) => {
this.fileExplorerUtils.patchFileExplorerFolder(fileExplorer);
this.explorerUI.initialize(fileExplorer);
// Apply sorting after plugin load
// Use a small delay to ensure the patch is applied
setTimeout(() => {
this.fileExplorerUtils.applySort();
}, 100);
})
.catch((err: unknown) => {
console.error('[DailyNotesSorter] Error initializing FileExplorer:', err);
// Note: We don't show a Notice here as the plugin might still work
// if FileExplorer loads later
});
onunload(): void {
// Cleanup is handled automatically by Obsidian:
// - Patches registered via this.plugin.register() are automatically uninstalled
// - Setting tabs are automatically removed
// - Event listeners registered via this.registerEvent() are automatically removed
// Cleanup UI components (e.g., remove sort button from FileExplorer)
this.explorerUI.cleanup();
}
this.addSettingTab(new SorterSettings(this.app, this));
}
async loadSettings(): Promise<void> {
try {
const loadedData: unknown = await this.loadData();
// Initialize settings with default values
this.settings = Object.assign({}, DEFAULT_SETTINGS);
// If there is loaded data, apply it
if (loadedData && typeof loadedData === 'object') {
const data = loadedData as {
items?: Array<{ path?: string; dateFormat?: string }>;
sortAscending?: boolean;
};
onunload(): void {
// Cleanup is handled automatically by Obsidian:
// - Patches registered via this.plugin.register() are automatically uninstalled
// - Setting tabs are automatically removed
// - Event listeners registered via this.registerEvent() are automatically removed
if (Array.isArray(data.items)) {
this.settings.items = data.items.map((item) => ({
path: item.path || "",
dateFormat: item.dateFormat || DEFAULT_DAILY_NOTE_FORMAT,
}));
}
// Load saved sort state
if (typeof data.sortAscending === 'boolean') {
this.settings.sortAscending = data.sortAscending;
}
}
} catch (error) {
console.error("Error loading settings:", error);
// In case of error, use default settings
this.settings = Object.assign({}, DEFAULT_SETTINGS);
}
}
// Cleanup UI components (e.g., remove sort button from FileExplorer)
this.explorerUI.cleanup();
}
async saveSettings(): Promise<void> {
try {
// Ensure data structure is correct before saving
// Validate and normalize data before saving
this.settings.items = this.settings.items.map((item) => ({
path: item.path || "",
dateFormat: item.dateFormat || DEFAULT_DAILY_NOTE_FORMAT,
}));
// Save sort state from FileExplorerUtils
this.settings.sortAscending = this.fileExplorerUtils.sortAscending;
await this.saveData(this.settings);
// Update settings in FileExplorerUtils after saving
this.fileExplorerUtils.updateSettings(this.settings);
// Apply sorting after updating settings
this.fileExplorerUtils.applySort();
} catch (error) {
console.error("Error saving settings:", error);
}
}
async loadSettings(): Promise<void> {
try {
const loadedData: unknown = await this.loadData();
// Initialize settings with default values
this.settings = Object.assign({}, DEFAULT_SETTINGS);
// If there is loaded data, apply it
if (loadedData && typeof loadedData === 'object') {
const data = loadedData as {
items?: Array<{ path?: string; dateFormat?: string }>;
sortAscending?: boolean;
};
if (Array.isArray(data.items)) {
this.settings.items = data.items.map((item) => ({
path: item.path || '',
dateFormat: item.dateFormat || DEFAULT_DAILY_NOTE_FORMAT,
}));
}
// Load saved sort state
if (typeof data.sortAscending === 'boolean') {
this.settings.sortAscending = data.sortAscending;
}
}
} catch (error) {
console.error('Error loading settings:', error);
// In case of error, use default settings
this.settings = Object.assign({}, DEFAULT_SETTINGS);
}
}
async saveSettings(): Promise<void> {
try {
// Ensure data structure is correct before saving
// Validate and normalize data before saving
this.settings.items = this.settings.items.map((item) => ({
path: item.path || '',
dateFormat: item.dateFormat || DEFAULT_DAILY_NOTE_FORMAT,
}));
// Save sort state from FileExplorerUtils
this.settings.sortAscending = this.fileExplorerUtils.sortAscending;
await this.saveData(this.settings);
// Update settings in FileExplorerUtils after saving
this.fileExplorerUtils.updateSettings(this.settings);
// Apply sorting after updating settings
this.fileExplorerUtils.applySort();
} catch (error) {
console.error('Error saving settings:', error);
}
}
}

View file

@ -2,210 +2,215 @@
* Class for managing autocomplete in input field
*/
export class AutocompleteInput {
private suggestionsList: HTMLElement;
private inputEl: HTMLInputElement;
private onValueChange: (value: string) => void;
private getSuggestions: () => string[];
private suggestionsList: HTMLElement;
private inputEl: HTMLInputElement;
private onValueChange: (value: string) => void;
private getSuggestions: () => string[];
constructor(
container: HTMLElement,
inputEl: HTMLInputElement,
getSuggestions: () => string[],
onValueChange: (value: string) => void
) {
this.inputEl = inputEl;
this.onValueChange = onValueChange;
this.getSuggestions = getSuggestions;
this.suggestionsList = this.createSuggestionsList(container);
this.attachEventListeners();
constructor(
container: HTMLElement,
inputEl: HTMLInputElement,
getSuggestions: () => string[],
onValueChange: (value: string) => void,
) {
this.inputEl = inputEl;
this.onValueChange = onValueChange;
this.getSuggestions = getSuggestions;
this.suggestionsList = this.createSuggestionsList(container);
this.attachEventListeners();
}
/**
* Updates suggestion list if it's visible
*/
public refresh(): void {
if (!this.suggestionsList.hasClass('hidden')) {
const query = this.inputEl.value.trim();
if (query) {
this.handleInput();
} else {
// If query is empty but list is visible, update height
this.adjustListHeight();
}
}
}
/**
* Checks if suggestion list is visible
*/
public isVisible(): boolean {
return !this.suggestionsList.hasClass('hidden');
}
private createSuggestionsList(container: HTMLElement): HTMLElement {
return container.createDiv({
cls: 'suggestions-list_1 hidden',
});
}
private attachEventListeners(): void {
this.inputEl.addEventListener('input', () => {
this.handleInput();
});
this.inputEl.addEventListener('focus', () => {
this.handleFocus();
});
this.inputEl.addEventListener('blur', () => {
this.handleBlur();
});
// Recalculate height on window resize
window.addEventListener('resize', () => {
if (!this.suggestionsList.hasClass('hidden')) {
this.adjustListHeight();
}
});
}
private handleInput(): void {
const query = this.inputEl.value.trim();
this.suggestionsList.empty();
if (!query) {
this.hideSuggestions();
return;
}
/**
* Updates suggestion list if it's visible
*/
public refresh(): void {
if (!this.suggestionsList.hasClass("hidden")) {
const query = this.inputEl.value.trim();
if (query) {
this.handleInput();
} else {
// If query is empty but list is visible, update height
this.adjustListHeight();
}
}
const filtered = this.filterSuggestions(query);
if (filtered.length > 0) {
this.showSuggestions(filtered);
} else {
this.hideSuggestions();
}
}
/**
* Checks if suggestion list is visible
*/
public isVisible(): boolean {
return !this.suggestionsList.hasClass("hidden");
}
private createSuggestionsList(container: HTMLElement): HTMLElement {
return container.createDiv({
cls: "suggestions-list_1 hidden",
});
}
private attachEventListeners(): void {
this.inputEl.addEventListener("input", () => { this.handleInput(); });
this.inputEl.addEventListener("focus", () => { this.handleFocus(); });
this.inputEl.addEventListener("blur", () => { this.handleBlur(); });
// Recalculate height on window resize
window.addEventListener("resize", () => {
if (!this.suggestionsList.hasClass("hidden")) {
this.adjustListHeight();
}
});
}
private handleInput(): void {
const query = this.inputEl.value.trim();
private handleFocus(): void {
if (this.inputEl.value.trim()) {
const query = this.inputEl.value.trim();
const filtered = this.filterSuggestions(query);
if (filtered.length > 0) {
// Clear list before showing suggestions
this.suggestionsList.empty();
this.showSuggestions(filtered);
}
}
}
if (!query) {
this.hideSuggestions();
return;
}
private handleBlur(): void {
// Delay for handling click on suggestion
setTimeout(() => {
this.hideSuggestions();
}, 100);
}
const filtered = this.filterSuggestions(query);
if (filtered.length > 0) {
this.showSuggestions(filtered);
} else {
this.hideSuggestions();
}
private filterSuggestions(query: string): string[] {
const lowerQuery = query.toLowerCase();
const suggestions = this.getSuggestions();
return suggestions.filter((s) => s.toLowerCase().includes(lowerQuery));
}
private showSuggestions(suggestions: string[]): void {
// Always clear list before adding new elements
this.suggestionsList.empty();
this.suggestionsList.removeClass('hidden');
// Limit number of displayed suggestions for performance
const maxSuggestions = 20;
const limitedSuggestions = suggestions.slice(0, maxSuggestions);
// Get existing element texts for duplicate checking
const existingTexts = new Set<string>();
limitedSuggestions.forEach((suggestion) => {
// Check if such element already exists
if (!existingTexts.has(suggestion)) {
this.createSuggestionItem(suggestion);
existingTexts.add(suggestion);
}
});
// Show message if there are more suggestions
if (suggestions.length > maxSuggestions) {
this.suggestionsList.createDiv({
cls: 'suggestion-item_1 suggestion-more',
text: `... and ${suggestions.length - maxSuggestions} more`,
});
}
private handleFocus(): void {
if (this.inputEl.value.trim()) {
const query = this.inputEl.value.trim();
const filtered = this.filterSuggestions(query);
if (filtered.length > 0) {
// Clear list before showing suggestions
this.suggestionsList.empty();
this.showSuggestions(filtered);
}
}
// Adjust list size to content after adding elements
// Use requestAnimationFrame for correct calculation after DOM update
requestAnimationFrame(() => {
this.adjustListHeight();
});
}
/**
* Adjusts list height to content considering available screen space
*/
private adjustListHeight(): void {
// Check if list is visible
if (this.suggestionsList.hasClass('hidden')) {
return;
}
private handleBlur(): void {
// Delay for handling click on suggestion
setTimeout(() => { this.hideSuggestions(); }, 100);
// Reset any fixed heights for automatic calculation
this.suggestionsList.setCssProps({
'--suggestions-list-height': 'auto',
'--suggestions-list-overflow': 'auto',
});
this.suggestionsList.removeClass('no-scroll');
// Get actual content height
const contentHeight = this.suggestionsList.scrollHeight;
// Calculate available screen space
const rect = this.suggestionsList.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - rect.top;
const maxAllowedHeight = Math.min(400, Math.max(spaceBelow - 10, 50)); // Minimum 50px, 10px margin from screen edge
// Set height: either by content or maximum allowed
if (contentHeight <= maxAllowedHeight && contentHeight > 0) {
// Content fits, use its actual height
this.suggestionsList.setCssProps({
'--suggestions-list-height': `${contentHeight}px`,
'--suggestions-list-overflow': 'hidden',
});
this.suggestionsList.addClass('no-scroll');
} else if (contentHeight > 0) {
// Content doesn't fit, set maximum height with scrolling
this.suggestionsList.setCssProps({
'--suggestions-list-height': `${maxAllowedHeight}px`,
'--suggestions-list-overflow': 'auto',
});
this.suggestionsList.removeClass('no-scroll');
}
}
private createSuggestionItem(suggestion: string): void {
// Check if element with such text already exists in list
const existingItems = Array.from(this.suggestionsList.children);
const alreadyExists = existingItems.some((child) => {
return child.textContent.trim() === suggestion;
});
if (alreadyExists) {
return; // Skip if element already exists
}
private filterSuggestions(query: string): string[] {
const lowerQuery = query.toLowerCase();
const suggestions = this.getSuggestions();
return suggestions.filter((s) =>
s.toLowerCase().includes(lowerQuery)
);
}
const item = this.suggestionsList.createDiv({
cls: 'suggestion-item_1',
text: suggestion,
});
private showSuggestions(suggestions: string[]): void {
// Always clear list before adding new elements
this.suggestionsList.empty();
this.suggestionsList.removeClass("hidden");
// Limit number of displayed suggestions for performance
const maxSuggestions = 20;
const limitedSuggestions = suggestions.slice(0, maxSuggestions);
// Get existing element texts for duplicate checking
const existingTexts = new Set<string>();
limitedSuggestions.forEach((suggestion) => {
// Check if such element already exists
if (!existingTexts.has(suggestion)) {
this.createSuggestionItem(suggestion);
existingTexts.add(suggestion);
}
});
item.onclick = () => {
this.inputEl.value = suggestion;
this.onValueChange(suggestion);
this.hideSuggestions();
};
}
// Show message if there are more suggestions
if (suggestions.length > maxSuggestions) {
this.suggestionsList.createDiv({
cls: "suggestion-item_1 suggestion-more",
text: `... and ${suggestions.length - maxSuggestions} more`,
});
}
// Adjust list size to content after adding elements
// Use requestAnimationFrame for correct calculation after DOM update
requestAnimationFrame(() => {
this.adjustListHeight();
});
}
/**
* Adjusts list height to content considering available screen space
*/
private adjustListHeight(): void {
// Check if list is visible
if (this.suggestionsList.hasClass("hidden")) {
return;
}
// Reset any fixed heights for automatic calculation
this.suggestionsList.setCssProps({
"--suggestions-list-height": "auto",
"--suggestions-list-overflow": "auto"
});
this.suggestionsList.removeClass("no-scroll");
// Get actual content height
const contentHeight = this.suggestionsList.scrollHeight;
// Calculate available screen space
const rect = this.suggestionsList.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - rect.top;
const maxAllowedHeight = Math.min(400, Math.max(spaceBelow - 10, 50)); // Minimum 50px, 10px margin from screen edge
// Set height: either by content or maximum allowed
if (contentHeight <= maxAllowedHeight && contentHeight > 0) {
// Content fits, use its actual height
this.suggestionsList.setCssProps({
"--suggestions-list-height": `${contentHeight}px`,
"--suggestions-list-overflow": "hidden"
});
this.suggestionsList.addClass("no-scroll");
} else if (contentHeight > 0) {
// Content doesn't fit, set maximum height with scrolling
this.suggestionsList.setCssProps({
"--suggestions-list-height": `${maxAllowedHeight}px`,
"--suggestions-list-overflow": "auto"
});
this.suggestionsList.removeClass("no-scroll");
}
}
private createSuggestionItem(suggestion: string): void {
// Check if element with such text already exists in list
const existingItems = Array.from(this.suggestionsList.children);
const alreadyExists = existingItems.some((child) => {
return child.textContent.trim() === suggestion;
});
if (alreadyExists) {
return; // Skip if element already exists
}
const item = this.suggestionsList.createDiv({
cls: "suggestion-item_1",
text: suggestion,
});
item.onclick = () => {
this.inputEl.value = suggestion;
this.onValueChange(suggestion);
this.hideSuggestions();
};
}
private hideSuggestions(): void {
this.suggestionsList.addClass("hidden");
}
private hideSuggestions(): void {
this.suggestionsList.addClass('hidden');
}
}

View file

@ -1,93 +1,95 @@
import type { FileExplorerUtils } from '../file-explorer-utils';
import type { App, FileExplorerView, WorkspaceLeaf } from 'obsidian';
export class ExplorerUI {
private app: App;
private fileExplorerUtils: FileExplorerUtils;
private app: App;
private fileExplorerUtils: FileExplorerUtils;
private readonly SORT_OPTIONS = [
{ id: 'ascending', label: 'Ascending' },
{ id: 'descending', label: 'Descending' }
];
private readonly sortOptions = [
{ id: 'ascending', label: 'Ascending' },
{ id: 'descending', label: 'Descending' },
];
constructor(app: App, fileExplorerUtils: FileExplorerUtils) {
this.app = app;
this.fileExplorerUtils = fileExplorerUtils;
}
constructor(app: App, fileExplorerUtils: FileExplorerUtils) {
this.app = app;
this.fileExplorerUtils = fileExplorerUtils;
}
initialize(fileExplorer: FileExplorerView): void {
if (!fileExplorer.headerDom.navButtonsEl) {
return;
}
initialize(fileExplorer: FileExplorerView): void {
if (!fileExplorer.headerDom.navButtonsEl) {
return;
}
const sortOptions: string[][] = [this.SORT_OPTIONS.map(opt => opt.id)];
const descriptions: Record<string, () => string> = {};
this.SORT_OPTIONS.forEach(opt => {
descriptions[opt.id] = () => opt.label;
});
const sortOptions: string[][] = [this.sortOptions.map((opt) => opt.id)];
const descriptions: Record<string, () => string> = {};
this.sortOptions.forEach((opt) => {
descriptions[opt.id] = () => opt.label;
});
fileExplorer.headerDom.addSortButton(
sortOptions,
descriptions,
(value: string | number) => {
const ascending = typeof value === 'string' ? value === 'ascending' : value === 0;
this.fileExplorerUtils.setSortDirection(ascending);
},
() => this.fileExplorerUtils.sortAscending ? 'ascending' : 'descending'
);
fileExplorer.headerDom.addSortButton(
sortOptions,
descriptions,
(value: string | number) => {
const ascending = typeof value === 'string' ? value === 'ascending' : value === 0;
this.fileExplorerUtils.setSortDirection(ascending);
},
() => (this.fileExplorerUtils.sortAscending ? 'ascending' : 'descending'),
);
// Style the button using CSS class and custom color
setTimeout(() => {
const button = fileExplorer.headerDom.navButtonsEl?.lastElementChild as HTMLElement | null;
if (button) {
button.addClass("file-explorer-sort-button");
const svg = button.querySelector("svg") as { setCssProps?: (props: Record<string, string>) => void } | null;
if (svg && typeof svg.setCssProps === 'function') {
svg.setCssProps({
"--sort-button-icon-color": "#fff"
});
}
}
}, 100);
}
// Style the button using CSS class and custom color
setTimeout(() => {
const button = fileExplorer.headerDom.navButtonsEl?.lastElementChild as HTMLElement | null;
if (button) {
button.addClass('file-explorer-sort-button');
const svg = button.querySelector('svg') as {
setCssProps?: (props: Record<string, string>) => void;
} | null;
if (svg && typeof svg.setCssProps === 'function') {
svg.setCssProps({
'--sort-button-icon-color': '#fff',
});
}
}
}, 100);
}
/**
* Cleans up the UI by reloading FileExplorer to remove the sort button
* This is necessary because the button added via addSortButton() is not automatically removed
*/
cleanup(): void {
const fileExplorerLeaves = this.app.workspace.getLeavesOfType("file-explorer");
if (fileExplorerLeaves.length > 0) {
// Save the view states before detaching
const leavesData = fileExplorerLeaves.map(leaf => ({
viewState: leaf.getViewState()
}));
// Detach all FileExplorer leaves
fileExplorerLeaves.forEach(leaf => {
leaf.detach();
});
// Reopen FileExplorer leaves with saved state
// Use setTimeout to ensure detach completes before reopening
setTimeout(() => {
// Choose the view state to restore: use the first saved state
const preferred = leavesData[0];
/**
* Cleans up the UI by reloading FileExplorer to remove the sort button
* This is necessary because the button added via addSortButton() is not automatically removed
*/
cleanup(): void {
const fileExplorerLeaves = this.app.workspace.getLeavesOfType('file-explorer');
if (fileExplorerLeaves.length > 0) {
// Save the view states before detaching
const leavesData = fileExplorerLeaves.map((leaf) => ({
viewState: leaf.getViewState(),
}));
// Prefer the left leaf if available, otherwise create a new one
const targetLeaf: WorkspaceLeaf = this.app.workspace.getLeftLeaf(false) ?? this.app.workspace.getLeaf();
// Detach all FileExplorer leaves
fileExplorerLeaves.forEach((leaf) => {
leaf.detach();
});
targetLeaf.setViewState(preferred.viewState)
.then(() => {
// Always restore focus to the restored File Explorer leaf
this.app.workspace.setActiveLeaf(targetLeaf, { focus: true });
})
.catch((err: unknown) => {
console.error("[ExplorerUI] Error setting view state:", err);
});
}, 0);
}
}
// Reopen FileExplorer leaves with saved state
// Use setTimeout to ensure detach completes before reopening
setTimeout(() => {
// Choose the view state to restore: use the first saved state
const preferred = leavesData[0];
// Prefer the left leaf if available, otherwise create a new one
const targetLeaf: WorkspaceLeaf =
this.app.workspace.getLeftLeaf(false) ?? this.app.workspace.getLeaf();
targetLeaf
.setViewState(preferred.viewState)
.then(() => {
// Always restore focus to the restored File Explorer leaf
this.app.workspace.setActiveLeaf(targetLeaf, { focus: true });
})
.catch((err: unknown) => {
console.error('[ExplorerUI] Error setting view state:', err);
});
}, 0);
}
}
}

View file

@ -3,427 +3,426 @@ import { PluginSettingTab, Setting, TFolder, Notice } from 'obsidian';
import { AutocompleteInput } from './autocomplete-input';
import type DailyNotesSorter from '../main';
import type { App} from 'obsidian';
import type { App } from 'obsidian';
enum SettingsLoadState {
Unknown = "Unknown",
Loading = "Loading",
Loaded = "Loaded",
Unknown = 'Unknown',
Loading = 'Loading',
Loaded = 'Loaded',
}
// Configuration constants
const INPUT_PLACEHOLDER = "Example: /folder/note";
const INPUT_DESCRIPTION = "Enter the path to the folder";
const SETTINGS_TITLE = "Daily notes sorter settings";
const ADD_BUTTON_TEXT = "Add item";
const APPLY_BUTTON_TEXT = "Apply settings";
const INPUT_PLACEHOLDER = 'Example: /folder/note';
const INPUT_DESCRIPTION = 'Enter the path to the folder';
const SETTINGS_TITLE = 'Daily notes sorter settings';
const ADD_BUTTON_TEXT = 'Add item';
const APPLY_BUTTON_TEXT = 'Apply settings';
// Date formats
const DATE_FORMATS = [
{ value: "YYYY-MM-DD", label: "YYYY-MM-DD (ISO)" },
{ value: "DD.MM.YYYY", label: "DD.MM.YYYY (European)" },
{ value: "DD.MM.YY", label: "DD.MM.YY (European short)" },
{ value: "MM/DD/YYYY", label: "MM/DD/YYYY (US)" },
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD (ISO)' },
{ value: 'DD.MM.YYYY', label: 'DD.MM.YYYY (European)' },
{ value: 'DD.MM.YY', label: 'DD.MM.YY (European short)' },
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY (US)' },
] as const;
const DEFAULT_DATE_FORMAT = "YYYY-MM-DD";
const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
export class SorterSettings extends PluginSettingTab {
plugin: DailyNotesSorter;
private itemsContainer: HTMLElement | null = null;
private cachedFolders: string[] | null = null;
private activeAutocompleteInputs: Set<AutocompleteInput> = new Set();
private inputElements: Map<number, HTMLInputElement> = new Map();
private loadState: SettingsLoadState;
plugin: DailyNotesSorter;
private itemsContainer: HTMLElement | null = null;
private cachedFolders: string[] | null = null;
private activeAutocompleteInputs: Set<AutocompleteInput> = new Set();
private inputElements: Map<number, HTMLInputElement> = new Map();
private loadState: SettingsLoadState;
constructor(app: App, plugin: DailyNotesSorter) {
super(app, plugin);
this.plugin = plugin;
this.loadState = SettingsLoadState.Unknown;
// Update cache and open lists when vault changes
this.plugin.registerEvent(
this.plugin.app.vault.on("create", () => {
this.invalidateCache();
})
);
this.plugin.registerEvent(
this.plugin.app.vault.on("delete", () => {
this.invalidateCache();
})
);
this.plugin.registerEvent(
this.plugin.app.vault.on("rename", () => {
this.invalidateCache();
})
);
constructor(app: App, plugin: DailyNotesSorter) {
super(app, plugin);
this.plugin = plugin;
this.loadState = SettingsLoadState.Unknown;
// Update cache and open lists when vault changes
this.plugin.registerEvent(
this.plugin.app.vault.on('create', () => {
this.invalidateCache();
}),
);
this.plugin.registerEvent(
this.plugin.app.vault.on('delete', () => {
this.invalidateCache();
}),
);
this.plugin.registerEvent(
this.plugin.app.vault.on('rename', () => {
this.invalidateCache();
}),
);
}
/**
* Invalidates cache and updates all open suggestion lists
*/
private invalidateCache(): void {
this.cachedFolders = null;
// Update all visible suggestion lists
this.activeAutocompleteInputs.forEach((input) => {
if (input.isVisible()) {
input.refresh();
}
});
}
display(): void {
const { containerEl } = this;
containerEl.empty();
// Clear list of active components on redraw
this.activeAutocompleteInputs.clear();
this.inputElements.clear();
// Reset cache when opening settings
this.cachedFolders = null;
// State-driven loading and rendering
if (this.loadState !== SettingsLoadState.Loaded) {
if (this.loadState === SettingsLoadState.Unknown) {
this.loadState = SettingsLoadState.Loading;
this.plugin
.loadSettings()
.then(() => {
this.loadState = SettingsLoadState.Loaded;
this.display();
})
.catch((err: unknown) => {
console.error('[SorterSettings] Error loading settings:', err);
// Allow retry on next display invocation
this.loadState = SettingsLoadState.Unknown;
});
}
// Do not render anything until settings are fully loaded
return;
}
/**
* Invalidates cache and updates all open suggestion lists
*/
private invalidateCache(): void {
this.cachedFolders = null;
// Update all visible suggestion lists
this.activeAutocompleteInputs.forEach((input) => {
if (input.isVisible()) {
input.refresh();
}
});
this.renderHeader(containerEl);
this.itemsContainer = this.createItemsContainer(containerEl);
this.renderItems();
this.renderAddButton(containerEl);
this.renderApplyButton(containerEl);
}
private renderHeader(container: HTMLElement): void {
new Setting(container).setName(SETTINGS_TITLE).setHeading();
}
private createItemsContainer(container: HTMLElement): HTMLElement {
return container.createDiv();
}
private renderItems(): void {
if (!this.itemsContainer) {
return;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
this.itemsContainer.empty();
// Clear list of active components on redraw
this.activeAutocompleteInputs.clear();
this.inputElements.clear();
// Reset cache when opening settings
this.cachedFolders = null;
if (this.plugin.settings.items.length === 0) {
this.renderEmptyState();
return;
}
// State-driven loading and rendering
if (this.loadState !== SettingsLoadState.Loaded) {
if (this.loadState === SettingsLoadState.Unknown) {
this.loadState = SettingsLoadState.Loading;
this.plugin.loadSettings()
.then(() => {
this.loadState = SettingsLoadState.Loaded;
this.display();
})
.catch((err: unknown) => {
console.error("[SorterSettings] Error loading settings:", err);
// Allow retry on next display invocation
this.loadState = SettingsLoadState.Unknown;
});
}
// Do not render anything until settings are fully loaded
return;
this.plugin.settings.items.forEach((item, index: number) => {
this.renderItem(item, index);
});
}
private renderEmptyState(): void {
if (!this.itemsContainer) {
return;
}
this.itemsContainer.createDiv({
cls: 'setting-item-description',
text: "No items. Click 'Add item' to create a new one.",
});
}
private renderItem(item: { path: string; dateFormat?: string }, index: number): void {
if (!this.itemsContainer) {
return;
}
const itemDiv = this.itemsContainer.createDiv({
cls: 'setting-item-custom-plugin',
});
const inputSetting = this.createInputSetting(itemDiv, item.path || '', index);
this.addDateFormatComboBox(inputSetting, item.dateFormat || DEFAULT_DATE_FORMAT, index);
this.addDeleteButton(inputSetting, index);
}
/**
* Gets list of all folders from vault
* Uses caching to improve performance
*/
private getFoldersFromVault(): string[] {
// Return cached list if available
if (this.cachedFolders !== null) {
return this.cachedFolders;
}
try {
const allFiles = this.plugin.app.vault.getAllLoadedFiles();
const folders = allFiles
.filter((file): file is TFolder => file instanceof TFolder)
.map((folder) => folder.path)
.sort(); // Sort for user convenience
// Cache the result
this.cachedFolders = folders;
return folders;
} catch (error) {
console.error('Error getting folders from vault:', error);
return [];
}
}
/**
* Validates path - checks if it exists in vault
*/
private validatePath(path: string): boolean {
if (!path || path.trim() === '') {
return false;
}
const trimmedPath = path.trim();
const file = this.plugin.app.vault.getAbstractFileByPath(trimmedPath);
return file !== null && file instanceof TFolder;
}
/**
* Validates all settings items
*/
private validateAllItems(): boolean {
let isValid = true;
this.plugin.settings.items.forEach((item, index) => {
const isValidPath = this.validatePath(item.path);
const inputEl = this.inputElements.get(index);
if (inputEl) {
if (!isValidPath) {
inputEl.addClass('input-error');
inputEl.removeClass('input-valid');
isValid = false;
} else {
inputEl.removeClass('input-error');
inputEl.addClass('input-valid');
}
} else {
if (!isValidPath) {
isValid = false;
}
}
});
return isValid;
}
/**
* Validates a single item by index
*/
private validateItem(index: number): boolean {
const isValid = this.validatePath(this.plugin.settings.items[index].path);
const inputEl = this.inputElements.get(index);
if (inputEl) {
if (!isValid) {
inputEl.addClass('input-error');
inputEl.removeClass('input-valid');
} else {
inputEl.removeClass('input-error');
inputEl.addClass('input-valid');
}
}
return isValid;
}
private createInputSetting(container: HTMLElement, value: string, index: number): Setting {
const setting = new Setting(container)
.setName(`Item ${index + 1}`)
.setDesc(INPUT_DESCRIPTION)
.addText((text) => {
text.setPlaceholder(INPUT_PLACEHOLDER);
text.setValue(value);
// Save reference to input element for validation
this.inputElements.set(index, text.inputEl);
// Validation on load
if (value) {
this.validateItem(index);
}
this.renderHeader(containerEl);
this.itemsContainer = this.createItemsContainer(containerEl);
this.renderItems();
this.renderAddButton(containerEl);
this.renderApplyButton(containerEl);
}
private renderHeader(container: HTMLElement): void {
new Setting(container).setName(SETTINGS_TITLE).setHeading();
}
private createItemsContainer(container: HTMLElement): HTMLElement {
return container.createDiv();
}
private renderItems(): void {
if (!this.itemsContainer) {return;}
this.itemsContainer.empty();
if (this.plugin.settings.items.length === 0) {
this.renderEmptyState();
return;
}
this.plugin.settings.items.forEach((item, index: number) => {
this.renderItem(item, index);
});
}
private renderEmptyState(): void {
if (!this.itemsContainer) {return;}
this.itemsContainer.createDiv({
cls: "setting-item-description",
text: "No items. Click 'Add item' to create a new one.",
});
}
private renderItem(item: { path: string; dateFormat?: string }, index: number): void {
if (!this.itemsContainer) {return;}
const itemDiv = this.itemsContainer.createDiv({
cls: "setting-item-custom-plugin",
});
const inputSetting = this.createInputSetting(itemDiv, item.path || "", index);
this.addDateFormatComboBox(inputSetting, item.dateFormat || DEFAULT_DATE_FORMAT, index);
this.addDeleteButton(inputSetting, index);
}
/**
* Gets list of all folders from vault
* Uses caching to improve performance
*/
private getFoldersFromVault(): string[] {
// Return cached list if available
if (this.cachedFolders !== null) {
return this.cachedFolders;
}
try {
const allFiles = this.plugin.app.vault.getAllLoadedFiles();
const folders = allFiles
.filter((file): file is TFolder => file instanceof TFolder)
.map((folder) => folder.path)
.sort(); // Sort for user convenience
// Cache the result
this.cachedFolders = folders;
return folders;
} catch (error) {
console.error("Error getting folders from vault:", error);
return [];
}
}
/**
* Validates path - checks if it exists in vault
*/
private validatePath(path: string): boolean {
if (!path || path.trim() === "") {
return false;
}
const trimmedPath = path.trim();
const file = this.plugin.app.vault.getAbstractFileByPath(trimmedPath);
return file !== null && file instanceof TFolder;
}
/**
* Validates all settings items
*/
private validateAllItems(): boolean {
let isValid = true;
this.plugin.settings.items.forEach((item, index) => {
const isValidPath = this.validatePath(item.path);
const inputEl = this.inputElements.get(index);
if (inputEl) {
if (!isValidPath) {
inputEl.addClass("input-error");
inputEl.removeClass("input-valid");
isValid = false;
} else {
inputEl.removeClass("input-error");
inputEl.addClass("input-valid");
}
} else {
if (!isValidPath) {
isValid = false;
}
}
});
return isValid;
}
/**
* Validates a single item by index
*/
private validateItem(index: number): boolean {
const isValid = this.validatePath(this.plugin.settings.items[index].path);
const inputEl = this.inputElements.get(index);
if (inputEl) {
if (!isValid) {
inputEl.addClass("input-error");
inputEl.removeClass("input-valid");
} else {
inputEl.removeClass("input-error");
inputEl.addClass("input-valid");
}
}
return isValid;
}
private createInputSetting(
container: HTMLElement,
value: string,
index: number
): Setting {
const setting = new Setting(container)
.setName(`Item ${index + 1}`)
.setDesc(INPUT_DESCRIPTION)
.addText((text) => {
text.setPlaceholder(INPUT_PLACEHOLDER);
text.setValue(value);
// Save reference to input element for validation
this.inputElements.set(index, text.inputEl);
// Validation on load
if (value) {
this.validateItem(index);
}
// Create autocomplete component with folder retrieval function
const autocompleteInput = new AutocompleteInput(
container,
text.inputEl,
() => this.getFoldersFromVault(),
(newValue: string) => {
this.plugin.settings.items[index].path = newValue;
// Real-time validation
this.validateItem(index);
}
);
// Register component for real-time updates
this.activeAutocompleteInputs.add(autocompleteInput);
// Value change handler with real-time validation
text.onChange((newValue: string) => {
this.plugin.settings.items[index].path = newValue;
// Real-time validation
this.validateItem(index);
});
// Validation on blur
text.inputEl.addEventListener("blur", () => {
this.validateItem(index);
});
});
return setting;
}
/**
* Adds dropdown for date format selection
*/
private addDateFormatComboBox(
setting: Setting,
currentFormat: string,
index: number
): void {
setting.addDropdown((dropdown) => {
// Add options to dropdown
DATE_FORMATS.forEach((format) => {
dropdown.addOption(format.value, format.label);
});
// Set current value
dropdown.setValue(currentFormat || DEFAULT_DATE_FORMAT);
// Format change handler
dropdown.onChange((value: string) => {
this.plugin.settings.items[index].dateFormat = value;
});
});
}
private addDeleteButton(setting: Setting, index: number): void {
setting.addExtraButton((button) => {
button.setIcon("trash")
.setTooltip("Delete")
.onClick(() => {
this.deleteItem(index);
});
});
}
private deleteItem(index: number): void {
this.plugin.settings.items.splice(index, 1);
this.inputElements.delete(index);
// Update indices for remaining elements
const newMap = new Map<number, HTMLInputElement>();
this.inputElements.forEach((el, oldIndex) => {
if (oldIndex > index) {
newMap.set(oldIndex - 1, el);
} else if (oldIndex < index) {
newMap.set(oldIndex, el);
}
});
this.inputElements = newMap;
this.renderItems();
}
private renderAddButton(container: HTMLElement): void {
const buttonContainer = container.createDiv({
cls: "add-button-container",
});
buttonContainer.createEl("button", {
text: ADD_BUTTON_TEXT,
}).onclick = () => {
this.addItem();
};
}
private addItem(): void {
// Check if there are empty items before adding a new one
const hasEmptyItems = this.plugin.settings.items.some(
(item) => !item.path || item.path.trim() === ""
// Create autocomplete component with folder retrieval function
const autocompleteInput = new AutocompleteInput(
container,
text.inputEl,
() => this.getFoldersFromVault(),
(newValue: string) => {
this.plugin.settings.items[index].path = newValue;
// Real-time validation
this.validateItem(index);
},
);
if (hasEmptyItems) {
new Notice("Please fill in all existing items before adding a new one.");
return;
}
// Register component for real-time updates
this.activeAutocompleteInputs.add(autocompleteInput);
this.plugin.settings.items.push({
path: "",
dateFormat: DEFAULT_DATE_FORMAT,
});
this.renderItems();
}
/**
* Renders apply settings button
*/
private renderApplyButton(container: HTMLElement): void {
const buttonContainer = container.createDiv({
cls: "apply-button-container",
// Value change handler with real-time validation
text.onChange((newValue: string) => {
this.plugin.settings.items[index].path = newValue;
// Real-time validation
this.validateItem(index);
});
const applyButton = buttonContainer.createEl("button", {
cls: "apply-button",
text: APPLY_BUTTON_TEXT,
// Validation on blur
text.inputEl.addEventListener('blur', () => {
this.validateItem(index);
});
});
applyButton.onclick = async () => {
await this.applySettings();
};
return setting;
}
/**
* Adds dropdown for date format selection
*/
private addDateFormatComboBox(setting: Setting, currentFormat: string, index: number): void {
setting.addDropdown((dropdown) => {
// Add options to dropdown
DATE_FORMATS.forEach((format) => {
dropdown.addOption(format.value, format.label);
});
// Set current value
dropdown.setValue(currentFormat || DEFAULT_DATE_FORMAT);
// Format change handler
dropdown.onChange((value: string) => {
this.plugin.settings.items[index].dateFormat = value;
});
});
}
private addDeleteButton(setting: Setting, index: number): void {
setting.addExtraButton((button) => {
button
.setIcon('trash')
.setTooltip('Delete')
.onClick(() => {
this.deleteItem(index);
});
});
}
private deleteItem(index: number): void {
this.plugin.settings.items.splice(index, 1);
this.inputElements.delete(index);
// Update indices for remaining elements
const newMap = new Map<number, HTMLInputElement>();
this.inputElements.forEach((el, oldIndex) => {
if (oldIndex > index) {
newMap.set(oldIndex - 1, el);
} else if (oldIndex < index) {
newMap.set(oldIndex, el);
}
});
this.inputElements = newMap;
this.renderItems();
}
private renderAddButton(container: HTMLElement): void {
const buttonContainer = container.createDiv({
cls: 'add-button-container',
});
buttonContainer.createEl('button', {
text: ADD_BUTTON_TEXT,
}).onclick = () => {
this.addItem();
};
}
private addItem(): void {
// Check if there are empty items before adding a new one
const hasEmptyItems = this.plugin.settings.items.some(
(item) => !item.path || item.path.trim() === '',
);
if (hasEmptyItems) {
new Notice('Please fill in all existing items before adding a new one.');
return;
}
/**
* Applies settings: validates, saves and shows result
*/
private async applySettings(): Promise<void> {
// Remove empty items before validation
this.plugin.settings.items = this.plugin.settings.items.filter(
(item) => item.path && item.path.trim() !== ""
);
this.plugin.settings.items.push({
path: '',
dateFormat: DEFAULT_DATE_FORMAT,
});
this.renderItems();
}
// Validate all items
const isValid = this.validateAllItems();
/**
* Renders apply settings button
*/
private renderApplyButton(container: HTMLElement): void {
const buttonContainer = container.createDiv({
cls: 'apply-button-container',
});
if (!isValid) {
new Notice("Please fix all errors before applying settings.");
return;
}
const applyButton = buttonContainer.createEl('button', {
cls: 'apply-button',
text: APPLY_BUTTON_TEXT,
});
// Save settings
try {
await this.plugin.saveSettings();
// Remove all validation indicators after successful application
this.inputElements.forEach((inputEl) => {
inputEl.removeClass("input-error");
inputEl.removeClass("input-valid");
});
new Notice("Settings applied successfully!");
} catch (error) {
console.error("Error applying settings:", error);
new Notice("Error applying settings. Please try again.");
}
applyButton.onclick = async () => {
await this.applySettings();
};
}
/**
* Applies settings: validates, saves and shows result
*/
private async applySettings(): Promise<void> {
// Remove empty items before validation
this.plugin.settings.items = this.plugin.settings.items.filter(
(item) => item.path && item.path.trim() !== '',
);
// Validate all items
const isValid = this.validateAllItems();
if (!isValid) {
new Notice('Please fix all errors before applying settings.');
return;
}
// Save settings
try {
await this.plugin.saveSettings();
// Remove all validation indicators after successful application
this.inputElements.forEach((inputEl) => {
inputEl.removeClass('input-error');
inputEl.removeClass('input-valid');
});
new Notice('Settings applied successfully!');
} catch (error) {
console.error('Error applying settings:', error);
new Notice('Error applying settings. Please try again.');
}
}
}

View file

@ -1,96 +1,94 @@
.setting-item-custom-plugin {
position: relative; /* Child elements are now positioned relative to this container */
width: 100%; /* Ensure the container takes the required width */
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
border-radius: 5px;
position: relative; /* Child elements are now positioned relative to this container */
width: 100%; /* Ensure the container takes the required width */
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
border-radius: 5px;
}
.suggestions-list_1 {
position: absolute; /* Positioned relative to setting-item-custom-plugin */
top: 100%; /* Starts immediately below the input field */
left: 0; /* Aligned to the left edge of the parent */
width: 100%; /* Matches the width of the input field */
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
max-height: 400px; /* Maximum height to prevent overflow beyond the screen */
z-index: 1000;
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
border-radius: 5px;
/* Size adjusts to content automatically */
height: var(--suggestions-list-height, auto);
overflow-y: var(--suggestions-list-overflow, auto);
min-height: 0;
position: absolute; /* Positioned relative to setting-item-custom-plugin */
top: 100%; /* Starts immediately below the input field */
left: 0; /* Aligned to the left edge of the parent */
width: 100%; /* Matches the width of the input field */
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
max-height: 400px; /* Maximum height to prevent overflow beyond the screen */
z-index: 1000;
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
border-radius: 5px;
/* Size adjusts to content automatically */
height: var(--suggestions-list-height, auto);
overflow-y: var(--suggestions-list-overflow, auto);
min-height: 0;
}
.suggestion-item_1 {
padding: 10px;
cursor: pointer;
color: var(--text-normal);
font-size: 0.9em;
border-bottom: 1px solid var(--background-modifier-border);
padding: 10px;
cursor: pointer;
color: var(--text-normal);
font-size: 0.9em;
border-bottom: 1px solid var(--background-modifier-border);
}
.suggestion-item_1:hover {
background-color: var(--background-modifier-hover);
background-color: var(--background-modifier-hover);
}
.suggestion-item_1:last-child {
border-bottom: none;
border-bottom: none;
}
.suggestion-more {
font-style: italic;
opacity: 0.7;
font-style: italic;
opacity: 0.7;
}
.suggestions-list_1.no-scroll {
overflow-y: hidden;
overflow-y: hidden;
}
.hidden {
display: none;
display: none;
}
/* Validation styles */
.input-error {
border: 2px solid var(--text-error) !important;
background-color: var(--background-modifier-error) !important;
border: 2px solid var(--text-error) !important;
background-color: var(--background-modifier-error) !important;
}
.input-valid {
border: 2px solid var(--text-success) !important;
border: 2px solid var(--text-success) !important;
}
.apply-button-container {
display: flex;
justify-content: flex-end;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--background-modifier-border);
display: flex;
justify-content: flex-end;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--background-modifier-border);
}
.apply-button {
padding: 8px 20px;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
padding: 8px 20px;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.apply-button:hover {
background-color: var(--interactive-accent-hover);
background-color: var(--interactive-accent-hover);
}
.apply-button:disabled {
opacity: 0.5;
cursor: not-allowed;
opacity: 0.5;
cursor: not-allowed;
}
/* File Explorer sort button styles */
.file-explorer-sort-button svg {
color: var(--sort-button-icon-color, #fff);
}
color: var(--sort-button-icon-color, #fff);
}

View file

@ -11,13 +11,13 @@
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"lib": ["ES2020", "DOM"]
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"lib": ["ES2020", "DOM"]
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "main.js", "backup"]
}
}

122
types/types.d.ts vendored
View file

@ -1,79 +1,79 @@
import {TFolder, WorkspaceLeaf} from "obsidian";
import { TFolder, WorkspaceLeaf } from 'obsidian';
// Needed to support monkey-patching of the folder sort() function
declare module 'obsidian' {
export interface ViewRegistry {
viewByType: Record<string, (leaf: WorkspaceLeaf) => unknown>;
}
export interface ViewRegistry {
viewByType: Record<string, (leaf: WorkspaceLeaf) => unknown>;
}
// undocumented internal interface - for experimental features
export interface PluginInstance {
id: string;
}
// undocumented internal interface - for experimental features
export interface PluginInstance {
id: string;
}
export type CommunityPluginId = string
export type CommunityPluginId = string;
// undocumented internal interface - for experimental features
export interface CommunityPlugin {
manifest: {
id: CommunityPluginId
}
_loaded: boolean
}
// undocumented internal interface - for experimental features
export interface CommunityPlugin {
manifest: {
id: CommunityPluginId;
};
_loaded: boolean;
}
// undocumented internal interface - for experimental features
export interface CommunityPlugins {
enabledPlugins: Set<CommunityPluginId>
plugins: {[key: CommunityPluginId]: CommunityPlugin}
}
// undocumented internal interface - for experimental features
export interface CommunityPlugins {
enabledPlugins: Set<CommunityPluginId>;
plugins: { [key: CommunityPluginId]: CommunityPlugin };
}
export interface App {
plugins: CommunityPlugins;
internalPlugins: InternalPlugins; // undocumented internal API - for experimental features
viewRegistry: ViewRegistry;
}
export interface App {
plugins: CommunityPlugins;
internalPlugins: InternalPlugins; // undocumented internal API - for experimental features
viewRegistry: ViewRegistry;
}
// undocumented internal interface - for experimental features
export interface InstalledPlugin {
enabled: boolean;
instance: PluginInstance;
}
// undocumented internal interface - for experimental features
export interface InstalledPlugin {
enabled: boolean;
instance: PluginInstance;
}
// undocumented internal interface - for experimental features
export interface InternalPlugins {
plugins: Record<string, InstalledPlugin>;
getPluginById(id: string): InstalledPlugin;
}
// undocumented internal interface - for experimental features
export interface InternalPlugins {
plugins: Record<string, InstalledPlugin>;
getPluginById(id: string): InstalledPlugin;
}
interface FileExplorerFolder {
fileItems: Record<string, unknown>;
}
interface FileExplorerFolder {
fileItems: Record<string, unknown>;
}
interface EI {
navHeaderEl: HTMLElement | null;
navButtonsEl: HTMLElement | null;
addNavButton(e: string, t: string, n: (event: Event) => void, i?: string): HTMLElement;
addSortButton(
e: string[][],
t: Record<string, () => string>,
n: (value: string | number) => void,
i: () => string | number
): void;
}
interface EI {
navHeaderEl: HTMLElement | null;
navButtonsEl: HTMLElement | null;
addNavButton(e: string, t: string, n: (event: Event) => void, i?: string): HTMLElement;
addSortButton(
e: string[][],
t: Record<string, () => string>,
n: (value: string | number) => void,
i: () => string | number,
): void;
}
export interface FileExplorerView extends View {
createFolderDom(folder: TFolder): FileExplorerFolder;
getSortedFolderItems(sortedFolder: TFolder): unknown[];
export interface FileExplorerView extends View {
createFolderDom(folder: TFolder): FileExplorerFolder;
getSortedFolderItems(sortedFolder: TFolder): unknown[];
requestSort(): void;
requestSort(): void;
sortOrder: string;
headerDom: EI;
fileItems: Record<string, unknown>;
}
sortOrder: string;
headerDom: EI;
fileItems: Record<string, unknown>;
}
interface MenuItem {
setSubmenu: () => Menu;
}
interface MenuItem {
setSubmenu: () => Menu;
}
}