From 9b0f0679f32b785eeea017b042877f3d9f0f9469 Mon Sep 17 00:00:00 2001 From: Mantano <91400447+mntno@users.noreply.github.com> Date: Mon, 9 Jun 2025 20:13:44 +0700 Subject: [PATCH] 0.5.0 --- README.md | 20 +- manifest.json | 2 +- package.json | 4 +- pnpm-lock.yaml | 449 +++++++------- src/ContentParser.ts | 10 +- src/DataStore.ts | 66 ++- src/FileParser.ts | 46 ++ src/Localization.ts | 19 + src/Scheduler.ts | 29 +- src/Settings.ts | 23 +- src/TypeAssistant.ts | 32 +- src/UIAssistant.ts | 7 +- src/declarations/CardDeclaration.ts | 12 +- src/declarations/CommandDeclaration.ts | 8 +- src/declarations/Declaration.ts | 39 +- src/declarations/DeclarationManager.ts | 4 +- src/declarations/DeclarationParser.ts | 86 ++- src/env.ts | 68 +++ src/lang/en.ts | 40 ++ src/main.ts | 95 ++- src/modals/ConfirmationModal.ts | 3 +- src/renderings/MarkupAssistant.ts | 48 ++ src/renderings/RecycleComponent.ts | 64 ++ src/renderings/content/AudioProcessor.ts | 552 ++++++++++++++++++ src/renderings/content/ContentRenderer.ts | 144 +++++ .../content/ContentRendererProcessor.ts | 65 +++ src/renderings/content/HeadingProcessor.ts | 85 +++ .../content/HtmlElementWrapperProcessor.ts | 19 + src/renderings/content/LinkProcessor.ts | 23 + src/renderings/content/VideoProcessor.ts | 27 + .../AlternateHeadingsRenderer.ts | 2 +- .../CardDeclarationRenderer.ts | 7 +- .../DeclarationErrorRenderer.ts | 4 +- .../DeclarationRenderChild.ts | 18 +- .../DeclarationRenderable.ts | 0 .../HeadingAndDelimiterRenderer.ts | 2 +- .../HeadingIsFrontRenderer.ts | 2 +- src/renderings/dom-constants.ts | 114 ++++ src/views/BaseView.ts | 146 +++++ src/views/DecksView.ts | 98 ++-- src/views/DefinedContentView.ts | 183 ++++++ src/views/ReviewView.ts | 395 +++++++------ src/views/ViewAssistant.ts | 77 +-- styles.css | 199 ++++--- versions.json | 1 + 45 files changed, 2596 insertions(+), 741 deletions(-) create mode 100644 src/Localization.ts create mode 100644 src/env.ts create mode 100644 src/lang/en.ts create mode 100644 src/renderings/MarkupAssistant.ts create mode 100644 src/renderings/RecycleComponent.ts create mode 100644 src/renderings/content/AudioProcessor.ts create mode 100644 src/renderings/content/ContentRenderer.ts create mode 100644 src/renderings/content/ContentRendererProcessor.ts create mode 100644 src/renderings/content/HeadingProcessor.ts create mode 100644 src/renderings/content/HtmlElementWrapperProcessor.ts create mode 100644 src/renderings/content/LinkProcessor.ts create mode 100644 src/renderings/content/VideoProcessor.ts rename src/renderings/{ => declarations}/AlternateHeadingsRenderer.ts (95%) rename src/renderings/{ => declarations}/CardDeclarationRenderer.ts (78%) rename src/renderings/{ => declarations}/DeclarationErrorRenderer.ts (89%) rename src/renderings/{ => declarations}/DeclarationRenderChild.ts (86%) rename src/renderings/{ => declarations}/DeclarationRenderable.ts (100%) rename src/renderings/{ => declarations}/HeadingAndDelimiterRenderer.ts (96%) rename src/renderings/{ => declarations}/HeadingIsFrontRenderer.ts (94%) create mode 100644 src/renderings/dom-constants.ts create mode 100644 src/views/BaseView.ts create mode 100644 src/views/DefinedContentView.ts diff --git a/README.md b/README.md index 8b1c47e..3536302 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ side: back ``` ```` -- The `id` should not be changed (once you rate the card for the first time) or it will be treated as a new card. +> [!NOTE] +> +> The `id` should not be changed (once you rate the card for the first time) or it will be treated as a new card. The `id` of this type of card (the default type) must be unique across the vault, which allows for declaring one side of a certain card in one note each. The following note, for example, contains three sides: one complete card and one front side. @@ -108,9 +110,13 @@ To prevent this, the safest approach is to make changes on only one device at a ## Roadmap -- Quicker way to create cards. -- Review sort order options. -- Mechanism to avoid sync conflicts. -- A card can belong to more than one deck. -- A deck can be a subdeck of more than one deck. -- Reverse cards. +- More and quicker ways to create content to review. +- More ways to group defined content and select for review. +- Scheduler settings. + +## More + +See the [release notes](https://github.com/mntno/obsidian-come-through/releases) for more details. + + + diff --git a/manifest.json b/manifest.json index bfa0358..8a249e8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "come-through", "name": "Come Through", - "version": "0.4.2", + "version": "0.5.0", "minAppVersion": "1.8.0", "description": "Drill flashcards using spaced repetition.", "author": "mntno", diff --git a/package.json b/package.json index 46b619d..48c49f1 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,10 @@ "fast-equals": "5.2.2", "globals": "16.0.0", "obsidian": "latest", + "ts-fsrs": "5.2.1", "tslib": "2.8.1", "typescript": "5.8.2", - "typescript-eslint": "8.26.0", - "ts-fsrs": "4.7.1" + "typescript-eslint": "8.26.0" }, "type": "module", "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e424e4..ebb4e60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,10 +16,10 @@ importers: version: 22.13.9 '@typescript-eslint/eslint-plugin': specifier: 8.26.0 - version: 8.26.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(typescript@5.8.2) + version: 8.26.0(@typescript-eslint/parser@8.26.0(eslint@9.31.0)(typescript@5.8.2))(eslint@9.31.0)(typescript@5.8.2) '@typescript-eslint/parser': specifier: 8.26.0 - version: 8.26.0(eslint@8.57.1)(typescript@5.8.2) + version: 8.26.0(eslint@9.31.0)(typescript@5.8.2) builtin-modules: specifier: 5.0.0 version: 5.0.0 @@ -34,10 +34,10 @@ importers: version: 16.0.0 obsidian: specifier: latest - version: 1.8.7(@codemirror/state@6.5.2)(@codemirror/view@6.36.5) + version: 1.8.7(@codemirror/state@6.5.2)(@codemirror/view@6.38.0) ts-fsrs: - specifier: 4.7.1 - version: 4.7.1 + specifier: 5.2.1 + version: 5.2.1 tslib: specifier: 2.8.1 version: 2.8.1 @@ -46,15 +46,15 @@ importers: version: 5.8.2 typescript-eslint: specifier: 8.26.0 - version: 8.26.0(eslint@8.57.1)(typescript@5.8.2) + version: 8.26.0(eslint@9.31.0)(typescript@5.8.2) packages: '@codemirror/state@6.5.2': resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==} - '@codemirror/view@6.36.5': - resolution: {integrity: sha512-cd+FZEUlu3GQCYnguYm3EkhJ8KJVisqqUsCOKedBoAt/d9c76JUUap6U0UrpElln5k6VyrEOYliMuDAKIeDQLg==} + '@codemirror/view@6.38.0': + resolution: {integrity: sha512-yvSchUwHOdupXkd7xJ0ob36jdsSR/I+/C+VbY0ffBiL5NiSTEBDfB1ZGWbbIlDd5xgdUkody+lukAdOxYrOBeg==} '@esbuild/aix-ppc64@0.25.0': resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} @@ -206,8 +206,8 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.6.1': - resolution: {integrity: sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==} + '@eslint-community/eslint-utils@4.7.0': + resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -216,30 +216,57 @@ packages: resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/eslintrc@2.1.4': - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-array@0.21.0': + resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@8.57.1': - resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-helpers@0.3.0': + resolution: {integrity: sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.15.1': + resolution: {integrity: sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.21.0': resolution: {integrity: sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@humanwhocodes/config-array@0.13.0': - resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} - engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead + '@eslint/js@9.31.0': + resolution: {integrity: sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.6': + resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.3.4': + resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.6': + resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} + engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - '@humanwhocodes/object-schema@2.0.3': - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead + '@humanwhocodes/retry@0.3.1': + resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} + engines: {node: '>=18.18'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -259,8 +286,11 @@ packages: '@types/codemirror@5.60.8': resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==} - '@types/estree@1.0.7': - resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/node@22.13.9': resolution: {integrity: sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==} @@ -315,26 +345,19 @@ packages: resolution: {integrity: sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.14.1: - resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -345,11 +368,11 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -377,12 +400,15 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - debug@4.4.0: - resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -393,10 +419,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - esbuild@0.25.0: resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} engines: {node: '>=18'} @@ -406,27 +428,31 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint-visitor-keys@4.2.0: - resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@8.57.1: - resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + eslint@9.31.0: + resolution: {integrity: sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true - espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} esquery@1.6.0: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} @@ -464,9 +490,9 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} @@ -476,16 +502,13 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -494,13 +517,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported - - globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} globals@16.0.0: resolution: {integrity: sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==} @@ -525,13 +544,6 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -544,10 +556,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -608,9 +616,6 @@ packages: '@codemirror/state': ^6.0.0 '@codemirror/view': ^6.0.0 - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -631,10 +636,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -662,16 +663,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - semver@7.7.1: - resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} hasBin: true @@ -683,10 +679,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -698,9 +690,6 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -711,8 +700,8 @@ packages: peerDependencies: typescript: '>=4.8.4' - ts-fsrs@4.7.1: - resolution: {integrity: sha512-uYqKbSCNWLRPT0PfqFFy2rn0YuYz2fKmtgpgHOBd5/DP14oj6diG7P271AwJ8iGrKnmIspzXCF+nTKxict+Lcg==} + ts-fsrs@5.2.1: + resolution: {integrity: sha512-gyMwhrn08AFTGAjjevsih5M8cVvJee7UNNUn8MRLpZxHHrGm8S/C7+C20s2M02EuM/Zu/RlYcPq66VNmHAOy4Q==} engines: {node: '>=18.0.0'} tslib@2.8.1: @@ -722,10 +711,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - typescript-eslint@8.26.0: resolution: {integrity: sha512-PtVz9nAnuNJuAVeUFvwztjuUgSnJInODAUx47VDwWPXzd5vismPOtPtt83tzNXyOjVQbPRp786D6WFW/M2koIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -756,9 +741,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -769,9 +751,10 @@ snapshots: dependencies: '@marijn/find-cluster-break': 1.0.2 - '@codemirror/view@6.36.5': + '@codemirror/view@6.38.0': dependencies: '@codemirror/state': 6.5.2 + crelt: 1.0.6 style-mod: 4.1.2 w3c-keyname: 2.2.8 @@ -850,19 +833,33 @@ snapshots: '@esbuild/win32-x64@0.25.0': optional: true - '@eslint-community/eslint-utils@4.6.1(eslint@8.57.1)': + '@eslint-community/eslint-utils@4.7.0(eslint@9.31.0)': dependencies: - eslint: 8.57.1 + eslint: 9.31.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} - '@eslint/eslintrc@2.1.4': + '@eslint/config-array@0.21.0': + dependencies: + '@eslint/object-schema': 2.1.6 + debug: 4.4.1 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.3.0': {} + + '@eslint/core@0.15.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.0 - espree: 9.6.1 - globals: 13.24.0 + debug: 4.4.1 + espree: 10.4.0 + globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.0 @@ -871,21 +868,29 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@8.57.1': {} - '@eslint/js@9.21.0': {} - '@humanwhocodes/config-array@0.13.0': + '@eslint/js@9.31.0': {} + + '@eslint/object-schema@2.1.6': {} + + '@eslint/plugin-kit@0.3.4': dependencies: - '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.0 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color + '@eslint/core': 0.15.1 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.6': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.3.1 '@humanwhocodes/module-importer@1.0.1': {} - '@humanwhocodes/object-schema@2.0.3': {} + '@humanwhocodes/retry@0.3.1': {} + + '@humanwhocodes/retry@0.4.3': {} '@marijn/find-cluster-break@1.0.2': {} @@ -905,7 +910,9 @@ snapshots: dependencies: '@types/tern': 0.23.9 - '@types/estree@1.0.7': {} + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} '@types/node@22.13.9': dependencies: @@ -913,17 +920,17 @@ snapshots: '@types/tern@0.23.9': dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 - '@typescript-eslint/eslint-plugin@8.26.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(typescript@5.8.2)': + '@typescript-eslint/eslint-plugin@8.26.0(@typescript-eslint/parser@8.26.0(eslint@9.31.0)(typescript@5.8.2))(eslint@9.31.0)(typescript@5.8.2)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.26.0(eslint@8.57.1)(typescript@5.8.2) + '@typescript-eslint/parser': 8.26.0(eslint@9.31.0)(typescript@5.8.2) '@typescript-eslint/scope-manager': 8.26.0 - '@typescript-eslint/type-utils': 8.26.0(eslint@8.57.1)(typescript@5.8.2) - '@typescript-eslint/utils': 8.26.0(eslint@8.57.1)(typescript@5.8.2) + '@typescript-eslint/type-utils': 8.26.0(eslint@9.31.0)(typescript@5.8.2) + '@typescript-eslint/utils': 8.26.0(eslint@9.31.0)(typescript@5.8.2) '@typescript-eslint/visitor-keys': 8.26.0 - eslint: 8.57.1 + eslint: 9.31.0 graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -932,14 +939,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2)': + '@typescript-eslint/parser@8.26.0(eslint@9.31.0)(typescript@5.8.2)': dependencies: '@typescript-eslint/scope-manager': 8.26.0 '@typescript-eslint/types': 8.26.0 '@typescript-eslint/typescript-estree': 8.26.0(typescript@5.8.2) '@typescript-eslint/visitor-keys': 8.26.0 - debug: 4.4.0 - eslint: 8.57.1 + debug: 4.4.1 + eslint: 9.31.0 typescript: 5.8.2 transitivePeerDependencies: - supports-color @@ -949,12 +956,12 @@ snapshots: '@typescript-eslint/types': 8.26.0 '@typescript-eslint/visitor-keys': 8.26.0 - '@typescript-eslint/type-utils@8.26.0(eslint@8.57.1)(typescript@5.8.2)': + '@typescript-eslint/type-utils@8.26.0(eslint@9.31.0)(typescript@5.8.2)': dependencies: '@typescript-eslint/typescript-estree': 8.26.0(typescript@5.8.2) - '@typescript-eslint/utils': 8.26.0(eslint@8.57.1)(typescript@5.8.2) - debug: 4.4.0 - eslint: 8.57.1 + '@typescript-eslint/utils': 8.26.0(eslint@9.31.0)(typescript@5.8.2) + debug: 4.4.1 + eslint: 9.31.0 ts-api-utils: 2.1.0(typescript@5.8.2) typescript: 5.8.2 transitivePeerDependencies: @@ -966,23 +973,23 @@ snapshots: dependencies: '@typescript-eslint/types': 8.26.0 '@typescript-eslint/visitor-keys': 8.26.0 - debug: 4.4.0 + debug: 4.4.1 fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.1 + semver: 7.7.2 ts-api-utils: 2.1.0(typescript@5.8.2) typescript: 5.8.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.26.0(eslint@8.57.1)(typescript@5.8.2)': + '@typescript-eslint/utils@8.26.0(eslint@9.31.0)(typescript@5.8.2)': dependencies: - '@eslint-community/eslint-utils': 4.6.1(eslint@8.57.1) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0) '@typescript-eslint/scope-manager': 8.26.0 '@typescript-eslint/types': 8.26.0 '@typescript-eslint/typescript-estree': 8.26.0(typescript@5.8.2) - eslint: 8.57.1 + eslint: 9.31.0 typescript: 5.8.2 transitivePeerDependencies: - supports-color @@ -990,15 +997,13 @@ snapshots: '@typescript-eslint/visitor-keys@8.26.0': dependencies: '@typescript-eslint/types': 8.26.0 - eslint-visitor-keys: 4.2.0 + eslint-visitor-keys: 4.2.1 - '@ungap/structured-clone@1.3.0': {} - - acorn-jsx@5.3.2(acorn@8.14.1): + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: - acorn: 8.14.1 + acorn: 8.15.0 - acorn@8.14.1: {} + acorn@8.15.0: {} ajv@6.12.6: dependencies: @@ -1007,8 +1012,6 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ansi-regex@5.0.1: {} - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -1017,12 +1020,12 @@ snapshots: balanced-match@1.0.2: {} - brace-expansion@1.1.11: + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.1: + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 @@ -1047,22 +1050,20 @@ snapshots: concat-map@0.0.1: {} + crelt@1.0.6: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - debug@4.4.0: + debug@4.4.1: dependencies: ms: 2.1.3 deep-is@0.1.4: {} - doctrine@3.0.0: - dependencies: - esutils: 2.0.3 - esbuild@0.25.0: optionalDependencies: '@esbuild/aix-ppc64': 0.25.0 @@ -1093,63 +1094,60 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-scope@7.2.2: + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 eslint-visitor-keys@3.4.3: {} - eslint-visitor-keys@4.2.0: {} + eslint-visitor-keys@4.2.1: {} - eslint@8.57.1: + eslint@9.31.0: dependencies: - '@eslint-community/eslint-utils': 4.6.1(eslint@8.57.1) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0) '@eslint-community/regexpp': 4.12.1 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.1 - '@humanwhocodes/config-array': 0.13.0 + '@eslint/config-array': 0.21.0 + '@eslint/config-helpers': 0.3.0 + '@eslint/core': 0.15.1 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.31.0 + '@eslint/plugin-kit': 0.3.4 + '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.3.0 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.0 - doctrine: 3.0.0 + debug: 4.4.1 escape-string-regexp: 4.0.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 esquery: 1.6.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 + file-entry-cache: 8.0.0 find-up: 5.0.0 glob-parent: 6.0.2 - globals: 13.24.0 - graphemer: 1.4.0 ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.0 json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 lodash.merge: 4.6.2 minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 - strip-ansi: 6.0.1 - text-table: 0.2.0 transitivePeerDependencies: - supports-color - espree@9.6.1: + espree@10.4.0: dependencies: - acorn: 8.14.1 - acorn-jsx: 5.3.2(acorn@8.14.1) - eslint-visitor-keys: 3.4.3 + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 esquery@1.6.0: dependencies: @@ -1183,9 +1181,9 @@ snapshots: dependencies: reusify: 1.1.0 - file-entry-cache@6.0.1: + file-entry-cache@8.0.0: dependencies: - flat-cache: 3.2.0 + flat-cache: 4.0.1 fill-range@7.1.1: dependencies: @@ -1196,16 +1194,13 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flat-cache@3.2.0: + flat-cache@4.0.1: dependencies: flatted: 3.3.3 keyv: 4.5.4 - rimraf: 3.0.2 flatted@3.3.3: {} - fs.realpath@1.0.0: {} - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -1214,18 +1209,7 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - globals@13.24.0: - dependencies: - type-fest: 0.20.2 + globals@14.0.0: {} globals@16.0.0: {} @@ -1242,13 +1226,6 @@ snapshots: imurmurhash@0.1.4: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - is-extglob@2.1.1: {} is-glob@4.0.3: @@ -1257,8 +1234,6 @@ snapshots: is-number@7.0.0: {} - is-path-inside@3.0.3: {} - isexe@2.0.0: {} js-yaml@4.1.0: @@ -1295,11 +1270,11 @@ snapshots: minimatch@3.1.2: dependencies: - brace-expansion: 1.1.11 + brace-expansion: 1.1.12 minimatch@9.0.5: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 2.0.2 moment@2.29.4: {} @@ -1307,17 +1282,13 @@ snapshots: natural-compare@1.4.0: {} - obsidian@1.8.7(@codemirror/state@6.5.2)(@codemirror/view@6.36.5): + obsidian@1.8.7(@codemirror/state@6.5.2)(@codemirror/view@6.38.0): dependencies: '@codemirror/state': 6.5.2 - '@codemirror/view': 6.36.5 + '@codemirror/view': 6.38.0 '@types/codemirror': 5.60.8 moment: 2.29.4 - once@1.4.0: - dependencies: - wrappy: 1.0.2 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1341,8 +1312,6 @@ snapshots: path-exists@4.0.0: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} picomatch@2.3.1: {} @@ -1357,15 +1326,11 @@ snapshots: reusify@1.1.0: {} - rimraf@3.0.2: - dependencies: - glob: 7.2.3 - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 - semver@7.7.1: {} + semver@7.7.2: {} shebang-command@2.0.0: dependencies: @@ -1373,10 +1338,6 @@ snapshots: shebang-regex@3.0.0: {} - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - strip-json-comments@3.1.1: {} style-mod@4.1.2: {} @@ -1385,8 +1346,6 @@ snapshots: dependencies: has-flag: 4.0.0 - text-table@0.2.0: {} - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -1395,7 +1354,7 @@ snapshots: dependencies: typescript: 5.8.2 - ts-fsrs@4.7.1: {} + ts-fsrs@5.2.1: {} tslib@2.8.1: {} @@ -1403,14 +1362,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@0.20.2: {} - - typescript-eslint@8.26.0(eslint@8.57.1)(typescript@5.8.2): + typescript-eslint@8.26.0(eslint@9.31.0)(typescript@5.8.2): dependencies: - '@typescript-eslint/eslint-plugin': 8.26.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(typescript@5.8.2) - '@typescript-eslint/parser': 8.26.0(eslint@8.57.1)(typescript@5.8.2) - '@typescript-eslint/utils': 8.26.0(eslint@8.57.1)(typescript@5.8.2) - eslint: 8.57.1 + '@typescript-eslint/eslint-plugin': 8.26.0(@typescript-eslint/parser@8.26.0(eslint@9.31.0)(typescript@5.8.2))(eslint@9.31.0)(typescript@5.8.2) + '@typescript-eslint/parser': 8.26.0(eslint@9.31.0)(typescript@5.8.2) + '@typescript-eslint/utils': 8.26.0(eslint@9.31.0)(typescript@5.8.2) + eslint: 9.31.0 typescript: 5.8.2 transitivePeerDependencies: - supports-color @@ -1431,6 +1388,4 @@ snapshots: word-wrap@1.2.5: {} - wrappy@1.0.2: {} - yocto-queue@0.1.0: {} diff --git a/src/ContentParser.ts b/src/ContentParser.ts index 3cf4d89..1d6a9ae 100644 --- a/src/ContentParser.ts +++ b/src/ContentParser.ts @@ -8,7 +8,7 @@ import { asNoteID, fullIDFromDeclaration } from "TypeAssistant"; /** * The result of attempting to retrieve the content of a particular {@link FullID}. - * Returned by {@link ContentParser.getAllCards} and {@link ContentParser.getCard}. + * Returned by {@link ContentParser.getAllCards}, {@link ContentParser.getCardFromFile} and {@link ContentParser.getCard}. */ export type ParsedCardResult = { complete: ParsedCard | null; @@ -115,6 +115,12 @@ export class ContentParser extends DeclarationParser { return this.processParseResult(parseResult); } + public static async getCardFromFile(file: TFile, app: App, options?: ParseOptions) { + const parseResult: ParseResult = {}; + await this.getContentFromFile(file, app, parseResult, undefined, options); + return this.processParseResult(parseResult); + } + public static async getCard(id: FullID, app: App, options?: ParseOptions) { const predicate: PopulationPredicate = { @@ -494,7 +500,7 @@ export class ContentParser extends DeclarationParser { */ private static getAllFilesSortedByLikelihood(id: FullID, app: App, hints: NoteID[] = []) { // Note: finding the index and then inserting at index 0 is not necessarily more efficient. - return app.vault.getFiles().sort((fileA, fileB) => { + return app.vault.getMarkdownFiles().sort((fileA, fileB) => { if (fileA.path == id.noteID) return -1; if (fileB.path == id.noteID) diff --git a/src/DataStore.ts b/src/DataStore.ts index 2cc6e2f..7de4474 100644 --- a/src/DataStore.ts +++ b/src/DataStore.ts @@ -1,6 +1,6 @@ import { CardID, FullID, NoteID, DeckID, DeckableFullID } from "FullID"; import { deepEqual } from 'fast-equals'; -import { asNoteID } from "TypeAssistant"; +import { asNoteID, isDate, isString } from "TypeAssistant"; import { UniqueID } from "UniqueID"; //#region Data structure @@ -29,10 +29,12 @@ interface NoteData { } type RemovedData = Record; +/** Serves as a reminder that dates are read and stored as ISO strings. */ +type DateString = string; interface RemovedCardData extends CardData { /** Date marked for removal. */ - date: Date, + date: DateString; } interface RemovedNoteData { @@ -57,10 +59,10 @@ export interface StatisticsData { s: number; /** difficulty */ d: number; - /** elapsed_days */ - ed: number; /** scheduled_days */ sd: number; + /** learning_steps */ + ls: number; /** reps */ r: number; /** lapses */ @@ -82,6 +84,11 @@ export interface CardIDDataTuple { data: CardData; } +interface RemovedCardIDDataTuple { + id: FullID; + data: RemovedCardData; +} + export class CardEditor { constructor(public readonly id: FullID, public readonly data: CardData) { @@ -165,9 +172,12 @@ export class DataStore { private data: DataStoreRoot; private readonly saveData: (data: DataStoreRoot) => Promise; + /** The minimum number of seconds items will retained as removed items before they are deleted. */ + private purgeThreshold: number; - public constructor(data: DataStoreRoot, saveData: (data: DataStoreRoot) => Promise) { + public constructor(data: DataStoreRoot, purgeThreshold: number, saveData: (data: DataStoreRoot) => Promise) { this.data = data; + this.purgeThreshold = purgeThreshold; this.saveData = saveData; } @@ -281,6 +291,9 @@ export class DataStore { /** * Deletes {@link CardData} with {@link id} from {@link DataStoreRoot.removed} and returns it. + * + * Also removes its parent if it no longer has any children. Thus there is no need to call {@link deleteRemovedNote} + * * @param id * @param throwIfNotFound * @returns The removed {@link CardData}, or `null` if {@link id} was not found. @@ -301,13 +314,17 @@ export class DataStore { return removedCard; } - public deleteAllRemovedCards(removedBeforeDate?: Date) { + /** + * @param removedBeforeDate Delete only items that were removed before this date. Set to `undefined` to delete all items. + */ + private deleteRemovedCards(removedBeforeDate?: Date) { if (removedBeforeDate) { - for (const noteID of Object.keys(this.data.removed)) { - for (const [cardID, cardData] of Object.entries(this.data.removed[noteID].cs)) - if (cardData.date.getTime() < removedBeforeDate.getTime()) - this.deleteRemovedCard(StatisticsHelper.createFullID(noteID, cardID)); - } + const time = removedBeforeDate.getTime(); + this.getAllRemovedCards(undefined, (_cardID: CardID, data: RemovedCardData) => { + const date = StatisticsHelper.ensureDate(data.date); + console.assert(date, "Expected date parsable string."); + return date && date.getTime() < time ? true : false; + }).forEach(tuple => this.deleteRemovedCard(tuple.id)); } else { this.data.removed = { ...DataStore.DEFAULT_DATA.removed }; @@ -316,7 +333,7 @@ export class DataStore { } /** - * First checks if the card already exists but is marked for removal. If so, adds it back as active. + * First checks if the card already exists but is marked for deletion. If so, adds it back as active. * If not found, creates a new active card. * @param id * @param statisticsFactory @@ -334,7 +351,7 @@ export class DataStore { if (removedCard) { this.deleteRemovedCard(removedCard.id, true); - cardToAdd = StatisticsHelper.toCardIDDataTuple(id, removedCard.data); + cardToAdd = StatisticsHelper.toCardIDDataTuple(id, StatisticsHelper.removedCardToCard(removedCard.data)); } else { const deckIDs = id instanceof DeckableFullID ? id.deckIDs : []; @@ -618,7 +635,7 @@ export class DataStore { noteFilter?: (noteID: NoteID, data: RemovedNoteData) => boolean, cardFilter?: (cardID: CardID, data: RemovedCardData) => boolean) { - let cards: CardIDDataTuple[] = []; + let cards: RemovedCardIDDataTuple[] = []; for (const [noteID, removedData] of Object.entries(this.data.removed)) { @@ -632,7 +649,7 @@ export class DataStore { cards.push({ id: FullID.create(noteID, cardID, true), // back sides are not stored - data: StatisticsHelper.removedCardToCard(card), + data: card }); } } @@ -743,6 +760,8 @@ export class DataStore { public async save() { if (this._isDataDirty) { + const purgeRemovedBeforeDate = new Date((new Date()).getTime() - (this.purgeThreshold * 1000)); + this.deleteRemovedCards(purgeRemovedBeforeDate); await this.saveData(this.data); this._isDataDirty = false; this.triggerDataChanged(); @@ -839,6 +858,20 @@ export class DataStore { class StatisticsHelper { + public static ensureDate(value: DateString | Date) { + if (isString(value)) + value = new Date(value); + return isDate(value) ? value : null; + } + + public static ensureDateString(value: DateString | Date | undefined) { + if (value === undefined) + value = new Date(); + if (isDate(value)) + value = value.toISOString(); + return value as DateString; + } + public static isNoteEmpty(note: NoteData) { return Object.keys(note.cs).length == 0; } @@ -871,6 +904,7 @@ class StatisticsHelper { } satisfies RemovedNoteData; } + /** Removes properties of {@link RemovedCardData} that don't exist in {@link CardData}. This should be done, for example, before serializing the JSON. */ public static removedCardToCard(removedCard: RemovedCardData): CardData { const { date, @@ -881,7 +915,7 @@ class StatisticsHelper { public static cardToRemovedCard(card: CardData, removalDate?: Date): RemovedCardData { return { ...card, - date: removalDate ?? new Date(), + date: StatisticsHelper.ensureDateString(removalDate), } satisfies RemovedCardData; } diff --git a/src/FileParser.ts b/src/FileParser.ts index 04ed9c9..901ad37 100644 --- a/src/FileParser.ts +++ b/src/FileParser.ts @@ -32,6 +32,14 @@ export type SectionRange = { end: SectionCache | CacheItem | null; } +export type CodeBlockInfo = { + /** May be an empty string if language is not specified. */ + language: string; + /** The location of the content relative to the first tick of the block. */ + location: { start: number, end: number}; + content: string; +} + /** Represents the unlimited range. */ export const FullSectionRange: SectionRange = { start: null, @@ -105,4 +113,42 @@ export abstract class FileParser { protected static isSectionType(cache: CacheItem, type: string): cache is SectionCache { return this.isSectionCache(cache) && cache.type === type; } + + /** + * @todo Method expects sufficient {@link fileContent} length to slice with {@link section}'s location. + * @param section + * @param fileContent The full content of the file the section belongs to. + * @returns + */ + protected static extractContentFromSection(section: SectionCache, fileContent: string) { + return fileContent.slice(section.position.start.offset, section.position.end.offset); + } + + /** + * @todo This method does not support code blocks that start with four characters. + * @param source The code block including the three ticks at the beginning and end. See {@link extractContentFromSection}. + * @returns `null` is {@link source} is not a code block. + */ + public static parseCodeBlock(source: string): CodeBlockInfo | null { + const firstLine = source.split("\n", 1).first(); + if (!firstLine) + return null; + + // Make sure there are at least three ticks/tildes on first line + if (firstLine.length < FileParser.CODE_BLOCK_MARKER_LENGTH) + return null; + + const language = firstLine.slice(FileParser.CODE_BLOCK_MARKER_LENGTH).trim(); + const location = { + start: firstLine.length + 1, // Add \n back + end: source.length - FileParser.CODE_BLOCK_MARKER_LENGTH + }; + + return { + language, + location, + content: source.slice(location.start, location.end), + }; + } + private static readonly CODE_BLOCK_MARKER_LENGTH = 3; } diff --git a/src/Localization.ts b/src/Localization.ts new file mode 100644 index 0000000..2290a22 --- /dev/null +++ b/src/Localization.ts @@ -0,0 +1,19 @@ +import { getLanguage } from "obsidian"; +import { en } from "./lang/en"; + +// Import other languages here in the future, e.g.: +// import { sv } from "./lang/sv"; +// When a new language is added, the translator should always start by copying `en.ts` and then translating the values. This ensures the structure is always identical, virtually eliminating the risk of missing keys. + +const strings: Record = { + en, + // sv, +}; + +// Determine the user's language from Obsidian's settings +const currentLang = getLanguage(); + +// Select the appropriate strings, falling back to English if the language is not supported +const t = strings[currentLang] || en; + +export default t; diff --git a/src/Scheduler.ts b/src/Scheduler.ts index bc3f405..ddf518e 100644 --- a/src/Scheduler.ts +++ b/src/Scheduler.ts @@ -1,6 +1,6 @@ import { FullID } from 'FullID'; import { CardIDDataTuple, DataStore, StatisticsData } from 'DataStore'; -import { createEmptyCard, fsrs, generatorParameters, Rating, FSRS, Card, State, Grade, TypeConvert, default_request_retention, default_maximum_interval, default_enable_fuzz, default_enable_short_term } from 'ts-fsrs'; +import { createEmptyCard, fsrs, generatorParameters, Rating, FSRS, Card, State, Grade, TypeConvert, default_request_retention, default_maximum_interval, default_enable_fuzz, default_enable_short_term, default_learning_steps, default_relearning_steps } from 'ts-fsrs'; type DataItem = { id: FullID; @@ -18,6 +18,8 @@ export class Scheduler { enable_fuzz: default_enable_fuzz, enable_short_term: default_enable_short_term, maximum_interval: default_maximum_interval, + learning_steps: default_learning_steps, + relearning_steps: default_relearning_steps, // w: default_w, }); this.fsrs = fsrs(params); @@ -48,6 +50,8 @@ export class Scheduler { /** * Get the next review item from {@link cards}. * + * Not necessarily deterministic. + * * @param cards * @param reviewDate * @returns `null` if there is noting to review at {@link reviewDate}. @@ -270,12 +274,14 @@ export class Scheduler { private static asCard(s: StatisticsData): Card { return { due: TypeConvert.time(s.due), - stability: s.s, - difficulty: s.d, - elapsed_days: s.ed, - scheduled_days: s.sd, - reps: s.r, - lapses: s.l, + stability: s.s ?? 0, + difficulty: s.d ?? 0, + // TODO: Delete in ts-fsrs 6. Value not used. + elapsed_days: 0, + scheduled_days: s.sd ?? 0, + learning_steps: s.ls ?? 0, + reps: s.r ?? 0, + lapses: s.l ?? 0, state: s.st as State, last_review: s.lr ? TypeConvert.time(s.lr) : undefined, }; @@ -286,8 +292,8 @@ export class Scheduler { due: card.due.toISOString(), s: card.stability, d: card.difficulty, - ed: card.elapsed_days, sd: card.scheduled_days, + ls: card.learning_steps, r: card.reps, l: card.lapses, st: card.state, @@ -295,12 +301,17 @@ export class Scheduler { } satisfies StatisticsData; } + /** + * Updates the properties of a {@link StatisticsData} object with the values from a {@link Card} object. + * @param s The {@link StatisticsData} object to update. + * @param card The {@link Card} object to source the new values from. + */ private static setStatistics(s: StatisticsData, card: Card) { s.due = card.due.toISOString(); s.s = card.stability; s.d = card.difficulty; - s.ed = card.elapsed_days; s.sd = card.scheduled_days; + s.ls = card.learning_steps; s.r = card.reps; s.l = card.lapses; s.st = card.state; diff --git a/src/Settings.ts b/src/Settings.ts index f2f67c4..7f67f42 100644 --- a/src/Settings.ts +++ b/src/Settings.ts @@ -1,13 +1,15 @@ -import { PluginSettingTab, Setting, Plugin, Platform } from "obsidian"; +import { PluginSettingTab, Setting, Plugin } from "obsidian"; import { PLUGIN_NAME } from "UIAssistant"; export interface PluginSettings { uiPrefix: string; hideCardSectionMarker: boolean; hideDeclarationInReadingView: boolean; + /** Defines the time duration in seconds after which "removed" metadata items are eligible for permanent deletion. Items with a "removed date" property older than the current time minus this threshold will be purged. Must be a non-negative integer. */ + removedItemsPurgeThreshold: number; } -type SettingsChanged = (settings: PluginSettings) => void; +export type SettingsChanged = (settings: PluginSettings, isExternal: boolean) => void; export class SettingsManager { public settings: PluginSettings; @@ -19,16 +21,20 @@ export class SettingsManager { uiPrefix: PLUGIN_NAME, hideCardSectionMarker: false, hideDeclarationInReadingView: false, + removedItemsPurgeThreshold: 24 * 60 * 60, }; public constructor(settings: PluginSettings, save: (settings: PluginSettings) => Promise) { this.settings = settings; - this.save = () => save(this.settings); + this.save = async () => { + await save(this.settings); + this.notifyOnChangedListeners(false); + }; } public onSettingsChangedExternally(settings: PluginSettings) { this.settings = settings; - this.registeredChangedCallbacks.forEach(cb => cb(this.settings)); + this.notifyOnChangedListeners(true); } public registerOnChangedCallback(evt: SettingsChanged) { @@ -40,6 +46,10 @@ export class SettingsManager { this.registeredChangedCallbacks = this.registeredChangedCallbacks.filter(callback => callback !== evt); } + private notifyOnChangedListeners(isExternal: boolean) { + this.registeredChangedCallbacks.forEach(cb => cb(this.settings, isExternal)); + } + private registeredChangedCallbacks: SettingsChanged[] = []; } @@ -51,7 +61,10 @@ export class SettingTab extends PluginSettingTab { this.settingsManager = settingsManager; } - private onChangedCallback = () => this.display(); + private onChangedCallback: SettingsChanged = (_, isExternal) => { + if (isExternal) + this.display(); + } public display(): void { this.settingsManager.registerOnChangedCallback(this.onChangedCallback); diff --git a/src/TypeAssistant.ts b/src/TypeAssistant.ts index dc4819c..0d99012 100644 --- a/src/TypeAssistant.ts +++ b/src/TypeAssistant.ts @@ -1,6 +1,8 @@ -import { CardDeclarationAssistant, CardDeclarable } from "declarations/CardDeclaration"; +import { CardDeclarable, CardDeclarationAssistant } from "declarations/CardDeclaration"; import { DeckableFullID, FullID, NoteID } from "FullID"; import { TFile } from "obsidian"; +import { ContentProcessorConfig } from "renderings/content/ContentRendererProcessor"; +import { PluginSettings } from "Settings"; export function asNoteID(value: TFile | string): NoteID { if (value instanceof TFile) @@ -16,6 +18,12 @@ export function fullIDFromDeclaration(declaration: CardDeclarable, noteID: NoteI : FullID.create(noteID, declaration.id, CardDeclarationAssistant.isFrontSide(declaration, true)); } +/** + * If {@link value} is `null`, `false` is returned even thoigh `null` is an object. + * + * @param value + * @returns `true` if `typeof` for {@link value} returns `"object"` and {@link value} is not `null`. + */ export function isObject(value: unknown): value is object { return typeof value === "object" && value !== null; // `null` is an object } @@ -27,3 +35,25 @@ export function isString(value: unknown): value is string { export function isNumber(value: unknown): value is number { return typeof value === "number"; } + +export function isDate(value: unknown): value is Date { + // `getTime` returns NaN if the date is invalid. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/getTime + return value instanceof Date && !isNaN(value.getTime()); +} + +export function parseStrictFloat(value: unknown): number | null { + if (value === null || value === undefined || !isString(value)) + return null; + + // The unary plus (+) is a concise way to perform a strict conversion. + // It returns NaN if the entire string isn't a valid number. + const num = +value; + + if (Number.isNaN(num)) { + //console.error(`Conversion failed for: ${value}`); + return null; + } + + return num; +} diff --git a/src/UIAssistant.ts b/src/UIAssistant.ts index f9822f4..6eaaf1f 100644 --- a/src/UIAssistant.ts +++ b/src/UIAssistant.ts @@ -34,13 +34,11 @@ export class UIAssistant { checked?: boolean, icon?: string, prefix?: boolean, + isLabel?: boolean, onClick?: (evt: MouseEvent | KeyboardEvent) => any callback?: (item: MenuItem) => any, }): Menu { const { - section, - checked, - onClick, callback, } = options || {}; @@ -57,6 +55,7 @@ export class UIAssistant { checked?: boolean, icon?: string, prefix?: boolean, + isLabel?: boolean, onClick?: (evt: MouseEvent | KeyboardEvent) => any }): MenuItem { const { @@ -65,10 +64,12 @@ export class UIAssistant { onClick, icon = PLUGIN_ICON, prefix = true, + isLabel = false, } = options || {}; item.setTitle(prefix ? this.contextulize(title) : title); item.setIcon(icon); + item.setIsLabel(isLabel); if (checked !== undefined) item.setChecked(checked); diff --git a/src/declarations/CardDeclaration.ts b/src/declarations/CardDeclaration.ts index 712697d..a10b1f5 100644 --- a/src/declarations/CardDeclaration.ts +++ b/src/declarations/CardDeclaration.ts @@ -57,8 +57,6 @@ export class CardDeclaration implements CardDeclarable { export class CardDeclarationAssistant extends Declaration { - //#region - public static fromFrontmatter(maybeDeclaration: Record, incompleteCallback?: (incomplete: DefaultableCardDeclarable, position: DeclarationRange) => void) { if (this.conformsToDeclarable(maybeDeclaration)) return new CardDeclaration(maybeDeclaration.id, maybeDeclaration.side, IDScope.UNIQUE, maybeDeclaration.deckID); @@ -80,11 +78,11 @@ export class CardDeclarationAssistant extends Declaration { onParseError?: YamlParseErrorCallback, incompleteCallback?: (incomplete: DefaultableCardDeclarable, position: DeclarationRange) => void) { - const location = this.contentOfCodeBlock(source); - if (!location) + const info = super.parseAndCheckCodeBlock(source); + if (!info) return null; - const maybeDeclaration = this.tryParseYaml(this.slice(source, location), onParseError); + const maybeDeclaration = this.tryParseYaml(info.content, onParseError); if (!maybeDeclaration) return null; @@ -92,13 +90,11 @@ export class CardDeclarationAssistant extends Declaration { return new CardDeclaration(maybeDeclaration.id, maybeDeclaration.side, IDScope.UNIQUE, maybeDeclaration.deckID); if (CardDeclarationAssistant.conformsToDefaultable(maybeDeclaration) && incompleteCallback) - incompleteCallback(maybeDeclaration, location); + incompleteCallback(maybeDeclaration, info.location satisfies DeclarationRange); return null; } - //#endregion - //#region public static canMakeValidCardDeclarable(decl: unknown): decl is DefaultableCardDeclarable { diff --git a/src/declarations/CommandDeclaration.ts b/src/declarations/CommandDeclaration.ts index fdc66e6..1b1cbb2 100644 --- a/src/declarations/CommandDeclaration.ts +++ b/src/declarations/CommandDeclaration.ts @@ -47,11 +47,11 @@ export class CommandDeclarationAssistant extends Declaration { onParseError?: YamlParseErrorCallback, onInvalidType?: (command: CommandableDeclarable, range: DeclarationRange) => void) { - const range = this.contentOfCodeBlock(source); - if (!range) + const info = super.parseAndCheckCodeBlock(source); + if (!info) return null; - const obj = super.tryParseYaml(Declaration.slice(source, range), onParseError); + const obj = super.tryParseYaml(info.content, onParseError); const commandable = obj && CommandDeclarationAssistant.conforms(obj) ? obj : null; if (!commandable) return null; @@ -66,7 +66,7 @@ export class CommandDeclarationAssistant extends Declaration { parser = HeadingIsFrontAssistant.tryCreateParser(commandable); if (!parser && onInvalidType) - onInvalidType(commandable, range); + onInvalidType(commandable, info.location satisfies DeclarationRange); return parser; } diff --git a/src/declarations/Declaration.ts b/src/declarations/Declaration.ts index 7090e64..c8bd868 100644 --- a/src/declarations/Declaration.ts +++ b/src/declarations/Declaration.ts @@ -1,3 +1,4 @@ +import { FileParser } from "FileParser"; import { parseYaml, stringifyYaml } from "obsidian"; import { isObject, isString } from "TypeAssistant"; import { UniqueID } from "UniqueID"; @@ -18,7 +19,10 @@ const DeckablePropertyName = { DECK_ID: "deckID", }; -/** Locates the portion of a string that contains the raw declaration string. */ +/** + * Locates the portion of a string that contains the raw declaration string. + * @todo Remove. See types in {@link FileParser} + */ export interface DeclarationRange { start: number, end: number, @@ -44,30 +48,27 @@ export abstract class Declaration { } /** - * @param source The code block including the three ticks at the beginning and end. - * @returns The location of the block's content within {@link source} or `null` if {@link source} or code "language" is unexpected. - */ - protected static contentOfCodeBlock(source: string): DeclarationRange | null { - const firstLine = source.split("\n", 1).first(); - if (!firstLine) + * Checks if this a code block with one of the expected languages; if so, parses it. + * @param source The code block including the three ticks at the beginning and end. + * @returns `null` is {@link source} is not a code block or if the block's language is unexpected. + */ + protected static parseAndCheckCodeBlock(source: string) { + const info = FileParser.parseCodeBlock(source); + + if (info && !this.supportedCodeBlockLanguages.includes(info.language)) return null; - const language = firstLine.slice(this.CODE_BLOCK_MARKER_LENGTH).trim(); - if (language !== this.LANGUAGE && language !== this.LANGUAGE_SHORT) - return null; - - const secondLineOffset = firstLine.length + 1; // Add \n back - return { start: secondLineOffset, end: source.length - this.CODE_BLOCK_MARKER_LENGTH } + return info; } private static readonly CODE_BLOCK_MARKER_LENGTH = 3; /** - * Case insensitive. - * - * @param yaml Will be converted to lower case before parsing. - * @param onParseError - * @returns `null` if YAML parsing failed, in which case {@link onParseError} will be invoked. - */ + * Case insensitive. + * + * @param yaml Will be converted to lower case before parsing. + * @param onParseError + * @returns `null` if YAML parsing failed, in which case {@link onParseError} will be invoked. + */ public static tryParseYaml(yaml: string, onParseError?: YamlParseErrorCallback) { let parsedObject: Record | null = null; diff --git a/src/declarations/DeclarationManager.ts b/src/declarations/DeclarationManager.ts index f3adc85..31510a6 100644 --- a/src/declarations/DeclarationManager.ts +++ b/src/declarations/DeclarationManager.ts @@ -1,7 +1,7 @@ import { DataStore } from "DataStore"; import { CardDeclarationAssistant } from "declarations/CardDeclaration"; import { DeckableDeclarable, Declaration } from "declarations/Declaration"; -import { DeclarationRenderChild } from "renderings/DeclarationRenderChild"; +import { DeclarationRenderChild } from "renderings/declarations/DeclarationRenderChild"; import { DeckModal } from "modals/DeckModal"; import { App, MarkdownPostProcessorContext, MarkdownSectionInformation, TFile, Vault } from "obsidian"; @@ -26,7 +26,7 @@ export class DeclarationManager { const renderer = new DeclarationRenderChild(el, source, { getAllDecks: () => data.getAllDecks() }); - ctx.addChild(renderer); + ctx.addChild(renderer); // The MarkdownPostProcessorContext manage unload, e.g., when file is closed. const handleChangedDeclaration = async ( changedDeclaration: DeckableDeclarable, diff --git a/src/declarations/DeclarationParser.ts b/src/declarations/DeclarationParser.ts index dbb3cea..c082d69 100644 --- a/src/declarations/DeclarationParser.ts +++ b/src/declarations/DeclarationParser.ts @@ -51,6 +51,69 @@ export class DeclarationParser extends FileParser { protected static readonly FULL_ID_REGEX = /(front|f|back|b)@([^\s]+)/i; + /** + * Returns `true` as soon as any hint of a declaration is found in {@link file}; + * + * @todo This method returns `true` as soon as a code block is found — irregardless of its language. + * @todo This method is somewhat redundant with {@link getAllIDsFromMetadata} in where they look for declarations. + * + * @param file + * @param app + * @param fileContent If content is known, this method can accurately determine whether {@link file} contains declarations. + * @returns + */ + public static containsDeclarations(file: TFile, app: App, fileContent?: string) { + + let cache; + try { + cache = this.fileCacheOrThrow(app, file); + } + catch (error) { + console.error(error); + return false; + } + + const noteID = asNoteID(file); + + // Check frontmatter for explicit declaration + if (cache.frontmatter) { + for (const key of Declaration.supportedFrontmatterKeys) + if (Object.hasOwn(cache.frontmatter, key)) + return true; + } + + // Check there headings that contain an ID + if (cache.headings) { + for (const currentHeading of cache.headings) + if (this.findFullIDInText(currentHeading.heading, noteID)) + return true; + } + + if (cache.sections) { + for (const section of cache.sections) { + if (this.isCodeSection(section)) { + if (fileContent) { + const info = this.parseCodeBlock(this.extractContentFromSection(section, fileContent)); + if (info && Declaration.supportedCodeBlockLanguages.includes(info.language)) + return true; + } + else { + // It's not possible to get the language of a code block without reading the file content, which is async, and this method must be sync. + // So true is returned as soon as a code block is found, even though it may not be a declaration. + return true; + } + } + } + } + + return false; + } + + public static async containsDeclarationsAsync(file: TFile, app: App) { + const fileContent = await this.cachedRead(app, file) + return this.containsDeclarations(file, app, fileContent); + } + public static async getAllIDsInFile(file: TFile, app: App, filter?: IDFilter) { return this.getAllIDsFromMetadata( asNoteID(file), @@ -60,13 +123,16 @@ export class DeclarationParser extends FileParser { } /** - * Finds all declared {@link FullID|ids} in {@link fileContent} of {@link noteID} based on the provided {@link cache}. - * @param noteID - * @param fileContent - * @param cache The cache for the {@link file} - * @param filter - * @returns - */ + * Finds all declared {@link FullID|ids} in {@link fileContent} of {@link noteID} based on the provided {@link cache}. + * + * @see {@link containsDeclarations} + * + * @param noteID + * @param fileContent + * @param cache The cache for the {@link file} + * @param filter + * @returns + */ public static getAllIDsFromMetadata(noteID: NoteID, fileContent: string, cache: CachedMetadata, filter?: IDFilter) { const ids: FullID[] = []; @@ -92,7 +158,7 @@ export class DeclarationParser extends FileParser { } }; - // Check frontmatter for card declaration + // Check frontmatter for explicit declaration if (cache.frontmatter) { const declaration = this.getDeclarationFromFrontmatter(cache.frontmatter, noteID, parseInfo); if (declaration) { @@ -102,7 +168,7 @@ export class DeclarationParser extends FileParser { } } - // Look for ID declarations in headings + // Check there headings that contain an ID for (const currentHeading of cache.headings ?? []) { const id = this.findFullIDInText(currentHeading.heading, noteID); if (id && !checkExistance(id)) { @@ -192,7 +258,7 @@ export class DeclarationParser extends FileParser { if (!this.isCodeSection(section)) return null; - const source = fileContent.slice(section.position.start.offset, section.position.end.offset); + const source = FileParser.extractContentFromSection(section, fileContent); return CardDeclarationAssistant.parseCodeBlock( source, diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..ac9ad41 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,68 @@ +import { Platform } from "obsidian"; + +const isProduction = process.env.NODE_ENV === "production"; +const isDev = !isProduction; + +const noopLogger = { + debug: () => { }, + log: () => { }, + info: () => { }, + warn: () => { }, +}; + +const devLogger = isDev ? console : noopLogger; + +export const Env = { + isDev: !isProduction, + + /** Always logs */ + error: console.error, + assert: console.assert, + + /** + * + */ + log: { + /** To provide very granular, low-level, and highly detailed information. These logs are often too numerous to be helpful during general development but are invaluable when you're trying to diagnose a specific, complex bug. */ + d: devLogger.debug, + + /** + * - This is the most general-purpose logging method. It's a good default when a message doesn't neatly fit into the error, warn, or info categories, or when you're just doing quick, ad-hoc debugging. + * - When you use `console.log()`, you are typically saying: "Just output this general message." It's more of a catch-all, or often used for quick, ad-hoc debugging prints. + */ + l: devLogger.log, + + /** + * - To provide high-level, general information about the application's flow or significant events. These are like "milestones" that give you an overview of what the application is doing. + * - This is an informational message about a significant event or the general flow of the application. It implies a higher level of importance or a more structured type of message than a generic `log`. + */ + i: devLogger.info, + + /** To indicate a potential issue, a suboptimal practice, a deprecated feature being used, or a situation that might lead to an error later but isn't critical right now. It's a "heads up" or a "soft error." */ + w: devLogger.warn, + + /** Debug log for content processors. See {@link ContentRendererProcessor}. */ + proc: false ? devLogger.debug : noopLogger.debug, + + /** Debug log for views. */ + view: false ? devLogger.debug : noopLogger.debug, + + }, + + perf: { + now: (): DOMHighResTimeStamp => performance.now(), + log: (text: string, timestamp: DOMHighResTimeStamp) => { + const end = performance.now() - timestamp; + devLogger.warn(`${text}: ${end} ms`); + }, + }, + + /** @returns `true` if running in the capacitor-js mobile app or if compiled for development with UI in mobile mode. */ + get isMobile() { + if (Platform.isMobileApp) + return true; + if (isDev && Platform.isMobile) + return true; + return false; + }, +} as const; diff --git a/src/lang/en.ts b/src/lang/en.ts new file mode 100644 index 0000000..52dcf44 --- /dev/null +++ b/src/lang/en.ts @@ -0,0 +1,40 @@ +import { FullID } from "FullID"; +import { TFile } from "obsidian"; + +export const en = { + actions: { + viewDeclarationsInFile: "View defined content", + }, + commands: { + openReview: { + name: "Review", + }, + openDecks: { + name: "Decks", + }, + generateId: { + name: "Create a new id under cursor", + }, + openDeclarations: { + // View the current note’s defined review content. + name: "View content defined by the current note’s declarations", + } + }, + settings: { + // Add settings-related strings here + }, + modals: { + selectDeck: { + placeholder: "Select deck to review", + }, + confirmation: { + title: "External change detected", + }, + }, + views: { + declarations: { + title: (file?: TFile) => file ? `Defined content in “${file.basename}”` : "Defined content", + fileNotSet: "The note to show content definitions from is unknown." + } + } +}; diff --git a/src/main.ts b/src/main.ts index 716b54a..fbdb52c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,17 +1,18 @@ -import { DataStore, DataStoreRoot } from "DataStore"; import { DeclarationManager } from "declarations/DeclarationManager"; +import { DataStore, DataStoreRoot } from "DataStore"; import { DeclarationParser } from "declarations/DeclarationParser"; import { ConfirmationModal } from "modals/ConfirmationModal"; import { SelectDeckModal } from "modals/SelectDeckModal"; -import { Editor, Keymap, MarkdownFileInfo, MarkdownPostProcessorContext, MarkdownView, Menu, PaneType, Plugin, TFile } from "obsidian"; +import { Editor, Keymap, MarkdownPostProcessorContext, MarkdownView, PaneType, Plugin, TFile } from "obsidian"; import { Scheduler } from "Scheduler"; import { PluginSettings, SettingsManager, SettingTab } from "Settings"; import { SyncManager } from "SyncManager"; -import { asNoteID } from "TypeAssistant"; import { PLUGIN_ICON, UIAssistant } from "UIAssistant"; import { UniqueID } from "UniqueID"; import { DecksView } from "views/DecksView"; import { ReviewView } from "views/ReviewView"; +import t from "Localization"; +import { DefinedContentView } from "views/DefinedContentView"; interface PluginData { settings: PluginSettings; @@ -27,27 +28,37 @@ export default class ComeThroughPlugin extends Plugin { private syncManager: SyncManager; public async onload() { - //#region this.pluginData = await ComeThroughPlugin.loadPluginData(this); this.settingsManager = new SettingsManager(this.pluginData.settings, async (_settings) => await this.savePluginData()); this.ui = new UIAssistant(this.settingsManager); - this.dataStore = new DataStore(this.pluginData.data, async (_data) => await this.savePluginData()); + this.dataStore = new DataStore(this.pluginData.data, this.pluginData.settings.removedItemsPurgeThreshold, async (_data) => await this.savePluginData()); this.scheduler = new Scheduler(this.dataStore); this.syncManager = new SyncManager(this.dataStore, this.app, () => this.scheduler.createItem()); - //#endregion this.addSettingTab(new SettingTab(this, this.settingsManager)); + this.addRibbonIcon(PLUGIN_ICON, this.ui.contextulize("Review"), (evt: MouseEvent) => { + this.openReviewView(Keymap.isModEvent(evt)); + }); + this.app.workspace.onLayoutReady(() => this.registerEvents()); for (const language of DeclarationManager.supportedCodeBlockLanguages) { this.registerMarkdownCodeBlockProcessor(language, (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { if (!this.settingsManager.settings.hideDeclarationInReadingView || UIAssistant.isInInLivePreview(this.app)) DeclarationManager.processCodeBlock(this.app, source, el, ctx, this.dataStore); - }, undefined); + }, -100); // Process this code block last, allowing other plugins to alter user input first. + + // Register yaml highlighting. Seen while editing (comments, explicit strings, ...). TODO: This is CodeMirror 5 API. + window.CodeMirror.defineMode(language, (config) => window.CodeMirror.getMode(config, "text/x-yaml")); + this.register(() => { + window.CodeMirror.defineMode(language, (config) => window.CodeMirror.getMode(config, "null")); + }); } + // Views + this.registerView( ReviewView.TYPE, (leaf) => new ReviewView(leaf, this.settingsManager, this.scheduler, this.ui, this.dataStore) @@ -55,36 +66,49 @@ export default class ComeThroughPlugin extends Plugin { this.registerView( DecksView.TYPE, - (leaf) => new DecksView(leaf, this.dataStore) + (leaf) => new DecksView(leaf, this.settingsManager, this.dataStore) ); - this.addRibbonIcon(PLUGIN_ICON, this.ui.contextulize("Review"), (evt: MouseEvent) => { - this.openReviewView(Keymap.isModEvent(evt)); - }); + this.registerView( + DefinedContentView.TYPE, + (leaf) => new DefinedContentView(leaf, this.settingsManager, this.dataStore) + ); - //#region Commands + // Commands this.addCommand({ id: 'open-review', - name: 'Review', + name: t.commands.openReview.name, callback: () => this.openReviewView(true) }); this.addCommand({ id: 'open-decks', - name: 'Decks', + name: t.commands.openDecks.name, callback: () => this.openDecksView(true) }); + this.addCommand({ + id: 'view-defined-content-in-current-note', + name: t.commands.openDeclarations.name, + checkCallback: (checking: boolean) => { + const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (markdownView && markdownView.file && DeclarationParser.containsDeclarations(markdownView.file, this.app)) { + if (!checking) + this.viewDefinedContent(markdownView.file, false); + return true + } + return false + } + }); + this.addCommand({ id: 'generate-id-cursor', - name: 'Create a new id under cursor', + name: t.commands.generateId.name, editorCallback: (editor: Editor, _view: MarkdownView) => { editor.replaceRange(UniqueID.generateID(), editor.getCursor()) } }); - - //#endregion } public onunload() { @@ -92,6 +116,11 @@ export default class ComeThroughPlugin extends Plugin { this.confirmationModal.forceClose(); } + /** + * Handles external changes to the plugin's settings and data. + * This is triggered when the data file is modified by an external source, such as a sync service. + * It disables the internal sync manager, reloads the data, and prompts the user to accept the change. + */ public async onExternalSettingsChange() { if (!this.pluginData) return; @@ -134,6 +163,10 @@ export default class ComeThroughPlugin extends Plugin { // public onUserEnable(): void {} + /** + * Registers the necessary event listeners for the plugin to function. + * This includes file events for synchronization and context menu events for user actions. + */ private registerEvents() { this.registerEvent(this.app.workspace.on("file-open", this.syncManager.open.bind(this.syncManager))); @@ -145,11 +178,12 @@ export default class ComeThroughPlugin extends Plugin { if (!(file instanceof TFile)) return; - const isFileIncluded = this.dataStore.getNote(asNoteID(file)) ? true : false; + const isFileIncluded = DeclarationParser.containsDeclarations(file, this.app); - if (isFileIncluded && (source === "file-explorer-context-menu" || source === "more-options" || source === "tab-header")) { - this.ui.addMenuItem(menu, "View flashcard info", { - onClick: async () => this.viewInfo(file) + if (isFileIncluded && (/*source === "file-explorer-context-menu" ||*/ source === "more-options" || source === "tab-header")) { + this.ui.addMenuItem(menu, t.actions.viewDeclarationsInFile, { + onClick: async (evt) => this.viewDefinedContent(file, Keymap.isModEvent(evt)), + section: "open", }); } })); @@ -185,9 +219,9 @@ export default class ComeThroughPlugin extends Plugin { this.dataStore, [...[UIAssistant.allDecksOptionItem()], ...allDecks], async (deck, evt) => { - await openView(Keymap.isModEvent(evt), { deckID: deck.id }); + await openView(Keymap.isModEvent(evt), ReviewView.createViewState(deck.id)); }); - modal.setPlaceholder("Select deck to review"); + modal.setPlaceholder(t.modals.selectDeck.placeholder); modal.open(); } else { @@ -195,13 +229,14 @@ export default class ComeThroughPlugin extends Plugin { } } - private async viewInfo(file: TFile) { - - const { ids } = await DeclarationParser.getAllIDsInFile(file, this.app); - - let info: string = `${file.basename} defines ${ids.length} card sides\n\n`; - info += ids.map(id => this.dataStore.cardInfo(id)).join("\n\n"); - this.ui.displayNotice(info, { prefix: false, preventDismissal: true }); + private async viewDefinedContent(file: TFile, paneType: PaneType | boolean) { + await this.app.workspace.getLeaf(paneType).setViewState({ + type: DefinedContentView.TYPE, + state: DefinedContentView.createViewState(file), + active: true, + pinned: undefined, + group: undefined, + }); } private static async loadPluginData(plugin: Plugin): Promise { diff --git a/src/modals/ConfirmationModal.ts b/src/modals/ConfirmationModal.ts index c576c38..002f658 100644 --- a/src/modals/ConfirmationModal.ts +++ b/src/modals/ConfirmationModal.ts @@ -1,3 +1,4 @@ +import t from "Localization"; import { App, Modal, Setting } from "obsidian"; import { Platform } from 'obsidian'; @@ -11,7 +12,7 @@ export class ConfirmationModal extends Modal { constructor(app: App) { super(app); - this.setTitle("External change detected"); + this.setTitle(t.modals.confirmation.title); new Setting(this.contentEl).setDesc(createFragment((f) => { f.createEl("i", { text: "Plugin: Come Through" }); diff --git a/src/renderings/MarkupAssistant.ts b/src/renderings/MarkupAssistant.ts new file mode 100644 index 0000000..bc07f9b --- /dev/null +++ b/src/renderings/MarkupAssistant.ts @@ -0,0 +1,48 @@ +export class MarkupAssistant { + + public static createWrappedPara(parent: HTMLElement, o?: DomElementInfo | string, callback?: (el: HTMLParagraphElement) => void): HTMLParagraphElement { + return MarkupAssistant.createElWrapper(parent, "p", false).createEl("p", o, callback); + } + + public static createWrappedEl(parent: HTMLElement, tag: K, o?: DomElementInfo | string, callback?: (el: HTMLElementTagNameMap[K]) => void): HTMLElementTagNameMap[K] { + return MarkupAssistant.createElWrapper(parent, tag).createEl(tag, o, callback); + } + + /** + * Mimics how Obsidian/CodeMirror adds wrapper elements. For example, a `

