mirror of
https://github.com/mntno/obsidian-come-through.git
synced 2026-07-22 05:47:56 +00:00
0.5.0
This commit is contained in:
parent
8b3e0e4caf
commit
9b0f0679f3
45 changed files with 2596 additions and 741 deletions
20
README.md
20
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.
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
449
pnpm-lock.yaml
449
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: {}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<NoteID, RemovedNoteData>;
|
||||
/** 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<void>;
|
||||
/** 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<void>) {
|
||||
public constructor(data: DataStoreRoot, purgeThreshold: number, saveData: (data: DataStoreRoot) => Promise<void>) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
19
src/Localization.ts
Normal file
19
src/Localization.ts
Normal file
|
|
@ -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<string, typeof en> = {
|
||||
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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<void>) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -57,8 +57,6 @@ export class CardDeclaration implements CardDeclarable {
|
|||
|
||||
export class CardDeclarationAssistant extends Declaration {
|
||||
|
||||
//#region
|
||||
|
||||
public static fromFrontmatter(maybeDeclaration: Record<string, unknown>, 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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, any> | null = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
68
src/env.ts
Normal file
68
src/env.ts
Normal file
|
|
@ -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;
|
||||
40
src/lang/en.ts
Normal file
40
src/lang/en.ts
Normal file
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
};
|
||||
95
src/main.ts
95
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<PluginData> {
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
48
src/renderings/MarkupAssistant.ts
Normal file
48
src/renderings/MarkupAssistant.ts
Normal file
|
|
@ -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<K extends keyof HTMLElementTagNameMap>(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 `<p>` is wrapped with `<div class="el-p">`.
|
||||
*
|
||||
* @param appendPara Sometimes a `<p>` 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<K extends keyof HTMLElementTagNameMap>(parent: HTMLElement, tag: K, appendPara = false) {
|
||||
|
||||
// If the second starting tag after the first is on the same line as the first (e.g., <audio ..></audio> or <audio ..><source ..>\n</audio>),
|
||||
// it's wrapped `<div class="el-p"><p dir="auto">`;
|
||||
// otherwise `<div class="el-tagname">`.
|
||||
//
|
||||
// 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<K extends keyof HTMLElementTagNameMap>(tag: K, html: string, appendPara = false) {
|
||||
if (appendPara)
|
||||
return `<div class="${MarkupAssistant.classForEl("p")}"><p>${html}</p></div>`;
|
||||
else
|
||||
return `<div class="${MarkupAssistant.classForEl(tag)}">${html}</div>`;
|
||||
}
|
||||
|
||||
public static classForEl<K extends keyof HTMLElementTagNameMap>(tag: K) {
|
||||
return "el-" + tag;
|
||||
}
|
||||
}
|
||||
64
src/renderings/RecycleComponent.ts
Normal file
64
src/renderings/RecycleComponent.ts
Normal file
|
|
@ -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<T extends Component>(component: T): T {
|
||||
Env.assert(false);
|
||||
return super.addChild(component);
|
||||
}
|
||||
|
||||
public override removeChild<T extends Component>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
552
src/renderings/content/AudioProcessor.ts
Normal file
552
src/renderings/content/AudioProcessor.ts
Normal file
|
|
@ -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`: `<audio controls src="https://externalvideo"></video>`.
|
||||
*
|
||||
* - If you embed a internal audio file like this `` in Obisidan, `controls` and `controlslist="nodownload"` will be added when rendered.
|
||||
* - This means that there is no way to include an audio file without controls/a player, which makes sense.
|
||||
*/
|
||||
private static setDefaults(el: HTMLAudioElement) {
|
||||
if (!el.hasAttribute(HtmlAttribute.MediaElement.Controls.NAME))
|
||||
el.setAttribute(HtmlAttribute.MediaElement.Controls.NAME, HtmlAttribute.MediaElement.Controls.Values.DISPLAY);
|
||||
|
||||
if (!el.hasAttribute(HtmlAttribute.MediaElement.ControlsList.NAME))
|
||||
el.setAttribute(HtmlAttribute.MediaElement.ControlsList.NAME, HtmlAttribute.MediaElement.ControlsList.Values.NODOWNLOAD + " " + HtmlAttribute.MediaElement.ControlsList.Values.NOFULLSCREEN + " " + HtmlAttribute.MediaElement.ControlsList.Values.NOREMOTEPLAYBACK);
|
||||
|
||||
// Load metadata if: `preload="none"` or `preload` was not specified.
|
||||
if (el.hasAttribute(HtmlAttribute.MediaElement.Preload.NAME) && el.getAttribute(HtmlAttribute.MediaElement.Preload.NAME) === HtmlAttribute.MediaElement.Preload.Values.NONE || !el.hasAttribute(HtmlAttribute.MediaElement.Preload.NAME)) {
|
||||
el.setAttribute(HtmlAttribute.MediaElement.Preload.NAME, HtmlAttribute.MediaElement.Preload.Values.METADATA);
|
||||
// Setting the attribute immediately changes the property.
|
||||
Env.assert(el.preload === "metadata", `Expected \`preload\` === "metadata"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TimeLoopController extends Component {
|
||||
|
||||
public readonly info: AudioPlayerInfo;
|
||||
public readonly param: PostProcessorParameter;
|
||||
|
||||
constructor(info: AudioPlayerInfo, param: PostProcessorParameter) {
|
||||
super();
|
||||
|
||||
this.info = info;
|
||||
this.param = param;
|
||||
}
|
||||
|
||||
public override onunload(): void {
|
||||
super.onunload();
|
||||
this.cancelLoopTimeout();
|
||||
}
|
||||
|
||||
public init(audioPlayer: HTMLAudioElement) {
|
||||
|
||||
// For first play only.
|
||||
if (this.info.seekOnPause === "current")
|
||||
this.state.forceControlPositionBeforeNextPlayback = true;
|
||||
|
||||
this.removeLoopAttribute(audioPlayer);
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/loadedmetadata_event
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/loadeddata_event
|
||||
// if `preload="none"`, loadedmetadata or loadeddata will not be called until user starts playback.
|
||||
// Not called if there is no start and/or end time, regardless of preload value.
|
||||
this.registerDomEvent(audioPlayer, "loadedmetadata", ev => this.onLoadedMetadata(ev.target as HTMLAudioElement));
|
||||
|
||||
// If user chooses to play outside of defined time range, stop interfering.
|
||||
// This is also called when setting `currentTime` programatically.
|
||||
this.registerDomEvent(audioPlayer, "seeking", ev => this.onSeeking(ev.target as HTMLAudioElement));
|
||||
|
||||
// The seeked event is fired when a seek operation completed, the current playback position has changed,
|
||||
// and the Boolean `seeking` attribute is changed to false. https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/seeked_event
|
||||
// Listen for seeked event to resume playback or perform iterative correction
|
||||
this.registerDomEvent(audioPlayer, "seeked", ev => this.onSeeked(ev.target as HTMLAudioElement));
|
||||
|
||||
this.registerDomEvent(audioPlayer, "play", ev => this.onPlay(ev.target as HTMLAudioElement));
|
||||
this.registerDomEvent(audioPlayer, "pause", ev => this.onPause(ev.target as HTMLAudioElement));
|
||||
this.registerDomEvent(audioPlayer, "timeupdate", ev => this.onTimeUpdate(ev.target as HTMLAudioElement));
|
||||
|
||||
this.register(() => this.cancelLoopTimeout());
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the `loop` attribute inorder to gain full control over the looping.
|
||||
* The "timeupdate" might not be called when playback has reached the end, and browser engine will not pause the playback, it just sets the playback position to 0.
|
||||
*/
|
||||
private removeLoopAttribute(audioPlayer: HTMLAudioElement) {
|
||||
if (this.info.isLooping)
|
||||
audioPlayer.removeAttribute(HtmlAttribute.MediaElement.Loop.NAME);
|
||||
}
|
||||
|
||||
private readonly state = {
|
||||
|
||||
/**
|
||||
* If `true`, attempting to start playback when {@link isTimeWithinStartTolerance} returns `false` for the player's current playback position will adjust the player's position.
|
||||
*
|
||||
* See {@link onPlay}.
|
||||
*
|
||||
* Is reset to `false` at the end of {@link onPlay}.
|
||||
*/
|
||||
forceControlPositionBeforeNextPlayback: false,
|
||||
|
||||
/** Flag to indicate if play() should be called after seeked event. */
|
||||
playAfterSeek: false,
|
||||
/** Flag to indicate if we are in an iterative seek correction process. */
|
||||
isCorrectingSeek: false,
|
||||
/** Counter for iterative seek attempts. */
|
||||
seekAttempts: 0,
|
||||
|
||||
/**
|
||||
* `true` if user seeked before start time or after end time, in which case, stop controlling the player.
|
||||
* Intended to be used as a flag to give up control over playback if the user seeked out of range.
|
||||
*
|
||||
* @todo Always `false` as there is no trivial way to know for sure wheather seek was initiated by user. This was set to `true` in {@link onSeeking} if current playback position was out of range, but caused problems on mobile.
|
||||
*/
|
||||
didSeekOutOfTimeRange: false,
|
||||
|
||||
/** `true` if playback stopped by itself because reached the end time. */
|
||||
didReachEnd: false,
|
||||
|
||||
/** The timeout for the {@link AudioPlayerInfo.loopDelay|loop delay}. */
|
||||
loopTimeout: null as NodeJS.Timeout | null,
|
||||
};
|
||||
|
||||
/** Use this instead of assigning `currentTime` directly because {@link state|state variables} need to be set before assignment. */
|
||||
private seekTo(player: HTMLAudioElement, time: number, options?: {
|
||||
/** Set the state so that playback begins after seek finished. */
|
||||
playAfterSeeked?: boolean,
|
||||
/** On the next `seeked` event, adjust the `currentTime` if outside tolerance. */
|
||||
adjustNextSeek?: boolean,
|
||||
}) {
|
||||
const {
|
||||
adjustNextSeek = true,
|
||||
playAfterSeeked = false,
|
||||
} = options || {};
|
||||
|
||||
this.state.playAfterSeek = playAfterSeeked;
|
||||
|
||||
if (adjustNextSeek) {
|
||||
this.state.isCorrectingSeek = true;
|
||||
this.state.seekAttempts = 0;
|
||||
}
|
||||
|
||||
player.currentTime = time;
|
||||
}
|
||||
|
||||
private isTimeWithinStartTolerance(time: number) {
|
||||
return (Math.max(0, time) > this.info.startTime + SEEK_TOLERANCE) || (Math.max(0, time) < Math.max(0, this.info.startTime - SEEK_TOLERANCE))
|
||||
}
|
||||
|
||||
public cancelLoopTimeout() {
|
||||
if (this.state.loopTimeout) {
|
||||
Env.log.proc(`\tcancelLoopTimeout: cleared timeout`);
|
||||
clearTimeout(this.state.loopTimeout);
|
||||
this.state.loopTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Currently paused but will begin playing after {@link AudioPlayerInfo.loopDelay}. */
|
||||
public get isPendingLoop() {
|
||||
return this.state.loopTimeout !== null;
|
||||
}
|
||||
|
||||
private onLoadedMetadata(player: HTMLAudioElement) {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState
|
||||
Env.log.proc(`\t(event) loadedmetadata:\n\t\treadyState: ${player.readyState} (at least metadata loaded), current time: ${player.currentTime}, desired time: ${this.info.startTime}`)
|
||||
|
||||
if (player.currentTime != this.info.startTime) {
|
||||
Env.log.proc(`\t\tSetting current time to ${this.info.startTime}`);
|
||||
this.seekTo(player, this.info.startTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure playback starts at the given start time.
|
||||
*/
|
||||
private onPlay(player: HTMLAudioElement) {
|
||||
Env.log.proc(`\t(event) play: current time: ${player.currentTime}\n\t\tdesired time: ${this.info.startTime}, didSeekOutOfTimeRange: ${this.state.didSeekOutOfTimeRange}`)
|
||||
|
||||
this.cancelLoopTimeout();
|
||||
|
||||
if (this.state.didSeekOutOfTimeRange)
|
||||
return;
|
||||
|
||||
if (this.state.forceControlPositionBeforeNextPlayback || this.info.seekOnPause === "init") {
|
||||
if (this.isTimeWithinStartTolerance(player.currentTime)) {
|
||||
Env.log.proc(`\t\tAdjusting seek because the player's current time beyond the tolerance margin.`);
|
||||
this.seekTo(player, this.info.startTime, { playAfterSeeked: true });
|
||||
}
|
||||
}
|
||||
this.state.forceControlPositionBeforeNextPlayback = false;
|
||||
}
|
||||
|
||||
private onPause(player: HTMLAudioElement) {
|
||||
Env.log.proc(`\t(event) pause: current time: ${player.currentTime}\n\t\tdesired time: ${this.info.startTime}, didSeekOutOfTimeRange: ${this.state.didSeekOutOfTimeRange}, didReachEnd: ${this.state.didReachEnd}`);
|
||||
|
||||
const didReachEnd = this.state.didReachEnd;
|
||||
this.state.didReachEnd = false;
|
||||
this.cancelLoopTimeout();
|
||||
|
||||
if (this.state.didSeekOutOfTimeRange)
|
||||
return;
|
||||
|
||||
if (didReachEnd && this.info.isLooping) {
|
||||
if (this.info.loopDelay) {
|
||||
Env.log.proc(`\t\tSetting playback position to ${this.info.startTime}. Will request playback in ${this.info.loopDelay}s`);
|
||||
this.seekTo(player, this.info.startTime);
|
||||
|
||||
this.state.loopTimeout = setTimeout(() => {
|
||||
this.cancelLoopTimeout();
|
||||
player.play();
|
||||
}, this.info.loopDelay);
|
||||
}
|
||||
else {
|
||||
Env.log.proc(`\t\tSetting playback position to ${this.info.startTime} and requesting playback.`);
|
||||
this.seekTo(player, this.info.startTime, { playAfterSeeked: true });
|
||||
}
|
||||
}
|
||||
else {
|
||||
switch (this.info.seekOnPause) {
|
||||
case "init":
|
||||
Env.log.proc(`\t\tSetting playback position to ${this.info.startTime}.`);
|
||||
this.seekTo(player, this.info.startTime);
|
||||
break;
|
||||
default:
|
||||
Env.log.proc(`\t\tRetaining playback position`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called whenever player is moving to a new playback position.
|
||||
* - This includes initial start time.
|
||||
*/
|
||||
private onSeeking(player: HTMLAudioElement) {
|
||||
//const isCurrentTimeWithinRange = player.currentTime >= this.info.startTime && (this.info.endTime === null || player.currentTime <= this.info.endTime);
|
||||
//this.state.didSeekOutOfTimeRange = !isCurrentTimeWithinRange;
|
||||
Env.log.proc(`\t(event) seeking: ${player.seeking}, currentTime: ${player.currentTime}\n\t\tdidSeekOutOfTimeRange: ${this.state.didSeekOutOfTimeRange}`);
|
||||
}
|
||||
|
||||
private onSeeked(player: HTMLAudioElement) {
|
||||
Env.log.proc(`\t(event) seeked: readyState: ${player.readyState}\n\t\tcurrent time: ${player.currentTime}, desired time: ${this.info.startTime}, didSeekOutOfTimeRange: ${this.state.didSeekOutOfTimeRange}\n\t\tplayAfterSeek: ${this.state.playAfterSeek}, isCorrectingSeek: ${this.state.isCorrectingSeek}/${this.state.seekAttempts}`);
|
||||
|
||||
if (this.state.didSeekOutOfTimeRange)
|
||||
return;
|
||||
|
||||
const requestBeginPlayback = () => {
|
||||
if (this.state.playAfterSeek && player.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||
this.state.playAfterSeek = false;
|
||||
Env.log.proc(`\t\tStarting playback. readyState is >= ${HTMLMediaElement.HAVE_CURRENT_DATA}`);
|
||||
player.play();
|
||||
}
|
||||
else {
|
||||
Env.log.proc(`\t\tDid not start playback (playAfterSeek: ${this.state.playAfterSeek}, readyState: ${player.readyState}).`);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.state.isCorrectingSeek && this.state.seekAttempts < MAX_SEEK_ATTEMPTS) {
|
||||
this.state.seekAttempts++;
|
||||
|
||||
if (this.isTimeWithinStartTolerance(player.currentTime)) {
|
||||
Env.log.proc(`\t\tSeeking back further (seek attempt ${this.state.seekAttempts - 1}).`);
|
||||
|
||||
const adjustedTime = player.currentTime > this.info.startTime
|
||||
? player.currentTime - SEEK_STEP_BACK_AMOUNT
|
||||
: player.currentTime + SEEK_STEP_BACK_AMOUNT;
|
||||
|
||||
this.seekTo(player, Math.max(0, adjustedTime), { adjustNextSeek: false });
|
||||
}
|
||||
else {
|
||||
Env.log.proc(`\t\tNo need to seek back ${this.state.seekAttempts > 1 ? "further" : ""}`);
|
||||
requestBeginPlayback();
|
||||
}
|
||||
}
|
||||
else {
|
||||
requestBeginPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whenever this is called depends on the engine.
|
||||
* - It'll likely not be called before looping when the {@link HtmlAttribute.MediaElement.Loop.NAME|loop} attribute is set, see {@link removeLoopAttribute}.
|
||||
* - {@link player}.`ended` is only `true` after the whole file as played, i.e., not when pausing because of a set end time.
|
||||
*
|
||||
* @param player
|
||||
*/
|
||||
private onTimeUpdate(player: HTMLAudioElement) {
|
||||
Env.log.proc(`\t(event) timeupdate: ${player.currentTime}\n\t\tEnd of file: ${player.ended}. Is player paused: ${player.paused}\n\t\tpast end time: ${(this.info.endTime ? player.currentTime >= this.info.endTime : player.ended)}, didSeekOutOfTimeRange: ${this.state.didSeekOutOfTimeRange}`);
|
||||
|
||||
if (this.state.didSeekOutOfTimeRange)
|
||||
return;
|
||||
|
||||
if (player.ended)
|
||||
this.state.didReachEnd = true;
|
||||
else
|
||||
this.state.didReachEnd = this.info.endTime ? player.currentTime >= this.info.endTime : false;
|
||||
|
||||
if (!this.state.didReachEnd)
|
||||
return;
|
||||
|
||||
// If `ended` is true (and there's no `loop` attribute) or playback has passed the end time, the engine pauses playback, which means that `onPause` will be called.
|
||||
// Extra measure. Player would normally already be paused.
|
||||
if (!player.paused) {
|
||||
Env.log.proc(`\t\tPausing because past end time`);
|
||||
player.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// These are involved in attemping to find the key frames in case the custom start time is set after a key frame, the playback position will begin at the next.
|
||||
/** Stop adjusting after this number of attempts even if the players position is beyond tolerance margin. */
|
||||
const MAX_SEEK_ATTEMPTS = 10;
|
||||
/** The time to adjust backwards on each attempt. */
|
||||
const SEEK_STEP_BACK_AMOUNT = 0.10; //0.50;
|
||||
const SEEK_TOLERANCE = 0.05; //0.10;
|
||||
144
src/renderings/content/ContentRenderer.ts
Normal file
144
src/renderings/content/ContentRenderer.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { App, Component, MarkdownRenderer, TFile } from "obsidian";
|
||||
import { AudioProcessor } from "./AudioProcessor";
|
||||
import { ContentProcessorConfig, ContentRendererProcessor, PostProcessorParameter, PreProcessorParameter } from "./ContentRendererProcessor";
|
||||
import { HtmlElementWrapperProcessor } from "./HtmlElementWrapperProcessor";
|
||||
import { LinkProcessor } from "./LinkProcessor";
|
||||
import { VideoProcessor } from "./VideoProcessor";
|
||||
import { Env } from "env";
|
||||
import { RecycleComponent } from "renderings/RecycleComponent";
|
||||
import { PluginSettings } from "Settings";
|
||||
|
||||
export function createRenderConfig(settings: PluginSettings): ContentProcessorConfig {
|
||||
return {
|
||||
media: {
|
||||
preventMultiplePlayback: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class ContentRenderer extends RecycleComponent {
|
||||
|
||||
private readonly app: App;
|
||||
public config: ContentProcessorConfig;
|
||||
|
||||
/**
|
||||
* The {@link TFile} of which {@link TFile.path} will be used as `sourcePath` when rendering.
|
||||
*
|
||||
* - The {@link TFile.path} will be updated automatically if the file is renamed (which is why the type is {@link TFile} rather than `string`).
|
||||
* - The `sourcePath` can be overridden by the options parameter of {@link render}.
|
||||
* - `null` is allowed because it's what `app.vault.getFileByPath` returns when it cannot find a file at the given path.
|
||||
* - If is `undefined` at the time of render, {@link render} will render a "failed" message and abort.
|
||||
*/
|
||||
public file: TFile | undefined | null;
|
||||
|
||||
/** These will always run, and before {@link customProcessors}. */
|
||||
private defaultProcessors: ContentRendererProcessor[] = [];
|
||||
|
||||
/** These can be overridden by the options parameter of {@link render}. */
|
||||
private customProcessors: ContentRendererProcessor[] = [];
|
||||
|
||||
public constructor(app: App, config: ContentProcessorConfig) {
|
||||
super();
|
||||
this.app = app;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
protected onRecycled(component: Component): void {
|
||||
|
||||
/** These will always run, and before any custom ones. */
|
||||
this.defaultProcessors = [
|
||||
new HtmlElementWrapperProcessor(),
|
||||
new LinkProcessor(),
|
||||
new AudioProcessor(),
|
||||
new VideoProcessor(),
|
||||
];
|
||||
|
||||
this.defaultProcessors.forEach(p => component.addChild(p));
|
||||
}
|
||||
|
||||
protected onRecycling(): void {
|
||||
this.defaultProcessors = [];
|
||||
this.customProcessors = [];
|
||||
}
|
||||
|
||||
/** Set processes that will be used in all following calls to {@link render}, replacing any previous ones. Note that these will get unloaded and removed on recycling and therefore, if desired, need to be created and set again ahead of any following call to {@link render}. */
|
||||
public setCustomProcessors(processors: ContentRendererProcessor[]) {
|
||||
this.customProcessors.forEach(p => this.recycleComponent.removeChild(p));
|
||||
this.customProcessors = processors;
|
||||
this.customProcessors.forEach(p => this.recycleComponent.addChild(p));
|
||||
}
|
||||
|
||||
/** Note that these will get unloaded and removed on recycling and therefore, if desired, need to be created and set again ahead of any following call to {@link render}. */
|
||||
public addCustomProcessors(processors: ContentRendererProcessor[]) {
|
||||
processors.forEach(p => {
|
||||
this.customProcessors.push(p);
|
||||
this.recycleComponent.addChild(p);
|
||||
});
|
||||
}
|
||||
|
||||
/** It is only necessary to remove processors if you do not want to use them during the next call to {@link render}. */
|
||||
public removeCustomProcessors(processors: ContentRendererProcessor[]) {
|
||||
processors.forEach(p => {
|
||||
this.customProcessors.remove(p);
|
||||
this.recycleComponent.removeChild(p);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param markdown
|
||||
* @param el
|
||||
* @param options Values set here are only valid during this rendering pass. Processors are unloaded.
|
||||
* @returns
|
||||
*/
|
||||
public async render(markdown: string, el: HTMLElement, options?: { sourcePath?: string, processors?: ContentRendererProcessor[] }): Promise<void> {
|
||||
const sourcePath = options?.sourcePath ?? this.file?.path;
|
||||
const processors: ContentRendererProcessor[] = options?.processors ?? [];
|
||||
|
||||
if (!sourcePath) {
|
||||
el.createSpan({ text: "Failed to render." });
|
||||
console.error("Could not render markdown. `sourcePath` not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (processors)
|
||||
processors.forEach(p => this.recycleComponent.addChild(p));
|
||||
|
||||
const param = {
|
||||
app: this.app,
|
||||
config: this.config,
|
||||
component: this.recycleComponent,
|
||||
sourcePath,
|
||||
};
|
||||
|
||||
const preParameter = {
|
||||
...param,
|
||||
...{ markdown: markdown }
|
||||
} satisfies PreProcessorParameter;
|
||||
|
||||
const postParameter = {
|
||||
...param,
|
||||
...{ el: el }
|
||||
} satisfies PostProcessorParameter;
|
||||
|
||||
// default run first
|
||||
const allProcessors = [...this.defaultProcessors, ...this.customProcessors, ...processors];
|
||||
|
||||
Env.log.view(`Running ${allProcessors.filter(p => p.isPreProcessor()).length} pre processors: ${allProcessors.filter(p => p.isPreProcessor()).map(a => a.constructor.name).join(", ")}`);
|
||||
for (const processor of allProcessors) {
|
||||
if (processor.isPreProcessor())
|
||||
processor.handleMarkdown(preParameter);
|
||||
}
|
||||
|
||||
await MarkdownRenderer.render(this.app, preParameter.markdown, el, sourcePath, this.recycleComponent);
|
||||
|
||||
Env.log.view(`Running ${allProcessors.filter(p => p.isPostProcessor()).length} post processors: ${allProcessors.filter(p => p.isPostProcessor()).map(a => a.constructor.name).join(", ")}`);
|
||||
for (const processor of allProcessors) {
|
||||
if (processor.isPostProcessor())
|
||||
processor.handleHtml(postParameter);
|
||||
}
|
||||
|
||||
if (processors)
|
||||
processors.forEach(p => this.recycleComponent.removeChild(p));
|
||||
}
|
||||
}
|
||||
65
src/renderings/content/ContentRendererProcessor.ts
Normal file
65
src/renderings/content/ContentRendererProcessor.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Env } from "env";
|
||||
import { App, Component } from "obsidian";
|
||||
|
||||
export type MediaContentProcessorConfig = {
|
||||
preventMultiplePlayback: boolean;
|
||||
}
|
||||
|
||||
export type ContentProcessorConfig = {
|
||||
media: MediaContentProcessorConfig;
|
||||
};
|
||||
|
||||
export type PreProcessorParameter = {
|
||||
markdown: string;
|
||||
readonly app: App;
|
||||
readonly config: ContentProcessorConfig;
|
||||
readonly sourcePath: string;
|
||||
};
|
||||
|
||||
export type PostProcessorParameter = {
|
||||
readonly el: HTMLElement;
|
||||
readonly app: App;
|
||||
readonly config: ContentProcessorConfig;
|
||||
readonly sourcePath: string;
|
||||
};
|
||||
|
||||
export interface ContentRendererPreProcessor {
|
||||
handleMarkdown(param: PreProcessorParameter): void;
|
||||
}
|
||||
|
||||
export interface ContentRendererPostProcessor {
|
||||
handleHtml(param: PostProcessorParameter): void;
|
||||
}
|
||||
|
||||
export abstract class ContentRendererProcessor extends Component {
|
||||
|
||||
public isPreProcessor(): this is ContentRendererPreProcessor {
|
||||
return "handleMarkdown" in this;
|
||||
}
|
||||
|
||||
public isPostProcessor(): this is ContentRendererPostProcessor {
|
||||
return "handleHtml" in this;
|
||||
}
|
||||
|
||||
public override onload(): void {
|
||||
Env.log.view(`ContentRendererPostProcessor:onload: `+ this.constructor.name);
|
||||
super.onload();
|
||||
}
|
||||
|
||||
public override onunload(): void {
|
||||
Env.log.view(`ContentRendererPostProcessor:onunload: `+ this.constructor.name);
|
||||
super.onunload();
|
||||
}
|
||||
|
||||
protected static getUrlWithoutParameters(fullUrl: string | null): string | null {
|
||||
if (fullUrl === null)
|
||||
return null;
|
||||
|
||||
try {
|
||||
const url = new URL(fullUrl);
|
||||
return url.origin + url.pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/renderings/content/HeadingProcessor.ts
Normal file
85
src/renderings/content/HeadingProcessor.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { ContentRendererPostProcessor, ContentRendererProcessor, PostProcessorParameter } from "./ContentRendererProcessor";
|
||||
|
||||
export class HeadingProcessor extends ContentRendererProcessor implements ContentRendererPostProcessor {
|
||||
private readonly baseHeadingLevel: number;
|
||||
private readonly allowNonConsecutiveLevels: boolean;
|
||||
|
||||
public constructor(baseHeadingLevel: number, allowNonConsecutiveLevels: boolean = false) {
|
||||
super();
|
||||
|
||||
this.baseHeadingLevel = baseHeadingLevel;
|
||||
this.allowNonConsecutiveLevels = allowNonConsecutiveLevels;
|
||||
}
|
||||
|
||||
public handleHtml(param: PostProcessorParameter) {
|
||||
HeadingProcessor.normalize(param, this.baseHeadingLevel, this.allowNonConsecutiveLevels)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the heading levels within the provided HTML element.
|
||||
* It identifies the highest heading level (e.g., h1, h2) present in the element
|
||||
* and adjusts all headings to ensure they are relative to the `baseHeadingLevel`
|
||||
* set in the constructor, keeping them within the h1-h6 range.
|
||||
* @param param The parameter object containing the HTML element to process.
|
||||
* @param baseHeadingLevel The base heading level to normalize against.
|
||||
* @param allowNonConsecutiveLevels If true, allows non-consecutive heading levels. If false (default), enforces consecutive levels.
|
||||
*/
|
||||
private static normalize(param: PostProcessorParameter, baseHeadingLevel: number, allowNonConsecutiveLevels: boolean) {
|
||||
|
||||
let highestLevel = 0;
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
if (param.el.querySelector(`h${i}`)) {
|
||||
highestLevel = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (highestLevel === 0) {
|
||||
return; // No headings to process
|
||||
}
|
||||
|
||||
const offset = baseHeadingLevel - highestLevel;
|
||||
const headings = Array.from(param.el.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
|
||||
let lastAdjustedLevel = 0; // To track the level of the previous heading after adjustment
|
||||
|
||||
headings.forEach(heading => {
|
||||
const currentLevel = parseInt(heading.tagName.substring(1), 10);
|
||||
|
||||
// First, apply the baseHeadingLevel normalization
|
||||
let targetLevel = Math.max(1, Math.min(6, currentLevel + offset));
|
||||
|
||||
// Then, if consecutiveLevels is true, enforce consecutiveness
|
||||
if (!allowNonConsecutiveLevels) {
|
||||
if (lastAdjustedLevel === 0) {
|
||||
// This is the first heading, its targetLevel is its adjusted level
|
||||
lastAdjustedLevel = targetLevel;
|
||||
} else {
|
||||
// Ensure current heading is at most 1 level deeper than the previous adjusted heading
|
||||
targetLevel = Math.min(targetLevel, lastAdjustedLevel + 1);
|
||||
lastAdjustedLevel = targetLevel;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLevel === targetLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newHeading = document.createElement(`h${targetLevel}`);
|
||||
|
||||
// Copy attributes
|
||||
for (let i = 0; i < heading.attributes.length; i++) {
|
||||
const attr = heading.attributes[i];
|
||||
newHeading.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
|
||||
// Move children
|
||||
while (heading.firstChild) {
|
||||
newHeading.appendChild(heading.firstChild);
|
||||
}
|
||||
|
||||
// Replace old heading with new one
|
||||
heading.parentNode?.replaceChild(newHeading, heading);
|
||||
});
|
||||
}
|
||||
}
|
||||
19
src/renderings/content/HtmlElementWrapperProcessor.ts
Normal file
19
src/renderings/content/HtmlElementWrapperProcessor.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { MarkupAssistant } from "renderings/MarkupAssistant";
|
||||
import { ContentRendererPreProcessor, ContentRendererProcessor, PreProcessorParameter } from "./ContentRendererProcessor";
|
||||
|
||||
/**
|
||||
* - Add `|x` to match on more elements.
|
||||
* - Matches both self-closing tags (with a <a />) and and normal closing.
|
||||
*/
|
||||
const ELEMENT_REGEX = /<(audio|video)\b[^>]*?(?:\/>|>[\s\S]*?<\/\1>)/gi;
|
||||
|
||||
/**
|
||||
* Processes inline HTML in the markdown string.
|
||||
*/
|
||||
export class HtmlElementWrapperProcessor extends ContentRendererProcessor implements ContentRendererPreProcessor {
|
||||
public handleMarkdown(param: PreProcessorParameter): void {
|
||||
param.markdown = param.markdown.replace(ELEMENT_REGEX, (html, tag) => {
|
||||
return MarkupAssistant.createElWrapperHtml(tag, html);
|
||||
});
|
||||
}
|
||||
}
|
||||
23
src/renderings/content/LinkProcessor.ts
Normal file
23
src/renderings/content/LinkProcessor.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Keymap } from "obsidian";
|
||||
import { ContentRendererPostProcessor, ContentRendererProcessor, PostProcessorParameter } from "./ContentRendererProcessor";
|
||||
|
||||
export class LinkProcessor extends ContentRendererProcessor implements ContentRendererPostProcessor {
|
||||
|
||||
public handleHtml(param: PostProcessorParameter) {
|
||||
|
||||
// External links works but not internal.
|
||||
// https://forum.obsidian.md/t/internal-links-dont-work-in-custom-view/90169/3
|
||||
param.el.querySelectorAll('a.internal-link').forEach(internalLinkEl => {
|
||||
|
||||
if (internalLinkEl instanceof HTMLElement) {
|
||||
const handler = async (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
const href = (event.currentTarget as HTMLAnchorElement).getAttribute('href');
|
||||
if (href)
|
||||
param.app.workspace.openLinkText(href, param.sourcePath, Keymap.isModEvent(event));
|
||||
};
|
||||
this.registerDomEvent(internalLinkEl, "click", handler);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/renderings/content/VideoProcessor.ts
Normal file
27
src/renderings/content/VideoProcessor.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { HtmlAttribute } from "renderings/dom-constants";
|
||||
import { ContentRendererPostProcessor, ContentRendererProcessor, PostProcessorParameter } from "./ContentRendererProcessor";
|
||||
|
||||
export class VideoProcessor extends ContentRendererProcessor implements ContentRendererPostProcessor {
|
||||
public handleHtml(param: PostProcessorParameter): void {
|
||||
param.el.querySelectorAll("video").forEach(el => this.handleVideo(param, el));
|
||||
}
|
||||
|
||||
private handleVideo(param: PostProcessorParameter, el: HTMLVideoElement) {
|
||||
this.setDefaults(el);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows for video to be added by merely specifying the `src`: `<video src="https://externalvideo"></video>`
|
||||
*
|
||||
* - If you embed a internal video like this `` in Obisidan, `controls` and `preload="metadata"` will be added when rendered.
|
||||
* - This means that there is no way to include a video without controls; all videos will have controls, which makes sense.
|
||||
* - `preload` can be overridden, e.g., `preload="auto"`.
|
||||
*/
|
||||
private setDefaults(el: HTMLVideoElement) {
|
||||
if (!el.hasAttribute(HtmlAttribute.MediaElement.Controls.NAME))
|
||||
el.setAttribute(HtmlAttribute.MediaElement.Controls.NAME, HtmlAttribute.MediaElement.Controls.Values.DISPLAY);
|
||||
|
||||
if (!el.hasAttribute(HtmlAttribute.MediaElement.Preload.NAME))
|
||||
el.setAttribute(HtmlAttribute.MediaElement.Preload.NAME, HtmlAttribute.MediaElement.Preload.Values.METADATA);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "renderings/DeclarationRenderable";
|
||||
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "./DeclarationRenderable";
|
||||
import { AlternateHeadingsDeclarable, AlternateHeadingsAssistant } from "declarations/commands/AlternateHeadings";
|
||||
|
||||
export class AlternateHeadingsRenderer
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "renderings/DeclarationRenderable";
|
||||
import { HeadingAndDelimiterAssistant, HeadingAndDelimiterDeclarable } from "declarations/commands/HeadingAndDelimiter";
|
||||
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "./DeclarationRenderable";
|
||||
import { CardDeclarationAssistant, DefaultableCardDeclarable } from "declarations/CardDeclaration";
|
||||
|
||||
export class CardDeclarationRenderer
|
||||
|
|
@ -15,7 +14,9 @@ export class CardDeclarationRenderer
|
|||
|
||||
const rowID = body.createEl("tr");
|
||||
rowID.createEl("td", { text: "ID" });
|
||||
rowID.createEl("td", { text: this.declarable.id });
|
||||
rowID.createEl("td", {
|
||||
text: CardDeclarationAssistant.conformsToDeclarable(this.declarable) ? this.declarable.id : ""
|
||||
});
|
||||
|
||||
const rowSide = body.createEl("tr");
|
||||
rowSide.createEl("td", { text: "Side" });
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "renderings/DeclarationRenderable";
|
||||
import { Declarable, Declaration } from "declarations/Declaration";
|
||||
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "./DeclarationRenderable";
|
||||
import { Declarable } from "declarations/Declaration";
|
||||
|
||||
|
||||
export class DeclarationErrorRenderer
|
||||
|
|
@ -2,13 +2,14 @@ import { CardDeclarationAssistant } from "declarations/CardDeclaration";
|
|||
import { CommandDeclarationAssistant } from "declarations/CommandDeclaration";
|
||||
import { Declaration } from "declarations/Declaration";
|
||||
import { MarkdownRenderChild, setIcon } from "obsidian";
|
||||
import { AlternateHeadingsRenderer } from "renderings/AlternateHeadingsRenderer";
|
||||
import { CardDeclarationRenderer } from "renderings/CardDeclarationRenderer";
|
||||
import { DeclarationErrorRenderer } from "renderings/DeclarationErrorRenderer";
|
||||
import { DataProvider, DeclarationChangedEvent, DeclarationRenderable, DeclarationRenderAssistant } from "renderings/DeclarationRenderable";
|
||||
import { HeadingAndDelimiterRenderer } from "renderings/HeadingAndDelimiterRenderer";
|
||||
import { HeadingIsFrontRenderer } from "renderings/HeadingIsFrontRenderer";
|
||||
import { AlternateHeadingsRenderer } from "./AlternateHeadingsRenderer";
|
||||
import { CardDeclarationRenderer } from "./CardDeclarationRenderer";
|
||||
import { DeclarationErrorRenderer } from "./DeclarationErrorRenderer";
|
||||
import { DataProvider, DeclarationChangedEvent, DeclarationRenderable, DeclarationRenderAssistant } from "./DeclarationRenderable";
|
||||
import { HeadingAndDelimiterRenderer } from "./HeadingAndDelimiterRenderer";
|
||||
import { HeadingIsFrontRenderer } from "./HeadingIsFrontRenderer";
|
||||
import { PLUGIN_ICON } from "UIAssistant";
|
||||
import { Env } from "env";
|
||||
|
||||
export class DeclarationRenderChild extends MarkdownRenderChild {
|
||||
|
||||
|
|
@ -19,6 +20,11 @@ export class DeclarationRenderChild extends MarkdownRenderChild {
|
|||
this.dataProvider = dataProvider;
|
||||
}
|
||||
|
||||
public override onunload(): void {
|
||||
Env.log.d("DeclarationRenderChild:onunload");
|
||||
super.onunload();
|
||||
}
|
||||
|
||||
private contentContainerEl: HTMLDivElement;
|
||||
private source: string;
|
||||
private dataProvider: DataProvider;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "renderings/DeclarationRenderable";
|
||||
import { DeclarationRenderer, DeclarationRenderable, DeclarationRenderAssistant } from "./DeclarationRenderable";
|
||||
import { HeadingAndDelimiterAssistant, HeadingAndDelimiterDeclarable } from "declarations/commands/HeadingAndDelimiter";
|
||||
|
||||
export class HeadingAndDelimiterRenderer
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { HeadingIsFrontAssistant, HeadingIsFrontDeclarable } from "declarations/commands/HeadingIsFront";
|
||||
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "renderings/DeclarationRenderable";
|
||||
import { DeclarationRenderable, DeclarationRenderAssistant, DeclarationRenderer } from "./DeclarationRenderable";
|
||||
|
||||
export class HeadingIsFrontRenderer
|
||||
extends DeclarationRenderer<HeadingIsFrontDeclarable>
|
||||
114
src/renderings/dom-constants.ts
Normal file
114
src/renderings/dom-constants.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
export const CssClass = {
|
||||
|
||||
Plugin: {
|
||||
WORKSPACE_LEAF_CONTENT: "come-through-workspace-leaf-content"
|
||||
},
|
||||
|
||||
MarkdownView: {
|
||||
READING: () => "markdown-reading-view",
|
||||
|
||||
PREVIEW: () => [
|
||||
"markdown-preview-view",
|
||||
"markdown-rendered",
|
||||
"node-insert-event",
|
||||
"is-readable-line-width",
|
||||
"allow-fold-headings",
|
||||
"allow-fold-lists",
|
||||
"show-indentation-guide",
|
||||
"show-properties",
|
||||
],
|
||||
|
||||
SIZER: () => [
|
||||
"markdown-preview-sizer",
|
||||
"markdown-preview-section",
|
||||
],
|
||||
|
||||
PUSHER: () => [
|
||||
"markdown-preview-pusher"
|
||||
],
|
||||
|
||||
MOD: () => [
|
||||
"mod-header",
|
||||
"mod-ui"
|
||||
],
|
||||
},
|
||||
|
||||
Embed: {
|
||||
INTERNAL: "internal-embed",
|
||||
MEDIA: "media-embed",
|
||||
AUDIO: "audio-embed",
|
||||
},
|
||||
|
||||
State: {
|
||||
LOADED: "is-loaded",
|
||||
ERROR: "is-error",
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const HtmlAttribute = {
|
||||
Dir: {
|
||||
NAME: "dir",
|
||||
Values: {
|
||||
AUTO: "auto"
|
||||
}
|
||||
},
|
||||
|
||||
MediaElement: {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/video#controls
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/controls
|
||||
Controls: {
|
||||
NAME: "controls",
|
||||
Values: {
|
||||
DISPLAY: ""
|
||||
}
|
||||
},
|
||||
/** The controlsList property of an HTMLMediaElement (like <video> or <audio>) allows you to control which built-in playback controls the user agent (browser) should display. */
|
||||
ControlsList: {
|
||||
NAME: "controlslist",
|
||||
Values: {
|
||||
NODOWNLOAD: "nodownload",
|
||||
NOFULLSCREEN: "nofullscreen",
|
||||
/** Hide controls that allow for casting the media to another device for playback. */
|
||||
NOREMOTEPLAYBACK: "noremoteplayback",
|
||||
}
|
||||
},
|
||||
Loop: {
|
||||
NAME: "loop",
|
||||
Values: {
|
||||
DISPLAY: ""
|
||||
}
|
||||
},
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/preload
|
||||
Preload: {
|
||||
NAME: "preload",
|
||||
Values: {
|
||||
NONE: "none",
|
||||
METADATA: "metadata",
|
||||
AUTO: "auto",
|
||||
}
|
||||
},
|
||||
Plugin: {
|
||||
Data: {
|
||||
START_OFFSET_MOBILE: "data-start-offset-mobile",
|
||||
END_OFFSET_MOBILE: "data-end-offset-mobile",
|
||||
//if need more granularity: START_OFFSET_IOS/START_OFFSET_IPAD/START_OFFSET_ANDROID/..
|
||||
|
||||
/** Whether automatic seeking should occur when media is paused. */
|
||||
SeekOnPause: {
|
||||
NAME: "data-pause-seek",
|
||||
Values: {
|
||||
/** Do not seek on pause, i.e., playback will continue where paused. */
|
||||
CURRENT: "current",
|
||||
/** Seek to the initial position on pause: if a custom start time was set, seek to that; if not, seek to 0:00. */
|
||||
INITIAL: "init",
|
||||
}
|
||||
},
|
||||
|
||||
/** Delay in seconds before starting playback again after having reached the end. Only relevant when looping. */
|
||||
LoopDelay: {
|
||||
NAME: "data-loop-delay",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} as const;
|
||||
146
src/views/BaseView.ts
Normal file
146
src/views/BaseView.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { DataStore, DataStoreRoot } from "DataStore";
|
||||
import { Env } from "env";
|
||||
import { ItemView, ViewStateResult, WorkspaceLeaf } from "obsidian";
|
||||
import { ContentRenderer, createRenderConfig } from "renderings/content/ContentRenderer";
|
||||
import { PluginSettings, SettingsChanged, SettingsManager } from "Settings";
|
||||
import { PLUGIN_ICON } from "UIAssistant";
|
||||
import { ViewAssistant } from "./ViewAssistant";
|
||||
|
||||
export type BaseViewOptionalParameters = {
|
||||
data?: DataStore;
|
||||
};
|
||||
|
||||
export interface BaseViewState {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export abstract class BaseView<State extends BaseViewState> extends ItemView {
|
||||
|
||||
protected readonly settingsManager: SettingsManager;
|
||||
private readonly options?: BaseViewOptionalParameters;
|
||||
|
||||
protected readonly contentRenderer: ContentRenderer;
|
||||
protected readonly viewAssistant = new ViewAssistant();
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, settingsManager: SettingsManager, options?: BaseViewOptionalParameters) {
|
||||
super(leaf);
|
||||
|
||||
this.settingsManager = settingsManager;
|
||||
this.options = options;
|
||||
|
||||
this.contentRenderer = new ContentRenderer(this.app, createRenderConfig(settingsManager.settings));
|
||||
this.addChild(this.contentRenderer);
|
||||
}
|
||||
|
||||
public override onload(): void {
|
||||
Env.log.view("onload");
|
||||
super.onload();
|
||||
}
|
||||
|
||||
public override onunload(): void {
|
||||
Env.log.view("onunload");
|
||||
super.onunload();
|
||||
}
|
||||
|
||||
public override getIcon() {
|
||||
Env.log.view("getIcon");
|
||||
return PLUGIN_ICON;
|
||||
}
|
||||
|
||||
protected override async onOpen(): Promise<void> {
|
||||
Env.log.view("onOpen");
|
||||
this.settingsManager?.registerOnChangedCallback(this.settingsChangedCallback);
|
||||
if (this.options) {
|
||||
this.options.data?.registerOnChangedCallback(this.dataChangedCallback);
|
||||
}
|
||||
|
||||
this.contentEl.empty();
|
||||
this.viewAssistant.init(this);
|
||||
}
|
||||
|
||||
protected override async onClose(): Promise<void> {
|
||||
Env.log.view("onClose");
|
||||
this.settingsManager?.unregisterOnChangedCallback(this.settingsChangedCallback);
|
||||
if (this.options) {
|
||||
this.options.data?.unregisterOnChangedCallback(this.dataChangedCallback);
|
||||
}
|
||||
|
||||
this.contentRenderer.unload(); // Will also be unloaded when this view unloads.
|
||||
this.viewAssistant.deinit();
|
||||
}
|
||||
|
||||
public override onResize(): void {
|
||||
Env.log.view("onResize");
|
||||
this.viewAssistant.adjustAvailableVerticalScrolling();
|
||||
}
|
||||
|
||||
public override async setState(state: State, result: ViewStateResult) {
|
||||
Env.log.view("setState");
|
||||
this.onSetState(state, result);
|
||||
|
||||
await this.refreshView();
|
||||
await super.setState(state, result);
|
||||
}
|
||||
|
||||
public override getState(): Record<string, unknown> {
|
||||
Env.log.view("getState");
|
||||
return this.onGetState();
|
||||
}
|
||||
|
||||
private dataChangedCallback = async (data: DataStoreRoot) => {
|
||||
Env.log.view("dataChangedCallback");
|
||||
const outParams = {
|
||||
skipRender: false,
|
||||
};
|
||||
|
||||
this.onDataChanged(data, outParams);
|
||||
|
||||
if (!outParams.skipRender)
|
||||
await this.refreshView();
|
||||
}
|
||||
|
||||
private settingsChangedCallback: SettingsChanged = async (settings, isExternal) => {
|
||||
Env.log.view("settingsChangedCallback");
|
||||
if (!isExternal)
|
||||
this.contentRenderer.config = createRenderConfig(settings);
|
||||
|
||||
const outParams = {
|
||||
skipRender: false,
|
||||
};
|
||||
|
||||
this.onSettingsChanged(settings, isExternal, outParams);
|
||||
|
||||
if (!outParams.skipRender)
|
||||
await this.refreshView();
|
||||
}
|
||||
|
||||
protected refreshView = async () => {
|
||||
Env.log.view("refreshView");
|
||||
this.contentRenderer.recycle();
|
||||
this.viewAssistant.empty();
|
||||
|
||||
await this.onRender();
|
||||
this.viewAssistant.adjustAvailableVerticalScrolling();
|
||||
};
|
||||
|
||||
protected abstract onSetState(state: State, result: ViewStateResult): void;
|
||||
protected abstract onGetState(): State;
|
||||
protected abstract onRender(): Promise<void>;
|
||||
|
||||
protected onDataChanged(data: DataStoreRoot, out: { skipRender: boolean }): void {};
|
||||
protected onSettingsChanged(settings: PluginSettings, isExternal: boolean, out: { skipRender: boolean }): void {};
|
||||
|
||||
protected get scrollPosition(): { top: number, left: number } {
|
||||
return this.viewAssistant.scrollContainer
|
||||
? { top: this.viewAssistant.scrollContainer.scrollTop, left: this.viewAssistant.scrollContainer.scrollLeft }
|
||||
: { top: 0, left: 0};
|
||||
}
|
||||
|
||||
protected setScrollPosition(options: ScrollToOptions) {
|
||||
this.viewAssistant.scrollContainer?.scrollTo(options)
|
||||
}
|
||||
|
||||
protected saveState() {
|
||||
this.app.workspace.requestSaveLayout();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +1,62 @@
|
|||
import { DeckModal } from "modals/DeckModal";
|
||||
import { ItemView, Menu, setIcon, setTooltip, WorkspaceLeaf } from "obsidian";
|
||||
import { DataStore } from "DataStore";
|
||||
import { ViewAssistant } from "views/ViewAssistant";
|
||||
import { DeckID } from "FullID";
|
||||
import { DeckModal } from "modals/DeckModal";
|
||||
import { Menu, setIcon, setTooltip, ViewStateResult, WorkspaceLeaf } from "obsidian";
|
||||
import { SettingsManager } from "Settings";
|
||||
import { BaseView, BaseViewState } from "views/BaseView";
|
||||
|
||||
|
||||
export class DecksView extends ItemView {
|
||||
export class DecksView extends BaseView<BaseViewState> {
|
||||
|
||||
public static readonly TYPE = "come-through-view-decks";
|
||||
private readonly viewAssistant = new ViewAssistant();
|
||||
|
||||
constructor(
|
||||
leaf: WorkspaceLeaf,
|
||||
private readonly data: DataStore) {
|
||||
super(leaf);
|
||||
private readonly data: DataStore;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, settingsManager: SettingsManager, data: DataStore) {
|
||||
super(leaf, settingsManager, { data: data });
|
||||
this.data = data;
|
||||
|
||||
this.navigation = true;
|
||||
}
|
||||
|
||||
//#region
|
||||
|
||||
getIcon() {
|
||||
public override getIcon() {
|
||||
return "file-stack";
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
public getViewType(): string {
|
||||
return DecksView.TYPE;
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
public getDisplayText(): string {
|
||||
return "Decks";
|
||||
}
|
||||
|
||||
protected async onOpen() {
|
||||
this.data.registerOnChangedCallback(this.onDataChangedCallback);
|
||||
this.addAction("refresh-cw", "Reload", () => {
|
||||
this.refreshView();
|
||||
protected onSetState(state: BaseViewState, result: ViewStateResult): void {
|
||||
}
|
||||
|
||||
protected onGetState(): BaseViewState {
|
||||
return {};
|
||||
}
|
||||
|
||||
public override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string) {
|
||||
super.onPaneMenu(menu, source);
|
||||
if (source === "tab-header")
|
||||
return;
|
||||
|
||||
menu.addItem(item => {
|
||||
item.setTitle("Reload");
|
||||
item.setSection("pane");
|
||||
item.setIcon("refresh-cw");
|
||||
item.onClick(this.refreshView);
|
||||
});
|
||||
|
||||
this.contentEl.empty();
|
||||
this.viewAssistant.init(this.contentEl);
|
||||
|
||||
await this.refreshView();
|
||||
}
|
||||
|
||||
protected async onClose() {
|
||||
this.data.unregisterOnChangedCallback(this.onDataChangedCallback);
|
||||
this.viewAssistant.deinit();
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
private onDataChangedCallback = () => this.refreshView();
|
||||
|
||||
private async refreshView() {
|
||||
|
||||
this.viewAssistant.empty();
|
||||
protected async onRender() {
|
||||
|
||||
const allCards = this.data.getAllCards();
|
||||
const decks = this.data.getAllDecks();
|
||||
const numberOfCardsInDefaultDeck = allCards.filter(this.data.filter.cardsWithoutDeck).length;
|
||||
|
||||
//this.viewAssistant.createH1({ text: "Decks" });
|
||||
//this.viewAssistant.createEl("h1", { text: "Decks" });
|
||||
|
||||
this.viewAssistant.createPara({
|
||||
text: `There are ${allCards.length - numberOfCardsInDefaultDeck} cards in a total of ${decks.length} decks.`
|
||||
|
|
@ -83,7 +78,7 @@ export class DecksView extends ItemView {
|
|||
th.createEl("button", { text: "Add" }, (button) => {
|
||||
setIcon(button, "plus");
|
||||
setTooltip(button, "Add new deck");
|
||||
this.registerDomEvent(button, "click", this.add.bind(this));
|
||||
this.contentRenderer.registerDomEvent(button, "click", () => new DeckModal(this.app, this.data, () => { }).open());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -108,19 +103,22 @@ export class DecksView extends ItemView {
|
|||
rowID.createEl("td", {}, (td) => {
|
||||
const ellipsisButton = td.createEl("button", {}, (button) => {
|
||||
|
||||
this.registerDomEvent(button, 'click', (evt: MouseEvent) => {
|
||||
this.contentRenderer.registerDomEvent(button, 'click', (evt: MouseEvent) => {
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Edit");
|
||||
item.setIcon("pen");
|
||||
item.onClick(this.edit.bind(this, deck.id));
|
||||
item.onClick(() => new DeckModal(this.app, this.data, () => { }, deck.id).open());
|
||||
});
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Delete")
|
||||
item.setIcon("trash")
|
||||
item.setDisabled(numberOfCardsInDeck > 0)
|
||||
item.onClick(this.delete.bind(this, deck.id));
|
||||
item.onClick(async () => {
|
||||
this.data.deleteDeck(deck.id, undefined, true);
|
||||
await this.data.save();
|
||||
});
|
||||
});
|
||||
|
||||
menu.showAtMouseEvent(evt);
|
||||
|
|
@ -130,22 +128,4 @@ export class DecksView extends ItemView {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
private add() {
|
||||
new DeckModal(this.app, this.data, (_) => {
|
||||
this.refreshView();
|
||||
}).open();
|
||||
}
|
||||
|
||||
private edit(deckID: DeckID) {
|
||||
new DeckModal(this.app, this.data, (_) => {
|
||||
this.refreshView();
|
||||
}, deckID).open();
|
||||
}
|
||||
|
||||
private async delete(deckID: DeckID) {
|
||||
this.data.deleteDeck(deckID, undefined, true);
|
||||
await this.data.save();
|
||||
this.refreshView();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
183
src/views/DefinedContentView.ts
Normal file
183
src/views/DefinedContentView.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { ContentParser } from "ContentParser";
|
||||
import { DataStore } from "DataStore";
|
||||
import t from "Localization";
|
||||
import { Menu, Scope, TFile, ViewStateResult, WorkspaceLeaf } from "obsidian";
|
||||
import { HeadingProcessor } from "renderings/content/HeadingProcessor";
|
||||
import { SettingsManager } from "Settings";
|
||||
import { BaseView, BaseViewState } from "views/BaseView";
|
||||
|
||||
export interface DefinedContentViewState extends BaseViewState {
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export class DefinedContentView extends BaseView<DefinedContentViewState> {
|
||||
|
||||
public static readonly TYPE = "come-through-view-defined-content";
|
||||
public static createViewState(file: TFile): DefinedContentViewState {
|
||||
return {
|
||||
filePath: file.path,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* - `null` if {@link TFile} could not be created from a file path.
|
||||
* - `undefined` if for other reasons there is no file.
|
||||
*/
|
||||
private file: TFile | null | undefined;
|
||||
|
||||
/** Keeps track of which details elements have been dynamically created on expand. */
|
||||
private hasRendered: WeakMap<HTMLDetailsElement, boolean>;
|
||||
|
||||
/**
|
||||
* @param data Used to receive data changed notifications.
|
||||
*/
|
||||
constructor(leaf: WorkspaceLeaf, settingsManager: SettingsManager, data: DataStore) {
|
||||
super(leaf, settingsManager, {
|
||||
data: data,
|
||||
});
|
||||
|
||||
this.navigation = true;
|
||||
this.scope = new Scope(this.app.scope);
|
||||
this.scope.register(["Mod"], "R", this.refreshView);
|
||||
}
|
||||
|
||||
public getViewType() {
|
||||
return DefinedContentView.TYPE;
|
||||
}
|
||||
|
||||
public getDisplayText() {
|
||||
return t.views.declarations.title(this.file ?? undefined);
|
||||
}
|
||||
|
||||
public override onload(): void {
|
||||
super.onload();
|
||||
|
||||
// Note that the data change callback will also be called on file rename.
|
||||
// The "rename" event will be called first.
|
||||
// The difference is that when there's a rename event, we want to update the display text.
|
||||
this.registerEvent(this.app.vault.on("rename", this.onFileRename.bind(this)));
|
||||
}
|
||||
|
||||
public override onunload(): void {
|
||||
super.onunload();
|
||||
this.hasRendered = new WeakMap<HTMLDetailsElement, boolean>();
|
||||
}
|
||||
|
||||
public override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string) {
|
||||
super.onPaneMenu(menu, source);
|
||||
if (source === "tab-header")
|
||||
return;
|
||||
|
||||
menu.addItem(item => {
|
||||
item.setTitle("Reload");
|
||||
item.setSection("pane");
|
||||
item.setIcon("refresh-cw");
|
||||
item.onClick(this.refreshView);
|
||||
});
|
||||
}
|
||||
|
||||
protected onSetState(state: DefinedContentViewState, result: ViewStateResult): void {
|
||||
this.file = state.filePath ? this.app.vault.getFileByPath(state.filePath) : undefined;
|
||||
this.contentRenderer.file = this.file;
|
||||
}
|
||||
|
||||
protected onGetState(): DefinedContentViewState {
|
||||
return {
|
||||
filePath: this.file?.path,
|
||||
}
|
||||
}
|
||||
|
||||
protected override onDataChanged() {
|
||||
// On file rename, this will be called superflously.
|
||||
// But since this case is a rare and the solution to avoid the extra render call is too ugly, because of lack of obsidian APIs, it's not worth it implementing.
|
||||
// So let it call render two times.
|
||||
};
|
||||
|
||||
private onFileRename = async (file: TFile, oldPath: string) => {
|
||||
|
||||
// When the file is renamed, the `path` of the existing `TFile` reference automatically updates.
|
||||
// So this condition is expected to be false. Keep it nonetheless.
|
||||
if (this.file && this.file.path === oldPath)
|
||||
this.file = file;
|
||||
|
||||
if (this.file && this.file.path === file.path) {
|
||||
// This will explicitly tell the WorkspaceLeaf to update its view state,
|
||||
// which includes re-evaluating getDisplayText() and updating the tab header with the new file path.
|
||||
// However, the title in the "tab title bar" is not updated.
|
||||
//
|
||||
// `setViewState` calls `setState`, which calls `render` so no need to call `render here`.
|
||||
await this.leaf.setViewState({ type: DefinedContentView.TYPE, state: this.getState() });
|
||||
}
|
||||
};
|
||||
|
||||
protected async onRender() {
|
||||
if (!this.file) {
|
||||
console.error("No file set.")
|
||||
this.viewAssistant.createPara({ text: t.views.declarations.fileNotSet });
|
||||
return;
|
||||
}
|
||||
|
||||
// <summary> is styled as a <h2>, this will normalize its children to start at <h3>.
|
||||
this.contentRenderer.setCustomProcessors([new HeadingProcessor(3)]);
|
||||
this.hasRendered = new WeakMap<HTMLDetailsElement, boolean>();
|
||||
|
||||
this.viewAssistant.createEl("h1", { text: t.views.declarations.title(this.file) });
|
||||
const infoPara = this.viewAssistant.createParaWrapper();
|
||||
|
||||
const parsedContent = await ContentParser.getCardFromFile(this.file, this.app, {
|
||||
contentRead: {
|
||||
hideCardSectionMarker: this.settingsManager.settings.hideCardSectionMarker,
|
||||
}
|
||||
});
|
||||
|
||||
var numberOfContentDefinitions = 0;
|
||||
var numberOfIncompleteContentDefinitionsInFile = 0;
|
||||
|
||||
for (const parsedUnit of Object.values(parsedContent)) {
|
||||
|
||||
if (parsedUnit.complete !== null) {
|
||||
const completeUnit = parsedUnit.complete;
|
||||
this.createDefinedContentBlockSection(`${completeUnit.frontID.cardID}: Front`, completeUnit.frontMarkdown);
|
||||
this.createDefinedContentBlockSection(`${completeUnit.backID.cardID}: Back`, completeUnit.backMarkdown);
|
||||
numberOfContentDefinitions += 2;
|
||||
}
|
||||
else if (parsedUnit.incomplete !== null) {
|
||||
const incompleteUnit = parsedUnit.incomplete;
|
||||
|
||||
if (incompleteUnit.frontID) {
|
||||
this.createDefinedContentBlockSection(`${incompleteUnit.frontID.cardID}: Front (incomplete)`, incompleteUnit.frontMarkdown);
|
||||
numberOfIncompleteContentDefinitionsInFile += 1;
|
||||
}
|
||||
if (incompleteUnit.backID) {
|
||||
this.createDefinedContentBlockSection(`${incompleteUnit.backID.cardID}: Back (incomplete)`, incompleteUnit.backMarkdown);
|
||||
numberOfIncompleteContentDefinitionsInFile += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var markdownText = this.app.fileManager.generateMarkdownLink(this.file, "") + // custom view has not sourcePath.
|
||||
` contains declarations that defines ${numberOfContentDefinitions + numberOfIncompleteContentDefinitionsInFile} content blocks` +
|
||||
`${numberOfIncompleteContentDefinitionsInFile > 0 ? " (" + numberOfIncompleteContentDefinitionsInFile + " incomplete)" : ""}` +
|
||||
`, amounting to ${numberOfContentDefinitions / 2} review units.`;
|
||||
|
||||
await this.contentRenderer.render(markdownText, infoPara);
|
||||
}
|
||||
|
||||
private createDefinedContentBlockSection(summary: string, content?: string) {
|
||||
this.viewAssistant.createEl("details", undefined, (detailsEl) => {
|
||||
detailsEl.createEl("summary", { text: summary });
|
||||
const contentDiv = detailsEl.createDiv();
|
||||
this.contentRenderer.registerDomEvent(detailsEl, "toggle", async () => {
|
||||
if (detailsEl.open && !this.hasRendered.has(detailsEl)) {
|
||||
this.hasRendered.set(detailsEl, true);
|
||||
if (content)
|
||||
await this.contentRenderer.render(content, contentDiv);
|
||||
}
|
||||
// else if (!detailsEl.open && this.hasRendered.has(detailsEl)) {
|
||||
// contentDiv.empty();
|
||||
// this.hasRendered.delete(detailsEl);
|
||||
// }
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +1,54 @@
|
|||
import { ContentParser, ParsedCard } from "ContentParser";
|
||||
import { DataStore, DeckIDDataTuple, StatisticsData } from "DataStore";
|
||||
import { Env } from "env";
|
||||
import { FileParserError } from "FileParser";
|
||||
import { DeckID, FullID } from "FullID";
|
||||
import { App, Component, ItemView, Keymap, KeymapEventListener, MarkdownRenderer, Menu, Scope, setIcon, setTooltip, TFile, ViewStateResult, WorkspaceLeaf } from "obsidian";
|
||||
import { InternalApi } from "InternalApi";
|
||||
import { Keymap, KeymapEventListener, Menu, Scope, setIcon, setTooltip, TFile, ViewStateResult, WorkspaceLeaf } from "obsidian";
|
||||
import { HeadingProcessor } from "renderings/content/HeadingProcessor";
|
||||
import { Scheduler } from "Scheduler";
|
||||
import { SettingsManager } from "Settings";
|
||||
import { DeckIDDataTuple, DataStore, StatisticsData } from "DataStore";
|
||||
import { Grade, Rating, show_diff_message, State, TypeConvert } from "ts-fsrs";
|
||||
import { CARD_BACK_ICON as BACK_ICON, PLUGIN_ICON, CARD_FRONT_ICON as FRONT_ICON, UIAssistant } from "UIAssistant";
|
||||
import { ViewAssistant } from "views/ViewAssistant";
|
||||
import { InternalApi } from "InternalApi";
|
||||
import { FileParserError } from "FileParser";
|
||||
import { Grade, IPreview, Rating, show_diff_message, State, TypeConvert } from "ts-fsrs";
|
||||
import { CARD_BACK_ICON as BACK_ICON, CARD_FRONT_ICON as FRONT_ICON, PLUGIN_ICON, UIAssistant } from "UIAssistant";
|
||||
import { BaseView, BaseViewState } from "views/BaseView";
|
||||
|
||||
export interface IReviewViewState {
|
||||
export interface ReviewViewState extends BaseViewState {
|
||||
deckID?: DeckID;
|
||||
showMetadata: boolean;
|
||||
}
|
||||
|
||||
export class ReviewView extends ItemView {
|
||||
export class ReviewView extends BaseView<ReviewViewState> {
|
||||
|
||||
public static readonly TYPE = "come-through-view-review";
|
||||
public static createViewState(deckID: DeckID | undefined, showMetadata: boolean = false): ReviewViewState {
|
||||
return {
|
||||
deckID: deckID,
|
||||
showMetadata: showMetadata,
|
||||
} satisfies ReviewViewState;
|
||||
}
|
||||
|
||||
private readonly data: DataStore;
|
||||
private readonly ui: UIAssistant;
|
||||
private readonly scheduler: Scheduler;
|
||||
|
||||
constructor(
|
||||
leaf: WorkspaceLeaf,
|
||||
private readonly settingsManager: SettingsManager,
|
||||
private readonly scheduler: Scheduler,
|
||||
private readonly ui: UIAssistant,
|
||||
private readonly data: DataStore) {
|
||||
super(leaf);
|
||||
settingsManager: SettingsManager,
|
||||
scheduler: Scheduler,
|
||||
ui: UIAssistant,
|
||||
data: DataStore) {
|
||||
super(leaf, settingsManager, {
|
||||
data: data,
|
||||
});
|
||||
|
||||
this.scheduler = scheduler;
|
||||
this.ui = ui;
|
||||
this.data = data;
|
||||
|
||||
this.reviewDate = new Date(); // Will be refreshed as each item is presented.
|
||||
|
||||
this.navigation = true;
|
||||
this.scope = new Scope(this.app.scope);
|
||||
this.reviewDate = new Date(); // Will be refreshed as each item is presented.
|
||||
|
||||
//#region Hotkeys
|
||||
|
||||
const toggleFrontBackEventListener: KeymapEventListener = (_evt, _ctx) => {
|
||||
this.toggleAnswer();
|
||||
|
|
@ -39,7 +56,7 @@ export class ReviewView extends ItemView {
|
|||
};
|
||||
|
||||
const rate = (grade: Grade) => {
|
||||
if (this.reviewedItem && this.showBackSide)
|
||||
if (this.reviewedItem && this.eState.showBackSide)
|
||||
this.rate(this.reviewedItem.id, grade);
|
||||
};
|
||||
|
||||
|
|
@ -54,35 +71,38 @@ export class ReviewView extends ItemView {
|
|||
this.scope.register(null, "4", () => rate(Rating.Easy));
|
||||
|
||||
this.scope.register(["Mod"], "R", this.refreshView);
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
//#region
|
||||
|
||||
getIcon() {
|
||||
public override getIcon() {
|
||||
return PLUGIN_ICON;
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
public getViewType() {
|
||||
return ReviewView.TYPE;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return this.deck ? `${this.deck.data.n} deck review` : "Review";
|
||||
public getDisplayText() {
|
||||
return this.deck ? `Review: ${this.deck.data.n}` : "Review";
|
||||
}
|
||||
|
||||
onResize(): void {
|
||||
public override onload(): void {
|
||||
super.onload();
|
||||
|
||||
if (Env.isMobile)
|
||||
this.registerDomEvent(this.contentEl, "dblclick", () => { this.toggleAnswer(); });
|
||||
}
|
||||
|
||||
onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string) {
|
||||
public override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string) {
|
||||
super.onPaneMenu(menu, source);
|
||||
|
||||
if (source === "tab-header")
|
||||
return;
|
||||
|
||||
const file = this.sourceFile();
|
||||
if (!file)
|
||||
return;
|
||||
|
||||
this.ui.addMenuItem(menu, `Go to declaration`, {
|
||||
this.ui.addMenuItem(menu, `Open source note`, {
|
||||
section: "pane",
|
||||
icon: "file-code-2",
|
||||
prefix: false,
|
||||
|
|
@ -97,104 +117,92 @@ export class ReviewView extends ItemView {
|
|||
icon: "file-text",
|
||||
prefix: false,
|
||||
checked: this.showMetadata,
|
||||
onClick: evt => {
|
||||
onClick: () => {
|
||||
this.showMetadata = !this.showMetadata;
|
||||
this.app.workspace.requestSaveLayout();
|
||||
this.render();
|
||||
this.saveState();
|
||||
this.refreshView();
|
||||
}
|
||||
});
|
||||
|
||||
this.ui.addMenuItem(menu, "Reload", {
|
||||
section: "pane",
|
||||
icon: "refresh-cw",
|
||||
prefix: false,
|
||||
onClick: this.refreshView
|
||||
});
|
||||
}
|
||||
|
||||
protected async onOpen() {
|
||||
this.data.registerOnChangedCallback(this.onDataChangedCallback);
|
||||
this.addAction("refresh-cw", "Reload", this.refreshView);
|
||||
await super.onOpen();
|
||||
|
||||
this.toggleAnswerButton = this.addAction(
|
||||
this.showBackSide ? FRONT_ICON : BACK_ICON,
|
||||
this.showBackSide ? "View front" : "View back",
|
||||
this.eState.showBackSide ? FRONT_ICON : BACK_ICON,
|
||||
this.eState.showBackSide ? "View front" : "View back",
|
||||
() => this.toggleAnswer());
|
||||
|
||||
this.contentEl.empty();
|
||||
this.viewAssistant.init(this.contentEl);
|
||||
|
||||
this.frontContainer = createDiv();
|
||||
this.backContainer = createDiv();
|
||||
}
|
||||
|
||||
protected async onClose() {
|
||||
this.data.unregisterOnChangedCallback(this.onDataChangedCallback);
|
||||
this.viewAssistant.deinit();
|
||||
await this.recycleMarkdownResources();
|
||||
}
|
||||
|
||||
public override getState() {
|
||||
return {
|
||||
deckID: this.deck?.id,
|
||||
showMetadata: this.showMetadata,
|
||||
} satisfies IReviewViewState;
|
||||
}
|
||||
|
||||
public override async setState(state: IReviewViewState, result: ViewStateResult) {
|
||||
|
||||
protected onSetState(state: ReviewViewState, result: ViewStateResult): void {
|
||||
this.deck = state.deckID ? {
|
||||
id: state.deckID,
|
||||
data: this.data.getDeck(state.deckID, true)!
|
||||
} : undefined;
|
||||
this.showMetadata = state.showMetadata;
|
||||
|
||||
await this.render();
|
||||
await super.setState(state, result);
|
||||
this.resetEphemeralState();
|
||||
}
|
||||
|
||||
//#endregion
|
||||
protected onGetState(): ReviewViewState {
|
||||
return {
|
||||
deckID: this.deck?.id,
|
||||
showMetadata: this.showMetadata,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The {@link TFile} where the currently displayed side is declared, `undefined` if there's no side dislayed, `null` if the file couldn't be found.
|
||||
*/
|
||||
private sourceFile(): TFile | null | undefined {
|
||||
return this.currentCard && this.app.vault.getFileByPath(this.showBackSide ? this.currentCard.backID.noteID : this.currentCard.frontID.noteID);
|
||||
return this.eState.currentCard && this.app.vault.getFileByPath(this.eState.showBackSide ? this.eState.currentCard.backID.noteID : this.eState.currentCard.frontID.noteID);
|
||||
}
|
||||
|
||||
private onDataChangedCallback = () => {
|
||||
this.refreshView();
|
||||
};
|
||||
|
||||
private refreshView = () => {
|
||||
this.showBackSide = false;
|
||||
this.render();
|
||||
};
|
||||
|
||||
//#region
|
||||
|
||||
private deck?: DeckIDDataTuple;
|
||||
private showMetadata = false;
|
||||
|
||||
private readonly viewAssistant = new ViewAssistant();
|
||||
|
||||
/** The current item in review. */
|
||||
private reviewedItem?: { id: FullID, statistics: StatisticsData } | null;
|
||||
private reviewDate: Date;
|
||||
|
||||
private currentCard: ParsedCard | null;
|
||||
|
||||
/** If `true` then currently showing the answer. */
|
||||
private showBackSide = false;
|
||||
private readonly eState = {
|
||||
currentCard: null as ParsedCard | null,
|
||||
/** If `true` then currently showing the answer. */
|
||||
showBackSide: false,
|
||||
lastFrontScrollPosition: 0,
|
||||
lastBackScrollPosition: 0
|
||||
};
|
||||
|
||||
/** Where the front side's DOM is appened to. */
|
||||
private frontContainer: HTMLDivElement;
|
||||
/** Where the back side's DOM is appened to. */
|
||||
private backContainer: HTMLDivElement;
|
||||
|
||||
private markdownRenderComponent?: Component;
|
||||
|
||||
private ratingButtonsContainer?: HTMLDivElement;
|
||||
private toggleAnswerButton?: HTMLElement;
|
||||
|
||||
private async render() {
|
||||
this.viewAssistant.empty();
|
||||
protected async onRender() {
|
||||
this.frontContainer.remove();
|
||||
this.frontContainer.empty();
|
||||
this.backContainer.remove();
|
||||
this.backContainer.empty();
|
||||
this.ratingButtonsContainer?.remove();
|
||||
this.ratingButtonsContainer?.empty();
|
||||
this.ratingButtonsContainer = undefined;
|
||||
|
||||
const abort = () => {
|
||||
const onAbort = () => {
|
||||
this.toggleAnswerButton?.hide();
|
||||
this.resetEphemeralState();
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -204,94 +212,111 @@ export class ReviewView extends ItemView {
|
|||
this.viewAssistant.createPara({
|
||||
text: `There are no cards in ${this.deck ? `the deck named ${this.deck.data.n}` : "this vault"}.`
|
||||
});
|
||||
abort();
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
|
||||
this.reviewDate = new Date();
|
||||
this.reviewedItem = this.scheduler.getNextItem(cards, this.reviewDate); // Not necessarily deterministic.
|
||||
this.reviewedItem = this.scheduler.getNextItem(cards, this.reviewDate);
|
||||
|
||||
if (!this.reviewedItem) {
|
||||
this.viewAssistant.createPara({ text: `No more cards at the moment. All ${cards.length} cards ${this.deck ? `under ${this.deck.data.n}` : "in this vault"} are done.` });
|
||||
abort();
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
|
||||
console.assert(this.reviewedItem.id.cardID, "Card expected.");
|
||||
if (this.reviewedItem.id.cardID === undefined) {
|
||||
abort();
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeCompleteCard = await ContentParser.getCard(
|
||||
this.reviewedItem.id,
|
||||
this.app,
|
||||
{
|
||||
contentRead: {
|
||||
hideCardSectionMarker: this.settingsManager.settings.hideCardSectionMarker
|
||||
},
|
||||
likelyNoteIDs: this.data.getAllNotes()
|
||||
}
|
||||
);
|
||||
const maybeCompleteCard = await ContentParser.getCard(this.reviewedItem.id, this.app, {
|
||||
contentRead: {
|
||||
hideCardSectionMarker: this.settingsManager.settings.hideCardSectionMarker
|
||||
},
|
||||
likelyNoteIDs: this.data.getAllNotes() // Only notes that contain declarations
|
||||
});
|
||||
|
||||
if (maybeCompleteCard.complete === null) {
|
||||
if (maybeCompleteCard.complete === null && maybeCompleteCard.incomplete === null)
|
||||
this.viewAssistant.createPara({ text: `Could not find content of "${this.reviewedItem.id.cardID}" in "${this.reviewedItem.id.noteID}".` });
|
||||
if (maybeCompleteCard.incomplete !== null)
|
||||
this.viewAssistant.createPara({ text: `"${this.reviewedItem.id.toString()}" does not have a ${!maybeCompleteCard.incomplete.backMarkdown ? "back" : "front"} side.` });
|
||||
abort();
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentCard = maybeCompleteCard.complete;
|
||||
this.eState.currentCard = maybeCompleteCard.complete;
|
||||
|
||||
} catch (error: unknown) {
|
||||
this.handleError(error);
|
||||
abort();
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedCard = this.currentCard;
|
||||
const nextItems = this.scheduler.previewNextItem(this.reviewedItem.statistics, this.reviewDate);
|
||||
const ratingButtonsContainer = createDiv({ cls: "come-through-rating-buttons" });
|
||||
const parsedCard = this.eState.currentCard;
|
||||
const processors = [new HeadingProcessor(this.settingsManager.settings.hideCardSectionMarker ? 2 : 1)];
|
||||
|
||||
await this.recycleMarkdownResources(async (frontContainer, backContainer, component) => {
|
||||
this.contentRenderer.addCustomProcessors(processors);
|
||||
|
||||
await ReviewView.renderMarkdown(this.app, parsedCard.frontMarkdown.trim().length > 0 ? parsedCard.frontMarkdown : "Empty front side", frontContainer, parsedCard.frontID.noteID, component);
|
||||
await ReviewView.renderMarkdown(this.app, parsedCard.backMarkdown.trim().length > 0 ? parsedCard.backMarkdown : "Empty back side", backContainer, parsedCard.backID.noteID, component);
|
||||
|
||||
//#region Rating buttons
|
||||
|
||||
for (const { log: nextLog, card: nextCard } of nextItems) {
|
||||
ratingButtonsContainer.createEl("button", undefined, (button) => {
|
||||
setTooltip(button, `${TypeConvert.time(nextCard.due).toString()}`)
|
||||
const buttonText = button.createDiv();
|
||||
buttonText.createSpan({
|
||||
text: Rating[nextLog.rating],
|
||||
cls: "rating-button-text"
|
||||
});
|
||||
buttonText.createSpan({
|
||||
text: `${show_diff_message(nextCard.due, this.reviewDate, true, this.timeUnit)}`,
|
||||
cls: "rating-button-interval"
|
||||
});
|
||||
|
||||
component.registerDomEvent(button, "click", async () => {
|
||||
if (this.reviewedItem)
|
||||
await this.rate(this.reviewedItem.id, nextLog.rating as Grade);
|
||||
})
|
||||
});
|
||||
}
|
||||
ratingButtonsContainer.hide();
|
||||
|
||||
//#endregion
|
||||
await this.contentRenderer.render(
|
||||
parsedCard.frontMarkdown.trim().length > 0 ? parsedCard.frontMarkdown : "Empty front side",
|
||||
this.frontContainer, {
|
||||
sourcePath: parsedCard.frontID.noteID,
|
||||
});
|
||||
|
||||
this.toggleAnswerButton?.show();
|
||||
this.viewAssistant.contentEl.appendChild(this.showBackSide ? this.backContainer : this.frontContainer);
|
||||
await this.contentRenderer.render(
|
||||
parsedCard.backMarkdown.trim().length > 0 ? parsedCard.backMarkdown : "Empty back side",
|
||||
this.backContainer, {
|
||||
sourcePath: parsedCard.backID.noteID,
|
||||
});
|
||||
|
||||
this.contentRenderer.removeCustomProcessors(processors);
|
||||
|
||||
this.viewAssistant.contentEl.appendChild(this.eState.showBackSide ? this.backContainer : this.frontContainer);
|
||||
|
||||
const nextItems = this.scheduler.previewNextItem(this.reviewedItem.statistics, this.reviewDate);
|
||||
const ratingButtonsContainer = createDiv({ cls: "rating-buttons" });
|
||||
this.createRatingButtons(nextItems, ratingButtonsContainer);
|
||||
this.ratingButtonsContainer = this.viewAssistant.contentEl.appendChild(ratingButtonsContainer);
|
||||
if (!this.eState.showBackSide)
|
||||
ratingButtonsContainer.hide();
|
||||
|
||||
this.toggleAnswerButton?.show();
|
||||
|
||||
if (this.showMetadata)
|
||||
this.renderMetadata();
|
||||
|
||||
this.setRatingButtonWidths();
|
||||
}
|
||||
|
||||
private createRatingButtons(nextItems: IPreview, ratingButtonsContainer: HTMLDivElement) {
|
||||
// If width is not sufficient for four buttons on one line, this container is used to make two buttons wrap instead of one at the time.
|
||||
let pairContainer: HTMLDivElement | null = null;
|
||||
const ratingButtons: HTMLButtonElement[] = [];
|
||||
|
||||
for (const { log: nextLog, card: nextCard } of nextItems) {
|
||||
|
||||
if (ratingButtons.length % 2 == 0 || pairContainer === null)
|
||||
pairContainer = ratingButtonsContainer.createDiv({ cls: "button-pair" });
|
||||
|
||||
ratingButtons.push(pairContainer.createEl("button", undefined, (button) => {
|
||||
setTooltip(button, `${TypeConvert.time(nextCard.due).toString()}`)
|
||||
const buttonText = button.createDiv();
|
||||
buttonText.createSpan({
|
||||
text: Rating[nextLog.rating],
|
||||
});
|
||||
buttonText.createSpan({
|
||||
text: `${show_diff_message(nextCard.due, this.reviewDate, true, this.timeUnit)}`,
|
||||
});
|
||||
|
||||
this.contentRenderer.registerDomEvent(button, "click", async () => {
|
||||
if (this.reviewedItem)
|
||||
await this.rate(this.reviewedItem.id, nextLog.rating as Grade);
|
||||
})
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: unknown) {
|
||||
|
|
@ -322,14 +347,33 @@ export class ReviewView extends ItemView {
|
|||
// Use now as scheduling date rather than the date of rendering to avoid items being scheduled as due in the past, e.g., when next interval is 1 min.
|
||||
this.scheduler.rateItem(id, grade);
|
||||
this.ui.displayNotice(`You rated ${Rating[grade]}`);
|
||||
this.resetEphemeralState();
|
||||
await this.data.save(); // This will trigger a refresh via the registered change callback.
|
||||
}
|
||||
|
||||
await this.data.save(); // This will trigger a refresh
|
||||
//this.refreshView();
|
||||
private resetEphemeralState() {
|
||||
this.eState.showBackSide = false;
|
||||
this.eState.lastFrontScrollPosition = 0;
|
||||
this.eState.lastBackScrollPosition = 0;
|
||||
this.eState.currentCard = null;
|
||||
}
|
||||
|
||||
private toggleAnswer() {
|
||||
if (this.displayCard(!this.showBackSide))
|
||||
this.showBackSide = !this.showBackSide
|
||||
const scrollPosition = this.scrollPosition;
|
||||
|
||||
if (this.displayCard(!this.eState.showBackSide)) {
|
||||
this.eState.showBackSide = !this.eState.showBackSide
|
||||
|
||||
if (this.eState.showBackSide)
|
||||
this.eState.lastFrontScrollPosition = scrollPosition.top;
|
||||
else
|
||||
this.eState.lastBackScrollPosition = scrollPosition.top;
|
||||
|
||||
this.setScrollPosition({
|
||||
top: this.eState.showBackSide ? this.eState.lastBackScrollPosition : this.eState.lastFrontScrollPosition,
|
||||
behavior: "instant"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private displayCard(showFront: boolean) {
|
||||
|
|
@ -353,17 +397,51 @@ export class ReviewView extends ItemView {
|
|||
setIcon(this.toggleAnswerButton, showFront ? FRONT_ICON : BACK_ICON);
|
||||
}
|
||||
|
||||
this.setRatingButtonWidths();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the width on all rating buttons to the width of the widest button based on its content.
|
||||
*
|
||||
* - Requires that the button's initial `width` style is set to `"auto"`.
|
||||
*/
|
||||
private setRatingButtonWidths() {
|
||||
|
||||
if (!this.ratingButtonsContainer)
|
||||
return;
|
||||
|
||||
if (this.ratingButtonsContainer.dataset.didAdjustButtons)
|
||||
return;
|
||||
|
||||
// The container needs to be in the DOM otherwise widths may not be available.
|
||||
if (!this.ratingButtonsContainer.isShown())
|
||||
return;
|
||||
|
||||
const ratingButtons = this.ratingButtonsContainer.querySelectorAll("button");
|
||||
if (!ratingButtons)
|
||||
return;
|
||||
|
||||
let maxWidth = 0;
|
||||
ratingButtons.forEach((btn) => {
|
||||
const width = btn.offsetWidth;
|
||||
if (width > maxWidth)
|
||||
maxWidth = width;
|
||||
});
|
||||
ratingButtons.forEach(btn => btn.setCssProps({ "width": maxWidth + "px" }));
|
||||
|
||||
this.ratingButtonsContainer.dataset.didAdjustButtons = "yes";
|
||||
}
|
||||
|
||||
private renderMetadata() {
|
||||
if (!this.currentCard || !this.reviewedItem)
|
||||
if (!this.eState.currentCard || !this.reviewedItem)
|
||||
return;
|
||||
|
||||
const stats = this.reviewedItem.statistics;
|
||||
|
||||
const el = this.viewAssistant.contentEl.createDiv({ cls: "statistics" });
|
||||
el.createEl("span", { text: `IDs: ${this.currentCard.frontID} / ${this.currentCard.backID}` });
|
||||
el.createEl("span", { text: `IDs: ${this.eState.currentCard.frontID} / ${this.eState.currentCard.backID}` });
|
||||
el.createEl("span", { text: `State: ${State[stats.st]} (${stats.st})` });
|
||||
el.createEl("span", { text: `${this.scheduler.isStatisticsDue(stats, this.reviewDate) ? "Due now" : "Not yet due:"}: ${TypeConvert.time(stats.due).toString()}` });
|
||||
if (stats.lr) {
|
||||
|
|
@ -371,53 +449,10 @@ export class ReviewView extends ItemView {
|
|||
}
|
||||
|
||||
el.createEl("span", { text: `Retrievability: now: ${this.scheduler.retrievability(stats, this.reviewDate)}, @due: ${this.scheduler.retrievability(stats, stats.due)}` });
|
||||
el.createEl("span", { text: `Elapsed days: ${stats.ed}` });
|
||||
el.createEl("span", { text: `Learning steps: ${stats.ls}` });
|
||||
el.createEl("span", { text: `Scheduled days: ${stats.sd}` });
|
||||
el.createEl("span", { text: `Lapses: ${stats.l}` });
|
||||
}
|
||||
|
||||
private readonly timeUnit = [' sec', ' min', ' hours', ' days', ' months', ' years'];
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Markdown
|
||||
|
||||
private static async renderMarkdown(app: App, markdown: string, el: HTMLElement, filePath: string, component: Component) {
|
||||
|
||||
await MarkdownRenderer.render(app, markdown, el, filePath, component);
|
||||
|
||||
// External links works but not internal.
|
||||
// https://forum.obsidian.md/t/internal-links-dont-work-in-custom-view/90169/3
|
||||
el.querySelectorAll('a.internal-link').forEach(internalLinkEl => {
|
||||
if (internalLinkEl instanceof HTMLElement) {
|
||||
const handler = async (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
const href = (event.currentTarget as HTMLAnchorElement).getAttribute('href');
|
||||
if (href)
|
||||
app.workspace.openLinkText(href, filePath, false); // false = same tab
|
||||
};
|
||||
component.registerDomEvent(internalLinkEl, "click", handler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recycles {@link frontContainer} and {@link backContainer} and their event listeners.
|
||||
*/
|
||||
private async recycleMarkdownResources(reuseCallback?: (front: HTMLElement, back: HTMLElement, component: Component) => Promise<void>) {
|
||||
|
||||
this.frontContainer.remove();
|
||||
this.frontContainer.empty();
|
||||
this.backContainer.remove();
|
||||
this.backContainer.empty();
|
||||
|
||||
this.markdownRenderComponent?.unload();
|
||||
this.markdownRenderComponent = undefined;
|
||||
|
||||
if (reuseCallback) {
|
||||
this.markdownRenderComponent = new Component();
|
||||
this.markdownRenderComponent.load();
|
||||
await reuseCallback(this.frontContainer, this.backContainer, this.markdownRenderComponent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,24 @@
|
|||
import { Env } from "env";
|
||||
import { ItemView } from "obsidian";
|
||||
import { CssClass } from "renderings/dom-constants";
|
||||
import { MarkupAssistant } from "renderings/MarkupAssistant";
|
||||
|
||||
export class ViewAssistant {
|
||||
|
||||
private markdownViewRootEl: HTMLDivElement | undefined;
|
||||
private previewView: HTMLDivElement | undefined;
|
||||
private previewSizerEl: HTMLDivElement | undefined;
|
||||
|
||||
public init(viewRootEl: HTMLElement) {
|
||||
public init(view: ItemView) {
|
||||
this.deinit();
|
||||
|
||||
this.markdownViewRootEl = viewRootEl.createDiv({ cls: "markdown-reading-view" }, (readerViewEl) => {
|
||||
readerViewEl.createDiv({ cls: ViewAssistant.markdownPreviewClasses }, (previewViewEl) => {
|
||||
this.previewSizerEl = previewViewEl.createDiv({ cls: ViewAssistant.markdownSizer }, (el) => {
|
||||
el.createDiv({ cls: ViewAssistant.markdownPusher });
|
||||
el.createDiv({ cls: ViewAssistant.markdownMod });
|
||||
view.containerEl.addClass(CssClass.Plugin.WORKSPACE_LEAF_CONTENT);
|
||||
|
||||
this.markdownViewRootEl = view.contentEl.createDiv({ cls: CssClass.MarkdownView.READING() }, (readerViewEl) => {
|
||||
this.previewView = readerViewEl.createDiv({ cls: CssClass.MarkdownView.PREVIEW() }, (previewViewEl) => {
|
||||
this.previewSizerEl = previewViewEl.createDiv({ cls: CssClass.MarkdownView.SIZER() }, (el) => {
|
||||
el.createDiv({ cls: CssClass.MarkdownView.PUSHER() });
|
||||
el.createDiv({ cls: CssClass.MarkdownView.MOD() });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -39,48 +47,45 @@ export class ViewAssistant {
|
|||
return this.previewSizerEl;
|
||||
}
|
||||
|
||||
public createH1(o: DomElementInfo | string) {
|
||||
this.contentEl.createDiv({ cls: "el-h1" }, el => {
|
||||
el.createEl("h1", o);
|
||||
public get scrollContainer() {
|
||||
return this.previewView;
|
||||
}
|
||||
|
||||
public adjustAvailableVerticalScrolling() {
|
||||
if (!this.scrollContainer || !this.previewSizerEl)
|
||||
return;
|
||||
|
||||
const containerHeight = this.scrollContainer.clientHeight;
|
||||
const paddingBottom = Math.floor(containerHeight * 0.5);
|
||||
const minHeight = Math.floor(containerHeight * 0.53);
|
||||
|
||||
this.previewSizerEl.setCssStyles({
|
||||
"paddingBottom": `${paddingBottom}px`,
|
||||
"minHeight": `${minHeight}px`,
|
||||
});
|
||||
|
||||
Env.log.view(`ViewAssistant:adjustAvailableVerticalScrolling: \`padding-bottom\`: "${paddingBottom}px", \`min-height\`: "${minHeight}px"`);
|
||||
}
|
||||
|
||||
public createEl<K extends keyof HTMLElementTagNameMap>(tag: K, o?: DomElementInfo | string, callback?: (el: HTMLElementTagNameMap[K]) => void): HTMLElementTagNameMap[K] {
|
||||
return MarkupAssistant.createWrappedEl(this.contentEl, tag, o, callback);
|
||||
}
|
||||
|
||||
public createPara(o?: DomElementInfo | string) {
|
||||
return this.contentEl.createDiv({ cls: "el-p" }).createEl("p", o);
|
||||
return MarkupAssistant.createWrappedPara(this.contentEl, o);
|
||||
}
|
||||
|
||||
public createParaWrapper() {
|
||||
return MarkupAssistant.createElWrapper(this.contentEl, "p");
|
||||
}
|
||||
|
||||
public createTable() {
|
||||
const container = this.contentEl.createDiv({
|
||||
cls: "el-table",
|
||||
cls: MarkupAssistant.classForEl("table"),
|
||||
attr: {
|
||||
//"dir": "ltr"
|
||||
}
|
||||
});
|
||||
return container.createEl("table");
|
||||
}
|
||||
|
||||
private static readonly markdownPreviewClasses = [
|
||||
"markdown-preview-view",
|
||||
"markdown-rendered",
|
||||
"node-insert-event",
|
||||
"is-readable-line-width",
|
||||
"allow-fold-headings",
|
||||
"allow-fold-lists",
|
||||
"show-indentation-guide",
|
||||
"show-properties",
|
||||
];
|
||||
|
||||
private static readonly markdownSizer = [
|
||||
"markdown-preview-sizer",
|
||||
"markdown-preview-section",
|
||||
];
|
||||
|
||||
private static readonly markdownPusher = [
|
||||
"markdown-preview-pusher"
|
||||
];
|
||||
|
||||
private static readonly markdownMod = [
|
||||
"mod-header",
|
||||
"mod-ui"
|
||||
];
|
||||
}
|
||||
|
|
|
|||
199
styles.css
199
styles.css
|
|
@ -1,128 +1,157 @@
|
|||
|
||||
|
||||
.workspace-leaf-content[data-type='come-through-view-review'] .view-content,
|
||||
.workspace-leaf-content[data-type='come-through-view-decks'] .view-content {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
div.come-through-workspace-leaf-content .view-content {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-leaf-content[data-type='come-through-view-review'] .markdown-reading-view,
|
||||
.workspace-leaf-content[data-type='come-through-view-decks'] .markdown-reading-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* <div class="markdown-reading-view" style="width: 100%; height: 100%;"> */
|
||||
div.come-through-workspace-leaf-content .markdown-reading-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.come-through-rating-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1em;
|
||||
margin-top: 4em;
|
||||
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons {
|
||||
justify-content: center;
|
||||
margin-top: 4em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.come-through-rating-buttons button {
|
||||
line-height: 2.4;
|
||||
height: auto;
|
||||
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons,
|
||||
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons .button-pair {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.come-through-rating-buttons button div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons > div > button {
|
||||
line-height: 2.4;
|
||||
height: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.come-through-rating-buttons .rating-button-text {
|
||||
font-weight: bolder;
|
||||
/* Counter this selector: body.is-phone:not(.is-android:not(.material-off)) button:not(.clickable-icon) */
|
||||
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .view-content .rating-buttons > div > button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.come-through-rating-buttons .rating-button-interval {
|
||||
font-weight: lighter;
|
||||
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons button div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workspace-leaf-content[data-type='come-through-view-review'] .markdown-reading-view .statistics {
|
||||
background-color: var(--ribbon-background);
|
||||
padding: 1em;
|
||||
margin: 4em auto;
|
||||
font-weight: lighter;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
line-height: 200%;
|
||||
border-radius: 8px;
|
||||
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons button > div > span {
|
||||
/* display: block; */
|
||||
}
|
||||
|
||||
.block-language-comethrough,
|
||||
.block-language-ct {
|
||||
color: var(--text-normal);
|
||||
font-style: var(--font-monospace-theme);
|
||||
font-size: var(--font-text-size);
|
||||
font-weight: var(--font-weight);
|
||||
border-radius: var(--callout-radius);
|
||||
padding: var(--callout-padding);
|
||||
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons button > div > span:nth-child(1) {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.block-language-comethrough .callout-title,
|
||||
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .rating-buttons button > div > span:nth-child(2) {
|
||||
font-weight: lighter;
|
||||
}
|
||||
|
||||
div.come-through-workspace-leaf-content[data-type='come-through-view-review'] .statistics {
|
||||
background-color: var(--ribbon-background);
|
||||
padding: 1em;
|
||||
margin: 4em auto;
|
||||
font-weight: lighter;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
line-height: 200%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.block-language-comethrough,
|
||||
.block-language-ct {
|
||||
color: var(--text-normal);
|
||||
font-style: var(--font-monospace-theme);
|
||||
font-size: var(--font-text-size);
|
||||
font-weight: var(--font-weight);
|
||||
border-radius: var(--callout-radius);
|
||||
padding: var(--callout-padding);
|
||||
}
|
||||
|
||||
.block-language-comethrough .callout-title,
|
||||
.block-language-ct .callout-title {
|
||||
padding: var(--callout-title-padding);
|
||||
padding: var(--callout-title-padding);
|
||||
}
|
||||
|
||||
.block-language-comethrough .callout-content,
|
||||
.block-language-ct .callout-content {
|
||||
padding: var(--callout-content-padding);
|
||||
.block-language-comethrough .callout-content,
|
||||
.block-language-ct .callout-content {
|
||||
padding: var(--callout-content-padding);
|
||||
}
|
||||
|
||||
.callout.block-language-comethrough.error,
|
||||
.callout.block-language-comethrough.error,
|
||||
.callout.block-language-ct.error {
|
||||
background-color: rgba(var(--color-red-rgb), 0.1);
|
||||
border-width: var(--border-width);
|
||||
border-color: var(--color-red);
|
||||
background-color: rgba(var(--color-red-rgb), 0.1);
|
||||
border-width: var(--border-width);
|
||||
border-color: var(--color-red);
|
||||
}
|
||||
|
||||
.callout.block-language-comethrough .callout-title.error, .callout.block-language-comethrough .callout-title.error .svg-icon,
|
||||
.callout.block-language-ct .callout-title.error, .callout.block-language-ct .callout-title.error .svg-icon {
|
||||
color: var(--color-red);
|
||||
.callout.block-language-comethrough .callout-title.error,
|
||||
.callout.block-language-comethrough .callout-title.error .svg-icon,
|
||||
.callout.block-language-ct .callout-title.error,
|
||||
.callout.block-language-ct .callout-title.error .svg-icon {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.block-language-comethrough table,
|
||||
.block-language-comethrough table,
|
||||
.block-language-ct table {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
table-layout: auto;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.block-language-ct table th:first-child,
|
||||
.block-language-ct table td:first-child {
|
||||
width: 40%;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.block-language-comethrough td.select-deck-cell,
|
||||
.block-language-comethrough td.select-deck-cell,
|
||||
.block-language-ct td.select-deck-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.block-language-comethrough td.select-deck-cell select,
|
||||
.block-language-comethrough td.select-deck-cell select,
|
||||
.block-language-ct td.select-deck-cell select {
|
||||
flex-grow: 1;
|
||||
background-color: inherit;
|
||||
box-shadow: none;
|
||||
font-style: inherit;
|
||||
font-size: inherit;
|
||||
padding-left: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
height: auto;
|
||||
flex-grow: 1;
|
||||
background-color: inherit;
|
||||
box-shadow: none;
|
||||
font-style: inherit;
|
||||
font-size: inherit;
|
||||
padding-left: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.block-language-comethrough td.select-deck-cell button,
|
||||
.block-language-ct td.select-deck-cell button {
|
||||
padding: 0;
|
||||
height: auto;
|
||||
width: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-color: inherit;
|
||||
box-shadow: none;
|
||||
}
|
||||
padding: 0;
|
||||
height: auto;
|
||||
width: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-color: inherit;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
div.come-through-workspace-leaf-content[data-type='come-through-view-defined-content'] .markdown-reading-view details {
|
||||
margin-block-start: var(--p-spacing);
|
||||
}
|
||||
|
||||
div.come-through-workspace-leaf-content[data-type='come-through-view-defined-content'] .markdown-reading-view summary {
|
||||
cursor: pointer;
|
||||
color: var(--h2-color);
|
||||
font: var(--h2-font);
|
||||
line-height: var(--h2-line-height);
|
||||
font-size: var(--h2-size);
|
||||
font-style: var(--h2-style);
|
||||
font-variant: var(--h2-variant);
|
||||
font-weight: var(--h2-weight);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"0.5.0": "1.8.0",
|
||||
"0.4.2": "1.8.0",
|
||||
"0.4.1": "1.8.0",
|
||||
"0.4.0": "1.8.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue