diff --git a/.github/assets/fullscreen-viewer-icon.png b/.github/assets/fullscreen-viewer-icon.png index 1db420b..ef75831 100644 Binary files a/.github/assets/fullscreen-viewer-icon.png and b/.github/assets/fullscreen-viewer-icon.png differ diff --git a/.github/assets/fullscreen-viewer-showcase.png b/.github/assets/fullscreen-viewer-showcase.png index 5bb8bd6..ce39552 100644 Binary files a/.github/assets/fullscreen-viewer-showcase.png and b/.github/assets/fullscreen-viewer-showcase.png differ diff --git a/.github/assets/fullscreen-viewer-tags.png b/.github/assets/fullscreen-viewer-tags.png new file mode 100644 index 0000000..9ccbac9 Binary files /dev/null and b/.github/assets/fullscreen-viewer-tags.png differ diff --git a/.github/assets/staff-duplicates-diff.png b/.github/assets/staff-duplicates-diff.png new file mode 100644 index 0000000..1ccd035 Binary files /dev/null and b/.github/assets/staff-duplicates-diff.png differ diff --git a/.github/workflows/build-and-tests.yml b/.github/workflows/build-and-tests.yml index a672dd9..85fac00 100644 --- a/.github/workflows/build-and-tests.yml +++ b/.github/workflows/build-and-tests.yml @@ -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 diff --git a/.github/workflows/build-extensions.yml b/.github/workflows/build-extensions.yml index 5885ba6..14d44ab 100644 --- a/.github/workflows/build-extensions.yml +++ b/.github/workflows/build-extensions.yml @@ -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/ diff --git a/README.md b/README.md index b238b8e..41fe42c 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,11 @@ feature is opt-in and should be enabled in the settings first. ![Fullscreen Viewer Showcase](.github/assets/fullscreen-viewer-showcase.png) +Fullscreen view now also has interface for quick addition and removal of tags. It will show both currently active +tagging profile and tags currently assigned to the image. + +![Fullscreen Viewer with Tags](.github/assets/fullscreen-viewer-tags.png) + ## Building Recommendations on environment: diff --git a/manifest.json b/manifest.json index 795647e..1ea6f8b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "Furbooru Tagging Assistant", "description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.", - "version": "0.7.2", + "version": "0.8.0", "browser_specific_settings": { "gecko": { "id": "furbooru-tagging-assistant@thecore.city", @@ -10,7 +10,8 @@ "none" ] } - } + }, + "gecko_android": {} }, "icons": { "16": "icon16.png", @@ -28,7 +29,8 @@ "*://*.furbooru.org/*" ], "js": [ - "src/content/deps/amd.ts" + "src/content/deps/amd.ts", + "src/content/user-details.ts" ] }, { @@ -103,6 +105,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": { diff --git a/package-lock.json b/package-lock.json index a045ff1..bca9a2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,33 +1,36 @@ { "name": "furbooru-tagging-assistant", - "version": "0.7.2", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "furbooru-tagging-assistant", - "version": "0.7.2", + "version": "0.8.0", "dependencies": { - "@fortawesome/fontawesome-free": "^7.2.0", + "@fortawesome/fontawesome-free": "^7.3.1", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.65.0", + "@sveltejs/kit": "^2.70.3", "amd-lite": "^1.0.1", "lz-string": "^1.5.0", - "sass": "^1.101.0", - "svelte": "^5.56.3" + "sass": "^1.105.1", + "svelte": "^5.57.2" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@types/chrome": "^0.1.43", - "@types/node": "^25.9.3", - "@vitest/coverage-v8": "^4.1.8", + "@types/chrome": "^0.3.5", + "@types/node": "^25.9.9", + "@vitest/coverage-v8": "^4.1.11", "cheerio": "^1.2.0", "cross-env": "^10.1.0", "jsdom": "^29.1.1", - "svelte-check": "^4.6.0", + "svelte-check": "^4.7.6", "typescript": "^6.0.3", - "vite": "^7.3.5", - "vitest": "^4.1.8" + "vite": "^7.3.7", + "vitest": "^4.1.11" + }, + "engines": { + "node": ">=25" } }, "node_modules/@asamuzakjp/css-color": { @@ -736,9 +739,9 @@ } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz", - "integrity": "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz", + "integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==", "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", "engines": { "node": ">=6" @@ -784,9 +787,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -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" @@ -1205,9 +1440,9 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", - "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1223,9 +1458,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.65.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.65.0.tgz", - "integrity": "sha512-nUWJ4dSKNo8mIOh+HTL+XyRj8FX9Dyb1ayBxj4q9+WrTJfn4jfTt21p3WUFTnnmdnt9xAXpBKLQ+H9y41x0X7Q==", + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1264,9 +1499,9 @@ } }, "node_modules/@sveltejs/load-config": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz", - "integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", "dev": true, "license": "MIT", "engines": { @@ -1322,9 +1557,9 @@ } }, "node_modules/@types/chrome": { - "version": "0.1.43", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.43.tgz", - "integrity": "sha512-ukH/HhmR6ht+UTX3PLUWJxgJ/RQcK2Foj4lBzsF24SIWsXgqhGuXqjd8FFuwioPP7d/JUKLM4g8GZxw3F4HTcA==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.3.5.tgz", + "integrity": "sha512-7HYsK7xalNxzLwGrP9lkCQPAA6V56VtPpOmv2CQnBnMzX5/Hpr+Iv5fqaIOIY/H/Q6ZBksLmurWh7zXPcZ4B7Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1368,30 +1603,24 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "version": "25.9.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.9.tgz", + "integrity": "sha512-b4e2xxj/yMeT2hNlD6x7zNFiQK2Dlmkb9CQ6v3/1G2RezKN4YS2lcEv8lzGm1mhs4f2lM4yNAMI/zUDBZBBt7w==", "devOptional": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" - }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", - "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1405,8 +1634,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.8", - "vitest": "4.1.8" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1415,16 +1644,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1433,13 +1662,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1460,9 +1689,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1473,13 +1702,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1487,14 +1716,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1503,9 +1732,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -1513,13 +1742,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1528,9 +1757,9 @@ } }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1611,9 +1840,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -1822,9 +2051,9 @@ } }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", "license": "MIT" }, "node_modules/dom-serializer": { @@ -1959,9 +2188,9 @@ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" }, "node_modules/esrap": { - "version": "2.2.11", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", - "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -2279,279 +2508,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" @@ -2893,9 +2849,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.101.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", - "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "version": "1.105.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.105.1.tgz", + "integrity": "sha512-1CnAWPyrrnVQzXGB/DUep1vYCKCm1CIBr+YxjjiW6wk9qtzemnGP4qicH56T2F9S4Hjtv/Ne3jDz8JqfdDU4oA==", "license": "MIT", "dependencies": { "chokidar": "^5.0.0", @@ -3049,23 +3005,22 @@ } }, "node_modules/svelte": { - "version": "5.56.3", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz", - "integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==", + "version": "5.57.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", + "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.8.1", + "devalue": "^5.9.2", "esm-env": "^1.2.1", - "esrap": "^2.2.11", + "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -3076,14 +3031,14 @@ } }, "node_modules/svelte-check": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.6.0.tgz", - "integrity": "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==", + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", - "@sveltejs/load-config": "0.1.1", + "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", @@ -3097,7 +3052,7 @@ }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": ">=5.0.0" + "typescript": "^5.0.0 || ^6.0.0" } }, "node_modules/svelte-check/node_modules/fdir": { @@ -3114,19 +3069,11 @@ } } }, - "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/svelte/node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" }, "node_modules/symbol-tree": { "version": "3.2.4", @@ -3303,12 +3250,12 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", - "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -3425,19 +3372,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -3465,12 +3412,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 6aa55c8..b879a03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "furbooru-tagging-assistant", - "version": "0.7.2", + "version": "0.8.0", "private": true, "type": "module", "scripts": { @@ -16,24 +16,27 @@ }, "dependencies": { "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.65.0", - "@fortawesome/fontawesome-free": "^7.2.0", + "@sveltejs/kit": "^2.70.3", + "@fortawesome/fontawesome-free": "^7.3.1", "amd-lite": "^1.0.1", "lz-string": "^1.5.0", - "sass": "^1.101.0", - "svelte": "^5.56.3" + "sass": "^1.105.1", + "svelte": "^5.57.2" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@types/chrome": "^0.1.43", - "@types/node": "^25.9.3", - "@vitest/coverage-v8": "^4.1.8", + "@types/chrome": "^0.3.5", + "@types/node": "^25.9.9", + "@vitest/coverage-v8": "^4.1.11", "cheerio": "^1.2.0", "cross-env": "^10.1.0", "jsdom": "^29.1.1", - "svelte-check": "^4.6.0", + "svelte-check": "^4.7.6", "typescript": "^6.0.3", - "vite": "^7.3.5", - "vitest": "^4.1.8" + "vite": "^7.3.7", + "vitest": "^4.1.11" + }, + "engines": { + "node": ">=25" } } diff --git a/src/config/tags.ts b/src/config/tags.ts index 2065e88..34080d8 100644 --- a/src/config/tags.ts +++ b/src/config/tags.ts @@ -1,18 +1,24 @@ +/** + * Enum-like object with categories used by the Philomena, available for calling in the code. Values are technical + * names of these categories. + */ +export const CategoriesEnum = { + Rating: 'rating', + Spoiler: 'spoiler', + Origin: 'origin', + OC: 'oc', + Error: 'error', + Character: 'character', + ContentOfficial: 'content-official', + ContentFanmade: 'content-fanmade', + Species: 'species', + BodyType: 'body-type', +} + /** * List of categories defined by the sites. */ -export const categories: string[] = [ - 'rating', - 'spoiler', - 'origin', - 'oc', - 'error', - 'character', - 'content-official', - 'content-fanmade', - 'species', - 'body-type', -]; +export const categories: string[] = Object.values(CategoriesEnum); /** * Mapping of namespaces to their respective categories. These namespaces are automatically assigned to them, so we can @@ -23,21 +29,21 @@ export const categories: string[] = [ * @see https://github.com/philomena-dev/philomena/blob/6086757b654da8792ae52adb2a2f501ea6c30d12/lib/philomena/tags/tag.ex#L33-L45 */ export const namespaceCategories: Map = new Map([ - ['artist', 'origin'], - ['art pack', 'content-fanmade'], - ['colorist', 'origin'], - ['comic', 'content-fanmade'], - ['editor', 'origin'], - ['fanfic', 'content-fanmade'], - ['oc', 'oc'], - ['photographer', 'origin'], - ['series', 'content-fanmade'], - ['spoiler', 'spoiler'], - ['video', 'content-fanmade'], - ...(__CURRENT_SITE__ === 'tantabus' ? [ - ["prompter", "origin"], - ["creator", "origin"], - ["generator", "origin"] + ['artist', CategoriesEnum.Origin], + ['art pack', CategoriesEnum.ContentFanmade], + ['colorist', CategoriesEnum.Origin], + ['comic', CategoriesEnum.ContentFanmade], + ['editor', CategoriesEnum.Origin], + ['fanfic', CategoriesEnum.ContentFanmade], + ['oc', CategoriesEnum.OC], + ['photographer', CategoriesEnum.Origin], + ['series', CategoriesEnum.ContentFanmade], + ['spoiler', CategoriesEnum.Spoiler], + ['video', CategoriesEnum.ContentFanmade], + ...(__CURRENT_SITE__ === 'tantabus' ? [ + ["prompter", CategoriesEnum.Origin], + ["creator", CategoriesEnum.Origin], + ["generator", CategoriesEnum.Origin] ] : []) ]); @@ -128,3 +134,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', +]; diff --git a/src/content/components/events/comms.ts b/src/content/components/events/comms.ts index e5ba62a..643cae2 100644 --- a/src/content/components/events/comms.ts +++ b/src/content/components/events/comms.ts @@ -5,6 +5,7 @@ import type { BooruEventsMap } from "$content/components/events/booru-events"; import type { TagsFormEventsMap } from "$content/components/events/tags-form-events"; import type { TagDropdownEvents } from "$content/components/events/tag-dropdown-events"; import type { PresetBlockEventsMap } from "$content/components/events/preset-block-events"; +import type { TagsListTogglerEventsMap } from "$content/components/events/tags-list-toggler-events"; type EventsMapping = TaggingProfilePopupEventsMap @@ -12,7 +13,8 @@ type EventsMapping = & BooruEventsMap & TagsFormEventsMap & TagDropdownEvents - & PresetBlockEventsMap; + & PresetBlockEventsMap + & TagsListTogglerEventsMap; type EventCallback = (event: CustomEvent) => void; export type UnsubscribeFunction = () => void; diff --git a/src/content/components/events/tags-list-toggler-events.ts b/src/content/components/events/tags-list-toggler-events.ts new file mode 100644 index 0000000..824fca1 --- /dev/null +++ b/src/content/components/events/tags-list-toggler-events.ts @@ -0,0 +1,5 @@ +export const EVENT_TAG_TOGGLED = 'tag-toggled'; + +export interface TagsListTogglerEventsMap { + [EVENT_TAG_TOGGLED]: [string, boolean]; +} diff --git a/src/content/components/extension/FullscreenViewer.ts b/src/content/components/extension/FullscreenViewer.ts index 616acdf..dad4ab5 100644 --- a/src/content/components/extension/FullscreenViewer.ts +++ b/src/content/components/extension/FullscreenViewer.ts @@ -1,28 +1,62 @@ import { BaseComponent } from "$content/components/base/BaseComponent"; import MiscPreferences, { type FullscreenViewerSize } from "$lib/extension/preferences/MiscPreferences"; -import { emit, on } from "$content/components/events/comms"; +import { emit, on, type UnsubscribeFunction } from "$content/components/events/comms"; import { EVENT_SIZE_LOADED } from "$content/components/events/fullscreen-viewer-events"; +import type { MediaBox } from "$content/components/philomena/MediaBox"; +import TagsListToggler from "$content/components/extension/profiles/TagsListToggler"; +import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/tagging-profile-popup-events"; +import type TaggingProfile from "$entities/TaggingProfile"; +import { EVENT_TAG_TOGGLED } from "$content/components/events/tags-list-toggler-events"; +import type { TaggingProfilePopup } from "$content/components/extension/profiles/TaggingProfilePopup"; export class FullscreenViewer extends BaseComponent { + #currentMediaBox: MediaBox | null = null; #videoElement: HTMLVideoElement = document.createElement('video'); #imageElement: HTMLImageElement = document.createElement('img'); #spinnerElement: HTMLElement = document.createElement('i'); #sizeSelectorElement: HTMLSelectElement = document.createElement('select'); #closeButtonElement: HTMLElement = document.createElement('i'); + #tagsPopupElement: HTMLElement = document.createElement('div'); + #imageTagsToggler: TagsListToggler = TagsListToggler.create(); + #profileTagsToggler: TagsListToggler = TagsListToggler.create(); + #tagsPopupHider: HTMLElement = document.createElement('div'); + #activeProfileListenerUnsubscriber: UnsubscribeFunction | null = null; #touchId: number | null = null; #startX: number | null = null; #startY: number | null = null; #isClosingSwipeStarted: boolean | null = null; #isSizeFetched: boolean = false; - #currentURIs: App.ImageURIs | null = null; protected build() { this.container.classList.add('fullscreen-viewer'); + this.#imageTagsToggler.initialize(); + this.#profileTagsToggler.initialize(); + + const overflowContainer = document.createElement('div'); + overflowContainer.classList.add('tags-panel__scroller'); + + overflowContainer.append( + this.#profileTagsToggler.container, + document.createElement('hr'), + this.#imageTagsToggler.container, + ); + + this.#tagsPopupElement.append( + this.#tagsPopupHider, + overflowContainer, + ); + + this.#tagsPopupHider.classList.add('tags-panel__toggle'); + this.#tagsPopupHider.textContent = 'Toggle Tags'; + + this.#tagsPopupElement.classList.add('tags-panel'); + this.container.append( this.#spinnerElement, this.#sizeSelectorElement, this.#closeButtonElement, + this.#tagsPopupElement, ); this.#spinnerElement.classList.add('spinner', 'fa', 'fa-circle-notch', 'fa-spin'); @@ -52,11 +86,20 @@ export class FullscreenViewer extends BaseComponent { this.#videoElement.addEventListener('loadeddata', this.#onLoaded.bind(this)); this.#imageElement.addEventListener('load', this.#onLoaded.bind(this)); this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation()); + this.#tagsPopupElement.addEventListener('click', event => event.stopPropagation()); + this.#tagsPopupHider.addEventListener('click', () => this.#togglePopupVisibility()); FullscreenViewer.#preferences .fullscreenViewerSize.get() .then(this.#onSizeResolved.bind(this)) .then(this.#watchForSizeSelectionChanges.bind(this)); + + FullscreenViewer.#preferences + .fullscreenViewerPopupShown.get() + .then(this.#togglePopupVisibility.bind(this)); + + on(this.#profileTagsToggler, EVENT_TAG_TOGGLED, this.#onTagToggled.bind(this)); + on(this.#imageTagsToggler, EVENT_TAG_TOGGLED, this.#onTagToggled.bind(this)); } #onLoaded() { @@ -193,8 +236,8 @@ export class FullscreenViewer extends BaseComponent { this.#sizeSelectorElement.addEventListener('input', () => { const targetSize = this.#sizeSelectorElement.value; - if (this.#currentURIs) { - void this.show(this.#currentURIs); + if (this.#currentMediaBox) { + void this.show(this.#currentMediaBox); } if (!targetSize || targetSize === lastActiveSize || !(targetSize in FullscreenViewer.#previewSizes)) { @@ -206,8 +249,55 @@ export class FullscreenViewer extends BaseComponent { }); } + #onTagToggled(event: CustomEvent<[string, boolean]>) { + const sourceTogglerContainer = event.currentTarget; + + if (!(sourceTogglerContainer instanceof HTMLElement)) { + return; + } + + if (sourceTogglerContainer !== this.#imageTagsToggler.container) { + this.#imageTagsToggler.refreshStateFromChanges(); + } + + if (sourceTogglerContainer !== this.#profileTagsToggler.container) { + this.#profileTagsToggler.refreshStateFromChanges(); + } + + this.#currentMediaBox?.tools?.popup?.tagsToggler.refreshStateFromChanges(); + } + + #onActiveProfileChanged(eventOrTaggingProfile: CustomEvent | TaggingProfile | null) { + let resolvedActiveTaggingProfile: TaggingProfile | null = null; + + if (eventOrTaggingProfile instanceof CustomEvent) { + resolvedActiveTaggingProfile = eventOrTaggingProfile.detail; + } else { + resolvedActiveTaggingProfile = eventOrTaggingProfile; + } + + this.#profileTagsToggler.container.classList.toggle('is-hidden', !resolvedActiveTaggingProfile); + + if (!resolvedActiveTaggingProfile) { + return; + } + + const uniqueTagsSet = this.#currentMediaBox?.uniqueTagsSet; + + this.#profileTagsToggler.replaceTags(resolvedActiveTaggingProfile.settings.tags, uniqueTagsSet ?? new Set()); + } + #close() { - this.#currentURIs = null; + const taggingPopup = this.#currentMediaBox?.tools?.popup; + + if (taggingPopup) { + this.#activeProfileListenerUnsubscriber?.(); + + taggingPopup.tagsToggler.disconnectChangeSet(); + taggingPopup.toggleSubmissionLock(false); + } + + this.#currentMediaBox = null; this.container.classList.remove(FullscreenViewer.#shownState); document.body.style.removeProperty('overflow'); @@ -247,16 +337,23 @@ export class FullscreenViewer extends BaseComponent { return imageUris[targetSize as FullscreenViewerSize]; } - async show(imageUris: App.ImageURIs): Promise { - this.#currentURIs = imageUris; + async show(mediaBox: MediaBox): Promise { + const isSameMediaBox = this.#currentMediaBox === mediaBox; + this.#currentMediaBox = mediaBox; - const url = await this.#resolveCurrentSelectedSizeUrl(imageUris); + const url = await this.#resolveCurrentSelectedSizeUrl(mediaBox.imageLinks); if (!url) { console.warn('Failed to resolve media for the viewer!'); return; } + const taggingPopup = mediaBox?.tools?.popup; + + if (taggingPopup && !isSameMediaBox) { + this.#connectAndRefreshTagTogglers(taggingPopup, mediaBox); + } + this.container.classList.add('loading'); requestAnimationFrame(() => { @@ -285,6 +382,45 @@ export class FullscreenViewer extends BaseComponent { this.container.append(this.#imageElement); } + /** + * When connecting to the new popup, we need to connect the tag editor inside the fullscreen viewer to the original + * popup, since submission is still being sent from the popup inside media box. + * @param taggingPopup + * @param mediaBox + * @private + */ + #connectAndRefreshTagTogglers(taggingPopup: TaggingProfilePopup, mediaBox: MediaBox) { + taggingPopup.toggleSubmissionLock(true); + + const sourceToggler = taggingPopup.tagsToggler; + sourceToggler.connectChangeSet(this.#profileTagsToggler); + sourceToggler.connectChangeSet(this.#imageTagsToggler); + + const uniqueTagsSet = mediaBox.uniqueTagsSet ?? new Set(); + const uniqueTags = Array.from(uniqueTagsSet); + + this.#imageTagsToggler.replaceTags(uniqueTags, uniqueTagsSet); + + this.#activeProfileListenerUnsubscriber?.(); + this.#activeProfileListenerUnsubscriber = on( + taggingPopup, + EVENT_ACTIVE_PROFILE_CHANGED, + this.#onActiveProfileChanged.bind(this), + ); + + this.#onActiveProfileChanged(taggingPopup.activeProfile); + } + + #togglePopupVisibility(maybeState: boolean | undefined = undefined) { + if (typeof maybeState === 'undefined') { + const isHidden = this.#tagsPopupElement.classList.toggle(FullscreenViewer.#collapsedPopupState); + void FullscreenViewer.#preferences.fullscreenViewerPopupShown.set(!isHidden); + return; + } + + this.#tagsPopupElement.classList.toggle(FullscreenViewer.#collapsedPopupState, !maybeState); + } + static #isVideoUrl(url: string): boolean { return url.endsWith('.mp4') || url.endsWith('.webm'); } @@ -296,6 +432,7 @@ export class FullscreenViewer extends BaseComponent { static #shownState = 'shown'; static #swipeState = 'swiped'; static #minRequiredDistance = 50; + static #collapsedPopupState = 'is-collapsed'; static #previewSizes: Record = { full: 'Full', diff --git a/src/content/components/extension/ImageShowFullscreenButton.ts b/src/content/components/extension/ImageShowFullscreenButton.ts index 25422a8..6d21c91 100644 --- a/src/content/components/extension/ImageShowFullscreenButton.ts +++ b/src/content/components/extension/ImageShowFullscreenButton.ts @@ -45,15 +45,13 @@ export class ImageShowFullscreenButton extends BaseComponent { } #onButtonClicked() { - const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks; + const mediaBox = this.#mediaBoxTools?.mediaBox; - if (!imageLinks) { + if (!mediaBox) { throw new Error('Failed to resolve image links from media box tools!'); } - ImageShowFullscreenButton - .#resolveViewer() - ?.show(imageLinks); + ImageShowFullscreenButton.#resolveViewer()?.show(mediaBox); } static create(): HTMLElement { diff --git a/src/content/components/extension/MediaBoxTools.ts b/src/content/components/extension/MediaBoxTools.ts index 1de65b7..7ea50a6 100644 --- a/src/content/components/extension/MediaBoxTools.ts +++ b/src/content/components/extension/MediaBoxTools.ts @@ -50,6 +50,10 @@ export class MediaBoxTools extends BaseComponent { return this.#mediaBox; } + get popup(): TaggingProfilePopup | null { + return this.#popup; + } + /** * Create a maintenance popup element. * @param childrenElements List of children elements to append to the component. diff --git a/src/content/components/extension/profiles/TaggingProfilePopup.ts b/src/content/components/extension/profiles/TaggingProfilePopup.ts index 0b912cd..b332279 100644 --- a/src/content/components/extension/profiles/TaggingProfilePopup.ts +++ b/src/content/components/extension/profiles/TaggingProfilePopup.ts @@ -4,31 +4,22 @@ import { BaseComponent } from "$content/components/base/BaseComponent"; import { getComponent } from "$content/components/base/component-utils"; import ScrapedAPI from "$lib/philomena/scraping/ScrapedAPI"; import { tagsBlacklist } from "$config/tags"; -import { emitterAt } from "$content/components/events/comms"; +import { emitterAt, on } from "$content/components/events/comms"; import { EVENT_ACTIVE_PROFILE_CHANGED, EVENT_PROFILE_POPUP_STATE_CHANGED, EVENT_TAGS_UPDATED } from "$content/components/events/tagging-profile-popup-events"; import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools"; -import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils"; +import TagsListToggler, { BlackListedTagsEncounteredError } from "$content/components/extension/profiles/TagsListToggler"; +import { EVENT_TAG_TOGGLED } from "$content/components/events/tags-list-toggler-events"; -class BlackListedTagsEncounteredError extends Error { - constructor(tagName: string) { - super(`This tag is blacklisted and prevents submission: ${tagName}`, { - cause: tagName - }); - } -} export class TaggingProfilePopup extends BaseComponent { - #tagsListElement: HTMLElement = document.createElement('div'); - #tagsList: HTMLElement[] = []; - #suggestedInvalidTags: Map = new Map(); + #tagsListToggler = TagsListToggler.create(); #activeProfile: TaggingProfile | null = null; #mediaBoxTools: MediaBoxTools | null = null; - #tagsToRemove: Set = new Set(); - #tagsToAdd: Set = new Set(); + #isSubmissionLocked: boolean = false; #isPlanningToSubmit: boolean = false; #isSubmitting: boolean = false; #tagsSubmissionTimer: Timeout | null = null; @@ -41,11 +32,8 @@ export class TaggingProfilePopup extends BaseComponent { this.container.innerHTML = ''; this.container.classList.add('maintenance-popup'); - this.#tagsListElement.classList.add('tags-list'); - - this.container.append( - this.#tagsListElement, - ); + this.#tagsListToggler.initialize(); + this.container.append(this.#tagsListToggler.container); } /** @@ -67,7 +55,7 @@ export class TaggingProfilePopup extends BaseComponent { this.#mediaBoxTools = mediaBoxTools; TaggingProfilePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this)); - this.#tagsListElement.addEventListener('click', this.#handleTagClick.bind(this)); + on(this.#tagsListToggler, EVENT_TAG_TOGGLED, this.#onTagToggled.bind(this)); const mediaBox = this.#mediaBoxTools.mediaBox; @@ -75,108 +63,58 @@ export class TaggingProfilePopup extends BaseComponent { throw new Error('Media box component not found!'); } - mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this)); - mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this)); + mediaBox.on('mouseout', this.#maybeEnqueueSubmission.bind(this)); + mediaBox.on('mouseover', this.#cancelQueuedSubmission.bind(this)); + } + + get tagsToggler() { + return this.#tagsListToggler; + } + + get activeProfile() { + return this.#activeProfile; + } + + toggleSubmissionLock(shouldLockSubmission: boolean) { + this.#isSubmissionLocked = shouldLockSubmission; + + if (shouldLockSubmission) { + this.#cancelQueuedSubmission(); + return; + } + + // First notify that tags might have changed after submission lock was released and then enqueue it. + this.#onTagToggled(); + this.#maybeEnqueueSubmission(); } #onActiveProfileChanged(activeProfile: TaggingProfile | null) { this.#activeProfile = activeProfile; this.container.classList.toggle('is-active', activeProfile !== null); - this.#refreshTagsList(); + + this.#refreshTagsToggler(); this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile); } - #refreshTagsList() { + #refreshTagsToggler() { if (!this.#mediaBoxTools?.mediaBox) { return; } const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || []; + const currentPostTags = new Set(this.#mediaBoxTools.mediaBox.tagsAndAliases?.keys() || []); - for (const tagElement of this.#tagsList) { - tagElement.remove(); - } - - for (const tagElement of this.#suggestedInvalidTags.values()) { - tagElement.remove(); - } - - this.#tagsList = new Array(activeProfileTagsList.length); - this.#suggestedInvalidTags.clear(); - - const currentPostTags = this.#mediaBoxTools.mediaBox.tagsAndAliases; - - activeProfileTagsList - .sort((a, b) => a.localeCompare(b)) - .forEach((tagName, index) => { - const tagElement = TaggingProfilePopup.#buildTagElement(tagName); - this.#tagsList[index] = tagElement; - this.#tagsListElement.appendChild(tagElement); - - const isPresent = currentPostTags?.has(tagName); - - tagElement.classList.toggle('is-present', isPresent); - tagElement.classList.toggle('is-missing', !isPresent); - tagElement.classList.toggle('is-aliased', isPresent && currentPostTags?.get(tagName) !== tagName); - - // Just to prevent duplication, we need to include this tag to the map of suggested invalid tags - if (tagsBlacklist.includes(tagName)) { - TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error'); - this.#suggestedInvalidTags.set(tagName, tagElement); - } else { - TaggingProfilePopup.#markTagElementWithCategory( - tagElement, - resolveTagCategoryFromTagName(tagName) ?? '', - ); - } - }); + this.#tagsListToggler.replaceTags(activeProfileTagsList, currentPostTags); } /** * Detect and process clicks made directly to the tags. */ - #handleTagClick(event: MouseEvent) { - const targetObject = event.target; + #onTagToggled() { + const hasPendingChanges = this.#tagsListToggler.hasChanges; - - if (!targetObject || !(targetObject instanceof HTMLElement)) { - return; - } - - let tagElement: HTMLElement | null = targetObject; - - if (!tagElement.classList.contains('tag')) { - tagElement = tagElement.closest('.tag'); - } - - if (!tagElement?.dataset.name) { - return; - } - - const tagName = tagElement.dataset.name; - - if (tagElement.classList.contains('is-present')) { - const isToBeRemoved = tagElement.classList.toggle('is-removed'); - - if (isToBeRemoved) { - this.#tagsToRemove.add(tagName); - } else { - this.#tagsToRemove.delete(tagName); - } - } - - if (tagElement.classList.contains('is-missing')) { - const isToBeAdded = tagElement.classList.toggle('is-added'); - - if (isToBeAdded) { - this.#tagsToAdd.add(tagName); - } else { - this.#tagsToAdd.delete(tagName); - } - } - - if (this.#tagsToAdd.size || this.#tagsToRemove.size) { + if (hasPendingChanges) { // Notify only once, when first planning to submit if (!this.#isPlanningToSubmit) { TaggingProfilePopup.#notifyAboutPendingSubmission(true); @@ -188,7 +126,7 @@ export class TaggingProfilePopup extends BaseComponent { // Whenever user undoes the change they wanted to do in the popup, it's better to not send the submission and just // do nothing. - if (!this.#tagsToAdd.size && !this.#tagsToRemove.size && this.#isPlanningToSubmit) { + if (!hasPendingChanges && this.#isPlanningToSubmit) { this.#isPlanningToSubmit = false; this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'ready'); TaggingProfilePopup.#notifyAboutPendingSubmission(false); @@ -200,14 +138,14 @@ export class TaggingProfilePopup extends BaseComponent { } } - #onMouseEnteredArea() { + #cancelQueuedSubmission() { if (this.#tagsSubmissionTimer) { clearTimeout(this.#tagsSubmissionTimer); } } - #onMouseLeftArea() { - if (this.#isPlanningToSubmit && !this.#isSubmitting) { + #maybeEnqueueSubmission() { + if (this.#isPlanningToSubmit && !this.#isSubmitting && !this.#isSubmissionLocked) { this.#tagsSubmissionTimer = setTimeout( this.#onSubmissionTimerPassed.bind(this), TaggingProfilePopup.#delayBeforeSubmissionMs @@ -216,7 +154,7 @@ export class TaggingProfilePopup extends BaseComponent { } async #onSubmissionTimerPassed() { - if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) { + if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox || this.#isSubmissionLocked) { return; } @@ -232,29 +170,7 @@ export class TaggingProfilePopup extends BaseComponent { try { maybeTagsAndAliasesAfterUpdate = await TaggingProfilePopup.#scrapedAPI.updateImageTags( this.#mediaBoxTools.mediaBox.imageId, - tagsList => { - for (let tagName of this.#tagsToRemove) { - tagsList.delete(tagName); - } - - for (let tagName of this.#tagsToAdd) { - tagsList.add(tagName); - } - - if (shouldAutoRemove) { - for (let tagName of tagsBlacklist) { - tagsList.delete(tagName); - } - } else { - for (let tagName of tagsList) { - if (tagsBlacklist.includes(tagName)) { - throw new BlackListedTagsEncounteredError(tagName); - } - } - } - - return tagsList; - } + this.#tagsListToggler.createTagsUpdater(shouldAutoRemove), ); } catch (e) { if (e instanceof BlackListedTagsEncounteredError) { @@ -277,10 +193,8 @@ export class TaggingProfilePopup extends BaseComponent { this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'complete'); - this.#tagsToAdd.clear(); - this.#tagsToRemove.clear(); - - this.#refreshTagsList(); + this.#tagsListToggler.clearChangeSet(); + this.#refreshTagsToggler(); TaggingProfilePopup.#notifyAboutPendingSubmission(false); this.#isSubmitting = false; @@ -291,37 +205,13 @@ export class TaggingProfilePopup extends BaseComponent { return; } - const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases; + const activeTags = new Set(this.#mediaBoxTools.mediaBox.tagsAndAliases?.keys() || []); - if (!tagsAndAliases) { + if (!activeTags.size) { return; } - const firstTagInList = this.#tagsList[0]; - - for (let tagName of tagsBlacklist) { - if (tagsAndAliases.has(tagName)) { - if (this.#suggestedInvalidTags.has(tagName)) { - continue; - } - - const tagElement = TaggingProfilePopup.#buildTagElement(tagName); - TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error'); - tagElement.classList.add('is-present'); - - this.#suggestedInvalidTags.set(tagName, tagElement); - - if (firstTagInList && firstTagInList.isConnected) { - this.#tagsListElement.insertBefore(tagElement, firstTagInList); - } else { - this.#tagsListElement.appendChild(tagElement); - } - } - } - } - - get isActive() { - return this.container.classList.contains('is-active'); + this.#tagsListToggler.prependTags(tagsBlacklist, activeTags); } static create(): HTMLElement { @@ -332,24 +222,6 @@ export class TaggingProfilePopup extends BaseComponent { return container; } - static #buildTagElement(tagName: string): HTMLElement { - const tagElement = document.createElement('span'); - tagElement.classList.add('tag'); - tagElement.innerText = tagName; - tagElement.dataset.name = tagName; - - return tagElement; - } - - /** - * Mark the tag element with specified category. - * @param tagElement Element to mark. - * @param category Code name of category to mark. - */ - static #markTagElementWithCategory(tagElement: HTMLElement, category: string) { - tagElement.dataset.tagCategory = category; - tagElement.setAttribute('data-tag-category', category); - } /** * Controller with maintenance settings. @@ -433,5 +305,5 @@ export class TaggingProfilePopup extends BaseComponent { /** * Amount of pending submissions or NULL if logic was not yet initialized. */ - static #pendingSubmissionCount: number|null = null; + static #pendingSubmissionCount: number | null = null; } diff --git a/src/content/components/extension/profiles/TagsListToggler.ts b/src/content/components/extension/profiles/TagsListToggler.ts new file mode 100644 index 0000000..f70d727 --- /dev/null +++ b/src/content/components/extension/profiles/TagsListToggler.ts @@ -0,0 +1,230 @@ +import { BaseComponent } from "$content/components/base/BaseComponent"; +import { CategoriesEnum, tagsBlacklist } from "$config/tags"; +import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils"; +import { EVENT_TAG_TOGGLED } from "$content/components/events/tags-list-toggler-events"; +import { emit } from "$content/components/events/comms"; +import type { TagsUpdaterFunction } from "$lib/philomena/scraping/ScrapedAPI"; + +export default class TagsListToggler extends BaseComponent { + #tagsList: HTMLElement[] = []; + #invalidTagsList: Map = new Map(); + #tagsToAdd: Set = new Set(); + #tagsToRemove: Set = new Set(); + + protected build() { + this.container.classList.add('tags-list'); + } + + protected init() { + this.container.addEventListener('click', this.#handleTagClick.bind(this)); + } + + get hasChanges(): boolean { + return (this.#tagsToAdd.size + this.#tagsToRemove.size) > 0; + } + + replaceTags(tags: string[], activeTags: Set) { + for (const tagElement of this.#tagsList) { + tagElement.remove(); + } + + for (const tagElement of this.#invalidTagsList.values()) { + tagElement.remove(); + } + + this.#tagsList = []; + this.#invalidTagsList = new Map(); + + this.#renderAndInsertTags(tags, activeTags); + } + + prependTags(tags: string[], activeTags: Set) { + this.#renderAndInsertTags(tags, activeTags, true); + } + + refreshStateFromChanges() { + for (const tagElement of this.#tagsList) { + const tagName = tagElement.dataset.name; + + if (!tagName) { + throw new Error('Missing tag name!'); + } + + tagElement.classList.toggle(TagsListToggler.#addedClass, this.#tagsToAdd.has(tagName)); + tagElement.classList.toggle(TagsListToggler.#removedClass, this.#tagsToRemove.has(tagName)); + } + } + + #renderAndInsertTags(toggleableTags: string[], activeTags: Set, shouldPrepend: boolean = false) { + toggleableTags.sort((a, b) => a.localeCompare(b)); + + let lastInserted: HTMLElement|null = null; + + const hasStoredChanges = this.hasChanges; + + for (let tagIndex = 0; tagIndex < toggleableTags.length; tagIndex++) { + const tagName = toggleableTags[tagIndex]; + const tagElement = TagsListToggler.#buildTagElement(tagName); + + this.#tagsList.push(tagElement); + + const isPresent = activeTags.has(tagName); + + tagElement.classList.toggle(TagsListToggler.#missingClass, !isPresent); + tagElement.classList.toggle(TagsListToggler.#containsClass, isPresent); + + if (hasStoredChanges) { + tagElement.classList.toggle(TagsListToggler.#addedClass, !isPresent && this.#tagsToAdd.has(tagName)); + tagElement.classList.toggle(TagsListToggler.#removedClass, isPresent && this.#tagsToRemove.has(tagName)); + } + + const tagCategory = tagsBlacklist.includes(tagName) && CategoriesEnum.Error + || resolveTagCategoryFromTagName(tagName) + || ''; + + TagsListToggler.#markTagElementWithCategory(tagElement, tagCategory); + + if (tagCategory === CategoriesEnum.Error) { + this.#invalidTagsList.set(tagName, tagElement); + } + + if (shouldPrepend) { + if (lastInserted) { + lastInserted.after(tagElement); + } else { + this.container.prepend(tagElement); + } + + lastInserted = tagElement; + } else { + this.container.append(tagElement); + } + } + } + + createTagsUpdater(shouldAutoRemoveInvalid: boolean): TagsUpdaterFunction { + return tagsList => { + for (let tagName of this.#tagsToRemove) { + tagsList.delete(tagName); + } + + for (let tagName of this.#tagsToAdd) { + tagsList.add(tagName); + } + + if (shouldAutoRemoveInvalid) { + for (let tagName of tagsBlacklist) { + tagsList.delete(tagName); + } + } else { + for (let tagName of tagsList) { + if (tagsBlacklist.includes(tagName)) { + throw new BlackListedTagsEncounteredError(tagName); + } + } + } + + return tagsList; + } + } + + connectChangeSet(secondaryToggler: TagsListToggler) { + secondaryToggler.#tagsToAdd = this.#tagsToAdd; + secondaryToggler.#tagsToRemove = this.#tagsToRemove; + } + + disconnectChangeSet() { + this.#tagsToAdd = new Set(this.#tagsToAdd.values()); + this.#tagsToRemove = new Set(this.#tagsToRemove.values()); + } + + clearChangeSet() { + this.#tagsToAdd.clear(); + this.#tagsToRemove.clear(); + } + + #handleTagClick(event: MouseEvent) { + const targetObject = event.target; + + + if (!targetObject || !(targetObject instanceof HTMLElement)) { + return; + } + + let tagElement: HTMLElement | null = targetObject; + + if (!tagElement.classList.contains('tag')) { + tagElement = tagElement.closest('.tag'); + } + + if (!tagElement?.dataset.name) { + return; + } + + const tagName = tagElement.dataset.name; + + let willBeActive = false; + + if (tagElement.classList.contains(TagsListToggler.#containsClass)) { + const isToBeRemoved = tagElement.classList.toggle(TagsListToggler.#removedClass); + willBeActive = !isToBeRemoved; + + if (isToBeRemoved) { + this.#tagsToRemove.add(tagName); + } else { + this.#tagsToRemove.delete(tagName); + } + } + + if (tagElement.classList.contains(TagsListToggler.#missingClass)) { + const isToBeAdded = tagElement.classList.toggle(TagsListToggler.#addedClass); + willBeActive = isToBeAdded; + + if (isToBeAdded) { + this.#tagsToAdd.add(tagName); + } else { + this.#tagsToAdd.delete(tagName); + } + } + + emit(this.container, EVENT_TAG_TOGGLED, [tagName, willBeActive]); + } + + static #buildTagElement(tagName: string): HTMLElement { + const tagElement = document.createElement('span'); + tagElement.classList.add('tag'); + tagElement.innerText = tagName; + tagElement.dataset.name = tagName; + + return tagElement; + } + + /** + * Mark the tag element with specified category. + * @param tagElement Element to mark. + * @param category Code name of category to mark. + */ + static #markTagElementWithCategory(tagElement: HTMLElement, category: string) { + tagElement.dataset.tagCategory = category; + tagElement.setAttribute('data-tag-category', category); + } + + static create(): TagsListToggler { + const tagsListContainer = document.createElement('div'); + return new TagsListToggler(tagsListContainer); + } + + static #containsClass = 'is-present'; + static #missingClass = 'is-missing'; + + static #addedClass = 'is-added'; + static #removedClass = 'is-removed'; +} + +export class BlackListedTagsEncounteredError extends Error { + constructor(tagName: string) { + super(`This tag is blacklisted and prevents submission: ${tagName}`, { + cause: tagName + }); + } +} diff --git a/src/content/components/philomena/ImageContainer.ts b/src/content/components/philomena/ImageContainer.ts new file mode 100644 index 0000000..10430a5 --- /dev/null +++ b/src/content/components/philomena/ImageContainer.ts @@ -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 || []; + } +} diff --git a/src/content/components/philomena/MediaBox.ts b/src/content/components/philomena/MediaBox.ts index c7b1cc6..9c1f62e 100644 --- a/src/content/components/philomena/MediaBox.ts +++ b/src/content/components/philomena/MediaBox.ts @@ -3,15 +3,22 @@ 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"; +import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools"; export class MediaBox extends BaseComponent { - #thumbnailContainer: HTMLElement | null = null; - #imageLinkElement: HTMLAnchorElement | null = null; + #imageContainer: ImageContainer | null = null; #tagsAndAliases: Map | null = null; + #mediaBoxTools: MediaBoxTools | 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; + + this.#imageContainer?.initialize(); on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this)); } @@ -27,12 +34,22 @@ 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); } + get uniqueTagsSet(): Set | null { + const tagsAndAliases = this.tagsAndAliases; + + if (tagsAndAliases === null) { + return null; + } + + return new Set(tagsAndAliases.values()); + } + get tagsAndAliases(): Map | null { if (!this.#tagsAndAliases) { this.#tagsAndAliases = this.#calculateMediaBoxTags(); @@ -52,26 +69,31 @@ 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; + } + + get tools(): MediaBoxTools | null { + return this.#mediaBoxTools; } /** * Wrap the media box element into the special wrapper. */ - static initialize(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) { - new MediaBox(mediaBoxContainer) - .initialize(); + static initialize(mediaBoxContainer: HTMLElement, mediaBoxToolsElement: HTMLElement) { + const mediaBox = new MediaBox(mediaBoxContainer); + mediaBox.initialize(); - for (let childComponentElement of childComponentElements) { - mediaBoxContainer.appendChild(childComponentElement); - getComponent(childComponentElement)?.initialize(); - } + mediaBoxContainer.appendChild(mediaBoxToolsElement); + const mediaBoxToolsComponent = getComponent(mediaBoxToolsElement); + mediaBoxToolsComponent?.initialize(); + + mediaBox.#mediaBoxTools = mediaBoxToolsComponent; } static findElements(): NodeListOf { diff --git a/src/content/components/philomena/dupe/DupeDiff.ts b/src/content/components/philomena/dupe/DupeDiff.ts new file mode 100644 index 0000000..c4f73d9 --- /dev/null +++ b/src/content/components/philomena/dupe/DupeDiff.ts @@ -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('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([ + [ + ['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'], + ], + ]); +} diff --git a/src/content/components/philomena/dupe/DupeImage.ts b/src/content/components/philomena/dupe/DupeImage.ts new file mode 100644 index 0000000..b6f93d7 --- /dev/null +++ b/src/content/components/philomena/dupe/DupeImage.ts @@ -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('.image-container'); + + if (!imageContainerElement) { + throw new Error('Missing image container inside the dupe row!'); + } + + this.imageContainer = new ImageContainer(imageContainerElement); + this.imageContainer.initialize(); + } +} diff --git a/src/content/components/philomena/dupe/DupeReportRow.ts b/src/content/components/philomena/dupe/DupeReportRow.ts new file mode 100644 index 0000000..5b9ab9f --- /dev/null +++ b/src/content/components/philomena/dupe/DupeReportRow.ts @@ -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; + } +} diff --git a/src/content/components/philomena/dupe/GridDupeReportsList.ts b/src/content/components/philomena/dupe/GridDupeReportsList.ts new file mode 100644 index 0000000..902f1d2 --- /dev/null +++ b/src/content/components/philomena/dupe/GridDupeReportsList.ts @@ -0,0 +1,43 @@ +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 repotRowsList = this.container.querySelectorAll(':scope > tbody > tr'); + + for (const reportRow of repotRowsList) { + const startingCell = reportRow.firstElementChild; + const rightImageCell = startingCell?.nextElementSibling || null; + const diffCell = rightImageCell?.nextElementSibling || null; + const reportOptionsCell = diffCell?.nextElementSibling || null; + + if (!(startingCell instanceof HTMLElement) || !(rightImageCell instanceof HTMLElement) || !(diffCell instanceof HTMLElement) || !(reportOptionsCell instanceof HTMLElement)) { + console.error(`Unable to capture duplicate report!`, reportRow); + 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('.dupe-report-list')) { + new GridDupeReportsList(container).initialize(); + } + } +} diff --git a/src/content/duplicate-reports.ts b/src/content/duplicate-reports.ts new file mode 100644 index 0000000..866a71b --- /dev/null +++ b/src/content/duplicate-reports.ts @@ -0,0 +1,3 @@ +import GridDupeReportsList from "$content/components/philomena/dupe/GridDupeReportsList"; + +GridDupeReportsList.findAndInitialize(); diff --git a/src/content/listing.ts b/src/content/listing.ts index 21e240e..d91785e 100644 --- a/src/content/listing.ts +++ b/src/content/listing.ts @@ -8,13 +8,11 @@ import { ImageListContainer } from "$content/components/philomena/listing/ImageL const mediaBoxes = MediaBox.findElements(); mediaBoxes.forEach(mediaBoxElement => { - MediaBox.initialize(mediaBoxElement, [ - MediaBoxTools.create( - TaggingProfilePopup.create(), - TaggingProfileStatusIcon.create(), - ImageShowFullscreenButton.create(), - ) - ]); + MediaBox.initialize(mediaBoxElement, MediaBoxTools.create( + TaggingProfilePopup.create(), + TaggingProfileStatusIcon.create(), + ImageShowFullscreenButton.create(), + )); // Attempt to fix misplacement of media boxes requestAnimationFrame(() => { diff --git a/src/content/user-details.ts b/src/content/user-details.ts new file mode 100644 index 0000000..6d1cfa1 --- /dev/null +++ b/src/content/user-details.ts @@ -0,0 +1,14 @@ +import { UserDetails } from "$lib/extension/preferences/UserDetails"; + +(async () => { + const userDetails = new UserDetails(); + const userDataStore = document.querySelector('.js-datastore'); + + if (!userDataStore) { + return; + } + + await userDetails.isAuthorized.set(userDataStore.dataset.userIsSignedIn === 'true'); +})(); + + diff --git a/src/lib/dom-utils.ts b/src/lib/dom-utils.ts index bfef56b..9b2164f 100644 --- a/src/lib/dom-utils.ts +++ b/src/lib/dom-utils.ts @@ -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; } diff --git a/src/lib/extension/EntitiesController.ts b/src/lib/extension/EntitiesController.ts index 8a680e5..0df9dc9 100644 --- a/src/lib/extension/EntitiesController.ts +++ b/src/lib/extension/EntitiesController.ts @@ -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>(entityName: string, entityClass: new (...any: any[]) => Type): Promise { - 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): Promise { - 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 { - 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>(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); } } diff --git a/src/lib/extension/base/CacheablePreferences.ts b/src/lib/extension/base/CacheablePreferences.ts index 2bcdbd2..f981763 100644 --- a/src/lib/extension/base/CacheablePreferences.ts +++ b/src/lib/extension/base/CacheablePreferences.ts @@ -70,6 +70,10 @@ export class PreferenceField< set(value: Fields[Key]) { return this.#preferences.writeRaw(this.#fieldKey, value); } + + get key(): Key { + return this.#fieldKey; + } } /** diff --git a/src/lib/extension/preferences/MiscPreferences.ts b/src/lib/extension/preferences/MiscPreferences.ts index 0c14051..2482d9a 100644 --- a/src/lib/extension/preferences/MiscPreferences.ts +++ b/src/lib/extension/preferences/MiscPreferences.ts @@ -8,6 +8,7 @@ export type FullscreenViewerSize = keyof App.ImageURIs; interface MiscPreferencesFields { fullscreenViewer: boolean; fullscreenViewerSize: FullscreenViewerSize; + fullscreenViewerPopupShown: boolean; } export default class MiscPreferences extends CacheablePreferences implements WithFields { @@ -24,4 +25,9 @@ export default class MiscPreferences extends CacheablePreferences implements WithFields { + constructor() { + super('userDetails'); + } + + isAuthorized = new PreferenceField(this, { + field: 'isAuthorized', + defaultValue: false, + }); +} diff --git a/src/lib/philomena/scraping/ScrapedAPI.ts b/src/lib/philomena/scraping/ScrapedAPI.ts index 39cdf71..292b7ff 100644 --- a/src/lib/philomena/scraping/ScrapedAPI.ts +++ b/src/lib/philomena/scraping/ScrapedAPI.ts @@ -1,6 +1,6 @@ import PostParser from "$lib/philomena/scraping/parsing/PostParser"; -type UpdaterFunction = (tags: Set) => Set; +export type TagsUpdaterFunction = (tags: Set) => Set; export default class ScrapedAPI { /** @@ -9,7 +9,7 @@ export default class ScrapedAPI { * @param callback Callback to call to change the content. * @return Updated tags and aliases list for updating internal cached state. */ - async updateImageTags(imageId: number, callback: UpdaterFunction): Promise | null> { + async updateImageTags(imageId: number, callback: TagsUpdaterFunction): Promise | null> { const postParser = new PostParser(imageId); const formData = await postParser.resolveTagEditorFormData(); const tagsFieldValue = formData.get(PostParser.tagsInputName); diff --git a/src/lib/store-sync.ts b/src/lib/store-sync.ts new file mode 100644 index 0000000..772d757 --- /dev/null +++ b/src/lib/store-sync.ts @@ -0,0 +1,65 @@ +import type { Writable } from "svelte/store"; +import CacheablePreferences, { type PreferenceField } from "$lib/extension/base/CacheablePreferences"; + +interface PrerefenceConnectionOptions { + /** + * Optional callback to apply modifications to the value when sending them from the preference field to the single + * storage. If not provided, value itself will be sent instead. + */ + updateToStore?: (value: any) => ValueType; +} + +export class PreferenceSync = Record> { + readonly #preferences: CacheablePreferences; + readonly #syncedPairs: [PreferenceField, Writable, PrerefenceConnectionOptions][] = []; + + protected constructor(preferences: CacheablePreferences) { + this.#preferences = preferences; + } + + /** + * Connect the specific preference field to the provided storage. + * @param field Field object. + * @param store Storage object. Should contain the appropriate type for the value. + * @param [options] Additional settings. + */ + connect( + field: PreferenceField, + store: Writable, + options: PrerefenceConnectionOptions = {} + ): this { + this.#syncedPairs.push([field as any, store, options]); + + return this; + } + + async startSync(): Promise { + await Promise.allSettled( + this.#syncedPairs.map( + ([field, store, options]) => field.get() + .then(initialValue => store.set(options.updateToStore?.(initialValue) ?? initialValue)) + ) + ); + + for (const [field, store] of this.#syncedPairs) { + store.subscribe(value => field.set(value)); + } + + this.#preferences.subscribe(updatedSettings => { + for (const [field, store, options] of this.#syncedPairs) { + if (!(field.key in updatedSettings)) { + continue; + } + + const updatedValue = updatedSettings[field.key]; + store.set(options.updateToStore?.(updatedValue) ?? updatedValue); + } + }); + } + + static for< + PreferencesType extends Record = Record + >(preferences: CacheablePreferences): PreferenceSync { + return new PreferenceSync(preferences); + } +} diff --git a/src/routes/features/profiles/+page.svelte b/src/routes/features/profiles/+page.svelte index 5841977..e8d71c4 100644 --- a/src/routes/features/profiles/+page.svelte +++ b/src/routes/features/profiles/+page.svelte @@ -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 @@ Back Create New + +{#if $user?.isAuthorized === false} + +
+
+ + Tagging profiles will only work when you're signed in! +