` is wrapped with `

`. + * + * @param appendPara Sometimes a `

` is added as an additional child; for example, if the second starting tag after the first is on the same line as the first. This param serves as documentation of this behavior, which is considered abnormal. + * @returns A created wrapper element for the given {@link tag}. + */ + public static createElWrapper(parent: HTMLElement, tag: K, appendPara = false) { + + // If the second starting tag after the first is on the same line as the first (e.g., or ), + // it's wrapped `

`; + // otherwise `

`. + // + // Nevertheless, it's always wrapped in a div. + // - audio, video, blockquote, p, pre, details, ... + + if (appendPara) + return MarkupAssistant.createWrappedPara(parent, { attr: { dir: "auto" } }); + else + return parent.createDiv({ cls: MarkupAssistant.classForEl(tag) }); + } + + /** + * @param tag The tag name at the root of {@link html}. + * @param html The HTML string to wrap. + * @param appendPara See {@link createElWrapper}. + * @returns + */ + public static createElWrapperHtml(tag: K, html: string, appendPara = false) { + if (appendPara) + return `

${html}

`; + else + return `
${html}
`; + } + + public static classForEl(tag: K) { + return "el-" + tag; + } +} diff --git a/src/renderings/RecycleComponent.ts b/src/renderings/RecycleComponent.ts new file mode 100644 index 0000000..de79e0c --- /dev/null +++ b/src/renderings/RecycleComponent.ts @@ -0,0 +1,64 @@ +import { Env } from "env"; +import { Component } from "obsidian"; + +export abstract class RecycleComponent extends Component { + + private internalRecycleComponent: Component | null = null; + + public override onload(): void { + Env.log.d(`RecycleComponent:onload`); + super.onload(); + } + + public override onunload(): void { + Env.log.d(`RecycleComponent:onunload`); + super.onunload(); + this.dispose(); + } + + public override addChild(component: T): T { + Env.assert(false); + return super.addChild(component); + } + + public override removeChild(component: T): T { + Env.assert(false); + return super.removeChild(component); + } + + /** + * The {@link recycleComponent} was loaded. + * Create and add children here. + */ + protected onRecycled(component: Component): void {}; + + /** The {@link recycleComponent} is about to unload. */ + protected onRecycling(): void {}; + + protected get recycleComponent() { + return this.internalRecycleComponent!; + } + + public recycle(): void { + this.dispose(); + this.create(); + } + + private create(): void { + this.internalRecycleComponent = new Component(); + + if (this.internalRecycleComponent) { + this.internalRecycleComponent.load(); + this.onRecycled(this.internalRecycleComponent); + } + } + + private dispose(): void { + if (this.internalRecycleComponent) { + this.onRecycling(); + // Clears its internal list of children and deregisters all its resources. The component is disposed and should no longer be used. + this.internalRecycleComponent.unload(); + this.internalRecycleComponent = null; + } + } +} diff --git a/src/renderings/content/AudioProcessor.ts b/src/renderings/content/AudioProcessor.ts new file mode 100644 index 0000000..f1c56b2 --- /dev/null +++ b/src/renderings/content/AudioProcessor.ts @@ -0,0 +1,552 @@ +import { Env } from "env"; +import { Component } from "obsidian"; +import { HtmlAttribute } from "renderings/dom-constants"; +import { parseStrictFloat } from "TypeAssistant"; +import { ContentRendererPostProcessor, ContentRendererProcessor, PostProcessorParameter } from "./ContentRendererProcessor"; + +type AudioPlayerInfo = { + readonly source: string; + + /** `preload` attribute is explicitly set to `"none"` or `"metadata"`. */ + readonly isPreloadDisabled: boolean; + + /** Time in seconds when playback should begin. */ + readonly startTime: number; + /** Time in seconds when playback should end. If `null`, playback should continue until the end. */ + readonly endTime: number | null; + + /** See {@link HtmlAttribute.MediaElement.Plugin.Data.SeekOnPause} */ + readonly seekOnPause: "init" | "current"; + /** `true` it the `loop` attribute is present. */ + readonly isLooping: boolean; + /** Milliseconds. Greater than zero. */ + readonly loopDelay: number | null; + + readonly mobile: { + /** Seconds. See {@link HtmlAttribute.MediaElement.Plugin.Data.START_OFFSET_MOBILE} */ + readonly startOffset: number | null, + /** Seconds. See {@link HtmlAttribute.MediaElement.Plugin.Data.END_OFFSET_MOBILE} */ + readonly endOffset: number | null, + } +} + +export class AudioProcessor extends ContentRendererProcessor implements ContentRendererPostProcessor { + + public override onunload(): void { + Env.log.proc(`AudioProcessor:onunload: audio elements: ${this.registeredAudioItems.length}`); + super.onunload(); + + this.registeredAudioItems = []; + this.currentlyPlaying = null + } + + public handleHtml(param: PostProcessorParameter): void { + + param.el.querySelectorAll('audio').forEach(audioEl => { + AudioProcessor.setDefaults(audioEl); + + const info = AudioProcessor.getPlayerInfo(param, audioEl); + if (!info) { + Env.log.w(`No source set on audio element: ${audioEl.outerHTML}`); + return; + } + + Env.log.proc(info); + + const controller = this.addChild(new TimeLoopController(info, param)); + this.registeredAudioItems.push({ el: audioEl, controller: controller }); + controller.init(audioEl); + + if (param.config.media.preventMultiplePlayback) + this.preventMultiplePlayback(audioEl); + }); + } + + private preventMultiplePlayback(audioEl: HTMLAudioElement) { + + this.registerDomEvent(audioEl, "play", (el) => { + const audioElThatStartedPlaying = el.target as HTMLAudioElement; + + if (this.currentlyPlaying && this.currentlyPlaying.el !== audioElThatStartedPlaying) { + this.currentlyPlaying.controller.cancelLoopTimeout(); + this.currentlyPlaying.el.pause(); + } + + const match = this.registeredAudioItems.find(p => p.el === audioElThatStartedPlaying) ?? null; + Env.assert(match, "Expected to find the element.") + this.currentlyPlaying = match; + }); + + this.registerDomEvent(audioEl, "pause", (el) => { + const audioElThatPaused = el.target as HTMLAudioElement; + + if (this.currentlyPlaying?.el === audioElThatPaused && !this.currentlyPlaying.controller.isPendingLoop) + this.currentlyPlaying = null; + }); + } + + private registeredAudioItems: { el: HTMLAudioElement, controller: TimeLoopController }[] = []; + /** + * - Note: If paused because of {@link HtmlAttribute.MediaElement.Plugin.Data.LoopDelay} it is still considered playing. + */ + private currentlyPlaying: { el: HTMLAudioElement, controller: TimeLoopController } | null; + + /** + * @returns `null` if no source was set. + */ + private static getPlayerInfo(param: PostProcessorParameter, el: HTMLAudioElement): AudioPlayerInfo | null { + Env.log.proc(`getPlayerInfo`); + + const getSource = () => { + let src = el.getAttribute('src'); + if (!src) { + const sourceEl = el.querySelector('source'); + if (sourceEl) + src = sourceEl.getAttribute('src'); + } + return src; + } + + const getSeekOnPauseAttribute = () => { + switch (el.getAttribute(HtmlAttribute.MediaElement.Plugin.Data.SeekOnPause.NAME)) { + case HtmlAttribute.MediaElement.Plugin.Data.SeekOnPause.Values.CURRENT: + return "current"; + + case HtmlAttribute.MediaElement.Plugin.Data.SeekOnPause.Values.INITIAL: + default: + return "init"; + } + }; + + const getLoopDelay = () => { + const delay = parseStrictFloat(el.getAttribute(HtmlAttribute.MediaElement.Plugin.Data.LoopDelay.NAME)); + if (delay === null) + return null; + + // Make sure loop delay is positive and convert to milliseconds. + return delay <= 0 ? null : delay * 1000.00; + }; + + const getCustomStartAndEndTimes = (source: string, mobileStartOffset: number | null, mobileEndOffset: number | null) => { + const times = AudioProcessor.parseStartAndEndTimes(source); + if (times === null || times.start === undefined) + return null; + + const urlStartTime = AudioProcessor.timeToSeconds(times.start); + const urlEndTime = times.end ? AudioProcessor.timeToSeconds(times.end) : null; + + const effectiveTimes = { + start: mobileStartOffset && Env.isMobile ? urlStartTime + mobileStartOffset : urlStartTime, + end: urlEndTime && mobileEndOffset && Env.isMobile ? urlEndTime + mobileEndOffset : urlEndTime, + } + + if (Env.isMobile && (mobileStartOffset || mobileEndOffset)) { + Env.log.proc(`\tAdjusting start time (${urlStartTime}) to ${effectiveTimes.start}, end time: (${urlEndTime}) to ${effectiveTimes.end}`); + } + + return effectiveTimes; + } + + const source = getSource(); + if (source === null) + return null; + + const mobleStartOffset = parseStrictFloat(el.getAttribute(HtmlAttribute.MediaElement.Plugin.Data.START_OFFSET_MOBILE)); + const mobileEndOffset = parseStrictFloat(el.getAttribute(HtmlAttribute.MediaElement.Plugin.Data.END_OFFSET_MOBILE)); + const startEnd = getCustomStartAndEndTimes(source, mobleStartOffset, mobileEndOffset); + + return { + source: source, + isPreloadDisabled: el.preload == "none" || el.preload == "metadata", + startTime: startEnd ? startEnd.start : 0, + endTime: startEnd ? startEnd.end : null, + seekOnPause: getSeekOnPauseAttribute(), + isLooping: el.hasAttribute(HtmlAttribute.MediaElement.Loop.NAME), + loopDelay: getLoopDelay(), + mobile: { + startOffset: mobleStartOffset, + endOffset: mobileEndOffset, + }, + } as AudioPlayerInfo; + } + + /** + * Parses out the start and end times as strings. Supports optional milliseconds and optional end time. + * @param src For example, "https://domain.net/abc.mp3#t=00:20:41.400,00:20:48". + * @returns + */ + private static parseStartAndEndTimes(src: string): { start: string | undefined; end: string | undefined } | null { + + // - `\.` matches the literal dot. + // - `(\d{3})` captures exactly three digits (milliseconds). + // - `(?:...)` makes the group non-capturing (we only want the digits, not the dot itself). + // - `?` makes the entire milliseconds part optional. + + const regex = /#t=((\d{2}:\d{2}:\d{2}(?:\.(\d{1,3}))?)(?:,(\d{2}:\d{2}:\d{2}(?:\.(\d{1,3}))?))?)/; + const match = src.match(regex); + + if (!match) + return null; + + // match[1] is the full matched time string (e.g., "00:20:41.400,00:20:48") + // match[2] is the first full timestamp string (e.g., "00:20:41.400") + // match[3] is the milliseconds of the first timestamp (e.g., "400") + // match[4] is the second full timestamp string (e.g., "00:20:48") + // match[5] is the milliseconds of the second timestamp (e.g., undefined or "000") + + const startTime = match[2]; // This will include HH:MM:SS.mmm if milliseconds exist + const endTime = match[4]; // This will include HH:MM:SS.mmm if milliseconds exist + + return { start: startTime, end: endTime }; + } + + /** + * Converts a time string in HH:MM:SS.mmm format to seconds. + * @param timeString The time string (e.g., "00:01:30", or "00:01:30.432"). + * @returns The time in seconds. + */ + private static timeToSeconds(timeString: string): number { + let totalSeconds = 0; + let milliseconds = 0; + + // First, check for and extract milliseconds if present + const msParts = timeString.split('.'); + if (msParts.length > 1) { + timeString = msParts[0]; // The part before milliseconds + // Convert milliseconds string to number, handle cases like ".4" or ".40" + milliseconds = Number(`0.${msParts[1]}`) || 0; + } + + // Split the remaining time string by ':' + const parts = timeString.split(':').map(Number); + + // Parse parts from right to left (most common for time formats like HH:MM:SS, MM:SS) + // index 0: seconds (if only 1 part) or hours (if 3 parts) + // index 1: minutes (if 2 or 3 parts) + // index 2: hours (if 3 parts) + + // For media fragments, "10" means 10 seconds. + // "01:20" means 1 minute 20 seconds. + // "01:02:20" means 1 hour 2 minutes 20 seconds. + + if (parts.length === 3) { + // HH:MM:SS + totalSeconds = parts[0] * 3600 + parts[1] * 60 + parts[2]; + } else if (parts.length === 2) { + // MM:SS + totalSeconds = parts[0] * 60 + parts[1]; + } else if (parts.length === 1) { + // SS (single number implies seconds for media fragments) + totalSeconds = parts[0]; + } + // Add the fractional seconds from milliseconds + totalSeconds += milliseconds; + + Env.log.proc(`\ttimeToSeconds:\n\t\tinput: "${timeString}${msParts.length > 1 ? `.${msParts[1]}` : ''}", output ${totalSeconds}`); + + return totalSeconds; + } + + /** + * Allows for audio to be added by merely specifying the `src`: `