mirror of
https://github.com/mntno/obsidian-come-down.git
synced 2026-07-22 05:43:15 +00:00
1.0.0
This commit is contained in:
parent
7d6a8e8c6d
commit
3bae9d6ff9
17 changed files with 1791 additions and 855 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,6 +8,7 @@ node_modules
|
|||
.hotreload
|
||||
|
||||
main.js
|
||||
cache.json
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
|
|
|||
22
LICENSE
22
LICENSE
|
|
@ -1,5 +1,21 @@
|
|||
Copyright (C) 2025 by Mantano Bhikkhu.
|
||||
MIT License
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
|
||||
Copyright (c) 2025 Mantano Bhikkhu
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
49
README.md
49
README.md
|
|
@ -1,16 +1,53 @@
|
|||
# Come Down
|
||||
|
||||
An Obsidian plugin that caches embedded external images to your local device.
|
||||
An [Obsidian](https://obsidian.md) plugin that downloads external images embedded in your notes, allowing them to be reused when you reopen the note. Once downloaded (cached), images will load if you’re offline or if you open a synced copy of your vault on another device. The plugin doesn’t modify your notes, so if you disable or uninstall it, everything will work as it did before.
|
||||
|
||||
## How to Use
|
||||
|
||||
Embed external images as you normally would using a Markdown link. Here’s an example from [Obsidian Help](https://help.obsidian.md/embeds#Embed+an+image+in+a+note):
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
When the plugin detects an external image URL, it takes over the downloading process. Once the image is saved, the plugin will automatically load it from the cached file instead of the original URL. The next time you open the note, the cached version will be used.
|
||||
|
||||
If your vault is synced through a file-syncing service like iCloud Drive or Dropbox (which treats your vault as a regular folder with files[^1]), cached images will sync across devices.
|
||||
|
||||
However, if your vault is backed up to or synced via Git (e.g., GitHub, GitLab), the cache won’t be included. This is intentional for two reasons: First, Git is designed for versioning plain-text files, not storing binary data like images. Second, hosting services like GitHub and GitLab are meant for managing code and text-based content, not for storing personal images like a photo service.
|
||||
|
||||
## Further Details
|
||||
|
||||
### Cache Location
|
||||
|
||||
Having the cache located inside the plugin’s folder serves several purposes:
|
||||
|
||||
- It keeps the cache hidden from view while browsing the vault.
|
||||
- It ensures the cache syncs along with the rest of the vault if stored in iCloud Drive or another file-syncing service that syncs the entire vault.[^1]
|
||||
- It allows the cache to be automatically deleted if the plugin is uninstalled.
|
||||
- A .gitignore file can be placed in the cache folder to exclude it from Git. Had it been in a visible folder, users might mistakenly think that files they add there would be committed.
|
||||
|
||||
### Embedded Images Are Already Cached
|
||||
|
||||
Obsidian is built on Electron, which embeds the Chromium web browser. Therefore, it has built-in caching just as any web browser. But this cache exists outside your vault.
|
||||
|
||||
For example, if you copied your vault's root folder to a USB memory and opened it on another device, all embedded images would need to be downloaded again as each note is opened. Further, if that device were offline, they wouldn’t load at all.
|
||||
|
||||
### Disabling
|
||||
|
||||
If the plugin is disabled, everything will work as it did before the plugin was enabled — the embedded images will load by means of the underlying browser. Upon enabling it again, the plugin's already cached items will be used, bypassing the browser.
|
||||
|
||||
Note that if an embedded image is removed from a note while the plugin is disabled, its potential cached file will not be removed until that specific note is opened again with the plugin enabled. If you want to disable the plugin you may delete the cache first from settings.
|
||||
|
||||
[^1]: This excludes [Obsidian Sync](https://obsidian.md/sync).
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
The cache is located within the plugin's folder, so the cached files:
|
||||
|
||||
- will sync along with the rest of the vault if the vault is in iCloud Drive or any other file-syncing service that syncs the entire vault
|
||||
- will be excluded from Git because a `.gitignore` file is placed in the cache folder
|
||||
- will not sync with [Obsidian Sync](https://obsidian.md/sync)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
49
THIRD_PARTY_NOTICES
Normal file
49
THIRD_PARTY_NOTICES
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
THIRD-PARTY NOTICES
|
||||
|
||||
This project’s functionality relies on the following third-party dependencies, each with its own license:
|
||||
|
||||
License notice for image-size
|
||||
-----------------------------
|
||||
|
||||
https://github.com/image-size/image-size/blob/main/LICENSE
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright © 2013-Present Aditya Yadav, http://netroy.in
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
License notice for xxhash-wasm
|
||||
------------------------------
|
||||
|
||||
https://github.com/jungomi/xxhash-wasm/blob/main/LICENSE.md
|
||||
|
||||
The MIT License (MIT)
|
||||
=====================
|
||||
|
||||
Copyright © 2017 Michael Jungo
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the “Software”), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
"name": "Come Down",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "1.8.0",
|
||||
"description": "Caches embedded external images to your local device without modifying your notes.",
|
||||
"description": "Maintains a cache of your notes’ embedded external images.",
|
||||
"author": "mntno",
|
||||
"authorUrl": "https://github.com/mntno",
|
||||
"isDesktopOnly": false
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -10,10 +10,8 @@
|
|||
},
|
||||
"keywords": [],
|
||||
"author": "mntno",
|
||||
"license": "0BSD",
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "^6.10.8",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@codemirror/view": "^6.36.4",
|
||||
"@eslint/js": "9.21.0",
|
||||
"@lezer/common": "^1.2.3",
|
||||
|
|
@ -23,12 +21,12 @@
|
|||
"builtin-modules": "5.0.0",
|
||||
"esbuild": "0.25.0",
|
||||
"globals": "16.0.0",
|
||||
"image-size": "^2.0.1",
|
||||
"obsidian": "latest",
|
||||
"ts-md5": "^1.3.1",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "5.8.2",
|
||||
"typescript-eslint": "8.26.0",
|
||||
"image-size": "^2.0.0"
|
||||
"xxhash-wasm": "1.1.0"
|
||||
},
|
||||
"type": "module",
|
||||
"pnpm": {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,6 @@ importers:
|
|||
|
||||
.:
|
||||
devDependencies:
|
||||
'@codemirror/language':
|
||||
specifier: ^6.10.8
|
||||
version: 6.10.8
|
||||
'@codemirror/state':
|
||||
specifier: ^6.5.2
|
||||
version: 6.5.2
|
||||
'@codemirror/view':
|
||||
specifier: ^6.36.4
|
||||
version: 6.36.4
|
||||
|
|
@ -42,14 +36,11 @@ importers:
|
|||
specifier: 16.0.0
|
||||
version: 16.0.0
|
||||
image-size:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
obsidian:
|
||||
specifier: latest
|
||||
version: 1.8.7(@codemirror/state@6.5.2)(@codemirror/view@6.36.4)
|
||||
ts-md5:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
tslib:
|
||||
specifier: 2.8.1
|
||||
version: 2.8.1
|
||||
|
|
@ -59,12 +50,12 @@ importers:
|
|||
typescript-eslint:
|
||||
specifier: 8.26.0
|
||||
version: 8.26.0(eslint@9.21.0)(typescript@5.8.2)
|
||||
xxhash-wasm:
|
||||
specifier: 1.1.0
|
||||
version: 1.1.0
|
||||
|
||||
packages:
|
||||
|
||||
'@codemirror/language@6.10.8':
|
||||
resolution: {integrity: sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==}
|
||||
|
||||
'@codemirror/state@6.5.2':
|
||||
resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
|
||||
|
||||
|
|
@ -221,8 +212,8 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@eslint-community/eslint-utils@4.4.1':
|
||||
resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==}
|
||||
'@eslint-community/eslint-utils@4.5.1':
|
||||
resolution: {integrity: sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
|
|
@ -278,12 +269,6 @@ packages:
|
|||
'@lezer/common@1.2.3':
|
||||
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
|
||||
|
||||
'@lezer/highlight@1.2.1':
|
||||
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
|
||||
|
||||
'@lezer/lr@1.4.2':
|
||||
resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2':
|
||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||
|
||||
|
|
@ -441,8 +426,8 @@ packages:
|
|||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
eslint-scope@8.2.0:
|
||||
resolution: {integrity: sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==}
|
||||
eslint-scope@8.3.0:
|
||||
resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
eslint-visitor-keys@3.4.3:
|
||||
|
|
@ -545,8 +530,8 @@ packages:
|
|||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
image-size@2.0.0:
|
||||
resolution: {integrity: sha512-HP07n1SpdIXGUL4VotUIOQz66MQOq8g7VN+Yj02YTVowqZScQ5i/JYU0+lkNr2pwt5j4hOpk94/UBV1ZCbS2fA==}
|
||||
image-size@2.0.1:
|
||||
resolution: {integrity: sha512-NI6NK/2zchlZopsQrcVIS7jxA0/rtIy74B+/rx5s7rKQyFebmQjZVhzxXgRZJROk+WhhOq+S6sUaODxp0L5hfg==}
|
||||
engines: {node: '>=16.x'}
|
||||
hasBin: true
|
||||
|
||||
|
|
@ -714,10 +699,6 @@ packages:
|
|||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
ts-md5@1.3.1:
|
||||
resolution: {integrity: sha512-DiwiXfwvcTeZ5wCE0z+2A9EseZsztaiZtGrtSaY5JOD7ekPnR/GoIVD5gXZAlK9Na9Kvpo9Waz5rW64WKAWApg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
|
|
@ -755,21 +736,15 @@ packages:
|
|||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
xxhash-wasm@1.1.0:
|
||||
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@codemirror/language@6.10.8':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.36.4
|
||||
'@lezer/common': 1.2.3
|
||||
'@lezer/highlight': 1.2.1
|
||||
'@lezer/lr': 1.4.2
|
||||
style-mod: 4.1.2
|
||||
|
||||
'@codemirror/state@6.5.2':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.2
|
||||
|
|
@ -855,7 +830,7 @@ snapshots:
|
|||
'@esbuild/win32-x64@0.25.0':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.4.1(eslint@9.21.0)':
|
||||
'@eslint-community/eslint-utils@4.5.1(eslint@9.21.0)':
|
||||
dependencies:
|
||||
eslint: 9.21.0
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
|
@ -912,14 +887,6 @@ snapshots:
|
|||
|
||||
'@lezer/common@1.2.3': {}
|
||||
|
||||
'@lezer/highlight@1.2.1':
|
||||
dependencies:
|
||||
'@lezer/common': 1.2.3
|
||||
|
||||
'@lezer/lr@1.4.2':
|
||||
dependencies:
|
||||
'@lezer/common': 1.2.3
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
|
|
@ -1013,7 +980,7 @@ snapshots:
|
|||
|
||||
'@typescript-eslint/utils@8.26.0(eslint@9.21.0)(typescript@5.8.2)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.4.1(eslint@9.21.0)
|
||||
'@eslint-community/eslint-utils': 4.5.1(eslint@9.21.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)
|
||||
|
|
@ -1120,7 +1087,7 @@ snapshots:
|
|||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-scope@8.2.0:
|
||||
eslint-scope@8.3.0:
|
||||
dependencies:
|
||||
esrecurse: 4.3.0
|
||||
estraverse: 5.3.0
|
||||
|
|
@ -1131,7 +1098,7 @@ snapshots:
|
|||
|
||||
eslint@9.21.0:
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.4.1(eslint@9.21.0)
|
||||
'@eslint-community/eslint-utils': 4.5.1(eslint@9.21.0)
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
'@eslint/config-array': 0.19.2
|
||||
'@eslint/core': 0.12.0
|
||||
|
|
@ -1148,7 +1115,7 @@ snapshots:
|
|||
cross-spawn: 7.0.6
|
||||
debug: 4.4.0
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.2.0
|
||||
eslint-scope: 8.3.0
|
||||
eslint-visitor-keys: 4.2.0
|
||||
espree: 10.3.0
|
||||
esquery: 1.6.0
|
||||
|
|
@ -1242,7 +1209,7 @@ snapshots:
|
|||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
image-size@2.0.0: {}
|
||||
image-size@2.0.1: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
|
|
@ -1379,8 +1346,6 @@ snapshots:
|
|||
dependencies:
|
||||
typescript: 5.8.2
|
||||
|
||||
ts-md5@1.3.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
|
|
@ -1413,4 +1378,6 @@ snapshots:
|
|||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
xxhash-wasm@1.1.0: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,51 +1,11 @@
|
|||
|
||||
/**
|
||||
* - All datetimes are stored in ISO 8601, e.g., "2023-10-27T10:30:00Z".
|
||||
*/
|
||||
export interface CacheMetadata {
|
||||
type: CacheType;
|
||||
file: CacheMetadataFile;
|
||||
hash: CacheMetadataHash;
|
||||
image?: CacheMetadataImage;
|
||||
retainer: { [key: string]: CacheRetainer };
|
||||
time: CacheMetadataTime;
|
||||
export interface CacheRoot {
|
||||
retainers: Record<string, CacheRetainer>
|
||||
items: Record<string, CacheMetadata>;
|
||||
}
|
||||
|
||||
export const enum CacheType {
|
||||
UNDEFINED = 0,
|
||||
IMAGE = 1,
|
||||
};
|
||||
|
||||
export interface CacheMetadataFile {
|
||||
src: string;
|
||||
name: string;
|
||||
/** If empty string, then no extension. */
|
||||
ext: string;
|
||||
size: number;
|
||||
|
||||
/**
|
||||
* Content-Type form HTTP header.
|
||||
*/
|
||||
ct?: string;
|
||||
}
|
||||
|
||||
export interface CacheMetadataHash {
|
||||
/** Which has to use as the key. Set to "keyMD5". */
|
||||
key: string;
|
||||
/** The hash that is used as a key to identify each cache item. The cached files and their metadata also have this as their filename. */
|
||||
keyMD5: string;
|
||||
cntMD5: string;
|
||||
}
|
||||
|
||||
export function GetCacheKey(hash: CacheMetadataHash) {
|
||||
return hash[hash.key as keyof CacheMetadataHash];
|
||||
}
|
||||
|
||||
export interface CacheMetadataImage {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Type as parsed from image data by [image-size](https://github.com/image-size/image-size). If empty string, then not determined. */
|
||||
type: string;
|
||||
export const EMPTY_CACHE_ROOT: CacheRoot = {
|
||||
retainers: {},
|
||||
items: {},
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -53,10 +13,71 @@ export interface CacheMetadataImage {
|
|||
* The cache should only be remove when there are no "retainers".
|
||||
*/
|
||||
export interface CacheRetainer {
|
||||
|
||||
/** Cache items referenced, i.e., retained. */
|
||||
ref: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* - All datetimes are stored in ISO 8601, e.g., "2023-10-27T10:30:00Z".
|
||||
*/
|
||||
export interface CacheMetadata {
|
||||
ty: CacheType;
|
||||
|
||||
f: CacheMetadataFile;
|
||||
|
||||
i?: CacheMetadataImage;
|
||||
|
||||
ti: CacheMetadataTime;
|
||||
}
|
||||
|
||||
export const enum CacheType {
|
||||
UNDEFINED = 0,
|
||||
IMAGE = 1,
|
||||
};
|
||||
|
||||
/** Common things about a file and its content. */
|
||||
export interface CacheMetadataFile {
|
||||
/** src */
|
||||
s: string;
|
||||
|
||||
/** name */
|
||||
n: string;
|
||||
|
||||
/**
|
||||
* ext
|
||||
* If empty string, then no extension.
|
||||
*/
|
||||
e: string;
|
||||
|
||||
/** size in bytes */
|
||||
sz: number;
|
||||
|
||||
/**
|
||||
* Content-Type from the HTTP response header.
|
||||
*/
|
||||
ct?: string;
|
||||
|
||||
/** xxHash of the file's content. */
|
||||
ch: string;
|
||||
}
|
||||
|
||||
/** Image specific */
|
||||
export interface CacheMetadataImage {
|
||||
w: number;
|
||||
h: number;
|
||||
/** Type as parsed from image data by [image-size](https://github.com/image-size/image-size). If empty string, then not determined. */
|
||||
t: string;
|
||||
}
|
||||
|
||||
/** Time related */
|
||||
export interface CacheMetadataTime {
|
||||
download: string;
|
||||
lastAccess: string;
|
||||
|
||||
/** Download time */
|
||||
d: string;
|
||||
|
||||
/** Last accessed */
|
||||
l: string;
|
||||
|
||||
/** Value of the `Cache-Control` HTTP response header. */
|
||||
cc?: string;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Notice as ObsidianNotice } from "obsidian";
|
||||
import { App, Notice as ObsidianNotice, Platform } from "obsidian";
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
|
|
@ -10,13 +10,13 @@ export const ENV = {
|
|||
|
||||
export const Log = ENV.debugLog
|
||||
? (msg: string, error?: Error): void => {
|
||||
if (error) {
|
||||
console.log(msg, error);
|
||||
} else {
|
||||
console.log(msg);
|
||||
}
|
||||
if (error) {
|
||||
console.log(msg, error);
|
||||
} else {
|
||||
console.log(msg);
|
||||
}
|
||||
: (): void => {};
|
||||
}
|
||||
: (): void => { };
|
||||
|
||||
export class Notice {
|
||||
private innerNotice: ObsidianNotice;
|
||||
|
|
@ -30,3 +30,18 @@ export class Notice {
|
|||
this.innerNotice = new ObsidianNotice(`${omit ? "" : `${Notice.NAME}: `}${msg}`, duration);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearBrowserCache(appOrCallback: (() => void) | App) {
|
||||
if (ENV.dev && Platform.isDesktopApp) {
|
||||
require('electron').remote.session.defaultSession.clearCache()
|
||||
.then(() => {
|
||||
if (appOrCallback instanceof App) {
|
||||
// @ts-expect-error
|
||||
app.commands.executeCommandById("app:reload");
|
||||
}
|
||||
else
|
||||
appOrCallback();
|
||||
})
|
||||
.catch((error: any) => console.error('Error clearing cache:', error));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,68 @@
|
|||
import { ENV, Log } from "./Environment";
|
||||
import { ENV, Log } from "Environment";
|
||||
import { Url } from "Url";
|
||||
|
||||
export const enum HTMLElementCacheState {
|
||||
/** Untouched by the plugin. */
|
||||
ORIGINAL = 0,
|
||||
/** The element has been seen. The original src/url has been removed. */
|
||||
ORIGINAL_CANCELLED,
|
||||
/** Waiting for cache. Note that the src attrib might be set to an SVG here. */
|
||||
CACHE_REQUESTED,
|
||||
|
||||
/**
|
||||
* The original src has been removed. Element is ready to request.
|
||||
*
|
||||
* A `data` attribute has been set on the element with the original src, get it with {@link HtmlAssistant.originalSrc}.
|
||||
*/
|
||||
ORIGINAL_SRC_REMOVED,
|
||||
|
||||
/**
|
||||
* Requesting cache. If cache found, will be changed to {@link CACHE_SUCCEEDED}; if not, to {@link REQUESTING_DOWNLOADING}.
|
||||
*/
|
||||
REQUESTING,
|
||||
|
||||
/**
|
||||
* Cache item was not found. Downloading.
|
||||
*/
|
||||
REQUESTING_DOWNLOADING,
|
||||
|
||||
/**
|
||||
* The element's src is now pointing to the locally cached item, whether it was fetched from cache or downloaded.
|
||||
*/
|
||||
CACHE_SUCCEEDED,
|
||||
/** Cache was requested but failed. Note that the src attrib might be set to an SVG here. */
|
||||
|
||||
/**
|
||||
* Cache was requested but failed. As opposed to {@link INVALID}, these can be retried.
|
||||
* Basically equal to {@link ORIGINAL_SRC_REMOVED} except that they have requested at least once.
|
||||
*
|
||||
* For example, connection failed.
|
||||
*/
|
||||
CACHE_FAILED,
|
||||
|
||||
/**
|
||||
* For example, the url doesn't exist (404).
|
||||
*/
|
||||
INVALID,
|
||||
}
|
||||
|
||||
export enum HTMLElementAttribute {
|
||||
export const enum HTMLElementAttribute {
|
||||
SRC = "src",
|
||||
ALT = "alt",
|
||||
}
|
||||
};
|
||||
|
||||
export class HtmlAssistant {
|
||||
|
||||
public static isCacheState(element: HTMLElement, state: HTMLElementCacheState): boolean {
|
||||
return this.cacheState(element) == state;
|
||||
public static isElementCacheStateEqual(element: HTMLElement, states: HTMLElementCacheState[]): boolean {
|
||||
return this.isCacheStateEqual(this.cacheState(element), states);
|
||||
}
|
||||
|
||||
public static isCacheStateEqual(state: HTMLElementCacheState, states: HTMLElementCacheState[]): boolean {
|
||||
for (const stateToCheck of states)
|
||||
if (stateToCheck == state)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param element
|
||||
* @returns If {@link element} is lacking a state, {@link HTMLElementCacheState.ORIGINAL} is returned.
|
||||
*/
|
||||
public static cacheState(element: HTMLElement): HTMLElementCacheState {
|
||||
const state = element.dataset.comeDownState;
|
||||
|
||||
|
|
@ -33,10 +73,12 @@ export class HtmlAssistant {
|
|||
|
||||
if (
|
||||
numericState !== HTMLElementCacheState.ORIGINAL &&
|
||||
numericState !== HTMLElementCacheState.ORIGINAL_CANCELLED &&
|
||||
numericState !== HTMLElementCacheState.CACHE_REQUESTED &&
|
||||
numericState !== HTMLElementCacheState.ORIGINAL_SRC_REMOVED &&
|
||||
numericState !== HTMLElementCacheState.REQUESTING &&
|
||||
numericState !== HTMLElementCacheState.REQUESTING_DOWNLOADING &&
|
||||
numericState !== HTMLElementCacheState.CACHE_SUCCEEDED &&
|
||||
numericState !== HTMLElementCacheState.CACHE_FAILED
|
||||
numericState !== HTMLElementCacheState.CACHE_FAILED &&
|
||||
numericState !== HTMLElementCacheState.INVALID
|
||||
) {
|
||||
throw new Error(`Invalid cache state: ${state}`);
|
||||
}
|
||||
|
|
@ -48,64 +90,104 @@ export class HtmlAssistant {
|
|||
element.dataset.comeDownState = state.toString();
|
||||
}
|
||||
|
||||
public static setSuccess(element: HTMLElement, src: string) {
|
||||
HtmlAssistant.setCacheState(element, HTMLElementCacheState.CACHE_SUCCEEDED)
|
||||
if (element instanceof HTMLImageElement)
|
||||
element.setAttribute(HTMLElementAttribute.SRC, src);
|
||||
/**
|
||||
* Methods returns before the images have loaded but guarantees that they will load
|
||||
* unless the returned result's `error` is set.
|
||||
*
|
||||
* @param imageElements
|
||||
* @param src
|
||||
* @returns Assume file not found when an {@link Error} is returned, see {@link createBlobObjectUrl}.
|
||||
*/
|
||||
public static async loadImages(imageElements: HTMLImageElement[], src: string) {
|
||||
const result = await this.createBlobObjectUrl(src);
|
||||
|
||||
if (result instanceof Error) {
|
||||
return {
|
||||
error: result,
|
||||
fileNotFound: result instanceof HtmlAssistantFileNotFoundError,
|
||||
};
|
||||
}
|
||||
else {
|
||||
let remainingLoads = imageElements.length;
|
||||
|
||||
const onLoad = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.removeEventListener("load", onLoad);
|
||||
|
||||
remainingLoads--;
|
||||
if (remainingLoads === 0) {
|
||||
URL.revokeObjectURL(result);
|
||||
}
|
||||
};
|
||||
|
||||
for (const imageElement of imageElements) {
|
||||
imageElement.addEventListener("load", onLoad);
|
||||
imageElement.setAttribute(HTMLElementAttribute.SRC, result);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all the data sets from the element.
|
||||
* @param element
|
||||
*/
|
||||
public static resetElement(element: HTMLElement) {
|
||||
delete element.dataset.comeDownOriginalSource;
|
||||
delete element.dataset.comeDownState;
|
||||
}
|
||||
|
||||
public static setFailed(element: HTMLElement) {
|
||||
this.setCacheState(element, HTMLElementCacheState.CACHE_FAILED);
|
||||
if (element instanceof HTMLImageElement)
|
||||
this.setIcon(element, HtmlAssistant.ENCODED_FAILED_ICON);
|
||||
this.setIcon(element, this.SVG_FAILED_ICON_BLOB);
|
||||
}
|
||||
|
||||
public static setInvalid(element: HTMLElement) {
|
||||
this.setCacheState(element, HTMLElementCacheState.INVALID);
|
||||
if (element instanceof HTMLImageElement)
|
||||
this.setIcon(element, this.SVG_FAILED_ICON_BLOB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the `src` attribute to prevent ordinary loading.
|
||||
* Use {@link imageElementOriginalSrc} to get it `src` when needed.
|
||||
* @param imageElements
|
||||
* @returns The {@link imageElements} passed in for chaining.
|
||||
* - Removes the `src` attribute to prevent ordinary loading.
|
||||
* - Sets the state to {@link HTMLElementCacheState.ORIGINAL_SRC_REMOVED}.
|
||||
* - Will not do anything if: `src` attrib is missing or empty string; if the original src already has been set to the data set.
|
||||
*
|
||||
* @param imageElements
|
||||
*/
|
||||
public static preventImageLoading(imageElements: HTMLImageElement[]): HTMLImageElement[] {
|
||||
Log(`preventImageLoading: ${imageElements.length} image elements`);
|
||||
public static cancelImageLoading(imageElements: HTMLImageElement[]) {
|
||||
|
||||
const preventOnImage = (imageElement: HTMLImageElement): boolean => {
|
||||
if (!imageElement.hasAttribute(HTMLElementAttribute.SRC))
|
||||
const cancelImageLoad = (imageElement: HTMLImageElement): boolean => {
|
||||
|
||||
// Only continue if there's something to cancel.
|
||||
const src = imageElement.getAttribute(HTMLElementAttribute.SRC);
|
||||
if (!src) // Empty string is falsy.
|
||||
return false;
|
||||
|
||||
// TODO: Setting src="" immediately results in its value set to `app://obsidian.md/index.html`
|
||||
if (imageElement.dataset.comeDownOriginalSource) {
|
||||
Log(`\tAlready prevented. Skipping.`)
|
||||
return false;
|
||||
if (ENV.debugLog && src && Url.isBlob(src)) {
|
||||
console.warn(`cancelImageLoading: Setting dataset to blob.`);
|
||||
}
|
||||
|
||||
const src = imageElement.src;
|
||||
|
||||
if (src == "") {
|
||||
delete imageElement.dataset.comeDownOriginalSource;
|
||||
return false;
|
||||
}
|
||||
|
||||
HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.ORIGINAL_CANCELLED); // TODO: Neeed? As dataset will be set at the same time
|
||||
imageElement.dataset.comeDownOriginalSource = src;
|
||||
|
||||
// TODO: Setting src="" immediately results in its value set to `app://obsidian.md/index.html`
|
||||
//imageElement.src = "";
|
||||
|
||||
//Log(`\tRemoving src="...${imageElement.src.slice(-50)}"`);
|
||||
imageElement.removeAttribute(HTMLElementAttribute.SRC);
|
||||
HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.ORIGINAL_SRC_REMOVED);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
let counter = 0;
|
||||
imageElements.forEach((imageElement) => {
|
||||
//Log(`\tsrc: ${imageElement.src}, len:${imageElement.src.length}`);
|
||||
|
||||
if (!preventOnImage(imageElement) && ENV.dev && imageElement.hasAttribute(HTMLElementAttribute.SRC))
|
||||
Log(`\tSrc: ${imageElement.src}, len:${imageElement.src.length}`);
|
||||
if (cancelImageLoad(imageElement))
|
||||
counter++;
|
||||
});
|
||||
|
||||
return imageElements;
|
||||
Log(`cancelImageLoading: Cancelled ${counter} of ${imageElements.length}.`);
|
||||
}
|
||||
|
||||
public static originalSrc(element: HTMLImageElement) {
|
||||
return element.dataset.comeDownOriginalSource ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -142,73 +224,29 @@ export class HtmlAssistant {
|
|||
return null;*/
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Finds all image elements that are of interest
|
||||
* 2. Discards those already known.
|
||||
* 3. Calls {@link preventImageLoading} on the remaining.
|
||||
*
|
||||
* @param element Element to start searching from.
|
||||
* @returns
|
||||
*/
|
||||
public static preparedImageElements(imageElements: HTMLImageElement[]): HTMLImageElement[] {
|
||||
if (imageElements.length == 0)
|
||||
return imageElements;
|
||||
|
||||
Log(`preparedImageElements`);
|
||||
|
||||
let processed = 0;
|
||||
let original = 0;
|
||||
|
||||
const filtered = imageElements.filter((imageElement) => {
|
||||
switch (this.cacheState(imageElement)) {
|
||||
// These are new
|
||||
case HTMLElementCacheState.ORIGINAL: {
|
||||
//Log(`\tState ORIGINAL`);
|
||||
//Log(`\t\t${imageElement.outerHTML}`);
|
||||
original++;
|
||||
return true;
|
||||
}
|
||||
|
||||
// These have passed this stage
|
||||
case HTMLElementCacheState.ORIGINAL_CANCELLED:
|
||||
case HTMLElementCacheState.CACHE_REQUESTED:
|
||||
case HTMLElementCacheState.CACHE_SUCCEEDED: {
|
||||
processed++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// What TODO:
|
||||
case HTMLElementCacheState.CACHE_FAILED:
|
||||
return false;
|
||||
|
||||
default:
|
||||
throw new Error(`Unhandled cache state.`);
|
||||
}
|
||||
});
|
||||
|
||||
Log(`\tTotal: ${imageElements.length}, original: ${original}, processed: ${processed}`);
|
||||
|
||||
return filtered.length > 0 ? HtmlAssistant.preventImageLoading(filtered) : filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* - 'img' selects all <img> elements.
|
||||
* - [src] selects only <img> elements that have a src attribute.
|
||||
* - :not([aria-hidden="true"]) filters out <img> elements that have the aria-hidden="true" attribute.
|
||||
*
|
||||
* @param element Root element
|
||||
* @param requireSrcAttribute
|
||||
* @returns
|
||||
*/
|
||||
public static findAllRelevantImages(element: HTMLElement, requireSrcAttribute: boolean = true): HTMLImageElement[] {
|
||||
* This method is intended for finding unprocessed elements.
|
||||
*
|
||||
* - 'img' selects all <img> elements.
|
||||
* - [src] selects only <img> elements that have a src attribute.
|
||||
* - :not([aria-hidden="true"]) filters out <img> elements that have the aria-hidden="true" attribute.
|
||||
*
|
||||
* @param element
|
||||
* @param requireSrcAttribute If `true` will not return image elements that don't have the `src` attribute.
|
||||
* @param filter
|
||||
* @returns
|
||||
*/
|
||||
public static findRelevantImagesToProcess(element: HTMLElement, requireSrcAttribute: boolean = true, filter?: (imageElement: HTMLImageElement) => boolean): HTMLImageElement[] {
|
||||
let imageElements;
|
||||
if (requireSrcAttribute)
|
||||
return element.findAll('img[src]:not([aria-hidden="true"])') as HTMLImageElement[];
|
||||
imageElements = element.findAll('img[src]:not([aria-hidden="true"])') as HTMLImageElement[];
|
||||
else
|
||||
return element.findAll('img:not([aria-hidden="true"])') as HTMLImageElement[];
|
||||
imageElements = element.findAll('img:not([aria-hidden="true"])') as HTMLImageElement[];
|
||||
|
||||
/*
|
||||
Gave up with this. Not sure if it's more efficient than view.contentDOM.findAll.
|
||||
return filter ? imageElements.filter((imageElement) => filter(imageElement)) : imageElements;
|
||||
|
||||
/*
|
||||
syntaxTree(view.state).iterate({
|
||||
enter: ({ type, from, to }: SyntaxNodeRef) => {
|
||||
console.log(`${view.state.doc.sliceString(from, to)}`);
|
||||
|
|
@ -217,39 +255,85 @@ export class HtmlAssistant {
|
|||
*/
|
||||
}
|
||||
|
||||
public static setIcon(imageElement: HTMLImageElement, engodedSvg: string) {
|
||||
imageElement.setAttribute(HTMLElementAttribute.SRC, `data:image/svg+xml;charset=utf-8,${engodedSvg}`);
|
||||
/**
|
||||
*
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/File_API/Using_files_from_web_applications#using_object_urls|Using object URLs}
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* If {@link src} points to a local file that doesn't exist, the Dev Tools will show "net::ERR_FILE_NOT_FOUND".
|
||||
* The net::ERR_FILE_NOT_FOUND message you see in the console comes from the browser’s internal networking layer, only shown in DevTools, not available to JavaScript code.
|
||||
* However, in Electron and Chrome-based browsers, fetch() throwing a TypeError generally means the file doesn’t exist.
|
||||
*
|
||||
* Capacitor also, which runs on the native mobile webview, also throws a `TypeError` on iOS (not tested on Android).
|
||||
*
|
||||
* To be safe, assume all `Error`s is file not found.
|
||||
*
|
||||
* @param src Url to a local resource. Would start with `app://`, `capacitor://`, or perhaps `file://`
|
||||
* @returns On failure, a {@link HtmlAssistantFileNotFoundError} if it thinks the a give local file doesn't exist; otherwise a normal Error.
|
||||
*/
|
||||
public static async createBlobObjectUrl(src: string): Promise<string | Error> {
|
||||
let response;
|
||||
|
||||
try {
|
||||
// Response from `requestUrl` does not allow for creating blobs. These will always be local urls, so no need to handle CORS.
|
||||
response = await fetch(src);
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError)
|
||||
return new HtmlAssistantFileNotFoundError(src);
|
||||
else
|
||||
return error;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
try {
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (url && URL.canParse(url) && Url.isBlob("blob:"))
|
||||
return url;
|
||||
else
|
||||
return new Error(`Invalid blob URL: ${url}`);
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
else {
|
||||
return new Error(`Unsuccessful response: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static setLoadingIcon(imageElement: HTMLImageElement) {
|
||||
this.setIcon(imageElement, this.SVG_LOADING_ICON_BLOB);
|
||||
}
|
||||
|
||||
private static setIcon(imageElement: HTMLImageElement, blob: Blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const onLoad = (_event: Event) => {
|
||||
imageElement.removeEventListener("load", onLoad);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
imageElement.addEventListener("load", onLoad);
|
||||
imageElement.setAttribute(HTMLElementAttribute.SRC, url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo
|
||||
* @see {@link https://lucide.dev/icons/loader}
|
||||
*/
|
||||
private static readonly SVG_LOADING_ICON = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="transparant"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 2v4" />
|
||||
<path d="m16.2 7.8 2.9-2.9" />
|
||||
<path d="M18 12h4" />
|
||||
<path d="m16.2 16.2 2.9 2.9" />
|
||||
<path d="M12 18v4" />
|
||||
<path d="m4.9 19.1 2.9-2.9" />
|
||||
<path d="M2 12h4" />
|
||||
<path d="m4.9 4.9 2.9 2.9" />
|
||||
</svg>
|
||||
`;
|
||||
* @todo
|
||||
* @see {@link https://lucide.dev/icons/loader}
|
||||
*/
|
||||
private static readonly SVG_LOADING_ICON_BLOB = new Blob(
|
||||
[`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#919191" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-loader"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>`],
|
||||
{ type: "image/svg+xml" }
|
||||
);
|
||||
|
||||
private static readonly SVG_FAILED_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ff0000" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-image"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`;
|
||||
private static readonly SVG_FAILED_ICON_BLOB = new Blob(
|
||||
[`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ff0000" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-image"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`],
|
||||
{ type: "image/svg+xml" }
|
||||
);
|
||||
}
|
||||
|
||||
static readonly ENCODED_LOADING_ICON = encodeURIComponent(this.SVG_LOADING_ICON);
|
||||
static readonly ENCODED_FAILED_ICON = encodeURIComponent(this.SVG_FAILED_ICON);
|
||||
class HtmlAssistantFileNotFoundError extends Error {
|
||||
constructor(url: string) {
|
||||
super(`File not found: ${url}`);
|
||||
this.name = "HtmlAssistantFileNotFoundError";
|
||||
}
|
||||
}
|
||||
100
src/InfoModal.ts
Normal file
100
src/InfoModal.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { App, ButtonComponent, Modal, Setting } from 'obsidian';
|
||||
import { CacheManager } from 'CacheManager';
|
||||
import { PluginSettings } from 'Settings';
|
||||
import { Notice } from 'Environment';
|
||||
|
||||
export class InfoModal extends Modal {
|
||||
private cacheManager: CacheManager;
|
||||
private settings: PluginSettings;
|
||||
|
||||
constructor(app: App, cacheManager: CacheManager, settings: PluginSettings) {
|
||||
super(app);
|
||||
this.cacheManager = cacheManager;
|
||||
this.settings = settings;
|
||||
|
||||
this.setTitle('Cacheboard');
|
||||
|
||||
this.clearCacheSetting = new Setting(this.contentEl)
|
||||
.setName('Total number of files cached')
|
||||
.addButton((button) => {
|
||||
this.clearCacheButton = button;
|
||||
button.buttonEl.tabIndex = -1;
|
||||
button.setButtonText("Delete");
|
||||
button.onClick(() => {
|
||||
button.setButtonText("Confirm cache delete");
|
||||
button.setWarning();
|
||||
button.onClick(async () => {
|
||||
|
||||
await cacheManager.clearCached((error) => {
|
||||
if (error) {
|
||||
new Notice(`An error occured while clearing the cache: ${error.message}`, 0);
|
||||
console.error("Error clearing cache.", error);
|
||||
}
|
||||
else {
|
||||
new Notice("Cache cleared");
|
||||
|
||||
button.buttonEl.remove();
|
||||
this.populate();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (settings.showDebugInfo) {
|
||||
this.debugInfoSetting = new Setting(this.contentEl)
|
||||
.setName('Debug info')
|
||||
.addButton((button) => {
|
||||
button.setButtonText("Refresh metadata");
|
||||
button.onClick(async () => {
|
||||
await this.cacheManager.debug().loadMetadata();
|
||||
this.populate();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.addButton((button) => {
|
||||
this.closeButton = button;
|
||||
button.buttonEl.tabIndex = 1;
|
||||
button.setButtonText("Close");
|
||||
button.onClick(() => {
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
debugInfoSetting?: Setting;
|
||||
clearCacheSetting: Setting;
|
||||
clearCacheButton: ButtonComponent;
|
||||
closeButton: ButtonComponent;
|
||||
|
||||
onOpen(): void {
|
||||
super.onOpen();
|
||||
|
||||
setTimeout(() => {
|
||||
this.clearCacheButton.buttonEl.tabIndex = 0
|
||||
this.closeButton.buttonEl.focus();
|
||||
});
|
||||
|
||||
this.populate();
|
||||
}
|
||||
|
||||
private populate() {
|
||||
setTimeout(() => {
|
||||
|
||||
this.debugInfoSetting?.descEl.empty();
|
||||
|
||||
this.cacheManager.info((info) => {
|
||||
this.clearCacheSetting.setDesc(`${info.numberOfFilesCached} file${info.numberOfFilesCached == 1 ? `` : `s`}`);
|
||||
|
||||
if (this.debugInfoSetting) {
|
||||
for (const line of info.summary.split("\n"))
|
||||
this.debugInfoSetting.descEl.createEl("div", {}, (p) => {
|
||||
p.innerText = line;
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
159
src/ProcessingPass.ts
Normal file
159
src/ProcessingPass.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { MarkdownView, MarkdownPostProcessorContext, FileView, TFile, App } from "obsidian";
|
||||
import { Log, ENV } from "Environment";
|
||||
import { CacheRequest, CacheManager } from "CacheManager";
|
||||
|
||||
/**
|
||||
* - Gathers some state useful during the current pass.
|
||||
* - Call first in system callbacks to abort as early as possible.
|
||||
*/
|
||||
export class ProcessingPass {
|
||||
|
||||
private markdownView: MarkdownView | null;
|
||||
public associatedFile: TFile;
|
||||
|
||||
/** `true` when invoked by the Markdown post processor. */
|
||||
public isInPostProcessingPass: boolean;
|
||||
private viewUpdate?: ViewUpdate;
|
||||
|
||||
private static updatePassID: number = 0;
|
||||
private static postProcessorPassID: number = 0;
|
||||
public passID: number;
|
||||
|
||||
public static beginFromViewUpdate(app: App, viewUpdate: ViewUpdate, noFile: () => void, sourceMode?: () => void): ProcessingPass | null {
|
||||
|
||||
const processPassID = this.updatePassID++;
|
||||
|
||||
if (ENV.dev) {
|
||||
|
||||
const updated = this.viewUpdateChanges(viewUpdate);
|
||||
const changed = Object.keys(updated).filter(key => updated[key]);
|
||||
const notChanged = Object.keys(updated).filter(key => !updated[key]);
|
||||
|
||||
Log(`🥎 editorViewUpdateListener ${changed.length} view updates. 🛎️ ID ${processPassID}`);
|
||||
if (changed.length > 0)
|
||||
Log(`\t🟢 ${changed.join(", ")}`);
|
||||
if (changed.length > 0 && notChanged.length > 0)
|
||||
Log(`\t🔴 ${notChanged.join(", ")}`);
|
||||
}
|
||||
|
||||
const markdownView = app.workspace.getActiveViewOfType(MarkdownView);
|
||||
const associatedFile = markdownView?.file ?? null;
|
||||
|
||||
// `app.workspace.getActiveFile()` is of no help.
|
||||
if (!associatedFile) {
|
||||
Log(`\tNo associated file. ID ${processPassID}`);
|
||||
noFile();
|
||||
return null;
|
||||
}
|
||||
else if (markdownView && this.isInSourceMode(markdownView)) {
|
||||
sourceMode?.();
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
const instance = new this();
|
||||
instance.passID = processPassID;
|
||||
instance.markdownView = markdownView;
|
||||
instance.associatedFile = associatedFile;
|
||||
instance.isInPostProcessingPass = false;
|
||||
instance.viewUpdate = viewUpdate;
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
public static beginFromPostProcessorContext(app: App, context: MarkdownPostProcessorContext) : ProcessingPass {
|
||||
|
||||
const postProcessorPassID = this.postProcessorPassID++;
|
||||
|
||||
Log(`🏀 postProcessReadingModeHtml 🛎️ ID ${postProcessorPassID}`);
|
||||
|
||||
const instance = new this();
|
||||
instance.passID = postProcessorPassID;
|
||||
instance.isInPostProcessingPass = true;
|
||||
|
||||
instance.markdownView = app.workspace.getActiveViewOfType(MarkdownView);
|
||||
|
||||
let associatedFile = instance.markdownView?.file ?? app.vault.getFileByPath(context.sourcePath);
|
||||
console.assert(associatedFile);
|
||||
instance.associatedFile = associatedFile!;
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static viewUpdateChanges(viewUpdate: ViewUpdate) {
|
||||
const u: Record<string, boolean> = {};
|
||||
|
||||
u["docChanged"] = viewUpdate.docChanged;
|
||||
u["viewportChanged"] = viewUpdate.viewportChanged;
|
||||
u["selectionSet"] = viewUpdate.selectionSet;
|
||||
u["focusChanged"] = viewUpdate.focusChanged;
|
||||
u["geometryChanged"] = viewUpdate.geometryChanged;
|
||||
u["heightChanged"] = viewUpdate.heightChanged;
|
||||
u["viewportMoved"] = viewUpdate.viewportMoved;
|
||||
|
||||
return u;
|
||||
}
|
||||
|
||||
private static isInLivePreviewFromView(view: FileView) {
|
||||
const state = view?.getState();
|
||||
return state ? state.mode == "source" && state.source == false : false;
|
||||
}
|
||||
|
||||
private static isInSourceMode(view: FileView) {
|
||||
const state = view?.getState();
|
||||
return state ? state.mode == "source" && state.source == true : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link EditorView.updateListener.of} {@link Extension} is called in both reader and preview/source mode and let's you process all elements at once.
|
||||
* The {@link registerMarkdownPostProcessor} is only called first when the file is opened in reader mode. It is called several times as the DOM is beeing built, which makes it complicated to consolidate them into one call.
|
||||
*
|
||||
* Because the update listener extension callback supplies the DOM of the whole file's writing area, and further, because it is always run before the Markdown post processor,
|
||||
* it is enough and simplest to show the download notice only when invoked from the extension callback, i.e., when {@link processingContext.isInPostProcessingPass} is `true`.
|
||||
*
|
||||
*
|
||||
* @param processingContext
|
||||
* @param run
|
||||
* @returns
|
||||
*/
|
||||
public runInUpdatePass(run?: () => void) {
|
||||
if (this.isInUpdatePass) {
|
||||
run?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public get isInUpdatePass() {
|
||||
return this.viewUpdate ? true : false;
|
||||
}
|
||||
|
||||
public retainCacheFromRequest(request: CacheRequest) {
|
||||
console.assert(request.requesterPath == this.associatedFile.path);
|
||||
if (this.isInUpdatePass)
|
||||
this.requestsToRetain.push(request);
|
||||
}
|
||||
|
||||
public get currentNumberOfRequestsToRetain() {
|
||||
return this.requestsToRetain.length;
|
||||
}
|
||||
|
||||
private requestsToRetain: CacheRequest[] = [];
|
||||
|
||||
public end(cacheManager: CacheManager) {
|
||||
Log(`${this.isInUpdatePass ? `🥎 ProcessingPass:end: Update pass ✅ ID ${this.passID}` : `🏀 ProcessingPass:end: Post processing pass ✅ ID ${this.passID}`}`);
|
||||
|
||||
if (this.isInUpdatePass) {
|
||||
cacheManager.updateRetainedCaches(this.requestsToRetain, this.associatedFile.path).then(() => {
|
||||
Log(`\tSaving metadata: ID ${this.passID}`);
|
||||
cacheManager.saveMetadataIfDirty();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public abort() {
|
||||
Log(`${this.isInUpdatePass ? `🥎 ProcessingPass:abort: Update pass ⛔ ID ${this.passID}` : `🏀 ProcessingPass:end: Post processing pass ⛔ ID ${this.passID}`}`);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { PluginSettingTab, Setting, Plugin } from "obsidian";
|
||||
import { PluginSettingTab, Setting, Plugin, Platform } from "obsidian";
|
||||
import { ENV, Notice } from "Environment";
|
||||
import { CacheManager } from "CacheManager";
|
||||
|
||||
export interface PluginSettings {
|
||||
|
||||
/** Shows a {@link Notice} when file download starts. */
|
||||
/** Show a {@link Notice} when file download starts. */
|
||||
noticeOnDownload: boolean
|
||||
|
||||
|
||||
/** Remove the name of the plugin when showing the download message. */
|
||||
omitNameInNotice: boolean;
|
||||
|
||||
|
|
@ -15,24 +17,27 @@ export interface PluginSettings {
|
|||
* This can't be disabled in the UI. But those who take matters into their own hands with data.json...
|
||||
*/
|
||||
gitIgnoreCacheDir: boolean;
|
||||
|
||||
showDebugInfo: boolean;
|
||||
}
|
||||
|
||||
export class SettingsManager {
|
||||
settings: PluginSettings;
|
||||
save: () => Promise<void>;
|
||||
onChangedCallback: (name: string) => void | undefined;
|
||||
public settings: PluginSettings;
|
||||
public save: () => Promise<void>;
|
||||
public onChangedCallback: (name: string, value: any) => void | undefined;
|
||||
|
||||
static readonly DEFAULT_SETTINGS: PluginSettings = {
|
||||
noticeOnDownload: true,
|
||||
omitNameInNotice: false,
|
||||
gitIgnoreCacheDir: true,
|
||||
showDebugInfo: false,
|
||||
} as const;
|
||||
|
||||
static readonly SETTING_NAME = {
|
||||
gitIgnoreCacheDir: "gitIgnoreCacheDir",
|
||||
} as const;
|
||||
|
||||
constructor(settings: any, save: (settings: PluginSettings) => Promise<void>, onChangedCallback: (name: string) => void | undefined) {
|
||||
constructor(settings: any, save: (settings: PluginSettings) => Promise<void>, onChangedCallback: (name: string, value: any) => void | undefined) {
|
||||
this.settings = settings;
|
||||
this.save = () => save(this.settings);
|
||||
this.onChangedCallback = onChangedCallback;
|
||||
|
|
@ -40,11 +45,13 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
export class SettingTab extends PluginSettingTab {
|
||||
settingsManager: SettingsManager;
|
||||
private settingsManager: SettingsManager;
|
||||
private cacheManager: CacheManager;
|
||||
|
||||
constructor(plugin: Plugin, settingsManager: SettingsManager) {
|
||||
constructor(plugin: Plugin, settingsManager: SettingsManager, cacheManager: CacheManager) {
|
||||
super(plugin.app, plugin);
|
||||
this.settingsManager = settingsManager;
|
||||
this.cacheManager = cacheManager;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
|
|
@ -53,6 +60,14 @@ export class SettingTab extends PluginSettingTab {
|
|||
|
||||
containerEl.empty();
|
||||
|
||||
let compactDownloadMessage: Setting | undefined;
|
||||
const setCompactDownloadMessageVisibility = () => {
|
||||
if (settings.noticeOnDownload)
|
||||
compactDownloadMessage?.settingEl.show();
|
||||
else
|
||||
compactDownloadMessage?.settingEl.hide();
|
||||
};
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Display a message on download")
|
||||
.setDesc("Let’s you know when files are downloaded and how many.")
|
||||
|
|
@ -60,13 +75,14 @@ export class SettingTab extends PluginSettingTab {
|
|||
toggle.setValue(settings.noticeOnDownload);
|
||||
toggle.onChange(async (value) => {
|
||||
settings.noticeOnDownload = value;
|
||||
setCompactDownloadMessageVisibility();
|
||||
await this.settingsManager.save();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Do not show pluign’s name in download message")
|
||||
.setDesc(`Toggle on to remove 'Come Down:' from the message.`)
|
||||
compactDownloadMessage = new Setting(containerEl)
|
||||
.setName("Use compact download message")
|
||||
.setDesc("Shortens the message to make it less distracting.")
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(settings.omitNameInNotice);
|
||||
toggle.onChange(async (value) => {
|
||||
|
|
@ -74,7 +90,50 @@ export class SettingTab extends PluginSettingTab {
|
|||
await this.settingsManager.save();
|
||||
});
|
||||
});
|
||||
|
||||
setCompactDownloadMessageVisibility();
|
||||
|
||||
const cachedFilesSetting = new Setting(containerEl).setName('Number of files cached');
|
||||
setTimeout(() => {
|
||||
this.cacheManager.actualCachedFilePaths().then((filePaths) => {
|
||||
const num = filePaths.length;
|
||||
cachedFilesSetting.setDesc(`${num} file${num == 1 ? `` : `s`}.`);
|
||||
|
||||
if (num == 0)
|
||||
return;
|
||||
|
||||
cachedFilesSetting.addButton((button) => {
|
||||
button.buttonEl.tabIndex = -1;
|
||||
button.setButtonText("Delete all cached files");
|
||||
button.onClick(() => {
|
||||
button.setButtonText("Confirm cache delete");
|
||||
button.setWarning();
|
||||
button.onClick(async () => {
|
||||
|
||||
await this.cacheManager.clearCached((error) => {
|
||||
if (error) {
|
||||
new Notice(`An error occured while clearing the cache: ${error.message}`, 0);
|
||||
console.error("Error clearing cache.", error);
|
||||
}
|
||||
else {
|
||||
new Notice("Cache cleared");
|
||||
cachedFilesSetting.setDesc("Cache is empty.")
|
||||
|
||||
button.buttonEl.remove();
|
||||
|
||||
if (ENV.dev && Platform.isDesktopApp) {
|
||||
require('electron').remote.session.defaultSession.clearCache()
|
||||
.then(() => new Notice('Electron Cache cleared successfully. Restart vault.'))
|
||||
.catch((error: any) => console.error('Error clearing cache:', error));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
})
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Advanced").setHeading();
|
||||
|
||||
|
|
@ -96,8 +155,8 @@ export class SettingTab extends PluginSettingTab {
|
|||
settings.gitIgnoreCacheDir = value;
|
||||
await this.settingsManager.save();
|
||||
refreshDisabled();
|
||||
this.settingsManager?.onChangedCallback(SettingsManager.SETTING_NAME.gitIgnoreCacheDir);
|
||||
this.settingsManager?.onChangedCallback(SettingsManager.SETTING_NAME.gitIgnoreCacheDir, value);
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
src/Url.ts
54
src/Url.ts
|
|
@ -3,36 +3,41 @@
|
|||
export class Url {
|
||||
|
||||
/**
|
||||
* These are case-sensitive. Use {@link normalizedHeaders} to be safer.
|
||||
* These are case-sensitive. Use with {@link normalizedHeaders}.
|
||||
*/
|
||||
public static readonly RESPONSE_HEADER = {
|
||||
contentType: "Content-Type",
|
||||
contentLength: "Content-Length",
|
||||
cacheControl: "Cache-Control",
|
||||
expires: "Expires",
|
||||
};
|
||||
|
||||
public static readonly RESPONSE_HEADER_LOWERCASE = {
|
||||
contentType: Url.RESPONSE_HEADER.contentType.toLowerCase(),
|
||||
contentLength: Url.RESPONSE_HEADER.contentLength.toLowerCase(),
|
||||
cacheControl: Url.RESPONSE_HEADER.cacheControl.toLowerCase(),
|
||||
expires: Url.RESPONSE_HEADER.expires.toLowerCase(),
|
||||
};
|
||||
contentType: "Content-Type".toLowerCase(),
|
||||
contentLength: "Content-Length".toLowerCase(),
|
||||
cacheControl: "Cache-Control".toLowerCase(),
|
||||
expires: "Expires".toLowerCase(),
|
||||
} as const;
|
||||
|
||||
public static readonly CACHE_CONTROL_LOWERCASE = {
|
||||
noStore: "no-store",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Normalize headers to make sure to, e.g., find both `Content-Type` and `content-type`.
|
||||
* Also ignores empty strings and trims.
|
||||
*/
|
||||
public static normalizeHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const normalizedHeaders: Record<string, string> = {};
|
||||
|
||||
for (const key in headers)
|
||||
normalizedHeaders[key.toLowerCase()] = headers[key];
|
||||
if (key.length > 0)
|
||||
normalizedHeaders[key.toLowerCase().trim()] = headers[key];
|
||||
|
||||
return normalizedHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @author Gemini
|
||||
public static isValid(url: string): boolean {
|
||||
return url && URL.canParse(url) ? true : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* These are not relevant: ftp, mailto, tel, ws: and wss:
|
||||
*/
|
||||
public static isExternal(src: string): boolean {
|
||||
public static isExternal(src: string): boolean {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
|
|
@ -41,12 +46,25 @@ export class Url {
|
|||
}
|
||||
}
|
||||
|
||||
public static isEmbedded(url: string): boolean {
|
||||
return this.isBlob(url) || url.startsWith("data:");
|
||||
}
|
||||
|
||||
public static isBlob(url: string): boolean {
|
||||
return url.startsWith("blob:"); // Note: no slashes
|
||||
}
|
||||
|
||||
public static isLocal(url: string): boolean {
|
||||
// Slashes are better: e.g., "app:data" or "file:info" are not URLs.
|
||||
return url.startsWith("app://") || url.startsWith("capacitor://") || url.startsWith("file://");
|
||||
}
|
||||
|
||||
/**
|
||||
* @author Gemini
|
||||
* @param url
|
||||
* @returns
|
||||
*/
|
||||
public static extractFilenameAndExtension(url: string): { filename: string, extension: string} | null {
|
||||
public static extractFilenameAndExtension(url: string): { filename: string, extension: string } | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
|
|
|
|||
627
src/main.ts
627
src/main.ts
|
|
@ -1,16 +1,18 @@
|
|||
import { MarkdownView, Plugin, editorLivePreviewField, MarkdownPostProcessorContext, normalizePath, Platform, FileView, TFile } from "obsidian";
|
||||
import { SettingTab, SettingsManager, PluginSettings } from "src/Settings";
|
||||
import { CacheManager, CacheRequest } from "src/CacheManager";
|
||||
import { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { Log, Notice, ENV } from "src/Environment";
|
||||
import { HTMLElementAttribute, HTMLElementCacheState, HtmlAssistant } from "src/HtmlAssistant";
|
||||
import { GetCacheKey } from "./CacheMetadata";
|
||||
import { Plugin, MarkdownPostProcessorContext, normalizePath, TFile } from "obsidian";
|
||||
import { SettingTab, SettingsManager, PluginSettings } from "Settings";
|
||||
import { CacheFetchError, CacheItem, CacheManager, CacheRequest, CacheTypeError } from "CacheManager";
|
||||
import { Log, Notice, ENV, clearBrowserCache } from "Environment";
|
||||
import { HTMLElementAttribute, HTMLElementCacheState, HtmlAssistant } from "HtmlAssistant";
|
||||
import { InfoModal } from "InfoModal";
|
||||
import { ProcessingPass } from "ProcessingPass";
|
||||
import { Url } from "Url";
|
||||
|
||||
interface PluginData {
|
||||
settings: PluginSettings;
|
||||
}
|
||||
|
||||
export const DEFAULT_DATA: PluginData = {
|
||||
const DEFAULT_DATA: PluginData = {
|
||||
settings: SettingsManager.DEFAULT_SETTINGS,
|
||||
} as const;
|
||||
|
||||
|
|
@ -21,94 +23,100 @@ export default class ComeDownPlugin extends Plugin {
|
|||
|
||||
async onload() {
|
||||
|
||||
//#region Init
|
||||
|
||||
//const startTime = performance.now();
|
||||
|
||||
Notice.setName(this.manifest.name);
|
||||
|
||||
this.data = Object.assign({}, DEFAULT_DATA, await this.loadData());
|
||||
|
||||
//#region Settings
|
||||
await this.ensureCacheDir();
|
||||
await this.ensureGitIgnore();
|
||||
|
||||
this.cacheManager = await CacheManager.create(this.app.vault, this.cacheDir, this.cacheMetadataPath, [this.gitIgnorePath]);
|
||||
|
||||
this.settingsManager = new SettingsManager(
|
||||
this.data.settings,
|
||||
async (_settings) => await this.saveData(this.data),
|
||||
(name) => {
|
||||
if (name === SettingsManager.SETTING_NAME.gitIgnoreCacheDir)
|
||||
this.ensureCacheDir();
|
||||
this.ensureGitIgnore();
|
||||
}
|
||||
);
|
||||
this.addSettingTab(new SettingTab(this, this.settingsManager));
|
||||
this.addSettingTab(new SettingTab(this, this.settingsManager, this.cacheManager));
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Cache
|
||||
|
||||
this.cacheManager = new CacheManager(this.app.vault, this.cacheDir);
|
||||
this.ensureCacheDir();
|
||||
//console.log(`ComeDown: init: ${performance.now() - startTime} milliseconds`);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Register
|
||||
|
||||
// The post processor runs after the Markdown has been processed into HTML. It lets you add, remove, or replace HTML elements to the rendered document.
|
||||
this.registerMarkdownPostProcessor((e, c) => this.processReadingMode(e, c));
|
||||
|
||||
this.registerMarkdownPostProcessor((e, c) => this.postProcessReadingModeHtml(e, c));
|
||||
this.registerEditorExtension(EditorView.updateListener.of((vu) => this.editorViewUpdateListener(vu)));
|
||||
|
||||
this.addCommand({
|
||||
id: "clear-all-cache",
|
||||
name: "Clear All Cached Files",
|
||||
callback: async () => {
|
||||
await this.cacheManager.clearCached([this.gitIgnorePath], (error) => {
|
||||
if (error) {
|
||||
new Notice(`An error occured while clearing the cache: ${error.message}`, 0);
|
||||
console.error(`Error clearing cache: ${error}`);
|
||||
}
|
||||
else {
|
||||
new Notice(`Cache cleared.`);
|
||||
if (ENV.dev && Platform.isDesktopApp) {
|
||||
require('electron').remote.session.defaultSession.clearCache()
|
||||
.then(() => {
|
||||
new Notice('Electron Cache cleared successfully. Restart vault.');
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Error clearing cache:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
this.registerEvent(
|
||||
this.app.vault.on('delete', (file) => {
|
||||
if (file instanceof TFile) {
|
||||
this.cacheManager.removeRetainer(file.path).then(() => this.cacheManager.saveMetadataIfDirty());
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// this.addCommand({
|
||||
// id: 'redownload-all-images-in-this-file',
|
||||
// name: 'Redownload all images in this file.',
|
||||
// checkCallback: (checking: boolean) => {
|
||||
// const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
// if (markdownView) {
|
||||
// if (!checking) {
|
||||
// }
|
||||
// return true;
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
this.registerEvent(
|
||||
this.app.vault.on('rename', (file, oldPath) => {
|
||||
if (file instanceof TFile) {
|
||||
this.cacheManager.renameRetainer(oldPath, file.path);
|
||||
this.cacheManager.saveMetadataIfDirty();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (ENV.dev) {
|
||||
|
||||
this.addCommand({
|
||||
id: "open-info-modal",
|
||||
name: "Open Cacheboard",
|
||||
callback: () => {
|
||||
new InfoModal(this.app, this.cacheManager, this.settingsManager.settings).open();
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "delete-all-cache-and-reload",
|
||||
name: "Delete cache and reload",
|
||||
callback: () => {
|
||||
this.cacheManager.clearCached((error) => {
|
||||
if (error)
|
||||
console.error("Failed to clear cache", error);
|
||||
else
|
||||
clearBrowserCache(this.app);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
}
|
||||
|
||||
async onunload() {
|
||||
await this.cacheManager?.cancelAllOngoing();
|
||||
}
|
||||
|
||||
//#region Cache
|
||||
//#region Cache Init
|
||||
|
||||
/**
|
||||
* Will be set once it's ensured that `this.manifest.dir` is set.
|
||||
* @throws {Error} - If this.manifest.dir isn't set.
|
||||
* @throws {Error} If `this.manifest.dir` isn't set.
|
||||
*/
|
||||
get cacheDir(): string {
|
||||
if (this.cacheDirBacking === undefined) {
|
||||
const pluginDir = this.manifest.dir;
|
||||
if (pluginDir) {
|
||||
this.cacheDirBacking = `${pluginDir}/cache`;
|
||||
this.cacheDirBacking = normalizePath(`${pluginDir}/cache`);
|
||||
this.gitIgnorePath = normalizePath(`${this.cacheDirBacking}/.gitignore`);
|
||||
this.cacheMetadataPath = normalizePath(`${pluginDir}/cache.json`);
|
||||
}
|
||||
else {
|
||||
const errorMsg = `Cannot load because plugin directory is unknown`;
|
||||
|
|
@ -118,24 +126,27 @@ export default class ComeDownPlugin extends Plugin {
|
|||
}
|
||||
return this.cacheDirBacking;
|
||||
}
|
||||
private cacheDirBacking: string | undefined;
|
||||
|
||||
get gitIgnorePath() {
|
||||
if (!this.gitIgnorePathBacking)
|
||||
this.gitIgnorePathBacking = normalizePath(`${this.cacheDir}/.gitignore`);
|
||||
return this.gitIgnorePathBacking;
|
||||
}
|
||||
private gitIgnorePathBacking: string | undefined;
|
||||
private cacheDirBacking?: string;
|
||||
private gitIgnorePath: string;
|
||||
private cacheMetadataPath: string;
|
||||
|
||||
/**
|
||||
* Ensures the cache directory exists.
|
||||
*
|
||||
* Do not catch {@link Error}s so as to prevent the plugin from being enabled in such cases.
|
||||
*/
|
||||
async ensureCacheDir() {
|
||||
const ensureGitIgnore = this.settingsManager.settings.gitIgnoreCacheDir;
|
||||
|
||||
const cacheFolderExists = await this.app.vault.adapter.exists(this.cacheDir);
|
||||
if (!cacheFolderExists)
|
||||
await this.app.vault.adapter.mkdir(this.cacheDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure a `.gitignore` file is added/removed.
|
||||
*/
|
||||
async ensureGitIgnore() {
|
||||
const ensureGitIgnore = this.data.settings.gitIgnoreCacheDir;
|
||||
const gitignoreExists = await this.app.vault.adapter.exists(this.gitIgnorePath);
|
||||
|
||||
if (ensureGitIgnore && !gitignoreExists)
|
||||
await this.app.vault.adapter.write(this.gitIgnorePath, "*");
|
||||
else if (!ensureGitIgnore && gitignoreExists)
|
||||
|
|
@ -144,293 +155,305 @@ export default class ComeDownPlugin extends Plugin {
|
|||
|
||||
//#endregion
|
||||
|
||||
//#region
|
||||
|
||||
private isInLivePreviewFromView(view: FileView) {
|
||||
const state = view?.getState();
|
||||
return state ? state.mode == "source" && state.source == false : false;
|
||||
}
|
||||
|
||||
private isInSourceModeFromView(view: FileView) {
|
||||
const state = view?.getState();
|
||||
return state ? state.mode == "source" && state.source == true : false;
|
||||
}
|
||||
|
||||
private inLivePreviewFromEditorView(view: EditorView) {
|
||||
return view.state.field(editorLivePreviewField);
|
||||
}
|
||||
|
||||
private getEditorRoot(element: HTMLElement): HTMLElement | null {
|
||||
let currentElement: HTMLElement | null = element;
|
||||
|
||||
while (currentElement) {
|
||||
if (currentElement.classList.contains("view-content")) {
|
||||
return currentElement;
|
||||
}
|
||||
currentElement = currentElement.parentElement as HTMLElement | null;
|
||||
}
|
||||
|
||||
return null; // Root not found
|
||||
}
|
||||
//#region Processing
|
||||
|
||||
/**
|
||||
* Remove requesting, downloading, done, and invalid.
|
||||
* - The done image element is already pointing to the cached resource. No need to do anything more.
|
||||
* - Those that are downloading will be handled as the download finishes as part of a previous pass.
|
||||
*
|
||||
* @param view
|
||||
* @returns true if file is open in Reading view.
|
||||
*/
|
||||
private isInReadingViewFromView(view: FileView): boolean {
|
||||
return view?.getState().mode == "preview"; // "source"
|
||||
}
|
||||
|
||||
private viewModeString(fileView: FileView, editorView: EditorView | undefined = undefined) {
|
||||
return this.isInReadingViewFromView(fileView) ? "Reading view (reading)" : "Live preview (editing)";
|
||||
//if (editorView)
|
||||
// Log(`\t${!this.inLivePreviewFromEditorView(editorView) ? `Reading view (reading)` : `Live preview (editing)`}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Call first in system callbacks to abort as early as possible.
|
||||
* @param context
|
||||
* @returns Do nothing further if `null`.
|
||||
*/
|
||||
private proceed(context?: MarkdownPostProcessorContext): { markdownView: MarkdownView, associatedFile: TFile } | null {
|
||||
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
|
||||
if (!markdownView) {
|
||||
Log(`\tSkipping because no active view.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.isInSourceModeFromView(markdownView)) {
|
||||
Log(`\tSkipping because in source mode.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// if (ENV.usePostProcessor && this.isInReadingViewFromView(markdownView)) {
|
||||
// Log(`\tSkipping because in Reading view.`);
|
||||
// return null;
|
||||
// }
|
||||
|
||||
const associatedFile = markdownView.file; // == this.app.workspace.getActiveFile()
|
||||
if (!associatedFile) {
|
||||
Log(`\tSkipping because in no associated file.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (context && associatedFile.path != context.sourcePath) {
|
||||
console.warn(`\tExpected \`getActiveFile().path\` be equal to \`sourcePath\` of \`MarkdownPostProcessorContext\``)
|
||||
return null;
|
||||
}
|
||||
|
||||
return { markdownView, associatedFile: associatedFile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the relevant html elements under {@link element} then aims to further filter out those that are unwanted.
|
||||
* Keep
|
||||
* - Original: these need to be cancelled
|
||||
* - Cancelled and Failed: these need to request cache.
|
||||
*
|
||||
* @param element
|
||||
* @param imageElement
|
||||
* @returns
|
||||
*/
|
||||
private imageElements(element: HTMLElement): HTMLImageElement[] {
|
||||
private filterIrrelevantCacheStates(imageElement: HTMLImageElement) {
|
||||
|
||||
// Note that if the `src` attribute is used for an svg icon this method will regard it as relevant and return it. But it will be filtered when the state is checked.
|
||||
let imageElements = HtmlAssistant.findAllRelevantImages(element);
|
||||
|
||||
if (imageElements.length == 0) {
|
||||
Log(`\tAborting: Found no relevant image elements.`)
|
||||
return imageElements;
|
||||
}
|
||||
else
|
||||
Log(`\tNumber of relevant image elements: ${imageElements.length}`);
|
||||
const state = HtmlAssistant.cacheState(imageElement)
|
||||
|
||||
imageElements = imageElements.filter((imageElement) => {
|
||||
const state = HtmlAssistant.cacheState(imageElement)
|
||||
Log(`\tCache state: ${state}`);
|
||||
return state != HTMLElementCacheState.CACHE_SUCCEEDED && state != HTMLElementCacheState.CACHE_REQUESTED;
|
||||
});
|
||||
// return HtmlAssistant.isCacheStateEqual(state, [
|
||||
// HTMLElementCacheState.ORIGINAL,
|
||||
// HTMLElementCacheState.ORIGINAL_SRC_REMOVED,
|
||||
// HTMLElementCacheState.CACHE_FAILED
|
||||
// ]);
|
||||
|
||||
if (imageElements.length == 0)
|
||||
Log(`\tAborting: All relevant images are either already using cache or are waiting for it.`)
|
||||
|
||||
return imageElements;
|
||||
// Difference between this and the above is that ORIGINAL matches all unprocessed elements.
|
||||
return !HtmlAssistant.isCacheStateEqual(state, [
|
||||
HTMLElementCacheState.REQUESTING,
|
||||
HTMLElementCacheState.REQUESTING_DOWNLOADING,
|
||||
HTMLElementCacheState.CACHE_SUCCEEDED,
|
||||
HTMLElementCacheState.INVALID
|
||||
]);
|
||||
}
|
||||
|
||||
private editorViewUpdateListener(update: ViewUpdate) {
|
||||
Log(`editorViewUpdateListener`)
|
||||
|
||||
const proceed = this.proceed();
|
||||
if (!proceed)
|
||||
return;
|
||||
|
||||
Log(`\t${this.viewModeString(proceed.markdownView, update.view)}`);
|
||||
const processingPass = ProcessingPass.beginFromViewUpdate(this.app, update, () => {
|
||||
// There is no file to work with. All that can be done is to cancel loading all external urls.
|
||||
const imageElements = HtmlAssistant.findRelevantImagesToProcess(update.view.contentDOM, true, (imageElement) => {
|
||||
const src = imageElement.getAttribute(HTMLElementAttribute.SRC);
|
||||
return src !== null && Url.isValid(src) && Url.isExternal(src);
|
||||
});
|
||||
HtmlAssistant.cancelImageLoading(imageElements);
|
||||
});
|
||||
|
||||
const imageElements = this.imageElements(update.view.contentDOM);
|
||||
if (imageElements.length == 0)
|
||||
return;
|
||||
|
||||
if (ENV.processOnAllViewUpdateChanges || (update.docChanged || update.viewportChanged || update.geometryChanged)) {
|
||||
if (!ENV.processOnAllViewUpdateChanges)
|
||||
Log(`\tdocChanged: ${update.docChanged}\n\tviewportChanged: ${update.viewportChanged}\n\tselectionSet: ${update.selectionSet}\n\tfocusChanged: ${update.focusChanged}\n\tgeometryChanged: ${update.geometryChanged}\n\theightChanged: ${update.heightChanged}`);
|
||||
if (processingPass) {
|
||||
|
||||
this.handleImages(HtmlAssistant.preparedImageElements(imageElements), proceed.associatedFile);
|
||||
}
|
||||
else {
|
||||
if (!ENV.processOnAllViewUpdateChanges)
|
||||
Log(`\tskipped \`handleImages\`: \n\tviewportChanged: ${update.viewportChanged}\n\tselectionSet: ${update.selectionSet}\n\tfocusChanged: ${update.focusChanged}\n\tgeometryChanged: ${update.geometryChanged}\n\theightChanged: ${update.heightChanged}`);
|
||||
// Elements in DOM at this stage might be in states in which the `src` attribute has been removed. Therefore the `src` attribute is not required when finding image elements.
|
||||
const imageElements = HtmlAssistant.findRelevantImagesToProcess(update.view.contentDOM, false, (imageElement) => {
|
||||
const src = imageElement.getAttribute(HTMLElementAttribute.SRC);
|
||||
|
||||
// 1. The user is editing the link (causing the source attribute to change) which resets the element's state.
|
||||
// External urls are only accepted in the element's original state.
|
||||
if (update.docChanged && src && Url.isExternal(src) && HtmlAssistant.cacheState(imageElement) != HTMLElementCacheState.ORIGINAL)
|
||||
HtmlAssistant.resetElement(imageElement); // Set state to "untouched".
|
||||
|
||||
// 2. As all images are retained in each pass, even though elements that are already cached are excluded from further processing, they still need to be retained.
|
||||
if (HtmlAssistant.cacheState(imageElement) == HTMLElementCacheState.CACHE_SUCCEEDED) {
|
||||
const src = HtmlAssistant.originalSrc(imageElement);
|
||||
console.assert(src !== null, "Expected original source dataset");
|
||||
if (src)
|
||||
processingPass.retainCacheFromRequest({ source: src, requesterPath: processingPass.associatedFile.path });
|
||||
}
|
||||
|
||||
// 3. Start filtering: If the image element has a src, only keep external urls; if not, keep all.
|
||||
let isSrcOk = src !== null ? Url.isValid(src) && Url.isExternal(src) : true;
|
||||
|
||||
// 4. If the url was ok, then remove all states that have passed this stage already.
|
||||
return isSrcOk && this.filterIrrelevantCacheStates(imageElement);
|
||||
});
|
||||
|
||||
if (imageElements.length > 0) {
|
||||
HtmlAssistant.cancelImageLoading(imageElements);
|
||||
this.requestCache(imageElements, processingPass);
|
||||
}
|
||||
else {
|
||||
// Special case when the user deletes an existing embed and all other image elements were filtered out: there are either no other embeds or all the other are already done.
|
||||
if (update.docChanged)
|
||||
processingPass.end(this.cacheManager);
|
||||
else
|
||||
processingPass.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - Will not be called if never in Read mode since there's no need to render Markdown.
|
||||
* - In Read mode, it will always be called after the update listener, {@link editorViewUpdateListener}, as it might make changes.
|
||||
*
|
||||
* @param element A chunk of html.
|
||||
* @param element A chunk of HTML.
|
||||
* @param context
|
||||
*/
|
||||
processReadingMode(element: HTMLElement, context: MarkdownPostProcessorContext) {
|
||||
Log(`processReadingMode`)
|
||||
|
||||
const proceed = this.proceed(context);
|
||||
if (!proceed)
|
||||
return;
|
||||
|
||||
Log(`\t${this.viewModeString(proceed.markdownView)}`);
|
||||
|
||||
const imageElements = this.imageElements(element);
|
||||
if (imageElements.length == 0)
|
||||
return;
|
||||
private postProcessReadingModeHtml(element: HTMLElement, context: MarkdownPostProcessorContext) {
|
||||
|
||||
//console.log(element);
|
||||
//console.log(context.getSectionInfo(element)?.text); // This is the Markdown text
|
||||
const processingPass = ProcessingPass.beginFromPostProcessorContext(this.app, context);
|
||||
const imageElements = HtmlAssistant.findRelevantImagesToProcess(element, true, (imageElement) => {
|
||||
const src = imageElement.getAttribute(HTMLElementAttribute.SRC);
|
||||
return src !== null && Url.isValid(src) && Url.isExternal(src);
|
||||
});
|
||||
|
||||
this.handleImages(HtmlAssistant.preparedImageElements(imageElements), proceed.associatedFile);
|
||||
if (imageElements.length > 0) {
|
||||
HtmlAssistant.cancelImageLoading(imageElements);
|
||||
this.requestCache(imageElements, processingPass);
|
||||
}
|
||||
else {
|
||||
processingPass.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a call to the {@link CacheManager} for each image element and changing the src to the cached file.
|
||||
*
|
||||
* @param imageElements All images in the post proceessed HTML.
|
||||
* @param filePath Path to the file containing this HTML.
|
||||
* @param imageElements
|
||||
* @param processingPass
|
||||
* @returns
|
||||
*/
|
||||
async handleImages(imageElements: HTMLImageElement[], associatedFile: TFile) {
|
||||
async requestCache(imageElements: HTMLImageElement[], processingPass: ProcessingPass) {
|
||||
|
||||
imageElements = imageElements.length == 0
|
||||
? []
|
||||
: imageElements.filter((imageElement) => HtmlAssistant.cacheState(imageElement) == HTMLElementCacheState.ORIGINAL_CANCELLED);
|
||||
imageElements = imageElements.filter((imageElement) => HtmlAssistant.isElementCacheStateEqual(imageElement, [HTMLElementCacheState.ORIGINAL_SRC_REMOVED, HTMLElementCacheState.CACHE_FAILED]));
|
||||
|
||||
Log(`handleImages\n\tGot ${imageElements.length} <img> elements to populate.`);
|
||||
Log(`requestCache\n\tGot ${imageElements.length} <img> elements to populate.`);
|
||||
if (imageElements.length == 0)
|
||||
return;
|
||||
|
||||
//#region Prepare requests and group equal elements (making it one request per file, not per element).
|
||||
|
||||
const groupedByRequest: Record<string, {
|
||||
request: CacheRequest,
|
||||
filePath: string,
|
||||
data: Record<string, any>,
|
||||
done: boolean,
|
||||
}> = {};
|
||||
|
||||
imageElements.forEach((imageElement) => {
|
||||
HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.CACHE_REQUESTED);
|
||||
|
||||
const src = HtmlAssistant.imageElementOriginalSrc(imageElement);
|
||||
|
||||
if (src) {
|
||||
const key = CacheManager.cacheKeyFromOriginalSrc(src);
|
||||
const group = groupedByRequest[key];
|
||||
const alt = imageElement.hasAttribute(HTMLElementAttribute.ALT) ? imageElement.alt : "";
|
||||
|
||||
if (group) {
|
||||
group.data.imageElements.push(imageElement);
|
||||
group.data.originalAlts.push(alt);
|
||||
}
|
||||
else {
|
||||
const request = { key: src };
|
||||
const validationError = this.cacheManager.validateRequest(request);
|
||||
if (validationError) {
|
||||
console.error(`Image element does not have a source. Omitting.`)
|
||||
HtmlAssistant.setFailed(imageElement);
|
||||
}
|
||||
else {
|
||||
groupedByRequest[key] = {
|
||||
request,
|
||||
filePath: associatedFile.path,
|
||||
data: { imageElements: [imageElement], originalAlts: [alt] },
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.error(`Image element does not have a source. Omitting.`)
|
||||
HtmlAssistant.setFailed(imageElement);
|
||||
}
|
||||
|
||||
});
|
||||
//#endregion
|
||||
|
||||
const requestGroups = Object.values(groupedByRequest); // TODO: Can be array.
|
||||
let numberFilesToDownload = 0;
|
||||
const requestGroups = groupRequests(imageElements, this.cacheManager, processingPass);
|
||||
|
||||
// First try to get src from local cache.
|
||||
for (const requestGroup of requestGroups) {
|
||||
const cacheItem = await this.cacheManager.existingCachedItem(requestGroup.request);
|
||||
const existingCacheResult = await this.cacheManager.existingCache(requestGroup.request, true);
|
||||
Log(`requestCache: ${existingCacheResult.item ? "Found" : "Did not find"} key ${existingCacheResult.cacheKey}. ${existingCacheResult.fileExists === undefined ? "Unknown if file exists." : `${existingCacheResult.fileExists ? "File exists." : "File does not exist."}`}`);
|
||||
if (existingCacheResult.item)
|
||||
await handleRequestGroup(existingCacheResult.item, requestGroup);
|
||||
};
|
||||
|
||||
if (cacheItem) {
|
||||
requestGroup.data.imageElements.forEach((imageElement: HTMLImageElement) =>
|
||||
HtmlAssistant.setSuccess(imageElement, cacheItem.filePath));
|
||||
requestGroup.done = true;
|
||||
Log(`handleImages:\n\t Used available cache: ${GetCacheKey(cacheItem.metadata.hash)}`)
|
||||
}
|
||||
else {
|
||||
numberFilesToDownload++;
|
||||
}
|
||||
const remainingRequestGroups = requestGroups.filter((requestGroup) => !requestGroup.cacheFileFound);
|
||||
let numberOfRemainingDownloads = remainingRequestGroups.length;
|
||||
|
||||
if (numberOfRemainingDownloads == 0) {
|
||||
Log(`requestCache: All images were in cache. Done ${processingPass.passID}`);
|
||||
processingPass.end(this.cacheManager);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we are here, some images need to be downloaded.
|
||||
|
||||
// Show download notice
|
||||
if (this.settingsManager.settings.noticeOnDownload) {
|
||||
if (numberFilesToDownload > 0) {
|
||||
const notice = `↓ ${numberFilesToDownload}`;// file${numberFilesToDownload != 1 ? `s` : ``}`;
|
||||
processingPass.runInUpdatePass(() => {
|
||||
const notice = `↓ ${numberOfRemainingDownloads}`;
|
||||
new Notice(notice, undefined, this.settingsManager.settings.omitNameInNotice);
|
||||
Log(`${associatedFile.path}: ${notice}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
requestGroups
|
||||
.filter((requestGroup) => !requestGroup.done)
|
||||
.forEach((requestGroup) => {
|
||||
|
||||
const imageElements: HTMLImageElement[] = requestGroup.data.imageElements;
|
||||
imageElements.forEach((ie) => {
|
||||
HtmlAssistant.setIcon(ie, HtmlAssistant.ENCODED_LOADING_ICON);
|
||||
ie.setAttribute(HTMLElementAttribute.ALT, "Loading...");
|
||||
remainingRequestGroups.forEach((requestGroup) => {
|
||||
|
||||
for (const imageElement of requestGroup.imageElements) {
|
||||
HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.REQUESTING_DOWNLOADING);
|
||||
HtmlAssistant.setLoadingIcon(imageElement);
|
||||
imageElement.setAttribute(HTMLElementAttribute.ALT, "Loading...");
|
||||
};
|
||||
|
||||
// Pass `true` flag because it is known at this point that the cache doesn't exist.
|
||||
this.cacheManager.getCache(requestGroup.request, true, async (result) => {
|
||||
|
||||
numberOfRemainingDownloads--;
|
||||
const imageElements: HTMLImageElement[] = requestGroup.imageElements;
|
||||
|
||||
// Restore alt texts.
|
||||
imageElements.forEach((imageElement, index) => {
|
||||
const originalAlt = requestGroup.altAttributeValues[index];
|
||||
if (originalAlt.length > 0)
|
||||
imageElement.setAttribute(HTMLElementAttribute.ALT, originalAlt);
|
||||
else
|
||||
imageElement.removeAttribute(HTMLElementAttribute.ALT); // TODO: Chromium sets it to the original url if removed?
|
||||
});
|
||||
|
||||
// Fire and forget
|
||||
this.cacheManager.getCache(requestGroup.request, (result) => {
|
||||
if (result.item) {
|
||||
Log(`requestCache:\n\tSettings src on ${imageElements.length} images\n\t${result.item.metadata.f.n}\n\t${processingPass.isInPostProcessingPass ? `In HTML post processor` : `In edit listener`}`);
|
||||
if (result.fileExists === true)
|
||||
await handleRequestGroup(result.item, requestGroup);
|
||||
else
|
||||
imageElements.forEach((imageElement) => HtmlAssistant.setFailed(imageElement));
|
||||
}
|
||||
else {
|
||||
imageElements.forEach((imageElement) => HtmlAssistant.setInvalid(imageElement));
|
||||
|
||||
const imageElements: HTMLImageElement[] = requestGroup.data.imageElements;
|
||||
if (ENV.debugLog && (result.error instanceof CacheFetchError || result.error instanceof CacheTypeError))
|
||||
console.error(`requestCache:\n\t${result.error.name}`, result.error);
|
||||
|
||||
// Restore alt texts.
|
||||
const originalAlts: string[] = requestGroup.data.originalAlts;
|
||||
imageElements.forEach((imageElement, index) => {
|
||||
const originalAlt = originalAlts[index];
|
||||
if (originalAlt.length > 0)
|
||||
imageElement.setAttribute(HTMLElementAttribute.ALT, originalAlt);
|
||||
else
|
||||
imageElement.removeAttribute(HTMLElementAttribute.ALT); // TODO: Chromium sets it to the original url if removed?
|
||||
});
|
||||
if (!(result.error instanceof CacheFetchError || result.error instanceof CacheTypeError))
|
||||
console.error("requestCache:\n\tFailed to fetch cache", result.error);
|
||||
}
|
||||
|
||||
if (result.item) {
|
||||
Log(`handleImages:\n\tCache result received. Settings src on ${imageElements.length} images\n\t${result.item.metadata.file.name}`)
|
||||
const filePath = result.item.filePath;
|
||||
imageElements.forEach((imageElement) => HtmlAssistant.setSuccess(imageElement, filePath));
|
||||
if (numberOfRemainingDownloads == 0) {
|
||||
processingPass.end(this.cacheManager);
|
||||
if (result.error instanceof CacheFetchError && result.error.isInternetDisconnected)
|
||||
new Notice("No internet connection.");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* - Uses the cache item to load each image element in the request group.
|
||||
* - Sets the resulting {@link HTMLElementCacheState}.
|
||||
* - Sets {@link RequestGroup.cacheFileFound}
|
||||
* - Marks the src reference as retained.
|
||||
*
|
||||
* @param cacheItem
|
||||
* @param requestGroup
|
||||
*/
|
||||
async function handleRequestGroup(cacheItem: CacheItem, requestGroup: RequestGroup) {
|
||||
const imageElements = requestGroup.imageElements;
|
||||
const errorResult = await HtmlAssistant.loadImages(imageElements, cacheItem.resourcePath);
|
||||
|
||||
if (errorResult) {
|
||||
// File as not found on disk.
|
||||
console.error(errorResult.error);
|
||||
//imageElements.forEach((imageElement) => HtmlAssistant.setFailed(imageElement));
|
||||
}
|
||||
else {
|
||||
imageElements.forEach((imageElement) => HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.CACHE_SUCCEEDED));
|
||||
}
|
||||
|
||||
requestGroup.cacheFileFound = errorResult ? false : true;
|
||||
|
||||
if (requestGroup.cacheFileFound) {
|
||||
Log(`requestCache:handleRequestGroup: Found cached image: ${CacheManager.createCacheKeyFromMetadata(cacheItem.metadata)}, ID ${processingPass.passID} 📦📦📦`);
|
||||
processingPass.retainCacheFromRequest(requestGroup.request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - Create {@link CacheRequest} for each unique image source.
|
||||
* - Group images per unique request.
|
||||
* - Set the state to {@link HTMLElementCacheState.REQUESTING} on each element.
|
||||
* - Retain info that could be overwritten while making the request to be able to restore it afterwards.
|
||||
*
|
||||
* @param imageElements
|
||||
* @param processingPass
|
||||
* @returns
|
||||
*/
|
||||
function groupRequests(imageElements: HTMLImageElement[], cacheManager: CacheManager, processingPass: ProcessingPass) {
|
||||
|
||||
const groupedByRequest: Record<string, RequestGroup> = {};
|
||||
|
||||
imageElements.forEach((imageElement) => {
|
||||
HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.REQUESTING);
|
||||
|
||||
const src = HtmlAssistant.imageElementOriginalSrc(imageElement);
|
||||
|
||||
if (src) {
|
||||
const key = CacheManager.createCacheKeyFromOriginalSrc(src);
|
||||
const group = groupedByRequest[key];
|
||||
const alt = imageElement.hasAttribute(HTMLElementAttribute.ALT) ? imageElement.alt : "";
|
||||
|
||||
if (group) {
|
||||
group.imageElements.push(imageElement);
|
||||
group.altAttributeValues.push(alt);
|
||||
}
|
||||
else {
|
||||
console.error("Failed to fetch cache", result.error);
|
||||
imageElements.forEach((imageElement) => HtmlAssistant.setFailed(imageElement));
|
||||
const request: CacheRequest = { source: src, requesterPath: processingPass.associatedFile.path };
|
||||
const validationError = cacheManager.validateRequest(request);
|
||||
if (validationError) {
|
||||
console.error(`Cache request failed validation.`, validationError)
|
||||
HtmlAssistant.setInvalid(imageElement);
|
||||
}
|
||||
else {
|
||||
groupedByRequest[key] = {
|
||||
request,
|
||||
imageElements: [imageElement],
|
||||
altAttributeValues: [alt],
|
||||
cacheFileFound: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
else {
|
||||
console.error(`Image element does not have a source. Omitting.`)
|
||||
HtmlAssistant.setInvalid(imageElement);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(groupedByRequest);
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
/**
|
||||
* Group of images that share the same cash request.
|
||||
*/
|
||||
interface RequestGroup {
|
||||
request: CacheRequest,
|
||||
imageElements: HTMLImageElement[],
|
||||
altAttributeValues: string[],
|
||||
/**
|
||||
* Set to true to indicate that the cache was available and there's no need to download it.
|
||||
* @description This is just a helper because one could iterate the image elements and check the {@link HTMLElementCacheState} to find out.
|
||||
*/
|
||||
cacheFileFound: boolean,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"baseUrl": "src",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"target": "ES2022",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
"lib": ["DOM", "ES2022"]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
|
|
|
|||
Loading…
Reference in a new issue