+ Unauthorized users have to solve the captcha before sending any submissions to the site. +
+{/if} + {#if profiles.length}
{/if} diff --git a/src/stores/preferences/misc.ts b/src/stores/preferences/misc.ts index 121939a..8395462 100644 --- a/src/stores/preferences/misc.ts +++ b/src/stores/preferences/misc.ts @@ -1,18 +1,11 @@ import { writable } from "svelte/store"; import MiscPreferences from "$lib/extension/preferences/MiscPreferences"; +import { PreferenceSync } from "$lib/store-sync"; export const fullScreenViewerEnabled = writable(true); const preferences = new MiscPreferences(); -Promise.allSettled([ - preferences.fullscreenViewer.get().then(v => fullScreenViewerEnabled.set(v)) -]).then(() => { - fullScreenViewerEnabled.subscribe(value => { - void preferences.fullscreenViewer.set(value); - }); - - preferences.subscribe(settings => { - fullScreenViewerEnabled.set(Boolean(settings.fullscreenViewer)); - }); -}); +void PreferenceSync.for(preferences) + .connect(preferences.fullscreenViewer, fullScreenViewerEnabled, {updateToStore: Boolean}) + .startSync(); diff --git a/src/stores/preferences/profiles.ts b/src/stores/preferences/profiles.ts index f7a65fc..55a056b 100644 --- a/src/stores/preferences/profiles.ts +++ b/src/stores/preferences/profiles.ts @@ -1,18 +1,13 @@ import { writable } from "svelte/store"; import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences"; +import { PreferenceSync } from "$lib/store-sync"; export const stripBlacklistedTagsEnabled = writable(true); const preferences = new TaggingProfilesPreferences(); -Promise - .all([ - preferences.stripBlacklistedTags.get().then(v => stripBlacklistedTagsEnabled.set(v ?? true)) - ]) - .then(() => { - preferences.subscribe(settings => { - stripBlacklistedTagsEnabled.set(typeof settings.stripBlacklistedTags === 'boolean' ? settings.stripBlacklistedTags : true); - }); - - stripBlacklistedTagsEnabled.subscribe(v => preferences.stripBlacklistedTags.set(v)); - }); +void PreferenceSync.for(preferences) + .connect(preferences.stripBlacklistedTags, stripBlacklistedTagsEnabled, { + updateToStore: maybeBoolean => typeof maybeBoolean === 'boolean' ? maybeBoolean : true, + }) + .startSync(); diff --git a/src/stores/preferences/tags.ts b/src/stores/preferences/tags.ts index 84b1239..3929f8a 100644 --- a/src/stores/preferences/tags.ts +++ b/src/stores/preferences/tags.ts @@ -1,5 +1,6 @@ import { writable } from "svelte/store"; import TagsPreferences from "$lib/extension/preferences/TagsPreferences"; +import { PreferenceSync } from "$lib/store-sync"; const preferences = new TagsPreferences(); @@ -7,28 +8,8 @@ export const shouldSeparateTagGroups = writable(false); export const shouldReplaceLinksOnForumPosts = writable(false); export const shouldReplaceTextOfTagLinks = writable(true); -Promise - .allSettled([ - preferences.groupSeparation.get().then(value => shouldSeparateTagGroups.set(value)), - preferences.replaceLinks.get().then(value => shouldReplaceLinksOnForumPosts.set(value)), - preferences.replaceLinkText.get().then(value => shouldReplaceTextOfTagLinks.set(value)), - ]) - .then(() => { - shouldSeparateTagGroups.subscribe(value => { - void preferences.groupSeparation.set(value); - }); - - shouldReplaceLinksOnForumPosts.subscribe(value => { - void preferences.replaceLinks.set(value); - }); - - shouldReplaceTextOfTagLinks.subscribe(value => { - void preferences.replaceLinkText.set(value); - }); - - preferences.subscribe(settings => { - shouldSeparateTagGroups.set(Boolean(settings.groupSeparation)); - shouldReplaceLinksOnForumPosts.set(Boolean(settings.replaceLinks)); - shouldReplaceTextOfTagLinks.set(Boolean(settings.replaceLinkText)); - }); - }); +void PreferenceSync.for(preferences) + .connect(preferences.groupSeparation, shouldSeparateTagGroups, {updateToStore: Boolean}) + .connect(preferences.replaceLinks, shouldReplaceLinksOnForumPosts, {updateToStore: Boolean}) + .connect(preferences.replaceLinkText, shouldReplaceTextOfTagLinks, {updateToStore: Boolean}) + .startSync(); diff --git a/src/stores/preferences/user.ts b/src/stores/preferences/user.ts new file mode 100644 index 0000000..2c968e6 --- /dev/null +++ b/src/stores/preferences/user.ts @@ -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(null, set => { + userDetails.subscribe(settings => { + set({ + isAuthorized: Boolean(settings.isAuthorized) + }); + }); + + userDetails.isAuthorized.get().then(isAuthorized => { + set({ + isAuthorized + }); + }); +}); diff --git a/src/styles/booru-vars.scss b/src/styles/booru-vars.scss index 1f9d5f6..1819a9e 100644 --- a/src/styles/booru-vars.scss +++ b/src/styles/booru-vars.scss @@ -5,8 +5,15 @@ $padding-small: var(--padding-small); $padding-normal: var(--padding-normal); $padding-large: var(--padding-large); $block-spacing: var(--block-spacing); +$block-header-light-color: var(--block-header-light-color); +$block-header-link-text-color: var(--block-header-link-text-color); +$border: var(--border); +$header-spacing: var(--header-spacing); +$block-header-sub-height: var(--block-header-sub-height); // These variables are defined dynamically based on the category of the tag $resolved-tag-background: var(--tag-background); $resolved-tag-border: var(--tag-border); $resolved-tag-color: var(--tag-color); + +$limited-layout-width: var(--limited-layout-width); diff --git a/src/styles/content/duplicate-reports.scss b/src/styles/content/duplicate-reports.scss new file mode 100644 index 0000000..e5f2a28 --- /dev/null +++ b/src/styles/content/duplicate-reports.scss @@ -0,0 +1,46 @@ +.dr__diff { + .source-icons { + &__list { + display: inline-flex; + gap: .25em; + } + } + + td { + display: flex; + flex-wrap: wrap; + column-gap: .5em; + } +} + +// Trying to make it more usable on mobile by regrouping table cells into grid. +@media screen and (max-width: 800px) { + .dupe-report-list { + & > thead { + display: none; + } + + & > tbody > tr { + display: grid; + grid-template-areas: "source target" "diff actions"; + grid-template-columns: 1fr 1fr; + + .dupe-report-list--image { + grid-area: source; + width: auto; + + + .dupe-report-list--image { + grid-area: target; + } + } + + .dr__diff { + grid-area: diff; + + & + td { + grid-area: actions; + } + } + } + } +} diff --git a/src/styles/content/listing.scss b/src/styles/content/listing.scss index 2306743..a296615 100644 --- a/src/styles/content/listing.scss +++ b/src/styles/content/listing.scss @@ -2,6 +2,38 @@ @use '$styles/booru-vars'; @use '$styles/environment'; +%box { + background: booru-vars.$background-color; + padding: 5px; +} + +%tags-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + + .tag { + margin: 0; + } +} + +%toggleable-tag { + cursor: pointer; + user-select: none; + padding: 0 5px; + gap: 0; + + &:hover { + background: booru-vars.$resolved-tag-color; + color: booru-vars.$resolved-tag-background; + } + + &.is-missing:not(.is-added), + &.is-present.is-removed { + opacity: 0.5; + } +} + // This will fix wierd misplacing of the modified media boxes in the listing. .js-resizable-media-container { display: flex; @@ -40,14 +72,14 @@ } .maintenance-popup { + @extend %box; + display: none; position: absolute; top: 100%; - padding: 5px; left: -50%; right: -50%; z-index: 8; - background: booru-vars.$background-color; border: { left: booru-vars.$media-border; right: booru-vars.$media-border; @@ -55,31 +87,13 @@ }; .tags-list { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 6px; + @extend %tags-list; - .tag { - margin: 0; - } + justify-content: center; } .tag { - cursor: pointer; - user-select: none; - padding: 0 5px; - gap: 0; - - &:hover { - background: booru-vars.$resolved-tag-color; - color: booru-vars.$resolved-tag-background; - } - - &.is-missing:not(.is-added), - &.is-present.is-removed { - opacity: 0.5; - } + @extend %toggleable-tag; } } @@ -244,4 +258,64 @@ opacity: 1; } } + + .tags-panel { + @extend %box; + + display: flex; + z-index: 1; + width: 96vw; + max-width: booru-vars.$limited-layout-width; + max-height: 50vh; + position: fixed; + bottom: 0; + left: 0; + right: 0; + margin-left: auto; + margin-right: auto; + transition: transform .25s ease-in-out; + border: { + top: booru-vars.$media-border; + left: booru-vars.$media-border; + right: booru-vars.$media-border; + } + + &__toggle { + position: absolute; + left: -1px; + bottom: calc(100% + 1px); + border: booru-vars.$border; + background: booru-vars.$block-header-light-color; + color: booru-vars.$block-header-link-text-color; + font-weight: 700; + padding: 0 calc(#{booru-vars.$header-spacing} * .5); + line-height: booru-vars.$block-header-sub-height; + cursor: pointer; + } + + &__scroller { + overflow-y: auto; + overflow-x: hidden; + } + + &.is-collapsed { + transform: translateY(100%); + } + + .tags-list { + @extend %tags-list; + + &.is-hidden { + display: none; + + & + hr { + display: none; + } + } + } + + .tag { + @extend %toggleable-tag; + } + } } diff --git a/tests/lib/extension/EntitiesController.spec.ts b/tests/lib/extension/EntitiesController.spec.ts new file mode 100644 index 0000000..e3f44d0 --- /dev/null +++ b/tests/lib/extension/EntitiesController.spec.ts @@ -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>> = { + [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}); + }) + }); +}); diff --git a/tests/lib/extension/base/CachablePreferences.spec.ts b/tests/lib/extension/base/CachablePreferences.spec.ts index 93a719e..ca91d51 100644 --- a/tests/lib/extension/base/CachablePreferences.spec.ts +++ b/tests/lib/extension/base/CachablePreferences.spec.ts @@ -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 implements WithFields { - 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; diff --git a/tests/lib/extension/base/StorageEntity.spec.ts b/tests/lib/extension/base/StorageEntity.spec.ts new file mode 100644 index 0000000..e213799 --- /dev/null +++ b/tests/lib/extension/base/StorageEntity.spec.ts @@ -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); + }); + }); +}); diff --git a/tests/lib/philomena/tag-utils.spec.ts b/tests/lib/philomena/tag-utils.spec.ts index a322a07..e04221e 100644 --- a/tests/lib/philomena/tag-utils.spec.ts +++ b/tests/lib/philomena/tag-utils.spec.ts @@ -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(); }); }); diff --git a/tests/stubs/Entity.ts b/tests/stubs/Entity.ts new file mode 100644 index 0000000..6e65521 --- /dev/null +++ b/tests/stubs/Entity.ts @@ -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 { + static readonly _entityName = "entity"; + + constructor(id: string, settings: TestedSettings) { + super(id, settings); + } +} diff --git a/tests/stubs/Preferences.ts b/tests/stubs/Preferences.ts new file mode 100644 index 0000000..d21c4ea --- /dev/null +++ b/tests/stubs/Preferences.ts @@ -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 implements WithFields { + 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, + }); + } +}