diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..db31677 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +node_modules +release +dist +build +coverage +.obsidian +*.min.js +*.min.css +package-lock.json +versions.json +*.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..9c226f9 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always", + "bracketSpacing": true, + "bracketSameLine": false, + "endOfLine": "lf" +} diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 309f17b..7727495 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -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(); } diff --git a/eslint.config.js b/eslint.config.js index ab81069..fcb2824 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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 } - }] - } - } -]; \ No newline at end of file + 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, +]; diff --git a/manifest.json b/manifest.json index 933ca88..d6bf77b 100644 --- a/manifest.json +++ b/manifest.json @@ -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 } diff --git a/package-lock.json b/package-lock.json index 9da29a8..fb8b0fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ceae5c2..aea9c4c 100644 --- a/package.json +++ b/package.json @@ -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 ", - "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 ", + "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" + } } diff --git a/scripts/release.js b/scripts/release.js index f4100c1..a3311d3 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -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: -.zip - const zipName = `${pluginId}-${pluginVersion}.zip`; - const zipPath = path.join(releaseDir, zipName); + // Archive name: -.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(); \ No newline at end of file +main(); diff --git a/src/core/sorter.ts b/src/core/sorter.ts index 7a9c17f..87b5050 100644 --- a/src/core/sorter.ts +++ b/src/core/sorter.ts @@ -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 = { - // 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 = { + // 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, + 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, ascending: boolean = true, dateFormat: string = "YYYY-MM-DD"): unknown[] { - const allFileItemsCollection = fileItems; + // Separate files into matching and non-matching format + const filesWithDate: Array = []; + const filesWithoutDate: Array = []; - // Separate files into matching and non-matching format - const filesWithDate: Array = []; - const filesWithoutDate: Array = []; + 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; - 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; + 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; + } } - diff --git a/src/file-explorer-utils.ts b/src/file-explorer-utils.ts index 22f1c24..6ae6d83 100644 --- a/src/file-explorer-utils.ts +++ b/src/file-explorer-utils.ts @@ -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 } | 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 } | 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 }) { - 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 }, + ) { + 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 { - 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 { + 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 }, 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 }, + 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; + } + } } - diff --git a/src/main.ts b/src/main.ts index 3d2ebbc..44b6f92 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { - - await this.loadSettings(); + async onload(): Promise { + 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 { - 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 { - 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 { + 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 { + 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); + } + } } - diff --git a/src/ui/autocomplete-input.ts b/src/ui/autocomplete-input.ts index 9a411f7..f151883 100644 --- a/src/ui/autocomplete-input.ts +++ b/src/ui/autocomplete-input.ts @@ -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(); + + 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(); - - 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'); + } } - diff --git a/src/ui/explorer-ui.ts b/src/ui/explorer-ui.ts index aab88ff..1b77c5b 100644 --- a/src/ui/explorer-ui.ts +++ b/src/ui/explorer-ui.ts @@ -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> = {}; - this.SORT_OPTIONS.forEach(opt => { - descriptions[opt.id] = () => opt.label; - }); + const sortOptions: string[][] = [this.sortOptions.map((opt) => opt.id)]; + const descriptions: Record 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) => 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) => 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); + } + } } - diff --git a/src/ui/settings.ts b/src/ui/settings.ts index cc485de..0ac60aa 100644 --- a/src/ui/settings.ts +++ b/src/ui/settings.ts @@ -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 = new Set(); - private inputElements: Map = new Map(); - private loadState: SettingsLoadState; + plugin: DailyNotesSorter; + private itemsContainer: HTMLElement | null = null; + private cachedFolders: string[] | null = null; + private activeAutocompleteInputs: Set = new Set(); + private inputElements: Map = 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(); - 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(); + 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 { - // 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 { + // 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.'); + } + } } - diff --git a/styles.css b/styles.css index 7c061ed..f52e7e6 100644 --- a/styles.css +++ b/styles.css @@ -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); -} \ No newline at end of file + color: var(--sort-button-icon-color, #fff); +} diff --git a/tsconfig.json b/tsconfig.json index 072238e..bb20ac2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] -} \ No newline at end of file +} diff --git a/types/types.d.ts b/types/types.d.ts index 274de85..f462f75 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -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 unknown>; - } + export interface ViewRegistry { + viewByType: Record 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 - plugins: {[key: CommunityPluginId]: CommunityPlugin} - } + // undocumented internal interface - for experimental features + export interface CommunityPlugins { + enabledPlugins: Set; + 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; - getPluginById(id: string): InstalledPlugin; - } + // undocumented internal interface - for experimental features + export interface InternalPlugins { + plugins: Record; + getPluginById(id: string): InstalledPlugin; + } - interface FileExplorerFolder { - fileItems: Record; - } + interface FileExplorerFolder { + fileItems: Record; + } - 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>, - 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>, + 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; - } + sortOrder: string; + headerDom: EI; + fileItems: Record; + } - interface MenuItem { - setSubmenu: () => Menu; - } + interface MenuItem { + setSubmenu: () => Menu; + } }