mirror of
https://github.com/koloml/philomena-tagging-assistant.git
synced 2026-09-22 20:09:39 +00:00
Compare commits
20 Commits
515485b3b8
...
feature/du
| Author | SHA1 | Date | |
|---|---|---|---|
| f4e92e313a | |||
| e1b56586be | |||
| b87a8a43e0 | |||
| d19764699f | |||
| f873cae544 | |||
| c23c871337 | |||
| 38d84d0830 | |||
| 4eecde6b02 | |||
| 394df69936 | |||
| c268eb564d | |||
| cfb4c71f58 | |||
| 67cd17d2b6 | |||
| 1b4cb284da | |||
| 7af6124278 | |||
| 51ea806ddc | |||
| 234db4f147 | |||
| 978918735d | |||
| a6eae657c7 | |||
| 9a245ed0f5 | |||
| 3e5266ca7b |
6
.github/workflows/build-and-tests.yml
vendored
6
.github/workflows/build-and-tests.yml
vendored
@@ -14,12 +14,12 @@ jobs:
|
||||
name: 'Run Unit Tests'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '25'
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
12
.github/workflows/build-extensions.yml
vendored
12
.github/workflows/build-extensions.yml
vendored
@@ -14,12 +14,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '25'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
zip -r "../${{ matrix.site }}-tagging-assistant-extension.zip" .
|
||||
|
||||
- name: Upload extension artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.site }}-tagging-assistant-extension
|
||||
path: ${{ matrix.site }}-tagging-assistant-extension.zip
|
||||
@@ -51,12 +51,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create combined artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: all-extensions
|
||||
path: artifacts/
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"*://*.furbooru.org/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/deps/amd.ts"
|
||||
"src/content/deps/amd.ts",
|
||||
"src/content/user-details.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -103,6 +104,18 @@
|
||||
"css": [
|
||||
"src/styles/content/tag-presets.scss"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/duplicate_reports",
|
||||
"*://*.furbooru.org/duplicate_reports?*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/duplicate-reports.ts"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/duplicate-reports.scss"
|
||||
]
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
|
||||
522
package-lock.json
generated
522
package-lock.json
generated
@@ -28,6 +28,9 @@
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.3.5",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
@@ -833,6 +836,178 @@
|
||||
"@parcel/watcher-win32-x64": "2.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-android-arm64": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz",
|
||||
"integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz",
|
||||
"integrity": "sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-x64": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz",
|
||||
"integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz",
|
||||
"integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz",
|
||||
"integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-musl": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz",
|
||||
"integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz",
|
||||
"integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz",
|
||||
"integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||
"version": "2.5.0",
|
||||
"cpu": [
|
||||
@@ -869,6 +1044,66 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-arm64": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz",
|
||||
"integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-ia32": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz",
|
||||
"integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-x64": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz",
|
||||
"integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.28",
|
||||
"license": "MIT"
|
||||
@@ -2279,279 +2514,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss/node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-character": {
|
||||
"version": "3.0.0",
|
||||
"license": "MIT"
|
||||
@@ -3114,20 +3076,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-check/node_modules/picomatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
|
||||
@@ -35,5 +35,8 @@
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.3.5",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=25"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,3 +128,15 @@ export const tagsBlacklist: string[] = (__CURRENT_SITE__ === 'furbooru' ? [
|
||||
"solo oc",
|
||||
"tag your shit"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Core rating tags used in the Philomena. These rarely change, so they're pretty safe to hardcode into the source code.
|
||||
*/
|
||||
export const ratingTags: string[] = [
|
||||
'safe',
|
||||
'suggestive',
|
||||
'questionable',
|
||||
'explicit',
|
||||
'grimdark',
|
||||
'grotesque',
|
||||
];
|
||||
|
||||
40
src/content/components/philomena/ImageContainer.ts
Normal file
40
src/content/components/philomena/ImageContainer.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
|
||||
export default class ImageContainer extends BaseComponent {
|
||||
#imageLink: HTMLAnchorElement | null = null;
|
||||
|
||||
protected init() {
|
||||
this.#imageLink = this.container.querySelector('a');
|
||||
}
|
||||
|
||||
extractActualTags(): string[] {
|
||||
return this.#imageLink?.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
}
|
||||
|
||||
extractTagsAndAliases(): string[] {
|
||||
return this.container.dataset.imageTagAliases?.split(', ') || [];
|
||||
}
|
||||
|
||||
extractImageLinks(): App.ImageURIs {
|
||||
const jsonUris = this.container?.dataset.uris;
|
||||
|
||||
if (!jsonUris) {
|
||||
throw new Error('Missing URIs!');
|
||||
}
|
||||
|
||||
return JSON.parse(jsonUris);
|
||||
}
|
||||
|
||||
extractSources(): string[] {
|
||||
const jsonSourceUrls = this.container.dataset.sourceUrls;
|
||||
let sourceUrls: string[] | null = null;
|
||||
|
||||
try {
|
||||
sourceUrls = JSON.parse(jsonSourceUrls || '[]');
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse source URLs for the image!', this, e);
|
||||
}
|
||||
|
||||
return sourceUrls || [];
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,18 @@ import { getComponent } from "$content/components/base/component-utils";
|
||||
import { buildTagsAndAliasesMap } from "$lib/philomena/tag-utils";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_TAGS_UPDATED } from "$content/components/events/tagging-profile-popup-events";
|
||||
import ImageContainer from "$content/components/philomena/ImageContainer";
|
||||
|
||||
export class MediaBox extends BaseComponent {
|
||||
#thumbnailContainer: HTMLElement | null = null;
|
||||
#imageLinkElement: HTMLAnchorElement | null = null;
|
||||
#imageContainer: ImageContainer | null = null;
|
||||
#tagsAndAliases: Map<string, string> | null = null;
|
||||
|
||||
init() {
|
||||
this.#thumbnailContainer = this.container.querySelector('.image-container');
|
||||
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
|
||||
const imageContainerElement = this.container.querySelector('.image-container');
|
||||
|
||||
this.#imageContainer = imageContainerElement instanceof HTMLElement
|
||||
? new ImageContainer(imageContainerElement)
|
||||
: null;
|
||||
|
||||
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
}
|
||||
@@ -27,8 +30,8 @@ export class MediaBox extends BaseComponent {
|
||||
}
|
||||
|
||||
#calculateMediaBoxTags() {
|
||||
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
|
||||
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
const tagAliases = this.#imageContainer?.extractTagsAndAliases() || [];
|
||||
const actualTags = this.#imageContainer?.extractActualTags() || [];
|
||||
|
||||
return buildTagsAndAliasesMap(tagAliases, actualTags);
|
||||
}
|
||||
@@ -52,13 +55,13 @@ export class MediaBox extends BaseComponent {
|
||||
}
|
||||
|
||||
get imageLinks(): App.ImageURIs {
|
||||
const jsonUris = this.#thumbnailContainer?.dataset.uris;
|
||||
const sourceUrls = this.#imageContainer?.extractImageLinks();
|
||||
|
||||
if (!jsonUris) {
|
||||
throw new Error('Missing URIs!');
|
||||
if (!sourceUrls) {
|
||||
throw new Error('Missing image container!');
|
||||
}
|
||||
|
||||
return JSON.parse(jsonUris);
|
||||
return sourceUrls;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
232
src/content/components/philomena/dupe/DupeDiff.ts
Normal file
232
src/content/components/philomena/dupe/DupeDiff.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { createFontAwesomeIcon } from "$lib/dom-utils";
|
||||
|
||||
const brandsSubtype = 'brands';
|
||||
|
||||
export default class DupeDiff extends BaseComponent {
|
||||
#sourcesLine: HTMLElement | null = null;
|
||||
#ratingsLine: HTMLElement | null = null;
|
||||
|
||||
protected init() {
|
||||
const {
|
||||
"6": sourcesLine,
|
||||
"7": ratingsLine,
|
||||
} = this.container.querySelectorAll<HTMLElement>('table tr > td')
|
||||
|
||||
this.#sourcesLine = sourcesLine;
|
||||
this.#ratingsLine = ratingsLine;
|
||||
}
|
||||
|
||||
renderSources(leftSources: string[], rightSources: string[]): void {
|
||||
if (!this.#sourcesLine) {
|
||||
console.error("Can't render sources since no sources line in diff fonud!");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const childElement of this.#sourcesLine.children) {
|
||||
childElement.remove();
|
||||
}
|
||||
|
||||
const sourceIconsContainer = document.createElement('span');
|
||||
sourceIconsContainer.classList.add('source-icons');
|
||||
|
||||
sourceIconsContainer.append(
|
||||
this.#renderSourceIcons(leftSources),
|
||||
" vs ",
|
||||
this.#renderSourceIcons(rightSources),
|
||||
);
|
||||
|
||||
this.#sourcesLine.append(sourceIconsContainer);
|
||||
}
|
||||
|
||||
renderRatings(leftRating: string | null, rightRating: string | null) {
|
||||
if (!this.#ratingsLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const childElement of this.#ratingsLine.children) {
|
||||
childElement.remove();
|
||||
}
|
||||
|
||||
if (leftRating === rightRating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ratingDifference = document.createElement('span');
|
||||
ratingDifference.classList.add('ratings-difference');
|
||||
ratingDifference.textContent = `(${leftRating || '(none)'} vs ${rightRating || '(none)'})`;
|
||||
|
||||
this.#ratingsLine.append(ratingDifference);
|
||||
}
|
||||
|
||||
#renderSourceIcons(sourcesList: string[]): HTMLElement {
|
||||
const iconsContainer = document.createElement('span');
|
||||
iconsContainer.classList.add('source-icons__list');
|
||||
|
||||
for (const sourceUrl of sourcesList) {
|
||||
iconsContainer.append(this.#renderIcon(sourceUrl));
|
||||
}
|
||||
|
||||
if (!sourcesList.length) {
|
||||
iconsContainer.append('(none)');
|
||||
}
|
||||
|
||||
return iconsContainer;
|
||||
}
|
||||
|
||||
#renderIcon(url: string): HTMLElement {
|
||||
let iconSlug = 'globe';
|
||||
let maybeSubtype: string | undefined;
|
||||
|
||||
for (const [iconOrIconWithSubtype, singleOrMultiPattern] of DupeDiff.#iconsToPatternsMap) {
|
||||
if (
|
||||
typeof singleOrMultiPattern === 'string' && url.includes(singleOrMultiPattern)
|
||||
|| Array.isArray(singleOrMultiPattern) && singleOrMultiPattern.some(singlePattern => url.includes(singlePattern))
|
||||
) {
|
||||
if (Array.isArray(iconOrIconWithSubtype)) {
|
||||
[iconSlug, maybeSubtype] = iconOrIconWithSubtype;
|
||||
} else {
|
||||
iconSlug = iconOrIconWithSubtype;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceIcon = createFontAwesomeIcon(iconSlug, maybeSubtype);
|
||||
sourceIcon.title = url;
|
||||
|
||||
return sourceIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirroring of mapping from source URL domains to appropriate FontAwesome icons which is used in Philomena. Doesn't
|
||||
* match 1-to-1, but pretty close.
|
||||
*
|
||||
* Keys are the icons and values are the patterns extension should check for each URL.
|
||||
*/
|
||||
static #iconsToPatternsMap = new Map<string[] | string, string[] | string>([
|
||||
[
|
||||
['artstation', brandsSubtype],
|
||||
'artstation.com'
|
||||
],
|
||||
[
|
||||
'bed',
|
||||
'pillowfort.social',
|
||||
],
|
||||
[
|
||||
'bolt-lightning',
|
||||
'boosty.to',
|
||||
],
|
||||
[
|
||||
['bluesky', brandsSubtype],
|
||||
'bsky.app',
|
||||
],
|
||||
[
|
||||
'brush',
|
||||
['artfight.net', 'newgrounds.com'],
|
||||
],
|
||||
[
|
||||
'coffee',
|
||||
['ko-fi.com', 'buymeacoffee.com'],
|
||||
],
|
||||
[
|
||||
['deviantart', brandsSubtype],
|
||||
['deviantart.com', 'sta.sh', 'fav.me'],
|
||||
],
|
||||
[
|
||||
['discord', brandsSubtype],
|
||||
['discordapp.com', 'discord.com', 'discord.gg'],
|
||||
],
|
||||
[
|
||||
'dove',
|
||||
'itaku.ee',
|
||||
],
|
||||
[
|
||||
['etsy', brandsSubtype],
|
||||
'etsy.com',
|
||||
],
|
||||
[
|
||||
['facebook', brandsSubtype],
|
||||
['facebook.com', 'fb.me'],
|
||||
],
|
||||
[
|
||||
['flickr', brandsSubtype],
|
||||
'flickr.com',
|
||||
],
|
||||
[
|
||||
['instagram', brandsSubtype],
|
||||
'instagram.com',
|
||||
],
|
||||
[
|
||||
['mastodon', brandsSubtype],
|
||||
[
|
||||
'awoo.space',
|
||||
'bark.light',
|
||||
'equestria.social',
|
||||
'mastodon.social',
|
||||
'meow.social',
|
||||
'pawoo.net',
|
||||
'pettingzoo.co',
|
||||
'pony.social',
|
||||
'vulpine.club',
|
||||
'yiff.life',
|
||||
'socel.net',
|
||||
'octodon.social',
|
||||
'filly.social',
|
||||
'pone.social',
|
||||
'hooves.social',
|
||||
'baraag.net',
|
||||
'furries.club',
|
||||
],
|
||||
],
|
||||
[
|
||||
'palette',
|
||||
['ych.art', 'commishes.com']
|
||||
],
|
||||
[
|
||||
['patreon', brandsSubtype],
|
||||
'patreon.com'
|
||||
],
|
||||
[
|
||||
'paw',
|
||||
['furaffinity.net', 'e621.net', 'furbooru.org', 'inkbunny.net', 'e926.net', 'sofurry.com', 'weasyl.co'],
|
||||
],
|
||||
[
|
||||
['pixiv', brandsSubtype],
|
||||
['pixiv.net', 'pixiv.me'],
|
||||
],
|
||||
[
|
||||
['reddit', brandsSubtype],
|
||||
['reddit.com', 'redd.it'],
|
||||
],
|
||||
// Patreon piracy website, applying custom icon to easily catch such posts.
|
||||
[
|
||||
'skull-crossbones',
|
||||
'kemono.cr',
|
||||
],
|
||||
[
|
||||
['telegram', brandsSubtype],
|
||||
't.me',
|
||||
],
|
||||
[
|
||||
['tiktok', brandsSubtype],
|
||||
'tiktok.com'
|
||||
],
|
||||
[
|
||||
['tumblr', brandsSubtype],
|
||||
['tumblr.com', 'tmblr.co', 'tumbex.com'],
|
||||
],
|
||||
[
|
||||
['vk', brandsSubtype],
|
||||
['vk.com', 'vk.ru'],
|
||||
],
|
||||
[
|
||||
['x-twitter', brandsSubtype],
|
||||
['x.com', 'twitter.com', 'twimg.com']
|
||||
],
|
||||
[
|
||||
['youtube', brandsSubtype],
|
||||
['youtube.com', 'youtu.be'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
19
src/content/components/philomena/dupe/DupeImage.ts
Normal file
19
src/content/components/philomena/dupe/DupeImage.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import ImageContainer from "$content/components/philomena/ImageContainer";
|
||||
|
||||
export default class DupeImage extends BaseComponent {
|
||||
readonly imageContainer: ImageContainer;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
super(container);
|
||||
|
||||
const imageContainerElement = container.querySelector<HTMLElement>('.image-container');
|
||||
|
||||
if (!imageContainerElement) {
|
||||
throw new Error('Missing image container inside the dupe row!');
|
||||
}
|
||||
|
||||
this.imageContainer = new ImageContainer(imageContainerElement);
|
||||
this.imageContainer.initialize();
|
||||
}
|
||||
}
|
||||
47
src/content/components/philomena/dupe/DupeReportRow.ts
Normal file
47
src/content/components/philomena/dupe/DupeReportRow.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import DupeDiff from "$content/components/philomena/dupe/DupeDiff";
|
||||
import DupeImage from "$content/components/philomena/dupe/DupeImage";
|
||||
import { ratingTags } from "$config/tags";
|
||||
|
||||
export default class DupeReportRow extends BaseComponent {
|
||||
#leftImage: DupeImage;
|
||||
#rightImage: DupeImage;
|
||||
#difference: DupeDiff;
|
||||
|
||||
constructor(leftCell: HTMLElement, rightCell: HTMLElement, diffCell: HTMLElement, reportOptions: HTMLElement) {
|
||||
super(reportOptions);
|
||||
|
||||
this.#leftImage = new DupeImage(leftCell);
|
||||
this.#rightImage = new DupeImage(rightCell);
|
||||
this.#difference = new DupeDiff(diffCell);
|
||||
}
|
||||
|
||||
protected build() {
|
||||
this.#leftImage.initialize();
|
||||
this.#rightImage.initialize();
|
||||
this.#difference.initialize();
|
||||
}
|
||||
|
||||
protected init() {
|
||||
this.#extractAndRenderSourcesIcons();
|
||||
this.#extractAndDisplayRatings();
|
||||
}
|
||||
|
||||
#extractAndRenderSourcesIcons() {
|
||||
this.#difference.renderSources(
|
||||
this.#leftImage.imageContainer.extractSources(),
|
||||
this.#rightImage.imageContainer.extractSources(),
|
||||
);
|
||||
}
|
||||
|
||||
#extractAndDisplayRatings() {
|
||||
this.#difference.renderRatings(
|
||||
DupeReportRow.#extractRating(this.#leftImage),
|
||||
DupeReportRow.#extractRating(this.#rightImage),
|
||||
);
|
||||
}
|
||||
|
||||
static #extractRating(image: DupeImage): string | null {
|
||||
return image.imageContainer.extractActualTags().find(tagName => ratingTags.includes(tagName)) || null;
|
||||
}
|
||||
}
|
||||
49
src/content/components/philomena/dupe/GridDupeReportsList.ts
Normal file
49
src/content/components/philomena/dupe/GridDupeReportsList.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import DupeReportRow from "$content/components/philomena/dupe/DupeReportRow";
|
||||
|
||||
export default class GridDupeReportsList extends BaseComponent {
|
||||
readonly #reports: DupeReportRow[] = [];
|
||||
|
||||
protected build() {
|
||||
const childrenElements = this.container.children;
|
||||
|
||||
for (let cellIndex = 0; cellIndex < this.container.childElementCount; cellIndex += 4) {
|
||||
const startingCell = childrenElements.item(cellIndex);
|
||||
|
||||
// First 4 cells are actually table headers, skipping them.
|
||||
if (!(startingCell instanceof HTMLElement) || startingCell.tagName === 'P') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rightImageCell = startingCell.nextElementSibling;
|
||||
const diffCell = rightImageCell?.nextElementSibling || null;
|
||||
const reportOptionsCell = diffCell?.nextElementSibling || null;
|
||||
|
||||
if (!(rightImageCell instanceof HTMLElement) || !(diffCell instanceof HTMLElement) || !(reportOptionsCell instanceof HTMLElement)) {
|
||||
console.error(`Unable to capture duplicate report row from starting cell at index ${cellIndex}!`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.#reports.push(
|
||||
new DupeReportRow(
|
||||
startingCell,
|
||||
rightImageCell,
|
||||
diffCell,
|
||||
reportOptionsCell,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected init() {
|
||||
for (const report of this.#reports) {
|
||||
report.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
static findAndInitialize() {
|
||||
for (const container of document.querySelectorAll<HTMLElement>('.grid--dupe-report-list')) {
|
||||
new GridDupeReportsList(container).initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/content/duplicate-reports.ts
Normal file
3
src/content/duplicate-reports.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import GridDupeReportsList from "$content/components/philomena/dupe/GridDupeReportsList";
|
||||
|
||||
GridDupeReportsList.findAndInitialize();
|
||||
14
src/content/user-details.ts
Normal file
14
src/content/user-details.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { UserDetails } from "$lib/extension/preferences/UserDetails";
|
||||
|
||||
(async () => {
|
||||
const userDetails = new UserDetails();
|
||||
const userDataStore = document.querySelector<HTMLElement>('.js-datastore');
|
||||
|
||||
if (!userDataStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userDetails.isAuthorized.set(userDataStore.dataset.userIsSignedIn === 'true');
|
||||
})();
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* Reusable function to create icons from FontAwesome. Usable only for website, since extension doesn't host its own
|
||||
* copy of FA styles. Extension should use imports of SVGs inside CSS instead.
|
||||
* @param iconSlug Slug of the icon to be added.
|
||||
* @param [subtype="solid"] Subtype of the icon. Some icons only exist in specific subtypes.
|
||||
* @return Element with classes for FontAwesome icon added.
|
||||
*/
|
||||
export function createFontAwesomeIcon(iconSlug: string): HTMLElement {
|
||||
export function createFontAwesomeIcon(iconSlug: string, subtype: string = 'solid'): HTMLElement {
|
||||
const iconElement = document.createElement('i');
|
||||
iconElement.classList.add('fa-solid', `fa-${iconSlug}`);
|
||||
iconElement.classList.add(`fa-${subtype}`, `fa-${iconSlug}`);
|
||||
return iconElement;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,15 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export default class EntitiesController {
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
/**
|
||||
* Instance of storage helper used to store/read/subscribe to storage changes.
|
||||
*
|
||||
* Mainly exposed for the testing purposes. When class is loaded outside of extension context, will hold `null`
|
||||
* instead. Any operations of entities will throw an error in this case.
|
||||
*/
|
||||
static storage: StorageHelper | null = typeof chrome !== 'undefined'
|
||||
? new StorageHelper(chrome.storage.local)
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
|
||||
@@ -14,7 +22,11 @@ export default class EntitiesController {
|
||||
* @return List of entities of the given type.
|
||||
*/
|
||||
static async readAllEntities<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type): Promise<Type[]> {
|
||||
const rawEntities = await this.#storageHelper.read(entityName, {});
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
const rawEntities = await this.storage.read(entityName, {});
|
||||
|
||||
if (!rawEntities || Object.keys(rawEntities).length === 0) {
|
||||
return [];
|
||||
@@ -32,10 +44,14 @@ export default class EntitiesController {
|
||||
* @param entity Entity to update.
|
||||
*/
|
||||
static async updateEntity(entityName: string, entity: StorageEntity<Object>): Promise<void> {
|
||||
this.#storageHelper.write(
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
this.storage.write(
|
||||
entityName,
|
||||
Object.assign(
|
||||
await this.#storageHelper.read(
|
||||
await this.storage.read(
|
||||
entityName, {}
|
||||
),
|
||||
{
|
||||
@@ -52,9 +68,13 @@ export default class EntitiesController {
|
||||
* @param entityId ID of the entity to delete.
|
||||
*/
|
||||
static async deleteEntity(entityName: string, entityId: string): Promise<void> {
|
||||
const entities = await this.#storageHelper.read(entityName, {});
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
const entities = await this.storage.read(entityName, {});
|
||||
delete entities[entityId];
|
||||
this.#storageHelper.write(entityName, entities);
|
||||
this.storage.write(entityName, entities);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +88,12 @@ export default class EntitiesController {
|
||||
* @return Unsubscribe function.
|
||||
*/
|
||||
static subscribeToEntity<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type, callback: (entities: Type[]) => void): () => void {
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
const storage = this.storage;
|
||||
|
||||
/**
|
||||
* Watch the changes made to the storage and call the callback when the entity changes.
|
||||
*/
|
||||
@@ -80,8 +106,8 @@ export default class EntitiesController {
|
||||
.then(callback);
|
||||
}
|
||||
|
||||
this.#storageHelper.subscribe(subscriber);
|
||||
storage.subscribe(subscriber);
|
||||
|
||||
return () => this.#storageHelper.unsubscribe(subscriber);
|
||||
return () => storage.unsubscribe(subscriber);
|
||||
}
|
||||
}
|
||||
|
||||
16
src/lib/extension/preferences/UserDetails.ts
Normal file
16
src/lib/extension/preferences/UserDetails.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
|
||||
export interface UserDetailsFields {
|
||||
isAuthorized: boolean;
|
||||
}
|
||||
|
||||
export class UserDetails extends CacheablePreferences<UserDetailsFields> implements WithFields<UserDetailsFields> {
|
||||
constructor() {
|
||||
super('userDetails');
|
||||
}
|
||||
|
||||
isAuthorized = new PreferenceField(this, {
|
||||
field: 'isAuthorized',
|
||||
defaultValue: false,
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
import { activeTaggingProfile, taggingProfiles } from "$stores/entities/tagging-profiles";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
import { user } from "$stores/preferences/user";
|
||||
import Notice from "$components/ui/Notice.svelte";
|
||||
|
||||
$popupTitle = 'Tagging Profiles';
|
||||
|
||||
@@ -28,6 +30,18 @@
|
||||
<Menu>
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/profiles/new/edit" icon="plus">Create New</MenuItem>
|
||||
</Menu>
|
||||
{#if $user?.isAuthorized === false}
|
||||
<Menu>
|
||||
<hr>
|
||||
</Menu>
|
||||
<Notice level="warning">
|
||||
<strong>Tagging profiles will only work when you're signed in!</strong>
|
||||
<br><br>
|
||||
Unauthorized users have to solve the captcha before sending any submissions to the site.
|
||||
</Notice>
|
||||
{/if}
|
||||
<Menu>
|
||||
{#if profiles.length}
|
||||
<hr>
|
||||
{/if}
|
||||
|
||||
18
src/stores/preferences/user.ts
Normal file
18
src/stores/preferences/user.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { UserDetails, type UserDetailsFields } from "$lib/extension/preferences/UserDetails";
|
||||
import { readable } from "svelte/store";
|
||||
|
||||
const userDetails = new UserDetails();
|
||||
|
||||
export const user = readable<UserDetailsFields | null>(null, set => {
|
||||
userDetails.subscribe(settings => {
|
||||
set({
|
||||
isAuthorized: Boolean(settings.isAuthorized)
|
||||
});
|
||||
});
|
||||
|
||||
userDetails.isAuthorized.get().then(isAuthorized => {
|
||||
set({
|
||||
isAuthorized
|
||||
});
|
||||
});
|
||||
});
|
||||
18
src/styles/content/duplicate-reports.scss
Normal file
18
src/styles/content/duplicate-reports.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
%with-margin {
|
||||
margin-left: .5em;
|
||||
}
|
||||
|
||||
.dr__diff {
|
||||
.source-icons {
|
||||
@extend %with-margin;
|
||||
|
||||
&__list {
|
||||
display: inline-flex;
|
||||
gap: .25em;
|
||||
}
|
||||
}
|
||||
|
||||
.ratings-difference {
|
||||
@extend %with-margin;
|
||||
}
|
||||
}
|
||||
261
tests/lib/extension/EntitiesController.spec.ts
Normal file
261
tests/lib/extension/EntitiesController.spec.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import EntitiesController from "$lib/extension/EntitiesController";
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import { TestedEntity, type TestedSettings } from "$tests/stubs/Entity";
|
||||
import { randomString } from "$tests/utils";
|
||||
import { randomInt } from "crypto";
|
||||
|
||||
describe('EntitiesController', () => {
|
||||
let mockedStorage: ChromeStorageArea;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedStorage = new ChromeStorageArea();
|
||||
EntitiesController.storage = new StorageHelper(mockedStorage);
|
||||
});
|
||||
|
||||
it('should throw when storage is not present', async () => {
|
||||
EntitiesController.storage = null;
|
||||
|
||||
const readPromise = EntitiesController.readAllEntities(
|
||||
TestedEntity._entityName,
|
||||
TestedEntity,
|
||||
);
|
||||
|
||||
const deletePromise = EntitiesController.deleteEntity(
|
||||
TestedEntity._entityName,
|
||||
randomString(),
|
||||
);
|
||||
|
||||
const updatePromise = EntitiesController.updateEntity(
|
||||
TestedEntity._entityName,
|
||||
new TestedEntity(randomString(), {
|
||||
numberField: randomInt(1000),
|
||||
stringField: randomString(),
|
||||
}),
|
||||
);
|
||||
|
||||
const subscribe = () => {
|
||||
EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, vi.fn());
|
||||
}
|
||||
|
||||
await expect(readPromise).rejects.toThrow(Error);
|
||||
await expect(deletePromise).rejects.toThrow(Error);
|
||||
await expect(updatePromise).rejects.toThrow(Error);
|
||||
|
||||
expect(subscribe).toThrow(Error);
|
||||
});
|
||||
|
||||
describe('readAllEntities', () => {
|
||||
it('should return empty array when nothing in the storage yet', async () => {
|
||||
const entities = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
expect(entities).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should properly capture different entities from storage', async () => {
|
||||
const storageWithEntities: Record<string, Record<string, Partial<TestedSettings>>> = {
|
||||
[TestedEntity._entityName]: {
|
||||
[randomString()]: {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100_000, 100_000),
|
||||
},
|
||||
[randomString()]: {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100_000, 100_000),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
mockedStorage.insertMockedData(storageWithEntities);
|
||||
|
||||
const loadedEntities = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
|
||||
expect(loadedEntities).toHaveLength(2);
|
||||
|
||||
for (const entity of loadedEntities) {
|
||||
const rawStorageEntry = storageWithEntities[TestedEntity._entityName][entity.id];
|
||||
|
||||
expect(entity.settings.stringField).toBe(rawStorageEntry.stringField);
|
||||
expect(entity.settings.numberField).toBe(rawStorageEntry.numberField);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEntity', () => {
|
||||
it('should create a storage structure if it is not created yet', async () => {
|
||||
expect(mockedStorage.mockedData).toEqual({});
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(100_000),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[entity.id]: {
|
||||
stringField: entity.settings.stringField,
|
||||
numberField: entity.settings.numberField,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should update entity inside the existing', async () => {
|
||||
const id = randomString();
|
||||
const initialStringValue = randomString();
|
||||
const updatedStringValue = randomString();
|
||||
|
||||
mockedStorage.insertMockedData({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: {
|
||||
stringField: initialStringValue,
|
||||
numberField: randomInt(100_000),
|
||||
} as TestedSettings,
|
||||
}
|
||||
});
|
||||
|
||||
const [entity] = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
expect(entity.settings.stringField).toBe(initialStringValue);
|
||||
|
||||
entity.settings.stringField = updatedStringValue;
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
const entityInsideStorage = mockedStorage.mockedData[TestedEntity._entityName][id];
|
||||
expect(entityInsideStorage.stringField).toBe(updatedStringValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEntity', () => {
|
||||
it('should initialize the storage structure if delete called', async () => {
|
||||
expect(mockedStorage.mockedData).toEqual({});
|
||||
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, randomString());
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete entity and keep the storage object empty', async () => {
|
||||
const id = randomString();
|
||||
const settings: TestedSettings = {
|
||||
numberField: randomInt(100_000),
|
||||
stringField: randomString(),
|
||||
};
|
||||
|
||||
mockedStorage.insertMockedData({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: settings,
|
||||
}
|
||||
});
|
||||
|
||||
// Doesn't touch existing instance if ID is not found in the storage
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, randomString());
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: settings,
|
||||
}
|
||||
});
|
||||
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, id);
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeToEntity', () => {
|
||||
it('should notify about changes and return new entities', async () => {
|
||||
let receivedEntities: TestedEntity[] | null = null;
|
||||
|
||||
const subscriber = vi.fn((entities: TestedEntity[]) => {
|
||||
receivedEntities = entities;
|
||||
});
|
||||
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, subscriber);
|
||||
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
|
||||
const createdEntity = new TestedEntity(randomString(), {
|
||||
numberField: randomInt(100),
|
||||
stringField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, createdEntity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalled();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
const [firstReceivedEntity] = receivedEntities || [];
|
||||
|
||||
expect(firstReceivedEntity).toBeInstanceOf(TestedEntity);
|
||||
expect(firstReceivedEntity).not.toBe(createdEntity);
|
||||
expect(firstReceivedEntity).toEqual(createdEntity);
|
||||
});
|
||||
|
||||
it('should stop receiving updates once unsubscribed', async () => {
|
||||
const firstSubscriber = vi.fn();
|
||||
const unsubscribeFirst = EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, firstSubscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
numberField: randomInt(100_000),
|
||||
stringField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(firstSubscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
firstSubscriber.mockReset();
|
||||
unsubscribeFirst();
|
||||
|
||||
const secondSubscriber = vi.fn();
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, secondSubscriber);
|
||||
|
||||
entity.settings.stringField = randomString();
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(secondSubscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
expect(firstSubscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not notify when something else was changed in the storage', async () => {
|
||||
const rawStorageSubscriber = vi.fn();
|
||||
const entitiesSubscriber = vi.fn();
|
||||
|
||||
void EntitiesController.storage?.subscribe(rawStorageSubscriber);
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, entitiesSubscriber);
|
||||
|
||||
EntitiesController.storage?.write('otherStorage', {
|
||||
someField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(
|
||||
TestedEntity._entityName,
|
||||
new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(100_000),
|
||||
}),
|
||||
);
|
||||
|
||||
EntitiesController.storage?.write('otherStorage', {
|
||||
someField: randomString(),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(entitiesSubscriber).toHaveBeenCalledOnce();
|
||||
expect(rawStorageSubscriber).toHaveBeenCalledTimes(3);
|
||||
}, {timeout: 10, interval: 1});
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,6 @@
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import { randomString } from "$tests/utils";
|
||||
import { randomInt } from "crypto";
|
||||
|
||||
interface TestedFields {
|
||||
numberField: number;
|
||||
stringField: string;
|
||||
}
|
||||
|
||||
class TestedPreferences extends CacheablePreferences<TestedFields> implements WithFields<TestedFields> {
|
||||
readonly defaults: TestedFields;
|
||||
readonly mockedSettingsNamespace: string;
|
||||
readonly mockedStorageArea: ChromeStorageArea;
|
||||
readonly mockedStorageHelper: StorageHelper;
|
||||
|
||||
numberField;
|
||||
stringField;
|
||||
|
||||
constructor(settingsNamespace: string, mockedDefaults: TestedFields) {
|
||||
const mockedStorageArea = new ChromeStorageArea();
|
||||
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
|
||||
const mockedConfigurationController = new ConfigurationController(
|
||||
settingsNamespace,
|
||||
mockedStorageHelper,
|
||||
);
|
||||
|
||||
super(settingsNamespace, mockedConfigurationController);
|
||||
|
||||
this.mockedSettingsNamespace = settingsNamespace;
|
||||
this.mockedStorageArea = mockedStorageArea;
|
||||
this.mockedStorageHelper = mockedStorageHelper;
|
||||
this.defaults = mockedDefaults;
|
||||
|
||||
this.numberField = new PreferenceField(this, {
|
||||
field: 'numberField',
|
||||
defaultValue: this.defaults.numberField,
|
||||
});
|
||||
|
||||
this.stringField = new PreferenceField(this, {
|
||||
field: 'stringField',
|
||||
defaultValue: this.defaults.stringField,
|
||||
});
|
||||
}
|
||||
}
|
||||
import { TestedPreferences } from "$tests/stubs/Preferences";
|
||||
|
||||
describe('CachablePreferences', () => {
|
||||
let preferences: TestedPreferences;
|
||||
|
||||
239
tests/lib/extension/base/StorageEntity.spec.ts
Normal file
239
tests/lib/extension/base/StorageEntity.spec.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import { randomString } from "$tests/utils";
|
||||
import { randomInt } from "crypto";
|
||||
import EntitiesController from "$lib/extension/EntitiesController";
|
||||
import { TestedEntity } from "$tests/stubs/Entity";
|
||||
|
||||
describe("StorageEntity", () => {
|
||||
let mockedStorageArea: ChromeStorageArea;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedStorageArea = new ChromeStorageArea();
|
||||
EntitiesController.storage = new StorageHelper(mockedStorageArea);
|
||||
});
|
||||
|
||||
describe("readAll", () => {
|
||||
it("should return empty array if no entities stored", async () => {
|
||||
const entities = await TestedEntity.readAll();
|
||||
|
||||
expect(entities).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should read all entities from storage", async () => {
|
||||
const entity1 = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
const entity2 = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity1.save();
|
||||
await entity2.save();
|
||||
|
||||
const entities = await TestedEntity.readAll();
|
||||
|
||||
expect(entities).toHaveLength(2);
|
||||
expect(entities[0].id).toBe(entity1.id);
|
||||
expect(entities[0].settings).toEqual(entity1.settings);
|
||||
expect(entities[1].id).toBe(entity2.id);
|
||||
expect(entities[1].settings).toEqual(entity2.settings);
|
||||
});
|
||||
|
||||
it("should build entities with correct class", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
const [savedEntity] = await TestedEntity.readAll();
|
||||
|
||||
expect(savedEntity).toBeInstanceOf(TestedEntity);
|
||||
});
|
||||
});
|
||||
|
||||
describe("save", () => {
|
||||
it("should save entity to storage", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
expect(mockedStorageArea.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[entity.id]: entity.settings,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should overwrite existing entity with same ID", async () => {
|
||||
const id = randomString();
|
||||
const originalSettings = {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
};
|
||||
|
||||
const entity1 = new TestedEntity(id, originalSettings);
|
||||
await entity1.save();
|
||||
|
||||
const updatedSettings = {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
};
|
||||
|
||||
const entity2 = new TestedEntity(id, updatedSettings);
|
||||
await entity2.save();
|
||||
|
||||
expect(mockedStorageArea.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: updatedSettings,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should delete entity from storage", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
expect(mockedStorageArea.mockedData[TestedEntity._entityName]).not.toEqual({});
|
||||
|
||||
await entity.delete();
|
||||
expect(mockedStorageArea.mockedData[TestedEntity._entityName]).toEqual({});
|
||||
});
|
||||
|
||||
it("should not fail if entity does not exist", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await expect(entity.delete()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribe", () => {
|
||||
it("should notify about new entities", async () => {
|
||||
const subscriber = vi.fn();
|
||||
void TestedEntity.subscribe(subscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
// Saving is not notified about immediately.
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
expect(subscriber).toHaveBeenCalledWith([entity]);
|
||||
}, {timeout: 10, interval: 1});
|
||||
});
|
||||
|
||||
it("should notify about entity updates", async () => {
|
||||
const subscriber = vi.fn();
|
||||
void TestedEntity.subscribe(subscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
subscriber.mockReset();
|
||||
|
||||
entity.settings.stringField = randomString();
|
||||
await entity.save();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
expect(subscriber).toHaveBeenCalledWith([entity]);
|
||||
}, {interval: 1, timeout: 10});
|
||||
});
|
||||
|
||||
it("should notify about entity deletion", async () => {
|
||||
const subscriber = vi.fn();
|
||||
void TestedEntity.subscribe(subscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
await entity.delete();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledTimes(2);
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
expect(subscriber).toHaveBeenCalledWith([entity]);
|
||||
expect(subscriber).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("should stop notifications after unsubscribe", async () => {
|
||||
const subscriber = vi.fn();
|
||||
const unsubscribe = TestedEntity.subscribe(subscriber);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("properties", () => {
|
||||
it("should expose id", () => {
|
||||
const id = randomString();
|
||||
const entity = new TestedEntity(id, {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
expect(entity.id).toBe(id);
|
||||
});
|
||||
|
||||
it("should expose settings", () => {
|
||||
const settings = {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
};
|
||||
|
||||
const entity = new TestedEntity(randomString(), settings);
|
||||
|
||||
expect(entity.settings).toEqual(settings);
|
||||
});
|
||||
|
||||
it("should expose type from _entityName", () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
expect(entity.type).toBe(TestedEntity._entityName);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +1,141 @@
|
||||
import { URL } from 'url';
|
||||
import { resolveTagNameFromLink, slugEncodedCharacters } from '$lib/philomena/tag-utils';
|
||||
import {
|
||||
buildTagsAndAliasesMap,
|
||||
resolveTagCategoryFromTagName,
|
||||
resolveTagNameFromLink,
|
||||
slugEncodedCharacters
|
||||
} from '$lib/philomena/tag-utils';
|
||||
import { randomString } from "$tests/utils";
|
||||
import { namespaceCategories } from "$config/tags";
|
||||
|
||||
describe('tag-utils', () => {
|
||||
const origin = 'https://furbooru.org';
|
||||
const origin = 'https://furbooru.org';
|
||||
|
||||
describe('resolveTagNameFromLink', () => {
|
||||
function resolveFromSearchQuery(encodedQuery: string): string | null {
|
||||
return resolveTagNameFromLink(new URL(`/search?q=${encodedQuery}`, origin));
|
||||
}
|
||||
describe('buildTagsAndAliasesMap', () => {
|
||||
it('should return regular tags if both real and real+alias tags are the same', () => {
|
||||
const tagsAndAliases = ['avali', 'experiment (casualties unknown)', 'fictional species'];
|
||||
|
||||
describe('Parsing from /search/?q=tag links', () => {
|
||||
it('should resolve a single tag from /search URLs', () => {
|
||||
expect(resolveFromSearchQuery('safe')).toBe('safe');
|
||||
});
|
||||
|
||||
it('should return null for queries with multiple comma-separated tags', () => {
|
||||
// Comma acts as a separator in the query, resulting in multiple tokens
|
||||
expect(resolveFromSearchQuery('safe, suggestive')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null if query is empty or not a term', () => {
|
||||
expect(resolveFromSearchQuery('')).toBe(null);
|
||||
expect(resolveFromSearchQuery('!')).toBe(null);
|
||||
});
|
||||
|
||||
it('should properly treat parentheses in the query with single tag', () => {
|
||||
// Parentheses are operators in the query language, but when inside the tag name, they should still be properly
|
||||
// working.
|
||||
expect(resolveFromSearchQuery('experiment (casualties unknown)')).toBe('experiment (casualties unknown)');
|
||||
});
|
||||
|
||||
it('should properly resolve queries with encoded characters', () => {
|
||||
expect(resolveFromSearchQuery('pok%C3%A9mon')).toBe('pokémon');
|
||||
});
|
||||
|
||||
it('should unquote quoted term', () => {
|
||||
expect(resolveFromSearchQuery('"experiment (casualties unknown)"')).toBe('experiment (casualties unknown)')
|
||||
expect(resolveFromSearchQuery('"single tag, really"')).toBe('single tag, really');
|
||||
});
|
||||
})
|
||||
|
||||
describe('Parsing from /tags/name links', () => {
|
||||
function resolveFromTagLink(encodedTagName: string): string | null {
|
||||
return resolveTagNameFromLink(new URL(`/tags/${encodedTagName}`, origin));
|
||||
expect(buildTagsAndAliasesMap(tagsAndAliases, tagsAndAliases)).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"avali" => "avali",
|
||||
"experiment (casualties unknown)" => "experiment (casualties unknown)",
|
||||
"fictional species" => "fictional species",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should resolve a single tag', () => {
|
||||
expect(resolveFromTagLink('safe')).toBe('safe');
|
||||
});
|
||||
it('should identify any aliases going after the real tag', () => {
|
||||
const realTags = ['avali', 'experiment (casualties unknown)', 'fictional species'];
|
||||
const realAndAliasesTags = ['avali', 'experiment (casualties unknown)', 'experiment (gunsaw)', 'fictional species'];
|
||||
|
||||
it('should only read the tag page even if query is provided', () => {
|
||||
expect(resolveFromTagLink('grotesque?q=explicit')).toBe('grotesque');
|
||||
});
|
||||
expect(buildTagsAndAliasesMap(realAndAliasesTags, realTags)).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"avali" => "avali",
|
||||
"experiment (casualties unknown)" => "experiment (casualties unknown)",
|
||||
"fictional species" => "fictional species",
|
||||
"experiment (gunsaw)" => "experiment (casualties unknown)",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should properly resolve links with encoded characters', () => {
|
||||
expect(resolveFromTagLink('pok%C3%A9mon')).toBe('pokémon');
|
||||
});
|
||||
it('should ignore any non-real tags coming before first tag is found', () => {
|
||||
const outOfOrderTag = randomString();
|
||||
|
||||
it('should decoded slug-encoded characters', () => {
|
||||
// More common example where tag is.
|
||||
expect(resolveFromTagLink(`namespace-colon-tag+name`)).toBe('namespace:tag name');
|
||||
const realTags = ['avali', 'experiment (casualties unknown)', 'fictional species'];
|
||||
const realAndAliasesTags = [outOfOrderTag, 'avali', 'experiment (casualties unknown)', 'experiment (gunsaw)', 'fictional species'];
|
||||
|
||||
// Testing the whole list of encoded characters.
|
||||
for (const [encodedCharacter, decodedCharacter] of slugEncodedCharacters.entries()) {
|
||||
expect(resolveFromTagLink(`test+symbol${encodedCharacter}without+spaces`)).toBe(`test symbol${decodedCharacter}without spaces`);
|
||||
expect(resolveFromTagLink(`test+symbol+${encodedCharacter}+with+spaces`)).toBe(`test symbol ${decodedCharacter} with spaces`);
|
||||
}
|
||||
});
|
||||
});
|
||||
const warn = vi.spyOn(console, 'warn');
|
||||
|
||||
it('should return null for unsupported URLs', () => {
|
||||
expect(resolveTagNameFromLink(new URL('/pages/example', origin))).toBe(null);
|
||||
});
|
||||
expect(buildTagsAndAliasesMap(realAndAliasesTags, realTags)).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"avali" => "avali",
|
||||
"experiment (casualties unknown)" => "experiment (casualties unknown)",
|
||||
"fictional species" => "fictional species",
|
||||
"experiment (gunsaw)" => "experiment (casualties unknown)",
|
||||
}
|
||||
`);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(`No real tag found for the alias:`, outOfOrderTag);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTagNameFromLink', () => {
|
||||
function resolveFromSearchQuery(encodedQuery: string): string | null {
|
||||
return resolveTagNameFromLink(new URL(`/search?q=${encodedQuery}`, origin));
|
||||
}
|
||||
|
||||
describe('Parsing from /search/?q=tag links', () => {
|
||||
it('should resolve a single tag from /search URLs', () => {
|
||||
expect(resolveFromSearchQuery('safe')).toBe('safe');
|
||||
});
|
||||
|
||||
it('should return null for queries with multiple comma-separated tags', () => {
|
||||
// Comma acts as a separator in the query, resulting in multiple tokens
|
||||
expect(resolveFromSearchQuery('safe, suggestive')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null if query is empty or not a term', () => {
|
||||
expect(resolveFromSearchQuery('')).toBe(null);
|
||||
expect(resolveFromSearchQuery('!')).toBe(null);
|
||||
});
|
||||
|
||||
it('should properly treat parentheses in the query with single tag', () => {
|
||||
// Parentheses are operators in the query language, but when inside the tag name, they should still be properly
|
||||
// working.
|
||||
expect(resolveFromSearchQuery('experiment (casualties unknown)')).toBe('experiment (casualties unknown)');
|
||||
});
|
||||
|
||||
it('should properly resolve queries with encoded characters', () => {
|
||||
expect(resolveFromSearchQuery('pok%C3%A9mon')).toBe('pokémon');
|
||||
});
|
||||
|
||||
it('should unquote quoted term', () => {
|
||||
expect(resolveFromSearchQuery('"experiment (casualties unknown)"')).toBe('experiment (casualties unknown)')
|
||||
expect(resolveFromSearchQuery('"single tag, really"')).toBe('single tag, really');
|
||||
});
|
||||
})
|
||||
|
||||
describe('Parsing from /tags/name links', () => {
|
||||
function resolveFromTagLink(encodedTagName: string): string | null {
|
||||
return resolveTagNameFromLink(new URL(`/tags/${encodedTagName}`, origin));
|
||||
}
|
||||
|
||||
it('should resolve a single tag', () => {
|
||||
expect(resolveFromTagLink('safe')).toBe('safe');
|
||||
});
|
||||
|
||||
it('should only read the tag page even if query is provided', () => {
|
||||
expect(resolveFromTagLink('grotesque?q=explicit')).toBe('grotesque');
|
||||
});
|
||||
|
||||
it('should properly resolve links with encoded characters', () => {
|
||||
expect(resolveFromTagLink('pok%C3%A9mon')).toBe('pokémon');
|
||||
});
|
||||
|
||||
it('should decoded slug-encoded characters', () => {
|
||||
// More common example where tag is.
|
||||
expect(resolveFromTagLink(`namespace-colon-tag+name`)).toBe('namespace:tag name');
|
||||
|
||||
// Testing the whole list of encoded characters.
|
||||
for (const [encodedCharacter, decodedCharacter] of slugEncodedCharacters.entries()) {
|
||||
expect(resolveFromTagLink(`test+symbol${encodedCharacter}without+spaces`)).toBe(`test symbol${decodedCharacter}without spaces`);
|
||||
expect(resolveFromTagLink(`test+symbol+${encodedCharacter}+with+spaces`)).toBe(`test symbol ${decodedCharacter} with spaces`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for unsupported URLs', () => {
|
||||
expect(resolveTagNameFromLink(new URL('/pages/example', origin))).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTagCategoryFromTagName', () => {
|
||||
it('should resolve any known namespace into its known category', () => {
|
||||
for (const [namespace, category] of namespaceCategories) {
|
||||
expect(resolveTagCategoryFromTagName(`${namespace}:${randomString()}`)).toBe(category);
|
||||
}
|
||||
});
|
||||
|
||||
it('should ignore any namespace not listed in config', () => {
|
||||
expect(resolveTagCategoryFromTagName(`${randomString()}:${randomString()}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
17
tests/stubs/Entity.ts
Normal file
17
tests/stubs/Entity.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export interface TestedSettings {
|
||||
stringField: string;
|
||||
numberField: number;
|
||||
nested?: {
|
||||
field: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class TestedEntity extends StorageEntity<TestedSettings> {
|
||||
static readonly _entityName = "entity";
|
||||
|
||||
constructor(id: string, settings: TestedSettings) {
|
||||
super(id, settings);
|
||||
}
|
||||
}
|
||||
45
tests/stubs/Preferences.ts
Normal file
45
tests/stubs/Preferences.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
|
||||
export interface TestedFields {
|
||||
numberField: number;
|
||||
stringField: string;
|
||||
}
|
||||
|
||||
export class TestedPreferences extends CacheablePreferences<TestedFields> implements WithFields<TestedFields> {
|
||||
readonly defaults: TestedFields;
|
||||
readonly mockedSettingsNamespace: string;
|
||||
readonly mockedStorageArea: ChromeStorageArea;
|
||||
readonly mockedStorageHelper: StorageHelper;
|
||||
|
||||
numberField;
|
||||
stringField;
|
||||
|
||||
constructor(settingsNamespace: string, mockedDefaults: TestedFields) {
|
||||
const mockedStorageArea = new ChromeStorageArea();
|
||||
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
|
||||
const mockedConfigurationController = new ConfigurationController(
|
||||
settingsNamespace,
|
||||
mockedStorageHelper,
|
||||
);
|
||||
|
||||
super(settingsNamespace, mockedConfigurationController);
|
||||
|
||||
this.mockedSettingsNamespace = settingsNamespace;
|
||||
this.mockedStorageArea = mockedStorageArea;
|
||||
this.mockedStorageHelper = mockedStorageHelper;
|
||||
this.defaults = mockedDefaults;
|
||||
|
||||
this.numberField = new PreferenceField(this, {
|
||||
field: 'numberField',
|
||||
defaultValue: this.defaults.numberField,
|
||||
});
|
||||
|
||||
this.stringField = new PreferenceField(this, {
|
||||
field: 'stringField',
|
||||
defaultValue: this.defaults.stringField,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user