Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
906959205e | ||
|
|
37a8e77f97 | ||
|
|
971f9b8a60 | ||
|
|
05349feafc | ||
|
|
304c824045 | ||
|
|
0eafa074f5 | ||
|
|
ace783afc3 | ||
|
|
72a0d64809 | ||
|
|
c34c650005 | ||
|
|
af348621df | ||
|
|
223b72d9fb | ||
|
|
5432e63d6e | ||
|
|
a06fed078e | ||
|
|
221051ff41 | ||
|
|
87df78730b | ||
|
|
c6c48b2c36 | ||
|
|
5578e64306 | ||
|
|
7fd5a20b2f | ||
|
|
4dcddc3969 | ||
|
|
c7e1a95172 | ||
|
|
ecd95d3b4f | ||
|
|
f62c150724 | ||
|
|
665057d9f3 | ||
|
|
d75a9b434d | ||
|
|
bdac2692ce | ||
|
|
28c2f2f2e7 | ||
|
|
d150d87d89 | ||
|
|
690faa854f | ||
|
|
a2452a5fea | ||
|
|
b1c85efe03 | ||
|
|
34c0a11b4e | ||
|
|
f4e92e313a | ||
|
|
e1b56586be | ||
|
|
b87a8a43e0 | ||
|
|
d19764699f | ||
|
|
f873cae544 | ||
|
|
c23c871337 | ||
|
|
38d84d0830 | ||
|
|
4eecde6b02 | ||
|
|
394df69936 | ||
|
|
c268eb564d | ||
|
|
cfb4c71f58 | ||
|
|
67cd17d2b6 | ||
|
|
1b4cb284da | ||
|
|
7af6124278 | ||
|
|
51ea806ddc | ||
|
|
234db4f147 | ||
|
|
978918735d | ||
|
|
a6eae657c7 | ||
|
|
9a245ed0f5 | ||
|
|
3e5266ca7b | ||
|
|
515485b3b8 | ||
|
|
f311676097 | ||
|
|
a0e52955e6 | ||
|
|
74554bc9f6 | ||
|
|
4094babc82 | ||
|
|
1de2f3f97a | ||
|
|
c1c774f4bb | ||
|
|
2f5c37d21f | ||
|
|
3404877091 | ||
|
|
ed3db1240c | ||
|
|
9a6274c815 | ||
|
|
b8043bace6 | ||
|
|
3a31eb2519 | ||
|
|
ab255e535c | ||
|
|
f8758306b7 | ||
|
|
7c462e1b5c | ||
|
|
a909180798 | ||
|
|
6ceeabe170 | ||
|
|
40aa02ff70 | ||
|
|
8c4c32c4bf | ||
|
|
88ebcef18a | ||
|
|
de57432594 | ||
|
|
74f113412e | ||
|
|
3e0495a529 | ||
|
|
24d17416b5 | ||
|
|
736c0917c0 | ||
|
|
f01bfe8ae0 | ||
|
|
726c4dfe58 | ||
|
|
4142c1053b | ||
|
|
7559f0a802 |
No files matched your search
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1015 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -49,6 +49,11 @@ feature is opt-in and should be enabled in the settings first.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
## Building
|
||||
|
||||
Recommendations on environment:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Furbooru Tagging Assistant",
|
||||
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
|
||||
"version": "0.7.1",
|
||||
"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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -16,24 +16,27 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.55.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.98.0",
|
||||
"svelte": "^5.53.12"
|
||||
"sass": "^1.105.1",
|
||||
"svelte": "^5.57.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@types/chrome": "^0.1.37",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"@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": "^28.1.0",
|
||||
"svelte-check": "^4.4.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.0"
|
||||
"jsdom": "^29.1.1",
|
||||
"svelte-check": "^4.7.6",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.3.7",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=25"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Author Heads
|
||||
|
||||
Just small little head appearing inside the footer of popup. For now only for Furbooru version, maybe pony head will be
|
||||
added later.
|
||||
|
||||
## Credits
|
||||
|
||||
- Original avali head sketch by [@canaryarachnid](https://x.com/canaryarachnid).
|
||||
- Vectorized by me.
|
||||
|
||||
## License
|
||||
|
||||
These heads icons are available under [CC0](https://creativecommons.org/publicdomain/zero/1.0/) license. Feel free to
|
||||
use in any way you want.
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg width="236.02mm" height="148.13mm" version="1.1" viewBox="0 0 236.02 148.13" xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"><g transform="translate(11.797 -86.754)"><g fill="#968e99"><path d="m186.16 195.05 5.9113 8.1132-25.874 23.732-16.665-20.556 5.9795-8.3411z"/><path
|
||||
d="m26.25 195.06-5.9113 8.1132 25.874 23.732 16.666-20.582-5.9805-8.3147z"/><path
|
||||
d="m203.89 93.217-13.663 4.8447-32.193 45.799 3.6721 9.9725 37.277-16.965z"/><path
|
||||
d="m8.4989 93.206 13.663 4.8447 32.193 45.799-3.6721 9.9725-37.277-16.965z"/><path
|
||||
d="m63.729 147.62-3.547 9.6564 46.015 67.389 46.042-67.393-3.5471-9.6544z"/></g><g fill="#4f85c2"><path d="m20.339 203.17c-5.551 7.6763-11.833 16.516-18.939 26.694 27.59 13.761 53.74-7.154 65.586-19.106-0.49035-0.60034-3.0245-3.1978-4.1062-4.4385-4.2321 2.3924-10.049 3.5511-17.508 3.545-7.9048-0.35181-18.303-2.0451-25.032-6.6947z"/>
|
||||
<path d="m192.07 203.16c-7.4239 4.6488-16.449 6.3228-25.051 6.7081-7.4927 6e-3 -13.307-1.1725-17.486-3.5295-2.4713 2.8329-2.9096 2.911-4.1212 4.4013 11.836 11.949 38.001 32.897 65.607 19.128-7.1107-10.185-13.396-19.029-18.949-26.708z"/>
|
||||
<path d="m152.24 157.27c-13.612 1.3595-21.056 0.76683-30.326 12.205-4.1088 5.543-4.2586 10.12-3.699 14.359 0.45057 4.3583 3.1102 9.099 0.87695 13.343-2.1841 3.7829-5.9067 5.0599-8.4982 5.5645-2.4209 0.47143-6.339 0.47752-8.7912 0-2.5915-0.50464-6.3141-1.7816-8.4982-5.5645-2.1245-4.2051 0.25648-9.0329 0.87695-13.343 0.55962-4.2396 0.40976-8.8163-3.699-14.359-8.9349-11.92-17.402-10.288-30.299-12.201-0.88134 3.6253-1.4784 7.3599-1.4785 11.262-3.8e-5 13.042 5.3573 25.506 14.822 34.479 6.5392 7.1685 12.467 15.901 21.358 20.363 3.4326 1.6502 18.928 1.778 22.626 0 8.6078-4.1381 20.376-19.41 21.275-20.259 9.5319-8.9788 14.934-21.487 14.934-34.582-3e-5 -3.9035-0.59853-7.6394-1.4805-11.266z"/></g><g
|
||||
fill="#29191a"><path d="m106.21 121.62c-9.7679 8e-5 -18.634 3.0833-26.383 8.0279-6.7812 4.3268-12.47 10.607-16.098 17.978 15.479 0.41008 27.128 5.5727 34.785 15.902 5.7694 7.7834 6.3441 15.834 5.58 21.623-0.22478 2.4881-1.0451 4.8662-1.4805 7.3127 1.5355 0.93671 5.6306 0.93688 7.1665 0-0.55728-2.4263-1.1272-4.8453-1.4805-7.3127-0.76407-5.7885-0.18935-13.84 5.58-21.623 7.6614-10.336 19.319-15.5 34.812-15.904-3.6285-7.3698-9.3054-13.557-16.052-17.87-7.7665-4.9644-16.626-8.1339-26.429-8.1339z"/>
|
||||
<path d="m190.22 98.064c-24.54 9.3131-40.338 18.775-49.075 24.879 7.1493 5.5115 12.974 12.668 16.911 20.891 12.415-9.4444 26.363-34.709 32.164-45.77z"/>
|
||||
<path d="m22.162 98.05c7.7439 14.767 20.662 37.041 32.193 45.799 3.9367-8.23 9.7635-15.392 16.917-20.907-8.7414-6.1066-24.551-15.575-49.111-24.893z"/></g><path
|
||||
d="m165.29 167.8c20.288-15.729 58.433-49.439 58.433-80.549-7.0704 1.9242-13.674 3.925-19.83 5.9635-6.54 13.411-25.14 49.274-42.158 60.585 1.1931 4.4859 1.9164 9.1612 1.9834 14"
|
||||
fill="#4f85c2"/><path
|
||||
d="m163.71 167.8c0.0869 7.1007-1.1747 18.463-8.196 30.211 1.8946 0.85694 5.1546 1.8566 11.496 1.8516 9.5462-8e-3 17.335-3.8489 19.149-4.8188-9.0279-12.291-15.805-21.03-20.877-27.244"
|
||||
fill="#29191a"/><path
|
||||
d="m48.708 167.8c0.06664-4.8318 0.78533-9.5006 1.9752-13.981-17.491-11.602-36.614-49.192-42.185-60.616-6.1461-2.0346-12.739-4.0315-19.796-5.9521 0 31.11 38.144 64.82 58.432 80.549"
|
||||
fill="#4f85c2"/><path
|
||||
d="m47.135 167.8c-5.074 6.2158-11.853 14.958-20.885 27.255 1.8518 0.98731 9.6175 4.8001 19.129 4.808 6.3617 5e-3 9.63-1.0011 11.52-1.8598-7.141-11.953-8.2835-23.483-8.1903-30.203"
|
||||
fill="#29191a"/></g></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="236.01562mm"
|
||||
height="148.13016mm"
|
||||
viewBox="0 0 236.01562 148.13015"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||
sodipodi:docname="avali.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showguides="true"
|
||||
inkscape:zoom="0.5"
|
||||
inkscape:cx="164"
|
||||
inkscape:cy="261"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1009"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1"><sodipodi:guide
|
||||
position="91.628075,161.11368"
|
||||
orientation="0,-1"
|
||||
id="guide4"
|
||||
inkscape:locked="false" /><sodipodi:guide
|
||||
position="91.975095,138.23615"
|
||||
orientation="0,-1"
|
||||
id="guide5"
|
||||
inkscape:locked="false" /><sodipodi:guide
|
||||
position="0.50000074,203.49375"
|
||||
orientation="0,-1"
|
||||
id="guide12"
|
||||
inkscape:locked="false" /><sodipodi:guide
|
||||
position="22.993504,210.90259"
|
||||
orientation="-1,0"
|
||||
id="guide13"
|
||||
inkscape:locked="false"
|
||||
inkscape:label=""
|
||||
inkscape:color="rgb(0,134,229)" /><sodipodi:guide
|
||||
position="212.99349,242.70766"
|
||||
orientation="-1,0"
|
||||
id="guide14"
|
||||
inkscape:locked="false"
|
||||
inkscape:label=""
|
||||
inkscape:color="rgb(0,134,229)" /></sodipodi:namedview><defs
|
||||
id="defs1" /><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(11.797294,-86.753747)"><path
|
||||
style="fill:#968e99;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 186.163,195.04664 5.91126,8.1132 -25.87369,23.73181 -16.66531,-20.55576 5.97948,-8.34111 z"
|
||||
id="path78"
|
||||
sodipodi:nodetypes="cccccc" /><path
|
||||
style="fill:#968e99;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 26.250284,195.05749 -5.911266,8.1132 25.873694,23.73181 16.666345,-20.58213 -5.980515,-8.31474 z"
|
||||
id="path77" /><path
|
||||
style="fill:#968e99;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 203.88854,93.217193 -13.66325,4.844666 -32.19339,45.799271 3.67213,9.97252 37.27686,-16.96487 z"
|
||||
id="path76" /><path
|
||||
style="fill:#968e99;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 8.4989134,93.205824 13.6632486,4.844665 32.193383,45.799271 -3.672126,9.97252 -37.276861,-16.96487 z"
|
||||
id="path75" /><path
|
||||
style="fill:#968e99;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 63.728581,147.62093 -3.547036,9.65644 46.014665,67.38877 46.04225,-67.39291 -3.54711,-9.65437 z"
|
||||
id="path74" /><path
|
||||
style="fill:#4f85c2;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 20.339018,203.17069 c -5.551004,7.67626 -11.8329296,16.5156 -18.9394127,26.69449 27.5897217,13.76092 53.7397197,-7.15402 65.5856607,-19.10632 -0.490346,-0.60034 -3.024527,-3.19784 -4.106209,-4.43849 -4.232092,2.39241 -10.049149,3.55113 -17.507976,3.54498 -7.904761,-0.35181 -18.303247,-2.04506 -25.032064,-6.69468 z"
|
||||
id="path67"
|
||||
sodipodi:nodetypes="scccccs" /><path
|
||||
style="fill:#4f85c2;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 192.0722,203.15725 c -7.42386,4.64877 -16.44936,6.32276 -25.05066,6.70812 -7.49273,0.006 -13.30657,-1.1725 -17.48628,-3.52948 -2.47131,2.83288 -2.90958,2.91097 -4.1212,4.40128 11.83592,11.94876 38.00109,32.89721 65.60737,19.12803 -7.11066,-10.18486 -13.39576,-19.02853 -18.94924,-26.70793 z"
|
||||
id="path66"
|
||||
sodipodi:nodetypes="sscccss" /><path
|
||||
style="fill:#4f85c2;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 152.23846,157.27323 c -13.61237,1.35951 -21.05605,0.76683 -30.32631,12.20546 -4.10876,5.54304 -4.25862,10.11968 -3.699,14.35933 0.45057,4.35831 3.11017,9.09905 0.87695,13.34337 -2.18406,3.7829 -5.90669,5.05988 -8.49819,5.56452 -2.42092,0.47143 -6.33897,0.47752 -8.7912,0 -2.591495,-0.50464 -6.314128,-1.78162 -8.498187,-5.56452 -2.124547,-4.20509 0.256478,-9.03293 0.876949,-13.34337 0.559622,-4.23965 0.409755,-8.81629 -3.698999,-14.35933 -8.93488,-11.92025 -17.401686,-10.28815 -30.298926,-12.20132 -0.881339,3.62533 -1.478432,7.35986 -1.478463,11.26184 -3.8e-5,13.04221 5.357287,25.50569 14.822351,34.47852 6.539201,7.16852 12.466617,15.90057 21.358387,20.36258 3.432644,1.65018 18.927508,1.77799 22.626008,0 8.60782,-4.13814 20.37587,-19.41036 21.27519,-20.25923 9.53191,-8.97878 14.9341,-21.48697 14.93397,-34.58187 -3e-5,-3.90348 -0.59853,-7.63938 -1.48053,-11.26598 z"
|
||||
id="path65"
|
||||
sodipodi:nodetypes="csssssssscccsscsc" /><path
|
||||
style="fill:#29191a;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 106.21026,121.61546 c -9.767937,8e-5 -18.633933,3.08331 -26.3834,8.02793 -6.78116,4.32678 -12.469879,10.60703 -16.098277,17.97754 15.479213,0.41008 27.128138,5.57271 34.784957,15.90239 5.76937,7.78335 6.34409,15.83448 5.58003,21.62297 -0.22478,2.48809 -1.04514,4.86621 -1.48054,7.31273 1.53551,0.93671 5.63064,0.93688 7.16648,0 -0.55728,-2.42629 -1.12715,-4.84531 -1.48053,-7.31273 -0.76407,-5.78849 -0.18935,-13.83962 5.58002,-21.62297 7.66138,-10.33584 19.31928,-15.49987 34.81235,-15.90446 -3.62846,-7.36979 -9.30538,-13.55739 -16.05174,-17.86971 -7.7665,-4.96439 -16.62622,-8.1339 -26.4294,-8.13387 z"
|
||||
id="path64"
|
||||
sodipodi:nodetypes="caccssscssacc" /><path
|
||||
style="fill:#29191a;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 190.22322,98.063925 c -24.53952,9.313115 -40.33776,18.775385 -49.07504,24.879105 7.14927,5.51153 12.97385,12.66765 16.9106,20.89123 12.41453,-9.44437 26.36344,-34.70935 32.16444,-45.770335 z"
|
||||
id="path63"
|
||||
sodipodi:nodetypes="cccc" /><path
|
||||
style="fill:#29191a;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 22.162162,98.050489 c 7.743943,14.767141 20.661921,37.040771 32.193384,45.799271 3.936731,-8.23004 9.763525,-15.39177 16.917314,-20.90673 -8.74141,-6.10665 -24.550557,-15.57494 -49.110698,-24.892541 z"
|
||||
id="path62"
|
||||
sodipodi:nodetypes="cccc" /><path
|
||||
id="path61"
|
||||
style="fill:#4f85c2;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 165.28573,167.80282 v 0 c 20.28784,-15.72918 58.4326,-49.43941 58.4326,-80.549088 v 0 c -7.07035,1.924173 -13.674,3.925012 -19.82979,5.963461 -6.53997,13.410897 -25.13956,49.274067 -42.15815,60.585447 1.19314,4.48589 1.91642,9.16123 1.98341,14.00018"
|
||||
sodipodi:nodetypes="csssccc" /><path
|
||||
id="path73"
|
||||
style="fill:#29191a;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 163.7138,167.80282 c 0.0869,7.10074 -1.17473,18.46252 -8.19605,30.21108 1.89459,0.85694 5.15458,1.85656 11.49594,1.85156 9.54622,-0.008 17.33453,-3.84892 19.14921,-4.81882 -9.02792,-12.29058 -15.8048,-21.02979 -20.87727,-27.24382"
|
||||
sodipodi:nodetypes="ccccc" /><path
|
||||
id="path2"
|
||||
style="fill:#4f85c2;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 48.708262,167.80282 c 0.06664,-4.83184 0.785332,-9.50063 1.975157,-13.98054 C 33.192531,142.22039 14.069501,104.63007 8.4989136,93.205824 2.3527811,91.171256 -4.2399168,89.174373 -11.297294,87.253732 v 0 c 0,31.109548 38.144181,64.819828 58.432092,80.549088"
|
||||
sodipodi:nodetypes="cccssc" /><path
|
||||
id="path71"
|
||||
style="fill:#29191a;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 47.134798,167.80282 v 0 c -5.073961,6.21585 -11.852773,14.95841 -20.884513,27.25467 1.8518,0.98731 9.617497,4.80014 19.128548,4.80797 6.361731,0.005 9.629998,-1.00112 11.51971,-1.85983 -7.140975,-11.95305 -8.283505,-23.48271 -8.190265,-30.20281"
|
||||
sodipodi:nodetypes="cscccc" /></g></svg>
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
@@ -3,14 +3,16 @@
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
<a href="https://github.com/koloml/furbooru-tagging-assistant/releases/tag/{version}" target="_blank">
|
||||
<a href="https://github.com/koloml/philomena-tagging-assistant/releases/tag/{version}" target="_blank">
|
||||
v{version}
|
||||
</a>
|
||||
<span>, made with ♥ by KoloMl.</span>
|
||||
<span>, made with ♥ by
|
||||
{#if __CURRENT_SITE__ === 'furbooru'}<i class="head"></i>{/if} KoloMl.</span>
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
@use '$styles/environment';
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
@@ -29,4 +31,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if environment.$current-site == 'furbooru' {
|
||||
.head {
|
||||
content: '';
|
||||
height: 24px;
|
||||
width: 32px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
background: {
|
||||
image: url(/src/assets/heads/avali-optimized.svg);
|
||||
size: contain;
|
||||
repeat: no-repeat;
|
||||
};
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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<string, string> = 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' ? <const> [
|
||||
["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' ? <const>[
|
||||
["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',
|
||||
];
|
||||
@@ -77,7 +77,7 @@ export class BaseComponent<ContainerType extends HTMLElement = HTMLElement> {
|
||||
): () => void {
|
||||
this.#container.addEventListener(event, listener, options);
|
||||
|
||||
return () => void this.#container.removeEventListener(event, listener, options);
|
||||
return () => this.#container.removeEventListener(event, listener, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<EventDetails> = (event: CustomEvent<EventDetails>) => void;
|
||||
export type UnsubscribeFunction = () => void;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const EVENT_TAG_TOGGLED = 'tag-toggled';
|
||||
|
||||
export interface TagsListTogglerEventsMap {
|
||||
[EVENT_TAG_TOGGLED]: [string, boolean];
|
||||
}
|
||||
@@ -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 {
|
||||
#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');
|
||||
#currentMediaBox: MediaBox | null = null;
|
||||
readonly #videoElement: HTMLVideoElement = document.createElement('video');
|
||||
readonly #imageElement: HTMLImageElement = document.createElement('img');
|
||||
readonly #spinnerElement: HTMLElement = document.createElement('i');
|
||||
readonly #sizeSelectorElement: HTMLSelectElement = document.createElement('select');
|
||||
readonly #closeButtonElement: HTMLElement = document.createElement('i');
|
||||
readonly #tagsPopupElement: HTMLElement = document.createElement('div');
|
||||
readonly #imageTagsToggler: TagsListToggler = TagsListToggler.create();
|
||||
readonly #profileTagsToggler: TagsListToggler = TagsListToggler.create();
|
||||
readonly #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> | 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<void> {
|
||||
this.#currentURIs = imageUris;
|
||||
async show(mediaBox: MediaBox): Promise<void> {
|
||||
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,24 +382,64 @@ 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 (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');
|
||||
}
|
||||
|
||||
static #preferences = new MiscPreferences();
|
||||
static readonly #preferences = new MiscPreferences();
|
||||
|
||||
static #offsetProperty = '--offset';
|
||||
static #opacityProperty = '--opacity';
|
||||
static #shownState = 'shown';
|
||||
static #swipeState = 'swiped';
|
||||
static #minRequiredDistance = 50;
|
||||
static readonly #offsetProperty = '--offset';
|
||||
static readonly #opacityProperty = '--opacity';
|
||||
static readonly #shownState = 'shown';
|
||||
static readonly #swipeState = 'swiped';
|
||||
static readonly #minRequiredDistance = 50;
|
||||
static readonly #collapsedPopupState = 'is-collapsed';
|
||||
|
||||
static #previewSizes: Record<FullscreenViewerSize, string> = {
|
||||
static readonly #previewSizes: Record<FullscreenViewerSize, string> = {
|
||||
full: 'Full',
|
||||
large: 'Large',
|
||||
medium: 'Medium',
|
||||
small: 'Small'
|
||||
}
|
||||
|
||||
static #fallbackSize = 'large';
|
||||
static readonly #fallbackSize = 'large';
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -83,5 +81,5 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
return viewer;
|
||||
}
|
||||
|
||||
static #preferences = new MiscPreferences();
|
||||
static readonly #preferences = new MiscPreferences();
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -5,8 +5,8 @@ import { createFontAwesomeIcon } from "$lib/dom-utils";
|
||||
import { sortEntitiesByField } from "$lib/utils";
|
||||
|
||||
export default class EditorPresetsBlock extends BaseComponent {
|
||||
#presetsTable = document.createElement('table');
|
||||
#presetBlocks: PresetTableRow[] = [];
|
||||
readonly #presetsTable = document.createElement('table');
|
||||
readonly #presetBlocks: PresetTableRow[] = [];
|
||||
#tags: Set<string> = new Set();
|
||||
|
||||
protected build() {
|
||||
|
||||
@@ -207,6 +207,6 @@ export default class PresetTableRow extends BaseComponent {
|
||||
return new this(document.createElement('tr'), preset);
|
||||
}
|
||||
|
||||
static #tagMissingClassName = 'is-missing';
|
||||
static #presetWarningClassName = 'has-warning';
|
||||
static readonly #tagMissingClassName = 'is-missing';
|
||||
static readonly #presetWarningClassName = 'has-warning';
|
||||
}
|
||||
@@ -4,35 +4,26 @@ 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<string, HTMLElement> = new Map();
|
||||
readonly #tagsListToggler = TagsListToggler.create();
|
||||
#activeProfile: TaggingProfile | null = null;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
#tagsToRemove: Set<string> = new Set();
|
||||
#tagsToAdd: Set<string> = new Set();
|
||||
#isSubmissionLocked: boolean = false;
|
||||
#isPlanningToSubmit: boolean = false;
|
||||
#isSubmitting: boolean = false;
|
||||
#tagsSubmissionTimer: Timeout | null = null;
|
||||
#emitter = emitterAt(this);
|
||||
readonly #emitter = emitterAt(this);
|
||||
|
||||
/**
|
||||
* @protected
|
||||
@@ -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<HTMLElement>('.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.filter(tagName => activeTags.has(tagName)), activeTags);
|
||||
}
|
||||
|
||||
static create(): HTMLElement {
|
||||
@@ -332,29 +222,11 @@ 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.
|
||||
*/
|
||||
static #preferences = new TaggingProfilesPreferences();
|
||||
static readonly #preferences = new TaggingProfilesPreferences();
|
||||
|
||||
/**
|
||||
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
|
||||
@@ -426,12 +298,12 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
});
|
||||
}
|
||||
|
||||
static #scrapedAPI = new ScrapedAPI();
|
||||
static readonly #scrapedAPI = new ScrapedAPI();
|
||||
|
||||
static #delayBeforeSubmissionMs = 500;
|
||||
static readonly #delayBeforeSubmissionMs = 500;
|
||||
|
||||
/**
|
||||
* Amount of pending submissions or NULL if logic was not yet initialized.
|
||||
*/
|
||||
static #pendingSubmissionCount: number|null = null;
|
||||
static #pendingSubmissionCount: number | null = null;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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<string, HTMLElement> = new Map();
|
||||
#tagsToAdd: Set<string> = new Set();
|
||||
#tagsToRemove: Set<string> = new Set();
|
||||
#renderedTags: Set<string> = 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<string>) {
|
||||
for (const tagElement of this.#tagsList) {
|
||||
tagElement.remove();
|
||||
}
|
||||
|
||||
for (const tagElement of this.#invalidTagsList.values()) {
|
||||
tagElement.remove();
|
||||
}
|
||||
|
||||
this.#tagsList = [];
|
||||
this.#invalidTagsList = new Map();
|
||||
this.#renderedTags = new Set();
|
||||
|
||||
this.#renderAndInsertTags(tags, activeTags);
|
||||
}
|
||||
|
||||
prependTags(tags: string[], activeTags: Set<string>) {
|
||||
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<string>, shouldPrepend: boolean = false) {
|
||||
toggleableTags.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
let lastInserted: HTMLElement | null = null;
|
||||
|
||||
const hasStoredChanges = this.hasChanges;
|
||||
|
||||
for (const tagName of toggleableTags) {
|
||||
if (this.#renderedTags.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.#renderedTags.add(tagName);
|
||||
|
||||
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<HTMLElement>('.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;
|
||||
}
|
||||
|
||||
static create(): TagsListToggler {
|
||||
const tagsListContainer = document.createElement('div');
|
||||
return new TagsListToggler(tagsListContainer);
|
||||
}
|
||||
|
||||
static readonly #containsClass = 'is-present';
|
||||
static readonly #missingClass = 'is-missing';
|
||||
|
||||
static readonly #addedClass = 'is-added';
|
||||
static readonly #removedClass = 'is-removed';
|
||||
}
|
||||
|
||||
export class BlackListedTagsEncounteredError extends Error {
|
||||
constructor(tagName: string) {
|
||||
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
|
||||
cause: tagName
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -112,14 +112,14 @@ export class BlockCommunication extends BaseComponent {
|
||||
);
|
||||
}
|
||||
|
||||
static #preferences = new TagsPreferences();
|
||||
static readonly #preferences = new TagsPreferences();
|
||||
|
||||
/**
|
||||
* Map of links to their original texts. These texts need to be stored here to make them restorable. Keys is a link
|
||||
* element and value is a text.
|
||||
* @private
|
||||
*/
|
||||
static #originalTagLinkTexts: WeakMap<HTMLElement, string> = new WeakMap();
|
||||
static readonly #originalTagLinkTexts: WeakMap<HTMLElement, string> = new WeakMap();
|
||||
|
||||
static findAndInitializeAll() {
|
||||
for (const container of document.querySelectorAll<HTMLElement>('.block.communication')) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
|
||||
export default class ImageContainer extends BaseComponent {
|
||||
#imageLink: HTMLAnchorElement | null = null;
|
||||
|
||||
protected init() {
|
||||
this.#imageLink = this.container.querySelector('a');
|
||||
}
|
||||
|
||||
extractActualTags(): string[] {
|
||||
return this.#imageLink?.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
}
|
||||
|
||||
extractTagsAndAliases(): string[] {
|
||||
return this.container.dataset.imageTagAliases?.split(', ') || [];
|
||||
}
|
||||
|
||||
extractImageLinks(): App.ImageURIs {
|
||||
const jsonUris = this.container?.dataset.uris;
|
||||
|
||||
if (!jsonUris) {
|
||||
throw new Error('Missing URIs!');
|
||||
}
|
||||
|
||||
return JSON.parse(jsonUris);
|
||||
}
|
||||
|
||||
extractSources(): string[] {
|
||||
const jsonSourceUrls = this.container.dataset.sourceUrls;
|
||||
let sourceUrls: string[] | null = null;
|
||||
|
||||
try {
|
||||
sourceUrls = JSON.parse(jsonSourceUrls || '[]');
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse source URLs for the image!', this, e);
|
||||
}
|
||||
|
||||
return sourceUrls || [];
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,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<string, string> | 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<string> | null {
|
||||
const tagsAndAliases = this.tagsAndAliases;
|
||||
|
||||
if (tagsAndAliases === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Set(tagsAndAliases.values());
|
||||
}
|
||||
|
||||
get tagsAndAliases(): Map<string, string> | 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<MediaBoxTools>(mediaBoxToolsElement);
|
||||
mediaBoxToolsComponent?.initialize();
|
||||
|
||||
mediaBox.#mediaBoxTools = mediaBoxToolsComponent;
|
||||
}
|
||||
|
||||
static findElements(): NodeListOf<HTMLElement> {
|
||||
|
||||
@@ -88,14 +88,12 @@ export class TagDropdown extends BaseComponent {
|
||||
// Make sure original category is properly stored.
|
||||
this.originalCategory;
|
||||
|
||||
this.container.dataset.tagCategory = targetCategory;
|
||||
|
||||
if (targetCategory) {
|
||||
this.container.setAttribute('data-tag-category', targetCategory);
|
||||
this.container.dataset.tagCategory = targetCategory;
|
||||
return;
|
||||
}
|
||||
|
||||
this.container.removeAttribute('data-tag-category');
|
||||
delete this.container.dataset.tagCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,7 +201,7 @@ export class TagDropdown extends BaseComponent {
|
||||
await this.#activeProfile.save();
|
||||
}
|
||||
|
||||
static #preferences = new TaggingProfilesPreferences();
|
||||
static readonly #preferences = new TaggingProfilesPreferences();
|
||||
|
||||
/**
|
||||
* Watch for changes to active profile.
|
||||
@@ -262,8 +260,8 @@ export class TagDropdown extends BaseComponent {
|
||||
return dropdownLink;
|
||||
}
|
||||
|
||||
static #categoriesResolver = new CustomCategoriesResolver();
|
||||
static #processedElements: WeakSet<HTMLElement> = new WeakSet();
|
||||
static readonly #categoriesResolver = new CustomCategoriesResolver();
|
||||
static readonly #processedElements: WeakSet<HTMLElement> = new WeakSet();
|
||||
|
||||
static #findAll(parentNode: ParentNode = document): NodeListOf<HTMLElement> {
|
||||
return parentNode.querySelectorAll('.tag.dropdown');
|
||||
|
||||
@@ -7,8 +7,8 @@ import EditorPresetsBlock from "$content/components/extension/presets/EditorPres
|
||||
import { EVENT_PRESET_TAG_CHANGE_APPLIED, type PresetTagChange } from "$content/components/events/preset-block-events";
|
||||
|
||||
export class TagsForm extends BaseComponent {
|
||||
#togglePresetsButton: HTMLButtonElement = document.createElement('button');
|
||||
#presetsList = EditorPresetsBlock.create();
|
||||
readonly #togglePresetsButton: HTMLButtonElement = document.createElement('button');
|
||||
readonly #presetsList = EditorPresetsBlock.create();
|
||||
#plainEditorTextarea: HTMLTextAreaElement | null = null;
|
||||
#fancyEditorInput: HTMLInputElement | null = null;
|
||||
#tagsSet: Set<string> = new Set();
|
||||
@@ -127,7 +127,6 @@ export class TagsForm extends BaseComponent {
|
||||
const categoryName = tagCategories.get(tagName)!;
|
||||
|
||||
tagElement.dataset.tagCategory = categoryName;
|
||||
tagElement.setAttribute('data-tag-category', categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { createFontAwesomeIcon } from "$lib/dom-utils";
|
||||
|
||||
const brandsSubtype = 'brands';
|
||||
|
||||
export default class DupeDiff extends BaseComponent {
|
||||
#sourcesLine: HTMLElement | null = null;
|
||||
#ratingsLine: HTMLElement | null = null;
|
||||
|
||||
protected init() {
|
||||
const {
|
||||
"6": sourcesLine,
|
||||
"7": ratingsLine,
|
||||
} = this.container.querySelectorAll<HTMLElement>('table tr > td')
|
||||
|
||||
this.#sourcesLine = sourcesLine;
|
||||
this.#ratingsLine = ratingsLine;
|
||||
}
|
||||
|
||||
renderSources(leftSources: string[], rightSources: string[]): void {
|
||||
if (!this.#sourcesLine) {
|
||||
console.error("Can't render sources since no sources line in diff fonud!");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const childElement of this.#sourcesLine.children) {
|
||||
childElement.remove();
|
||||
}
|
||||
|
||||
const sourceIconsContainer = document.createElement('span');
|
||||
sourceIconsContainer.classList.add('source-icons');
|
||||
|
||||
sourceIconsContainer.append(
|
||||
this.#renderSourceIcons(leftSources),
|
||||
" vs ",
|
||||
this.#renderSourceIcons(rightSources),
|
||||
);
|
||||
|
||||
this.#sourcesLine.append(sourceIconsContainer);
|
||||
}
|
||||
|
||||
renderRatings(leftRating: string | null, rightRating: string | null) {
|
||||
if (!this.#ratingsLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const childElement of this.#ratingsLine.children) {
|
||||
childElement.remove();
|
||||
}
|
||||
|
||||
if (leftRating === rightRating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ratingDifference = document.createElement('span');
|
||||
ratingDifference.classList.add('ratings-difference');
|
||||
ratingDifference.textContent = `(${leftRating || '(none)'} vs ${rightRating || '(none)'})`;
|
||||
|
||||
this.#ratingsLine.append(ratingDifference);
|
||||
}
|
||||
|
||||
#renderSourceIcons(sourcesList: string[]): HTMLElement {
|
||||
const iconsContainer = document.createElement('span');
|
||||
iconsContainer.classList.add('source-icons__list');
|
||||
|
||||
for (const sourceUrl of sourcesList) {
|
||||
iconsContainer.append(this.#renderIcon(sourceUrl));
|
||||
}
|
||||
|
||||
if (!sourcesList.length) {
|
||||
iconsContainer.append('(none)');
|
||||
}
|
||||
|
||||
return iconsContainer;
|
||||
}
|
||||
|
||||
#renderIcon(url: string): HTMLElement {
|
||||
let iconSlug = 'globe';
|
||||
let maybeSubtype: string | undefined;
|
||||
|
||||
for (const [iconOrIconWithSubtype, singleOrMultiPattern] of DupeDiff.#iconsToPatternsMap) {
|
||||
if (
|
||||
typeof singleOrMultiPattern === 'string' && url.includes(singleOrMultiPattern)
|
||||
|| Array.isArray(singleOrMultiPattern) && singleOrMultiPattern.some(singlePattern => url.includes(singlePattern))
|
||||
) {
|
||||
if (Array.isArray(iconOrIconWithSubtype)) {
|
||||
[iconSlug, maybeSubtype] = iconOrIconWithSubtype;
|
||||
} else {
|
||||
iconSlug = iconOrIconWithSubtype;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceIcon = createFontAwesomeIcon(iconSlug, maybeSubtype);
|
||||
sourceIcon.title = url;
|
||||
|
||||
return sourceIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirroring of mapping from source URL domains to appropriate FontAwesome icons which is used in Philomena. Doesn't
|
||||
* match 1-to-1, but pretty close.
|
||||
*
|
||||
* Keys are the icons and values are the patterns extension should check for each URL.
|
||||
*/
|
||||
static readonly #iconsToPatternsMap = new Map<string[] | string, string[] | string>([
|
||||
[
|
||||
['artstation', brandsSubtype],
|
||||
'artstation.com'
|
||||
],
|
||||
[
|
||||
'bed',
|
||||
'pillowfort.social',
|
||||
],
|
||||
[
|
||||
'bolt-lightning',
|
||||
'boosty.to',
|
||||
],
|
||||
[
|
||||
['bluesky', brandsSubtype],
|
||||
'bsky.app',
|
||||
],
|
||||
[
|
||||
'brush',
|
||||
['artfight.net', 'newgrounds.com'],
|
||||
],
|
||||
[
|
||||
'coffee',
|
||||
['ko-fi.com', 'buymeacoffee.com'],
|
||||
],
|
||||
[
|
||||
['deviantart', brandsSubtype],
|
||||
['deviantart.com', 'sta.sh', 'fav.me'],
|
||||
],
|
||||
[
|
||||
['discord', brandsSubtype],
|
||||
['discordapp.com', 'discord.com', 'discord.gg'],
|
||||
],
|
||||
[
|
||||
'dove',
|
||||
'itaku.ee',
|
||||
],
|
||||
[
|
||||
['etsy', brandsSubtype],
|
||||
'etsy.com',
|
||||
],
|
||||
[
|
||||
['facebook', brandsSubtype],
|
||||
['facebook.com', 'fb.me'],
|
||||
],
|
||||
[
|
||||
['flickr', brandsSubtype],
|
||||
'flickr.com',
|
||||
],
|
||||
[
|
||||
['instagram', brandsSubtype],
|
||||
'instagram.com',
|
||||
],
|
||||
[
|
||||
['mastodon', brandsSubtype],
|
||||
[
|
||||
'awoo.space',
|
||||
'bark.light',
|
||||
'equestria.social',
|
||||
'mastodon.social',
|
||||
'meow.social',
|
||||
'pawoo.net',
|
||||
'pettingzoo.co',
|
||||
'pony.social',
|
||||
'vulpine.club',
|
||||
'yiff.life',
|
||||
'socel.net',
|
||||
'octodon.social',
|
||||
'filly.social',
|
||||
'pone.social',
|
||||
'hooves.social',
|
||||
'baraag.net',
|
||||
'furries.club',
|
||||
],
|
||||
],
|
||||
[
|
||||
'palette',
|
||||
['ych.art', 'commishes.com']
|
||||
],
|
||||
[
|
||||
['patreon', brandsSubtype],
|
||||
'patreon.com'
|
||||
],
|
||||
[
|
||||
'paw',
|
||||
['furaffinity.net', 'e621.net', 'furbooru.org', 'inkbunny.net', 'e926.net', 'sofurry.com', 'weasyl.co'],
|
||||
],
|
||||
[
|
||||
['pixiv', brandsSubtype],
|
||||
['pixiv.net', 'pixiv.me'],
|
||||
],
|
||||
[
|
||||
['reddit', brandsSubtype],
|
||||
['reddit.com', 'redd.it'],
|
||||
],
|
||||
// Patreon piracy website, applying custom icon to easily catch such posts.
|
||||
[
|
||||
'skull-crossbones',
|
||||
'kemono.cr',
|
||||
],
|
||||
[
|
||||
['telegram', brandsSubtype],
|
||||
't.me',
|
||||
],
|
||||
[
|
||||
['tiktok', brandsSubtype],
|
||||
'tiktok.com'
|
||||
],
|
||||
[
|
||||
['tumblr', brandsSubtype],
|
||||
['tumblr.com', 'tmblr.co', 'tumbex.com'],
|
||||
],
|
||||
[
|
||||
['vk', brandsSubtype],
|
||||
['vk.com', 'vk.ru'],
|
||||
],
|
||||
[
|
||||
['x-twitter', brandsSubtype],
|
||||
['x.com', 'twitter.com', 'twimg.com']
|
||||
],
|
||||
[
|
||||
['youtube', brandsSubtype],
|
||||
['youtube.com', 'youtu.be'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import ImageContainer from "$content/components/philomena/ImageContainer";
|
||||
|
||||
export default class DupeImage extends BaseComponent {
|
||||
readonly imageContainer: ImageContainer;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
super(container);
|
||||
|
||||
const imageContainerElement = container.querySelector<HTMLElement>('.image-container');
|
||||
|
||||
if (!imageContainerElement) {
|
||||
throw new Error('Missing image container inside the dupe row!');
|
||||
}
|
||||
|
||||
this.imageContainer = new ImageContainer(imageContainerElement);
|
||||
this.imageContainer.initialize();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
readonly #leftImage: DupeImage;
|
||||
readonly #rightImage: DupeImage;
|
||||
readonly #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;
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLElement>('.dupe-report-list')) {
|
||||
new GridDupeReportsList(container).initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
|
||||
export class ImageListInfo extends BaseComponent {
|
||||
#tagElement: HTMLElement | null = null;
|
||||
#impliedTags: string[] = [];
|
||||
#showUntaggedImplicationsButton: HTMLAnchorElement = document.createElement('a');
|
||||
readonly #impliedTags: string[] = [];
|
||||
readonly #showUntaggedImplicationsButton: HTMLAnchorElement = document.createElement('a');
|
||||
|
||||
protected build() {
|
||||
const sectionAfterImage = this.container.querySelector('.tag-info__image + .flex__grow');
|
||||
@@ -71,5 +71,5 @@ export class ImageListInfo extends BaseComponent {
|
||||
location.assign(url.href);
|
||||
}
|
||||
|
||||
static #implicationsStarterText = 'Implies:';
|
||||
static readonly #implicationsStarterText = 'Implies:';
|
||||
}
|
||||
@@ -38,7 +38,7 @@ amdLite.define = (name, dependencies, originalCallback) => {
|
||||
|
||||
// Workaround for the entry script not returning anything causing AMD-Lite to send warning about modules not
|
||||
// being loaded/not existing.
|
||||
return typeof callbackResult !== 'undefined' ? callbackResult : {};
|
||||
return callbackResult !== undefined ? callbackResult : {};
|
||||
});
|
||||
|
||||
// Schedule the auto run on the next available frame. Firefox and Chromium have a lot of differences in how they
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import GridDupeReportsList from "$content/components/philomena/dupe/GridDupeReportsList";
|
||||
|
||||
GridDupeReportsList.findAndInitialize();
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { UserDetails } from "$lib/extension/preferences/UserDetails";
|
||||
|
||||
(async () => {
|
||||
const userDetails = new UserDetails();
|
||||
const userDataStore = document.querySelector<HTMLElement>('.js-datastore');
|
||||
|
||||
if (!userDataStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userDetails.isAuthorized.set(userDataStore.dataset.userIsSignedIn === 'true');
|
||||
})();
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* Reusable function to create icons from FontAwesome. Usable only for website, since extension doesn't host its own
|
||||
* copy of FA styles. Extension should use imports of SVGs inside CSS instead.
|
||||
* @param iconSlug Slug of the icon to be added.
|
||||
* @param [subtype="solid"] Subtype of the icon. Some icons only exist in specific subtypes.
|
||||
* @return Element with classes for FontAwesome icon added.
|
||||
*/
|
||||
export function createFontAwesomeIcon(iconSlug: string): HTMLElement {
|
||||
export function createFontAwesomeIcon(iconSlug: string, subtype: string = 'solid'): HTMLElement {
|
||||
const iconElement = document.createElement('i');
|
||||
iconElement.classList.add('fa-solid', `fa-${iconSlug}`);
|
||||
iconElement.classList.add(`fa-${subtype}`, `fa-${iconSlug}`);
|
||||
return iconElement;
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export default class BulkEntitiesTransporter {
|
||||
&& Array.isArray(targetObject.elements);
|
||||
}
|
||||
|
||||
static #transporters: TransportersMapping = {
|
||||
static readonly #transporters: TransportersMapping = {
|
||||
profiles: new EntitiesTransporter(TaggingProfile),
|
||||
groups: new EntitiesTransporter(TagGroup),
|
||||
presets: new EntitiesTransporter(TagEditorPreset),
|
||||
|
||||
@@ -70,7 +70,7 @@ export default class ConfigurationController {
|
||||
return;
|
||||
}
|
||||
|
||||
callback(changes[this.#configurationName].newValue);
|
||||
callback(changes[this.#configurationName].newValue as Record<string, any>);
|
||||
}
|
||||
|
||||
this.#storage.subscribe(subscriber);
|
||||
|
||||
@@ -5,9 +5,9 @@ import { emit } from "$content/components/events/comms";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
|
||||
|
||||
export default class CustomCategoriesResolver {
|
||||
#exactGroupMatches = new Map<string, TagGroup>();
|
||||
#regExpGroupMatches = new Map<RegExp, TagGroup>();
|
||||
#tagDropdowns: TagDropdown[] = [];
|
||||
readonly #exactGroupMatches = new Map<string, TagGroup>();
|
||||
readonly #regExpGroupMatches = new Map<RegExp, TagGroup>();
|
||||
readonly #tagDropdowns: TagDropdown[] = [];
|
||||
#nextQueuedUpdate: Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
@@ -125,5 +125,5 @@ export default class CustomCategoriesResolver {
|
||||
);
|
||||
}
|
||||
|
||||
static #unprocessedTagsTimeout = 0;
|
||||
static readonly #unprocessedTagsTimeout = 0;
|
||||
}
|
||||
@@ -2,7 +2,15 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export default class EntitiesController {
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
/**
|
||||
* Instance of storage helper used to store/read/subscribe to storage changes.
|
||||
*
|
||||
* Mainly exposed for the testing purposes. When class is loaded outside of extension context, will hold `null`
|
||||
* instead. Any operations of entities will throw an error in this case.
|
||||
*/
|
||||
static storage: StorageHelper | null = typeof chrome !== 'undefined'
|
||||
? new StorageHelper(chrome.storage.local)
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
|
||||
@@ -14,7 +22,11 @@ export default class EntitiesController {
|
||||
* @return List of entities of the given type.
|
||||
*/
|
||||
static async readAllEntities<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type): Promise<Type[]> {
|
||||
const rawEntities = await this.#storageHelper.read(entityName, {});
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
const rawEntities = await this.storage.read(entityName, {});
|
||||
|
||||
if (!rawEntities || Object.keys(rawEntities).length === 0) {
|
||||
return [];
|
||||
@@ -32,10 +44,14 @@ export default class EntitiesController {
|
||||
* @param entity Entity to update.
|
||||
*/
|
||||
static async updateEntity(entityName: string, entity: StorageEntity<Object>): Promise<void> {
|
||||
this.#storageHelper.write(
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
this.storage.write(
|
||||
entityName,
|
||||
Object.assign(
|
||||
await this.#storageHelper.read(
|
||||
await this.storage.read(
|
||||
entityName, {}
|
||||
),
|
||||
{
|
||||
@@ -52,9 +68,13 @@ export default class EntitiesController {
|
||||
* @param entityId ID of the entity to delete.
|
||||
*/
|
||||
static async deleteEntity(entityName: string, entityId: string): Promise<void> {
|
||||
const entities = await this.#storageHelper.read(entityName, {});
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
const entities = await this.storage.read(entityName, {});
|
||||
delete entities[entityId];
|
||||
this.#storageHelper.write(entityName, entities);
|
||||
this.storage.write(entityName, entities);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +88,12 @@ export default class EntitiesController {
|
||||
* @return Unsubscribe function.
|
||||
*/
|
||||
static subscribeToEntity<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type, callback: (entities: Type[]) => void): () => void {
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
const storage = this.storage;
|
||||
|
||||
/**
|
||||
* Watch the changes made to the storage and call the callback when the entity changes.
|
||||
*/
|
||||
@@ -80,8 +106,8 @@ export default class EntitiesController {
|
||||
.then(callback);
|
||||
}
|
||||
|
||||
this.#storageHelper.subscribe(subscriber);
|
||||
storage.subscribe(subscriber);
|
||||
|
||||
return () => this.#storageHelper.unsubscribe(subscriber);
|
||||
return () => storage.unsubscribe(subscriber);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { validateImportedEntity } from "$lib/extension/transporting/validators";
|
||||
import { exportEntityToObject } from "$lib/extension/transporting/exporters";
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
|
||||
import type { ImportableElement } from "$lib/extension/transporting/importables";
|
||||
|
||||
/**
|
||||
* Status of the last import.
|
||||
|
||||
@@ -70,6 +70,10 @@ export class PreferenceField<
|
||||
set(value: Fields[Key]) {
|
||||
return this.#preferences.writeRaw(this.#fieldKey, value);
|
||||
}
|
||||
|
||||
get key(): Key {
|
||||
return this.#fieldKey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,16 +93,17 @@ export type WithFields<FieldsType extends Record<string, any>> = {
|
||||
* API.
|
||||
*/
|
||||
export default abstract class CacheablePreferences<Fields> {
|
||||
#controller: ConfigurationController;
|
||||
#cachedValues: Map<keyof Fields, any> = new Map();
|
||||
#disposables: Function[] = [];
|
||||
readonly #controller: ConfigurationController;
|
||||
readonly #cachedValues: Map<keyof Fields, any> = new Map();
|
||||
readonly #disposables: Function[] = [];
|
||||
|
||||
/**
|
||||
* @param settingsNamespace Name of the field inside the extension storage where these preferences stored.
|
||||
* @param [controller] Configuration controller. If not provided, default controller will be used.
|
||||
* @protected
|
||||
*/
|
||||
protected constructor(settingsNamespace: string) {
|
||||
this.#controller = new ConfigurationController(settingsNamespace);
|
||||
protected constructor(settingsNamespace: string, controller: ConfigurationController = new ConfigurationController(settingsNamespace)) {
|
||||
this.#controller = controller;
|
||||
|
||||
this.#disposables.push(
|
||||
this.#controller.subscribeToChanges(settings => {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type FullscreenViewerSize = keyof App.ImageURIs;
|
||||
interface MiscPreferencesFields {
|
||||
fullscreenViewer: boolean;
|
||||
fullscreenViewerSize: FullscreenViewerSize;
|
||||
fullscreenViewerPopupShown: boolean;
|
||||
}
|
||||
|
||||
export default class MiscPreferences extends CacheablePreferences<MiscPreferencesFields> implements WithFields<MiscPreferencesFields> {
|
||||
@@ -24,4 +25,9 @@ export default class MiscPreferences extends CacheablePreferences<MiscPreference
|
||||
field: "fullscreenViewerSize",
|
||||
defaultValue: "large",
|
||||
});
|
||||
|
||||
readonly fullscreenViewerPopupShown = new PreferenceField(this, {
|
||||
field: "fullscreenViewerPopupShown",
|
||||
defaultValue: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
|
||||
export interface UserDetailsFields {
|
||||
isAuthorized: boolean;
|
||||
}
|
||||
|
||||
export class UserDetails extends CacheablePreferences<UserDetailsFields> implements WithFields<UserDetailsFields> {
|
||||
constructor() {
|
||||
super('userDetails');
|
||||
}
|
||||
|
||||
isAuthorized = new PreferenceField(this, {
|
||||
field: 'isAuthorized',
|
||||
defaultValue: false,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import PostParser from "$lib/philomena/scraping/parsing/PostParser";
|
||||
|
||||
type UpdaterFunction = (tags: Set<string>) => Set<string>;
|
||||
export type TagsUpdaterFunction = (tags: Set<string>) => Set<string>;
|
||||
|
||||
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<Map<string, string> | null> {
|
||||
async updateImageTags(imageId: number, callback: TagsUpdaterFunction): Promise<Map<string, string> | null> {
|
||||
const postParser = new PostParser(imageId);
|
||||
const formData = await postParser.resolveTagEditorFormData();
|
||||
const tagsFieldValue = formData.get(PostParser.tagsInputName);
|
||||
|
||||
@@ -78,5 +78,5 @@ export default class PostParser extends PageParser {
|
||||
);
|
||||
}
|
||||
|
||||
static tagsInputName = 'image[tag_input]';
|
||||
static readonly tagsInputName = 'image[tag_input]';
|
||||
}
|
||||
@@ -41,21 +41,27 @@ export class QuotedTermToken extends Token {
|
||||
}
|
||||
|
||||
static decode(value: string): string {
|
||||
return value.replace(/\\([\\"])/g, "$1");
|
||||
return value
|
||||
.replaceAll(/\\([\\"])/g, "$1")
|
||||
.replaceAll(/^"|"$/g, '');
|
||||
}
|
||||
|
||||
static encode(value: string): string {
|
||||
return value.replace(/[\\"]/g, "\\$&");
|
||||
return `"${value.replaceAll(/[\\"]/g, "\\$&")}"`;
|
||||
}
|
||||
}
|
||||
|
||||
export class TermToken extends Token {
|
||||
}
|
||||
|
||||
type MatchResultCarry = {
|
||||
interface MatchResultCarry {
|
||||
match?: RegExpMatchArray | null
|
||||
}
|
||||
|
||||
interface SuccessfulMatchResultCarry {
|
||||
match: RegExpMatchArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search query tokenizer. Should mostly work for the cases of parsing and finding the selected term for
|
||||
* auto-completion. Follows the rules described in the Philomena booru engine.
|
||||
@@ -94,26 +100,26 @@ export class QueryLexer {
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#negotiationOperator, result)) {
|
||||
tokens.push(new NotToken(this.#index, result.match![0]));
|
||||
this.#index += result.match![0].length;
|
||||
tokens.push(new NotToken(this.#index, result.match[0]));
|
||||
this.#index += result.match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#andOperator, result)) {
|
||||
tokens.push(new AndToken(this.#index, result.match![0]));
|
||||
this.#index += result.match![0].length;
|
||||
tokens.push(new AndToken(this.#index, result.match[0]));
|
||||
this.#index += result.match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#orOperator, result)) {
|
||||
tokens.push(new OrToken(this.#index, result.match![0]));
|
||||
this.#index += result.match![0].length;
|
||||
tokens.push(new OrToken(this.#index, result.match[0]));
|
||||
this.#index += result.match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#notOperator, result)) {
|
||||
tokens.push(new NotToken(this.#index, result.match![0]));
|
||||
this.#index += result.match![0].length;
|
||||
tokens.push(new NotToken(this.#index, result.match[0]));
|
||||
this.#index += result.match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -130,26 +136,26 @@ export class QueryLexer {
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#boostOperator, result)) {
|
||||
tokens.push(new BoostToken(this.#index, result.match![0]));
|
||||
this.#index += result.match![0].length;
|
||||
tokens.push(new BoostToken(this.#index, result.match[0]));
|
||||
this.#index += result.match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#whitespaces, result)) {
|
||||
this.#index += result.match![0].length;
|
||||
this.#index += result.match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#quotedText, result)) {
|
||||
tokens.push(new QuotedTermToken(this.#index, result.match![0], result.match![1]));
|
||||
this.#index += result.match![0].length;
|
||||
tokens.push(new QuotedTermToken(this.#index, result.match[0], result.match[1]));
|
||||
this.#index += result.match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
dirtyText = this.#parseDirtyText(this.#index);
|
||||
|
||||
if (dirtyText) {
|
||||
tokens.push(new TermToken(this.#index, dirtyText));
|
||||
tokens.push(new TermToken(this.#index, dirtyText.trim()));
|
||||
this.#index += dirtyText.length;
|
||||
continue;
|
||||
}
|
||||
@@ -168,7 +174,7 @@ export class QueryLexer {
|
||||
*
|
||||
* @return Is there a match?
|
||||
*/
|
||||
#match(targetRegExp: RegExp, resultCarrier: MatchResultCarry = {}): boolean {
|
||||
#match(targetRegExp: RegExp, resultCarrier: MatchResultCarry = {}): resultCarrier is SuccessfulMatchResultCarry {
|
||||
return this.#matchAt(targetRegExp, this.#index, resultCarrier);
|
||||
}
|
||||
|
||||
@@ -181,9 +187,9 @@ export class QueryLexer {
|
||||
*
|
||||
* @return Is there a match?
|
||||
*/
|
||||
#matchAt(targetRegExp: RegExp, index: number, resultCarrier: MatchResultCarry = {}): boolean {
|
||||
#matchAt(targetRegExp: RegExp, index: number, resultCarrier: MatchResultCarry = {}): resultCarrier is SuccessfulMatchResultCarry {
|
||||
targetRegExp.lastIndex = index;
|
||||
resultCarrier.match = this.#value.match(targetRegExp);
|
||||
resultCarrier.match = targetRegExp.exec(this.#value);
|
||||
|
||||
return resultCarrier.match !== null;
|
||||
}
|
||||
@@ -207,16 +213,10 @@ export class QueryLexer {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.#matchAt(QueryLexer.#dirtyTextContent, index, result)) {
|
||||
resultValue += result.match![0];
|
||||
index += result.match![0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.#value[index] === QueryLexer.#bracketsOpenCharacter) {
|
||||
let bracketsContent = QueryLexer.#bracketsOpenCharacter + this.#parseDirtyText(index + 1);
|
||||
|
||||
if (this.#value[index + bracketsContent.length + 1] === QueryLexer.#bracketsCloseCharacter) {
|
||||
if (this.#value[index + bracketsContent.length] === QueryLexer.#bracketsCloseCharacter) {
|
||||
bracketsContent += QueryLexer.#bracketsCloseCharacter;
|
||||
}
|
||||
|
||||
@@ -227,22 +227,28 @@ export class QueryLexer {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.#matchAt(QueryLexer.#dirtyTextContent, index, result)) {
|
||||
resultValue += result.match[0];
|
||||
index += result.match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return resultValue;
|
||||
}
|
||||
|
||||
static #commaCharacter = ',';
|
||||
static #negotiationOperator = /[!-]/y;
|
||||
static #andOperator = /\s+(?:AND|&&)\s+/y;
|
||||
static #orOperator = /\s+(?:OR|\|\|)\s+/y;
|
||||
static #notOperator = /NOT\s+/y;
|
||||
static #bracketsOpenCharacter = "(";
|
||||
static #bracketsCloseCharacter = ")";
|
||||
static #boostOperator = /\^[+-]?\d+(?:\.\d+)?/y;
|
||||
static #whitespaces = /\s+/y;
|
||||
static #quotedText = /"((?:\\.|[^\\"])+)"/y;
|
||||
static #dirtyTextStopWords = /,|\s+(?:AND|&&|OR|\|\|)\s+|\s+(?:\)|\^[+-]?\d+(?:\.\d+)?)/y;
|
||||
static #dirtyTextContent = /\\.|[^()]/y;
|
||||
static readonly #commaCharacter = ',';
|
||||
static readonly #negotiationOperator = /[!-]/y;
|
||||
static readonly #andOperator = /\s+(?:AND|&&)\s+/y;
|
||||
static readonly #orOperator = /\s+(?:OR|\|\|)\s+/y;
|
||||
static readonly #notOperator = /NOT\s+/y;
|
||||
static readonly #bracketsOpenCharacter = "(";
|
||||
static readonly #bracketsCloseCharacter = ")";
|
||||
static readonly #boostOperator = /\^[+-]?\d+(?:\.\d+)?/y;
|
||||
static readonly #whitespaces = /\s+/y;
|
||||
static readonly #quotedText = /"\s*((?:\\.|[^\\"])+?)\s*"/y;
|
||||
static readonly #dirtyTextStopWords = /,|\s+(?:AND|&&|OR|\|\|)\s+|\s*(?:\)|\^[+-]?\d+(?:\.\d+)?)/y;
|
||||
static readonly #dirtyTextContent = /\\.|[^()]/y;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ const tagLinkRegExp = /\/tags\/(?<encodedTagName>[^/?#]+)/;
|
||||
*
|
||||
* @see https://github.com/philomena-dev/philomena/blob/6086757b654da8792ae52adb2a2f501ea6c30d12/lib/philomena/slug.ex#L52-L57
|
||||
*/
|
||||
const slugEncodedCharacters: Map<string, string> = new Map([
|
||||
export const slugEncodedCharacters: Map<string, string> = new Map([
|
||||
['-dash-', '-'],
|
||||
['-fwslash-', '/'],
|
||||
['-bwslash-', '\\'],
|
||||
@@ -101,9 +101,8 @@ export function resolveTagNameFromLink(tagLink: URL): string | null {
|
||||
}
|
||||
|
||||
return decodeURIComponent(encodedTagName)
|
||||
.replaceAll(/-[a-z]+-/gi, match => slugEncodedCharacters.get(match) ?? match)
|
||||
.replaceAll('-', ' ')
|
||||
.replaceAll('+', ' ');
|
||||
.replaceAll('+', ' ')
|
||||
.replaceAll(/-[a-z]+-/gi, match => slugEncodedCharacters.get(match) ?? match);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Writable } from "svelte/store";
|
||||
import CacheablePreferences, { type PreferenceField } from "$lib/extension/base/CacheablePreferences";
|
||||
|
||||
interface PrerefenceConnectionOptions<ValueType> {
|
||||
/**
|
||||
* 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<PreferencesType extends Record<string, any> = Record<string, any>> {
|
||||
readonly #preferences: CacheablePreferences<PreferencesType>;
|
||||
readonly #syncedPairs: [PreferenceField<PreferencesType, string>, Writable<any>, PrerefenceConnectionOptions<any>][] = [];
|
||||
|
||||
protected constructor(preferences: CacheablePreferences<PreferencesType>) {
|
||||
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<FieldKey extends keyof PreferencesType>(
|
||||
field: PreferenceField<PreferencesType, FieldKey>,
|
||||
store: Writable<PreferencesType[FieldKey]>,
|
||||
options: PrerefenceConnectionOptions<PreferencesType[FieldKey]> = {}
|
||||
): this {
|
||||
this.#syncedPairs.push([field as any, store, options]);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async startSync(): Promise<void> {
|
||||
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<string, any> = Record<string, any>
|
||||
>(preferences: CacheablePreferences<PreferencesType>): PreferenceSync<PreferencesType> {
|
||||
return new PreferenceSync<PreferencesType>(preferences);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
<MenuItem href={currentSiteUrl} icon="globe" target="_blank">
|
||||
Visit {__CURRENT_SITE_NAME__}
|
||||
</MenuItem>
|
||||
<MenuItem href="https://github.com/koloml/furbooru-tagging-assistant" icon="info-circle" target="_blank">
|
||||
<MenuItem href="https://github.com/koloml/philomena-tagging-assistant" icon="info-circle" target="_blank">
|
||||
GitHub Repo
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import { activeTaggingProfile, taggingProfiles } from "$stores/entities/tagging-profiles";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
import { user } from "$stores/preferences/user";
|
||||
import Notice from "$components/ui/Notice.svelte";
|
||||
|
||||
$popupTitle = 'Tagging Profiles';
|
||||
|
||||
@@ -28,6 +30,18 @@
|
||||
<Menu>
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/profiles/new/edit" icon="plus">Create New</MenuItem>
|
||||
</Menu>
|
||||
{#if $user?.isAuthorized === false}
|
||||
<Menu>
|
||||
<hr>
|
||||
</Menu>
|
||||
<Notice level="warning">
|
||||
<strong>Tagging profiles will only work when you're signed in!</strong>
|
||||
<br><br>
|
||||
Unauthorized users have to solve the captcha before sending any submissions to the site.
|
||||
</Notice>
|
||||
{/if}
|
||||
<Menu>
|
||||
{#if profiles.length}
|
||||
<hr>
|
||||
{/if}
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -0,0 +1,18 @@
|
||||
import { UserDetails, type UserDetailsFields } from "$lib/extension/preferences/UserDetails";
|
||||
import { readable } from "svelte/store";
|
||||
|
||||
const userDetails = new UserDetails();
|
||||
|
||||
export const user = readable<UserDetailsFields | null>(null, set => {
|
||||
userDetails.subscribe(settings => {
|
||||
set({
|
||||
isAuthorized: Boolean(settings.isAuthorized)
|
||||
});
|
||||
});
|
||||
|
||||
userDetails.isAuthorized.get().then(isAuthorized => {
|
||||
set({
|
||||
isAuthorized
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import EntitiesController from "$lib/extension/EntitiesController";
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import { TestedEntity, type TestedSettings } from "$tests/stubs/Entity";
|
||||
import { randomString } from "$tests/utils";
|
||||
import { randomInt } from "crypto";
|
||||
|
||||
describe('EntitiesController', () => {
|
||||
let mockedStorage: ChromeStorageArea;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedStorage = new ChromeStorageArea();
|
||||
EntitiesController.storage = new StorageHelper(mockedStorage);
|
||||
});
|
||||
|
||||
it('should throw when storage is not present', async () => {
|
||||
EntitiesController.storage = null;
|
||||
|
||||
const readPromise = EntitiesController.readAllEntities(
|
||||
TestedEntity._entityName,
|
||||
TestedEntity,
|
||||
);
|
||||
|
||||
const deletePromise = EntitiesController.deleteEntity(
|
||||
TestedEntity._entityName,
|
||||
randomString(),
|
||||
);
|
||||
|
||||
const updatePromise = EntitiesController.updateEntity(
|
||||
TestedEntity._entityName,
|
||||
new TestedEntity(randomString(), {
|
||||
numberField: randomInt(1000),
|
||||
stringField: randomString(),
|
||||
}),
|
||||
);
|
||||
|
||||
const subscribe = () => {
|
||||
EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, vi.fn());
|
||||
}
|
||||
|
||||
await expect(readPromise).rejects.toThrow(Error);
|
||||
await expect(deletePromise).rejects.toThrow(Error);
|
||||
await expect(updatePromise).rejects.toThrow(Error);
|
||||
|
||||
expect(subscribe).toThrow(Error);
|
||||
});
|
||||
|
||||
describe('readAllEntities', () => {
|
||||
it('should return empty array when nothing in the storage yet', async () => {
|
||||
const entities = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
expect(entities).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should properly capture different entities from storage', async () => {
|
||||
const storageWithEntities: Record<string, Record<string, Partial<TestedSettings>>> = {
|
||||
[TestedEntity._entityName]: {
|
||||
[randomString()]: {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100_000, 100_000),
|
||||
},
|
||||
[randomString()]: {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100_000, 100_000),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
mockedStorage.insertMockedData(storageWithEntities);
|
||||
|
||||
const loadedEntities = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
|
||||
expect(loadedEntities).toHaveLength(2);
|
||||
|
||||
for (const entity of loadedEntities) {
|
||||
const rawStorageEntry = storageWithEntities[TestedEntity._entityName][entity.id];
|
||||
|
||||
expect(entity.settings.stringField).toBe(rawStorageEntry.stringField);
|
||||
expect(entity.settings.numberField).toBe(rawStorageEntry.numberField);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEntity', () => {
|
||||
it('should create a storage structure if it is not created yet', async () => {
|
||||
expect(mockedStorage.mockedData).toEqual({});
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(100_000),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[entity.id]: {
|
||||
stringField: entity.settings.stringField,
|
||||
numberField: entity.settings.numberField,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should update entity inside the existing', async () => {
|
||||
const id = randomString();
|
||||
const initialStringValue = randomString();
|
||||
const updatedStringValue = randomString();
|
||||
|
||||
mockedStorage.insertMockedData({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: {
|
||||
stringField: initialStringValue,
|
||||
numberField: randomInt(100_000),
|
||||
} as TestedSettings,
|
||||
}
|
||||
});
|
||||
|
||||
const [entity] = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
expect(entity.settings.stringField).toBe(initialStringValue);
|
||||
|
||||
entity.settings.stringField = updatedStringValue;
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
const entityInsideStorage = mockedStorage.mockedData[TestedEntity._entityName][id];
|
||||
expect(entityInsideStorage.stringField).toBe(updatedStringValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEntity', () => {
|
||||
it('should initialize the storage structure if delete called', async () => {
|
||||
expect(mockedStorage.mockedData).toEqual({});
|
||||
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, randomString());
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete entity and keep the storage object empty', async () => {
|
||||
const id = randomString();
|
||||
const settings: TestedSettings = {
|
||||
numberField: randomInt(100_000),
|
||||
stringField: randomString(),
|
||||
};
|
||||
|
||||
mockedStorage.insertMockedData({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: settings,
|
||||
}
|
||||
});
|
||||
|
||||
// Doesn't touch existing instance if ID is not found in the storage
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, randomString());
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: settings,
|
||||
}
|
||||
});
|
||||
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, id);
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeToEntity', () => {
|
||||
it('should notify about changes and return new entities', async () => {
|
||||
let receivedEntities: TestedEntity[] | null = null;
|
||||
|
||||
const subscriber = vi.fn((entities: TestedEntity[]) => {
|
||||
receivedEntities = entities;
|
||||
});
|
||||
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, subscriber);
|
||||
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
|
||||
const createdEntity = new TestedEntity(randomString(), {
|
||||
numberField: randomInt(100),
|
||||
stringField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, createdEntity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalled();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
const [firstReceivedEntity] = receivedEntities || [];
|
||||
|
||||
expect(firstReceivedEntity).toBeInstanceOf(TestedEntity);
|
||||
expect(firstReceivedEntity).not.toBe(createdEntity);
|
||||
expect(firstReceivedEntity).toEqual(createdEntity);
|
||||
});
|
||||
|
||||
it('should stop receiving updates once unsubscribed', async () => {
|
||||
const firstSubscriber = vi.fn();
|
||||
const unsubscribeFirst = EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, firstSubscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
numberField: randomInt(100_000),
|
||||
stringField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(firstSubscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
firstSubscriber.mockReset();
|
||||
unsubscribeFirst();
|
||||
|
||||
const secondSubscriber = vi.fn();
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, secondSubscriber);
|
||||
|
||||
entity.settings.stringField = randomString();
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(secondSubscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
expect(firstSubscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not notify when something else was changed in the storage', async () => {
|
||||
const rawStorageSubscriber = vi.fn();
|
||||
const entitiesSubscriber = vi.fn();
|
||||
|
||||
void EntitiesController.storage?.subscribe(rawStorageSubscriber);
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, entitiesSubscriber);
|
||||
|
||||
EntitiesController.storage?.write('otherStorage', {
|
||||
someField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(
|
||||
TestedEntity._entityName,
|
||||
new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(100_000),
|
||||
}),
|
||||
);
|
||||
|
||||
EntitiesController.storage?.write('otherStorage', {
|
||||
someField: randomString(),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(entitiesSubscriber).toHaveBeenCalledOnce();
|
||||
expect(rawStorageSubscriber).toHaveBeenCalledTimes(3);
|
||||
}, {timeout: 10, interval: 1});
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { randomString } from "$tests/utils";
|
||||
import { randomInt } from "crypto";
|
||||
import { TestedPreferences } from "$tests/stubs/Preferences";
|
||||
|
||||
describe('CachablePreferences', () => {
|
||||
let preferences: TestedPreferences;
|
||||
|
||||
beforeEach(() => {
|
||||
preferences = new TestedPreferences(randomString(), {
|
||||
numberField: randomInt(-100_000, 100_000),
|
||||
stringField: randomString(),
|
||||
});
|
||||
});
|
||||
|
||||
describe('PreferenceField', () => {
|
||||
it('should get/set values in preferences with defaults in mind', async () => {
|
||||
expect(await preferences.numberField.get()).toBe(preferences.defaults.numberField);
|
||||
expect(await preferences.stringField.get()).toBe(preferences.defaults.stringField);
|
||||
|
||||
const randomUpdatedNumber = randomInt(100_000_000);
|
||||
const randomUpdatedString = randomString();
|
||||
|
||||
await preferences.numberField.set(randomUpdatedNumber);
|
||||
await preferences.stringField.set(randomUpdatedString);
|
||||
|
||||
expect(await preferences.numberField.get()).toBe(randomUpdatedNumber);
|
||||
expect(await preferences.stringField.get()).toBe(randomUpdatedString);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not store anything unless written into', async () => {
|
||||
expect(preferences.mockedStorageArea.mockedData).toEqual({});
|
||||
|
||||
const randomValue = randomInt(10000000);
|
||||
await preferences.numberField.set(randomValue);
|
||||
|
||||
expect(preferences.mockedStorageArea.mockedData).toEqual({
|
||||
[preferences.mockedSettingsNamespace]: {
|
||||
numberField: randomValue,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should read from cache on subsequent reads', async () => {
|
||||
void await preferences.readRaw('numberField', preferences.defaults.numberField);
|
||||
expect(preferences.mockedStorageArea.get).toHaveBeenCalledOnce();
|
||||
|
||||
preferences.mockedStorageArea.get.mockReset();
|
||||
|
||||
void await preferences.readRaw('numberField', preferences.defaults.numberField);
|
||||
expect(preferences.mockedStorageArea.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not write if cached value is the same unless forced to', async () => {
|
||||
const firstValue = randomString();
|
||||
const secondValue = randomString();
|
||||
|
||||
void await preferences.writeRaw('stringField', firstValue);
|
||||
expect(preferences.mockedStorageArea.set).toHaveBeenCalledOnce();
|
||||
|
||||
preferences.mockedStorageArea.set.mockReset();
|
||||
|
||||
void await preferences.writeRaw('stringField', firstValue);
|
||||
expect(preferences.mockedStorageArea.set).not.toHaveBeenCalled();
|
||||
|
||||
preferences.mockedStorageArea.set.mockReset();
|
||||
|
||||
void await preferences.writeRaw('stringField', secondValue);
|
||||
expect(preferences.mockedStorageArea.set).toHaveBeenCalledOnce();
|
||||
|
||||
preferences.mockedStorageArea.set.mockReset();
|
||||
|
||||
void await preferences.writeRaw('stringField', secondValue, true);
|
||||
expect(preferences.mockedStorageArea.set).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('will avoid writing default value if field was accessed previously', async () => {
|
||||
void await preferences.stringField.get();
|
||||
void await preferences.stringField.set(preferences.defaults.stringField);
|
||||
|
||||
expect(preferences.mockedStorageArea.set).not.toHaveBeenCalled();
|
||||
expect(preferences.mockedStorageArea.mockedData).toEqual({});
|
||||
});
|
||||
|
||||
it('should notify about changes', async () => {
|
||||
const subscriber = vi.fn();
|
||||
preferences.subscribe(subscriber);
|
||||
|
||||
const updatedValue = randomString();
|
||||
await preferences.stringField.set(updatedValue);
|
||||
|
||||
expect(subscriber).toHaveBeenCalledWith({
|
||||
stringField: updatedValue,
|
||||
});
|
||||
});
|
||||
|
||||
it('should stop sending changes when unsubscribed', async () => {
|
||||
const subscriber = vi.fn();
|
||||
const unsubscribe = preferences.subscribe(subscriber);
|
||||
|
||||
const updatedValue = randomString();
|
||||
await preferences.stringField.set(updatedValue);
|
||||
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
|
||||
subscriber.mockReset();
|
||||
unsubscribe();
|
||||
|
||||
const secondUpdatedValue = randomString();
|
||||
await preferences.stringField.set(secondUpdatedValue);
|
||||
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dispose of all subscriptions', async () => {
|
||||
const subscriber = vi.fn();
|
||||
preferences.subscribe(subscriber);
|
||||
|
||||
await preferences.stringField.set(randomString());
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
|
||||
subscriber.mockReset();
|
||||
|
||||
preferences.dispose();
|
||||
|
||||
await preferences.stringField.set(randomString());
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
AndToken,
|
||||
BoostToken,
|
||||
GroupEndToken,
|
||||
GroupStartToken,
|
||||
NotToken,
|
||||
OrToken,
|
||||
QueryLexer,
|
||||
QuotedTermToken,
|
||||
TermToken,
|
||||
Token
|
||||
} from "$lib/philomena/search/QueryLexer";
|
||||
|
||||
describe('QueryLexer', () => {
|
||||
function parseQuery(query: string): Token[] {
|
||||
return new QueryLexer(query).parse();
|
||||
}
|
||||
|
||||
function parseQueryTypes(query: string): (typeof Token)[] {
|
||||
return parseQuery(query)
|
||||
.map(term => (term.constructor as any) as typeof Token);
|
||||
}
|
||||
|
||||
it('should properly parse different kinds of queries', () => {
|
||||
expect(parseQueryTypes('safe')).toEqual([TermToken]);
|
||||
expect(parseQueryTypes('safe^1')).toEqual([TermToken, BoostToken]);
|
||||
expect(parseQueryTypes('safe, avali')).toEqual([TermToken, AndToken, TermToken]);
|
||||
expect(parseQueryTypes('!avali')).toEqual([NotToken, TermToken]);
|
||||
expect(parseQueryTypes('avali || 4 ears')).toEqual([TermToken, OrToken, TermToken]);
|
||||
expect(parseQueryTypes('avali && !4 ears')).toEqual([TermToken, AndToken, NotToken, TermToken]);
|
||||
|
||||
expect(parseQueryTypes('avali AND (NOT 4 ears OR -3 fingers)')).toEqual([
|
||||
TermToken, AndToken, GroupStartToken, NotToken, TermToken, OrToken, NotToken, TermToken, GroupEndToken,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not treat parentheses as groups inside the term', () => {
|
||||
expect(parseQueryTypes('!(experiment (casualties unknown) || milky (casualties unknown))')).toEqual([
|
||||
NotToken, GroupStartToken, TermToken, OrToken, TermToken, GroupEndToken,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should accept any amount of whitespaces between different tokens', () => {
|
||||
expect(parseQueryTypes('! ( avali , experiment (casualties unknown) ) && safe')).toEqual([
|
||||
NotToken, GroupStartToken, TermToken, AndToken, TermToken, GroupEndToken, AndToken, TermToken,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should trim whitespaces inside the terms, even in quoted ones', () => {
|
||||
const [termWithSpaces] = parseQuery(' avali ');
|
||||
expect(termWithSpaces.value).toBe('avali');
|
||||
|
||||
const [quotedTermWithSpaces] = parseQuery(' " avali " ');
|
||||
expect(quotedTermWithSpaces instanceof QuotedTermToken && quotedTermWithSpaces.decodedValue || new Error('Wrong token')).toBe('avali');
|
||||
});
|
||||
|
||||
it('should properly differentiate between word-like operators and parts of tags', () => {
|
||||
expect(parseQueryTypes('safe AND sound')).toEqual([TermToken, AndToken, TermToken]);
|
||||
expect(parseQueryTypes('NOT safe AND dangerous')).toEqual([NotToken, TermToken, AndToken, TermToken]);
|
||||
});
|
||||
|
||||
it('should only detect word-like operators when spaces are in place', () => {
|
||||
// Require whitespace between operator and other tokens
|
||||
expect(parseQueryTypes('NOT safeANDsound')).toEqual([NotToken, TermToken]);
|
||||
|
||||
// If none are there, just should treat it as a part of a term
|
||||
expect(parseQuery('safeAND sound')[0].value).toEqual('safeAND sound');
|
||||
|
||||
// All operators should be in all caps, otherwise it's just a term
|
||||
const [lowercaseOperatorWords] = parseQuery('avali are cute and you know it or else');
|
||||
expect(lowercaseOperatorWords.value).toBe('avali are cute and you know it or else');
|
||||
|
||||
// And if it in caps, but part of some word, then it's just a word
|
||||
const [wordsInCapsContainingOperators] = parseQuery('THAT POOR KNOT IS PLAIN AS SAND');
|
||||
expect(wordsInCapsContainingOperators.value).toBe('THAT POOR KNOT IS PLAIN AS SAND');
|
||||
});
|
||||
|
||||
it('should not treat any operators inside the quoted term as actual operators', () => {
|
||||
const tokens = parseQuery('"this AND that OR these NOT there || () && ^123"');
|
||||
const [quotedTermToken] = tokens;
|
||||
|
||||
expect(tokens).toHaveLength(1);
|
||||
|
||||
expect(quotedTermToken instanceof QuotedTermToken && quotedTermToken.decodedValue || null)
|
||||
.toBe('this AND that OR these NOT there || () && ^123');
|
||||
});
|
||||
|
||||
describe('QuotedTermToken', () => {
|
||||
it('should decode and encode quotes and backslash', () => {
|
||||
const encodedQuote = `"term with \\\" inside of it"`;
|
||||
const decodedQuote = 'term with " inside of it';
|
||||
|
||||
expect(QuotedTermToken.decode(encodedQuote)).toBe(decodedQuote);
|
||||
expect(QuotedTermToken.encode(decodedQuote)).toBe(encodedQuote);
|
||||
|
||||
const encodedBackslash = `"term with \\\\ inside of it"`;
|
||||
const decodedBackslash = 'term with \\ inside of it';
|
||||
|
||||
expect(QuotedTermToken.decode(encodedBackslash)).toBe(decodedBackslash);
|
||||
expect(QuotedTermToken.encode(decodedBackslash)).toBe(encodedBackslash);
|
||||
});
|
||||
|
||||
it('should not care for anything else', () => {
|
||||
const encodedTerm = '"operators: , && || AND OR NOT ! ^ ? *"';
|
||||
const decodedTerm = 'operators: , && || AND OR NOT ! ^ ? *';
|
||||
|
||||
expect(QuotedTermToken.decode(encodedTerm)).toBe(decodedTerm);
|
||||
expect(QuotedTermToken.encode(decodedTerm)).toBe(encodedTerm);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { URL } from 'url';
|
||||
import {
|
||||
buildTagsAndAliasesMap,
|
||||
resolveTagCategoryFromTagName,
|
||||
resolveTagNameFromLink,
|
||||
slugEncodedCharacters
|
||||
} from '$lib/philomena/tag-utils';
|
||||
import { randomString } from "$tests/utils";
|
||||
import { namespaceCategories } from "$config/tags";
|
||||
|
||||
const origin = 'https://furbooru.org';
|
||||
|
||||
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'];
|
||||
|
||||
expect(buildTagsAndAliasesMap(tagsAndAliases, tagsAndAliases)).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"avali" => "avali",
|
||||
"experiment (casualties unknown)" => "experiment (casualties unknown)",
|
||||
"fictional species" => "fictional species",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
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'];
|
||||
|
||||
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 ignore any non-real tags coming before first tag is found', () => {
|
||||
const outOfOrderTag = randomString();
|
||||
|
||||
const realTags = ['avali', 'experiment (casualties unknown)', 'fictional species'];
|
||||
const realAndAliasesTags = [outOfOrderTag, 'avali', 'experiment (casualties unknown)', 'experiment (gunsaw)', 'fictional species'];
|
||||
|
||||
const warn = vi.spyOn(console, 'warn');
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export interface TestedSettings {
|
||||
stringField: string;
|
||||
numberField: number;
|
||||
nested?: {
|
||||
field: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class TestedEntity extends StorageEntity<TestedSettings> {
|
||||
static readonly _entityName = "entity";
|
||||
|
||||
constructor(id: string, settings: TestedSettings) {
|
||||
super(id, settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
|
||||
export interface TestedFields {
|
||||
numberField: number;
|
||||
stringField: string;
|
||||
}
|
||||
|
||||
export class TestedPreferences extends CacheablePreferences<TestedFields> implements WithFields<TestedFields> {
|
||||
readonly defaults: TestedFields;
|
||||
readonly mockedSettingsNamespace: string;
|
||||
readonly mockedStorageArea: ChromeStorageArea;
|
||||
readonly mockedStorageHelper: StorageHelper;
|
||||
|
||||
numberField;
|
||||
stringField;
|
||||
|
||||
constructor(settingsNamespace: string, mockedDefaults: TestedFields) {
|
||||
const mockedStorageArea = new ChromeStorageArea();
|
||||
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
|
||||
const mockedConfigurationController = new ConfigurationController(
|
||||
settingsNamespace,
|
||||
mockedStorageHelper,
|
||||
);
|
||||
|
||||
super(settingsNamespace, mockedConfigurationController);
|
||||
|
||||
this.mockedSettingsNamespace = settingsNamespace;
|
||||
this.mockedStorageArea = mockedStorageArea;
|
||||
this.mockedStorageHelper = mockedStorageHelper;
|
||||
this.defaults = mockedDefaults;
|
||||
|
||||
this.numberField = new PreferenceField(this, {
|
||||
field: 'numberField',
|
||||
defaultValue: this.defaults.numberField,
|
||||
});
|
||||
|
||||
this.stringField = new PreferenceField(this, {
|
||||
field: 'stringField',
|
||||
defaultValue: this.defaults.stringField,
|
||||
});
|
||||
}
|
||||
}
|
||||