mirror of
https://github.com/koloml/philomena-tagging-assistant.git
synced 2026-06-23 18:22:20 +00:00
Compare commits
61 Commits
f0083169f3
...
feature/te
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| b905ed668c | |||
| 5c26888292 | |||
| b6329bc4ef | |||
| 4f52906123 | |||
| 7d41524b4a | |||
| 81b3d61a20 | |||
| dc78b2fe84 | |||
| c12b00817b | |||
| b4419b5de3 | |||
| 66fd093e5a | |||
| bc7f85eaf9 | |||
| 3f9412b02d | |||
| a75dd098dc | |||
| a45248cebf | |||
| c777b57efb | |||
| 025cbaebb7 | |||
| b031b88512 | |||
| 399e75809b | |||
| f581f84065 | |||
| e8b0afc81f | |||
| daceb9ad59 | |||
| c36929b824 | |||
| 9b262393fa | |||
| 83c7608e99 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Furbooru Tagging Assistant",
|
||||
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "furbooru-tagging-assistant@thecore.city",
|
||||
|
||||
583
package-lock.json
generated
583
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -16,24 +16,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.55.0",
|
||||
"@sveltejs/kit": "^2.65.0",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"amd-lite": "^1.0.1",
|
||||
"lz-string": "^1.5.0",
|
||||
"sass": "^1.98.0",
|
||||
"svelte": "^5.53.12"
|
||||
"sass": "^1.101.0",
|
||||
"svelte": "^5.56.3"
|
||||
},
|
||||
"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.1.43",
|
||||
"@types/node": "^25.9.3",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"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.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.3.5",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
14
src/assets/heads/README.md
Normal file
14
src/assets/heads/README.md
Normal file
@@ -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.
|
||||
19
src/assets/heads/avali-optimized.svg
Normal file
19
src/assets/heads/avali-optimized.svg
Normal file
@@ -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 |
120
src/assets/heads/avali.svg
Normal file
120
src/assets/heads/avali.svg
Normal file
@@ -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 |
@@ -10,6 +10,7 @@
|
||||
let { preset }: PresetViewProps = $props();
|
||||
|
||||
const sortedTagsList = $derived(preset.settings.tags.toSorted((a, b) => a.localeCompare(b)));
|
||||
const requiredTagsList = $derived(preset.settings.requiredTags.toSorted((a, b) => a.localeCompare(b)));
|
||||
</script>
|
||||
|
||||
<DetailsBlock title="Preset Name">
|
||||
@@ -18,3 +19,17 @@
|
||||
<DetailsBlock title="Tags">
|
||||
<TagsList tags={sortedTagsList}></TagsList>
|
||||
</DetailsBlock>
|
||||
{#if preset.settings.exclusive}
|
||||
<DetailsBlock title="Exclusivity">
|
||||
Only one tag in this preset should be active at a time. If you will click on other non-active tag, other tags will
|
||||
be automatically removed from the editor.
|
||||
</DetailsBlock>
|
||||
{/if}
|
||||
{#if preset.settings.conditional}
|
||||
<DetailsBlock title="Conditional">
|
||||
This preset will only appear when one of the tags below are present on image.
|
||||
</DetailsBlock>
|
||||
<DetailsBlock title="Conditional Tags">
|
||||
<TagsList tags={requiredTagsList}></TagsList>
|
||||
</DetailsBlock>
|
||||
{/if}
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import type { MaintenancePopupEventsMap } from "$content/components/events/maintenance-popup-events";
|
||||
import type { TaggingProfilePopupEventsMap } from "$content/components/events/tagging-profile-popup-events";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import type { FullscreenViewerEventsMap } from "$content/components/events/fullscreen-viewer-events";
|
||||
import type { BooruEventsMap } from "$content/components/events/booru-events";
|
||||
@@ -7,7 +7,7 @@ import type { TagDropdownEvents } from "$content/components/events/tag-dropdown-
|
||||
import type { PresetBlockEventsMap } from "$content/components/events/preset-block-events";
|
||||
|
||||
type EventsMapping =
|
||||
MaintenancePopupEventsMap
|
||||
TaggingProfilePopupEventsMap
|
||||
& FullscreenViewerEventsMap
|
||||
& BooruEventsMap
|
||||
& TagsFormEventsMap
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type TaggingProfile from "$entities/TaggingProfile";
|
||||
|
||||
export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed';
|
||||
export const EVENT_MAINTENANCE_STATE_CHANGED = 'maintenance-state-change';
|
||||
export const EVENT_PROFILE_POPUP_STATE_CHANGED = 'maintenance-state-change';
|
||||
export const EVENT_TAGS_UPDATED = 'tags-updated';
|
||||
|
||||
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
|
||||
export type ProfilePopupState = 'ready' | 'processing' | 'failed' | 'complete' | 'waiting';
|
||||
|
||||
export interface MaintenancePopupEventsMap {
|
||||
export interface TaggingProfilePopupEventsMap {
|
||||
[EVENT_ACTIVE_PROFILE_CHANGED]: TaggingProfile | null;
|
||||
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
|
||||
[EVENT_PROFILE_POPUP_STATE_CHANGED]: ProfilePopupState;
|
||||
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { TaggingProfilePopup } from "$content/components/extension/profiles/TaggingProfilePopup";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/maintenance-popup-events";
|
||||
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/tagging-profile-popup-events";
|
||||
import type { MediaBox } from "$content/components/philomena/MediaBox";
|
||||
import type TaggingProfile from "$entities/TaggingProfile";
|
||||
|
||||
export class MediaBoxTools extends BaseComponent {
|
||||
#mediaBox: MediaBox | null = null;
|
||||
#maintenancePopup: TaggingProfilePopup | null = null;
|
||||
#popup: TaggingProfilePopup | null = null;
|
||||
|
||||
init() {
|
||||
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
|
||||
@@ -34,8 +34,8 @@ export class MediaBoxTools extends BaseComponent {
|
||||
component.initialize();
|
||||
}
|
||||
|
||||
if (!this.#maintenancePopup && component instanceof TaggingProfilePopup) {
|
||||
this.#maintenancePopup = component;
|
||||
if (!this.#popup && component instanceof TaggingProfilePopup) {
|
||||
this.#popup = component;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +46,6 @@ export class MediaBoxTools extends BaseComponent {
|
||||
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
|
||||
}
|
||||
|
||||
get maintenancePopup(): TaggingProfilePopup | null {
|
||||
return this.#maintenancePopup;
|
||||
}
|
||||
|
||||
get mediaBox(): MediaBox | null {
|
||||
return this.#mediaBox;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ import { EVENT_PRESET_TAG_CHANGE_APPLIED } from "$content/components/events/pres
|
||||
import { createFontAwesomeIcon } from "$lib/dom-utils";
|
||||
|
||||
export default class PresetTableRow extends BaseComponent {
|
||||
#preset: TagEditorPreset;
|
||||
readonly #preset: TagEditorPreset;
|
||||
readonly #applyAllButton = document.createElement('button');
|
||||
readonly #removeAllButton = document.createElement('button');
|
||||
readonly #exclusiveWarning = document.createElement('div');
|
||||
readonly #alternateColorDummy = document.createElement('span');
|
||||
|
||||
#tagsList: HTMLElement[] = [];
|
||||
#applyAllButton = document.createElement('button');
|
||||
#removeAllButton = document.createElement('button');
|
||||
|
||||
constructor(container: HTMLElement, preset: TagEditorPreset) {
|
||||
super(container);
|
||||
@@ -32,6 +35,7 @@ export default class PresetTableRow extends BaseComponent {
|
||||
nameCell.textContent = this.#preset.settings.name;
|
||||
|
||||
const tagsCell = document.createElement('td');
|
||||
tagsCell.style.width = '70%';
|
||||
|
||||
const tagsListContainer = document.createElement('div');
|
||||
tagsListContainer.classList.add('tag-list');
|
||||
@@ -52,6 +56,18 @@ export default class PresetTableRow extends BaseComponent {
|
||||
this.#removeAllButton.append(createFontAwesomeIcon('circle-minus'));
|
||||
this.#removeAllButton.title = 'Remove all tags from this preset from the editor';
|
||||
|
||||
if (this.#preset.settings.exclusive) {
|
||||
this.#applyAllButton.disabled = true;
|
||||
this.#applyAllButton.title = "You can't add all tags from this preset since it only allows one tag to be active";
|
||||
|
||||
this.#exclusiveWarning.classList.add('block', 'block--fixed', 'block--warning');
|
||||
this.#exclusiveWarning.textContent = ' Multiple tags from this preset present in the editor! If you will click one of the tags here, other tags will be cleared automatically.'
|
||||
this.#exclusiveWarning.prepend(createFontAwesomeIcon('triangle-exclamation'));
|
||||
this.#exclusiveWarning.style.display = 'none';
|
||||
|
||||
tagsCell.append(this.#exclusiveWarning);
|
||||
}
|
||||
|
||||
actionsContainer.append(
|
||||
this.#applyAllButton,
|
||||
this.#removeAllButton,
|
||||
@@ -64,6 +80,8 @@ export default class PresetTableRow extends BaseComponent {
|
||||
tagsCell,
|
||||
actionsCell,
|
||||
);
|
||||
|
||||
this.#alternateColorDummy.style.display = 'none';
|
||||
}
|
||||
|
||||
protected init() {
|
||||
@@ -85,6 +103,30 @@ export default class PresetTableRow extends BaseComponent {
|
||||
const tagName = targetElement.dataset.tagName;
|
||||
const isMissing = targetElement.classList.contains(PresetTableRow.#tagMissingClassName);
|
||||
|
||||
if (!tagName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a user clicks on the tag which was missing, then we have to remove all other active tags that are in this
|
||||
// preset. But only when clicking on a tag which is missing, just so they will be able to remove any cases where
|
||||
// multiple tags from exclusive present are active.
|
||||
if (this.#preset.settings.exclusive && isMissing) {
|
||||
const tagNamesToRemove = this.#tagsList
|
||||
.filter(
|
||||
tagElement => tagElement !== targetElement
|
||||
&& !tagElement.classList.contains(PresetTableRow.#tagMissingClassName)
|
||||
)
|
||||
.map(tagElement => tagElement.dataset.tagName)
|
||||
.filter(tagName => typeof tagName === 'string');
|
||||
|
||||
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
|
||||
addedTags: new Set([tagName]),
|
||||
removedTags: new Set(tagNamesToRemove)
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
|
||||
[isMissing ? 'addedTags' : 'removedTags']: new Set([tagName])
|
||||
});
|
||||
@@ -108,13 +150,53 @@ export default class PresetTableRow extends BaseComponent {
|
||||
});
|
||||
}
|
||||
|
||||
#maybeRefreshVisibilityFromTags(sourceTags: Set<string>) {
|
||||
if (!this.#preset.settings.conditional || this.#isMatchesConditional(sourceTags)) {
|
||||
this.container.style.display = '';
|
||||
this.#alternateColorDummy.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
this.container.style.display = 'none';
|
||||
this.container.after(this.#alternateColorDummy);
|
||||
}
|
||||
|
||||
#isMatchesConditional(sourceTags: Set<string>): boolean {
|
||||
const listOfRequiredTags = this.#preset.settings.requiredTags;
|
||||
|
||||
return Boolean(
|
||||
listOfRequiredTags.length
|
||||
&& listOfRequiredTags.some(tagName => sourceTags.has(tagName))
|
||||
);
|
||||
}
|
||||
|
||||
updateTags(tags: Set<string>) {
|
||||
let presentTagsAmount = 0;
|
||||
|
||||
for (const tagElement of this.#tagsList) {
|
||||
tagElement.classList.toggle(
|
||||
const isTagMissing = tagElement.classList.toggle(
|
||||
PresetTableRow.#tagMissingClassName,
|
||||
!tags.has(tagElement.dataset.tagName || ''),
|
||||
);
|
||||
|
||||
if (!isTagMissing) {
|
||||
presentTagsAmount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#preset.settings.exclusive) {
|
||||
const multipleTagsInExclusivePreset = presentTagsAmount > 1;
|
||||
|
||||
this.container.classList.toggle(PresetTableRow.#presetWarningClassName, multipleTagsInExclusivePreset);
|
||||
|
||||
if (multipleTagsInExclusivePreset) {
|
||||
this.#exclusiveWarning.style.removeProperty('display');
|
||||
} else {
|
||||
this.#exclusiveWarning.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
this.#maybeRefreshVisibilityFromTags(tags);
|
||||
}
|
||||
|
||||
remove() {
|
||||
@@ -126,4 +208,5 @@ export default class PresetTableRow extends BaseComponent {
|
||||
}
|
||||
|
||||
static #tagMissingClassName = 'is-missing';
|
||||
static #presetWarningClassName = 'has-warning';
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import { tagsBlacklist } from "$config/tags";
|
||||
import { emitterAt } from "$content/components/events/comms";
|
||||
import {
|
||||
EVENT_ACTIVE_PROFILE_CHANGED,
|
||||
EVENT_MAINTENANCE_STATE_CHANGED,
|
||||
EVENT_PROFILE_POPUP_STATE_CHANGED,
|
||||
EVENT_TAGS_UPDATED
|
||||
} from "$content/components/events/maintenance-popup-events";
|
||||
} from "$content/components/events/tagging-profile-popup-events";
|
||||
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
|
||||
import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
|
||||
|
||||
@@ -183,7 +183,20 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = true;
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
|
||||
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'waiting');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
this.#isPlanningToSubmit = false;
|
||||
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'ready');
|
||||
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
// Probably shouldn't ever happen, but make sure we cancel any delayed submission.
|
||||
if (this.#tagsSubmissionTimer) {
|
||||
clearTimeout(this.#tagsSubmissionTimer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +223,7 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
this.#isPlanningToSubmit = false;
|
||||
this.#isSubmitting = true;
|
||||
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
|
||||
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'processing');
|
||||
|
||||
let maybeTagsAndAliasesAfterUpdate;
|
||||
|
||||
@@ -252,7 +265,7 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
|
||||
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
|
||||
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'failed');
|
||||
this.#isSubmitting = false;
|
||||
|
||||
return;
|
||||
@@ -262,7 +275,7 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
|
||||
}
|
||||
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
|
||||
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'complete');
|
||||
|
||||
this.#tagsToAdd.clear();
|
||||
this.#tagsToRemove.clear();
|
||||
@@ -360,7 +373,7 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeFromMaintenanceSettings = this.#preferences.subscribe(settings => {
|
||||
const unsubscribeFromPreferences = this.#preferences.subscribe(settings => {
|
||||
if (settings.activeProfile === lastActiveProfileId) {
|
||||
return;
|
||||
}
|
||||
@@ -382,7 +395,7 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
|
||||
return () => {
|
||||
unsubscribeFromProfilesChanges();
|
||||
unsubscribeFromMaintenanceSettings();
|
||||
unsubscribeFromPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$content/components/events/maintenance-popup-events";
|
||||
import {
|
||||
EVENT_PROFILE_POPUP_STATE_CHANGED,
|
||||
type ProfilePopupState
|
||||
} from "$content/components/events/tagging-profile-popup-events";
|
||||
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
|
||||
|
||||
export class TaggingProfileStatusIcon extends BaseComponent {
|
||||
@@ -22,10 +25,10 @@ export class TaggingProfileStatusIcon extends BaseComponent {
|
||||
throw new Error('Status icon element initialized outside of the media box!');
|
||||
}
|
||||
|
||||
on(this.#mediaBoxTools, EVENT_MAINTENANCE_STATE_CHANGED, this.#onMaintenanceStateChanged.bind(this));
|
||||
on(this.#mediaBoxTools, EVENT_PROFILE_POPUP_STATE_CHANGED, this.#onPopupStateChanged.bind(this));
|
||||
}
|
||||
|
||||
#onMaintenanceStateChanged(stateChangeEvent: CustomEvent<string>) {
|
||||
#onPopupStateChanged(stateChangeEvent: CustomEvent<ProfilePopupState>) {
|
||||
// TODO Replace those with FontAwesome icons later. Those icons can probably be sourced from the website itself.
|
||||
switch (stateChangeEvent.detail) {
|
||||
case "ready":
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
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/maintenance-popup-events";
|
||||
import { EVENT_TAGS_UPDATED } from "$content/components/events/tagging-profile-popup-events";
|
||||
|
||||
export class MediaBox extends BaseComponent {
|
||||
#thumbnailContainer: HTMLElement | null = null;
|
||||
|
||||
@@ -9,8 +9,8 @@ import { EVENT_PRESET_TAG_CHANGE_APPLIED, type PresetTagChange } from "$content/
|
||||
export class TagsForm extends BaseComponent {
|
||||
#togglePresetsButton: HTMLButtonElement = document.createElement('button');
|
||||
#presetsList = EditorPresetsBlock.create();
|
||||
#plainEditorTextarea: HTMLTextAreaElement|null = null;
|
||||
#fancyEditorInput: HTMLInputElement|null = null;
|
||||
#plainEditorTextarea: HTMLTextAreaElement | null = null;
|
||||
#fancyEditorInput: HTMLInputElement | null = null;
|
||||
#tagsSet: Set<string> = new Set();
|
||||
|
||||
protected build() {
|
||||
@@ -172,7 +172,8 @@ export class TagsForm extends BaseComponent {
|
||||
}
|
||||
|
||||
#onTagChangeRequested(event: CustomEvent<PresetTagChange>) {
|
||||
const { addedTags = null, removedTags = null } = event.detail;
|
||||
const targetElement = event.target instanceof HTMLElement ? event.target : null;
|
||||
const {addedTags = null, removedTags = null} = event.detail;
|
||||
let tagChangeList: string[] = [];
|
||||
|
||||
if (addedTags) {
|
||||
@@ -187,23 +188,33 @@ export class TagsForm extends BaseComponent {
|
||||
);
|
||||
}
|
||||
|
||||
const offsetBeforeSubmission = this.#presetsList.container.offsetTop;
|
||||
|
||||
this.#applyTagChangesWithFancyTagEditor(
|
||||
tagChangeList.join(',')
|
||||
this.#executeAndCompensateForLayoutShift(
|
||||
() => this.#applyTagChangesWithFancyTagEditor(tagChangeList.join(',')),
|
||||
[this.#presetsList.container, targetElement],
|
||||
);
|
||||
}
|
||||
|
||||
const offsetDifference = this.#presetsList.container.offsetTop - offsetBeforeSubmission;
|
||||
#executeAndCompensateForLayoutShift(executeOperation: () => void, elements: (HTMLElement | null)[]) {
|
||||
const offsetsListBefore = TagsForm.#gatherOffsetsFromElements(elements);
|
||||
executeOperation();
|
||||
const offsetsListAfter = TagsForm.#gatherOffsetsFromElements(elements);
|
||||
|
||||
// Compensating for the layout shift: when user clicks on a tag (or on "add/remove all tags"), tag editor might
|
||||
// overflow the current line and wrap tags around to the next line, causing presets section to shift. We need to
|
||||
// avoid that for better UX.
|
||||
if (offsetDifference !== 0) {
|
||||
window.scrollTo({
|
||||
top: window.scrollY + offsetDifference,
|
||||
behavior: 'instant',
|
||||
});
|
||||
const resultDifference = offsetsListAfter
|
||||
.map((offsetAfter, index) =>
|
||||
offsetAfter !== null && offsetsListBefore[index] !== null
|
||||
? offsetAfter - offsetsListBefore[index]
|
||||
: null)
|
||||
.filter(difference => difference !== null)
|
||||
.reduce((summary, difference) => summary + difference, 0);
|
||||
|
||||
if (resultDifference === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.scrollTo({
|
||||
top: scrollY + resultDifference,
|
||||
behavior: 'instant',
|
||||
})
|
||||
}
|
||||
|
||||
#applyTagChangesWithFancyTagEditor(tagsListWithChanges: string): void {
|
||||
@@ -232,7 +243,7 @@ export class TagsForm extends BaseComponent {
|
||||
this.refreshTagColors();
|
||||
}
|
||||
|
||||
#onPlainEditorReloadRequested(event: CustomEvent<ReloadCustomOptions|null>) {
|
||||
#onPlainEditorReloadRequested(event: CustomEvent<ReloadCustomOptions | null>) {
|
||||
if (!event.detail?.skipTagColorRefresh) {
|
||||
this.refreshTagColors();
|
||||
}
|
||||
@@ -242,6 +253,14 @@ export class TagsForm extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
static #gatherOffsetsFromElements(elements: (HTMLElement | null)[]): (number | null)[] {
|
||||
return elements.map(
|
||||
maybeElement => maybeElement?.checkVisibility()
|
||||
? maybeElement?.offsetTop
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
static watchForEditors() {
|
||||
document.body.addEventListener('click', event => {
|
||||
const targetElement = event.target;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,16 +89,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 => {
|
||||
|
||||
@@ -3,6 +3,9 @@ import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
interface TagEditorPresetSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
exclusive: boolean;
|
||||
conditional: boolean;
|
||||
requiredTags: string[];
|
||||
}
|
||||
|
||||
export default class TagEditorPreset extends StorageEntity<TagEditorPresetSettings> {
|
||||
@@ -10,6 +13,9 @@ export default class TagEditorPreset extends StorageEntity<TagEditorPresetSettin
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || [],
|
||||
exclusive: settings.exclusive ?? false,
|
||||
conditional: settings.conditional || false,
|
||||
requiredTags: settings.requiredTags || [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ const entitiesExporters: ExportersMap = {
|
||||
id: entity.id,
|
||||
name: entity.settings.name,
|
||||
tags: entity.settings.tags,
|
||||
exclusive: entity.settings.exclusive,
|
||||
conditional: entity.settings.conditional,
|
||||
requiredTags: entity.settings.requiredTags,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,7 +29,15 @@ function validateRequiredString(value: unknown): boolean {
|
||||
* @param value Value to be checked.
|
||||
*/
|
||||
function validateOptionalArray(value: unknown): boolean {
|
||||
return typeof value === 'undefined' || value === null || Array.isArray(value);
|
||||
return value === undefined || value === null || Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the following value is not set or is a valid boolean.
|
||||
* @param value Value to be checked.
|
||||
*/
|
||||
function validateOptionalBoolean(value: unknown): boolean {
|
||||
return value === undefined || typeof value === 'boolean';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +81,9 @@ const entitiesValidators: EntitiesValidationMap = {
|
||||
!validateRequiredString(importedObject?.id)
|
||||
|| !validateRequiredString(importedObject?.name)
|
||||
|| !validateOptionalArray(importedObject?.tags)
|
||||
|| !validateOptionalBoolean(importedObject?.exclusive)
|
||||
|| !validateOptionalBoolean(importedObject?.conditional)
|
||||
|| !validateOptionalArray(importedObject?.requiredTags)
|
||||
) {
|
||||
throw new Error('Invalid preset format detected!');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import TextField from "$components/ui/forms/TextField.svelte";
|
||||
import TagsEditor from "$components/tags/TagsEditor.svelte";
|
||||
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
|
||||
|
||||
let presetId = $derived(page.params.id);
|
||||
|
||||
@@ -23,6 +24,9 @@
|
||||
|
||||
let presetName = $state('');
|
||||
let tagsList = $state<string[]>([]);
|
||||
let isExclusive = $state(false);
|
||||
let isConditional = $state<boolean>(false);
|
||||
let requiredTags = $state<string[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (presetId === 'new') {
|
||||
@@ -39,6 +43,9 @@
|
||||
|
||||
presetName = targetPreset.settings.name;
|
||||
tagsList = [...targetPreset.settings.tags].sort((a, b) => a.localeCompare(b));
|
||||
isExclusive = targetPreset.settings.exclusive;
|
||||
isConditional = targetPreset.settings.conditional;
|
||||
requiredTags = [...targetPreset.settings.requiredTags].sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
async function savePreset() {
|
||||
@@ -49,6 +56,9 @@
|
||||
|
||||
targetPreset.settings.name = presetName;
|
||||
targetPreset.settings.tags = [...tagsList];
|
||||
targetPreset.settings.exclusive = isExclusive;
|
||||
targetPreset.settings.conditional = isConditional;
|
||||
targetPreset.settings.requiredTags = [...requiredTags];
|
||||
|
||||
await targetPreset.save();
|
||||
await goto(`/features/presets/${targetPreset.id}`);
|
||||
@@ -67,6 +77,21 @@
|
||||
<FormControl label="Tags">
|
||||
<TagsEditor bind:tags={tagsList}></TagsEditor>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={isExclusive}>
|
||||
Keep only one tag from this preset active at a time.
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={isConditional}>
|
||||
Show this preset only when any of specified tags are provided.
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
{#if isConditional}
|
||||
<FormControl label="Required Tags">
|
||||
<TagsEditor bind:tags={requiredTags}></TagsEditor>
|
||||
</FormControl>
|
||||
{/if}
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
|
||||
@@ -4,6 +4,7 @@ $media-box-color: var(--media-box-color);
|
||||
$padding-small: var(--padding-small);
|
||||
$padding-normal: var(--padding-normal);
|
||||
$padding-large: var(--padding-large);
|
||||
$block-spacing: var(--block-spacing);
|
||||
|
||||
// These variables are defined dynamically based on the category of the tag
|
||||
$resolved-tag-background: var(--tag-background);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
.block.tag-presets {
|
||||
.tag {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&.is-missing {
|
||||
opacity: 0.5;
|
||||
@@ -13,4 +14,11 @@
|
||||
background: booru-vars.$resolved-tag-color;
|
||||
}
|
||||
}
|
||||
|
||||
.block.block--fixed.block--warning {
|
||||
margin: {
|
||||
top: booru-vars.$block-spacing;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
261
tests/lib/extension/EntitiesController.spec.ts
Normal file
261
tests/lib/extension/EntitiesController.spec.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import EntitiesController from "$lib/extension/EntitiesController";
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import { TestedEntity, type TestedSettings } from "$tests/stubs/Entity";
|
||||
import { randomString } from "$tests/utils";
|
||||
import { randomInt } from "crypto";
|
||||
|
||||
describe('EntitiesController', () => {
|
||||
let mockedStorage: ChromeStorageArea;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedStorage = new ChromeStorageArea();
|
||||
EntitiesController.storage = new StorageHelper(mockedStorage);
|
||||
});
|
||||
|
||||
it('should throw when storage is not present', async () => {
|
||||
EntitiesController.storage = null;
|
||||
|
||||
const readPromise = EntitiesController.readAllEntities(
|
||||
TestedEntity._entityName,
|
||||
TestedEntity,
|
||||
);
|
||||
|
||||
const deletePromise = EntitiesController.deleteEntity(
|
||||
TestedEntity._entityName,
|
||||
randomString(),
|
||||
);
|
||||
|
||||
const updatePromise = EntitiesController.updateEntity(
|
||||
TestedEntity._entityName,
|
||||
new TestedEntity(randomString(), {
|
||||
numberField: randomInt(1000),
|
||||
stringField: randomString(),
|
||||
}),
|
||||
);
|
||||
|
||||
const subscribe = () => {
|
||||
EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, vi.fn());
|
||||
}
|
||||
|
||||
await expect(readPromise).rejects.toThrow(Error);
|
||||
await expect(deletePromise).rejects.toThrow(Error);
|
||||
await expect(updatePromise).rejects.toThrow(Error);
|
||||
|
||||
expect(subscribe).toThrow(Error);
|
||||
});
|
||||
|
||||
describe('readAllEntities', () => {
|
||||
it('should return empty array when nothing in the storage yet', async () => {
|
||||
const entities = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
expect(entities).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should properly capture different entities from storage', async () => {
|
||||
const storageWithEntities: Record<string, Record<string, Partial<TestedSettings>>> = {
|
||||
[TestedEntity._entityName]: {
|
||||
[randomString()]: {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100_000, 100_000),
|
||||
},
|
||||
[randomString()]: {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100_000, 100_000),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
mockedStorage.insertMockedData(storageWithEntities);
|
||||
|
||||
const loadedEntities = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
|
||||
expect(loadedEntities).toHaveLength(2);
|
||||
|
||||
for (const entity of loadedEntities) {
|
||||
const rawStorageEntry = storageWithEntities[TestedEntity._entityName][entity.id];
|
||||
|
||||
expect(entity.settings.stringField).toBe(rawStorageEntry.stringField);
|
||||
expect(entity.settings.numberField).toBe(rawStorageEntry.numberField);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEntity', () => {
|
||||
it('should create a storage structure if it is not created yet', async () => {
|
||||
expect(mockedStorage.mockedData).toEqual({});
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(100_000),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[entity.id]: {
|
||||
stringField: entity.settings.stringField,
|
||||
numberField: entity.settings.numberField,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should update entity inside the existing', async () => {
|
||||
const id = randomString();
|
||||
const initialStringValue = randomString();
|
||||
const updatedStringValue = randomString();
|
||||
|
||||
mockedStorage.insertMockedData({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: {
|
||||
stringField: initialStringValue,
|
||||
numberField: randomInt(100_000),
|
||||
} as TestedSettings,
|
||||
}
|
||||
});
|
||||
|
||||
const [entity] = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
expect(entity.settings.stringField).toBe(initialStringValue);
|
||||
|
||||
entity.settings.stringField = updatedStringValue;
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
const entityInsideStorage = mockedStorage.mockedData[TestedEntity._entityName][id];
|
||||
expect(entityInsideStorage.stringField).toBe(updatedStringValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEntity', () => {
|
||||
it('should initialize the storage structure if delete called', async () => {
|
||||
expect(mockedStorage.mockedData).toEqual({});
|
||||
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, randomString());
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete entity and keep the storage object empty', async () => {
|
||||
const id = randomString();
|
||||
const settings: TestedSettings = {
|
||||
numberField: randomInt(100_000),
|
||||
stringField: randomString(),
|
||||
};
|
||||
|
||||
mockedStorage.insertMockedData({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: settings,
|
||||
}
|
||||
});
|
||||
|
||||
// Doesn't touch existing instance if ID is not found in the storage
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, randomString());
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: settings,
|
||||
}
|
||||
});
|
||||
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, id);
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeToEntity', () => {
|
||||
it('should notify about changes and return new entities', async () => {
|
||||
let receivedEntities: TestedEntity[] | null = null;
|
||||
|
||||
const subscriber = vi.fn((entities: TestedEntity[]) => {
|
||||
receivedEntities = entities;
|
||||
});
|
||||
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, subscriber);
|
||||
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
|
||||
const createdEntity = new TestedEntity(randomString(), {
|
||||
numberField: randomInt(100),
|
||||
stringField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, createdEntity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalled();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
const [firstReceivedEntity] = receivedEntities || [];
|
||||
|
||||
expect(firstReceivedEntity).toBeInstanceOf(TestedEntity);
|
||||
expect(firstReceivedEntity).not.toBe(createdEntity);
|
||||
expect(firstReceivedEntity).toEqual(createdEntity);
|
||||
});
|
||||
|
||||
it('should stop receiving updates once unsubscribed', async () => {
|
||||
const firstSubscriber = vi.fn();
|
||||
const unsubscribeFirst = EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, firstSubscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
numberField: randomInt(100_000),
|
||||
stringField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(firstSubscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
firstSubscriber.mockReset();
|
||||
unsubscribeFirst();
|
||||
|
||||
const secondSubscriber = vi.fn();
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, secondSubscriber);
|
||||
|
||||
entity.settings.stringField = randomString();
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(secondSubscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
expect(firstSubscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not notify when something else was changed in the storage', async () => {
|
||||
const rawStorageSubscriber = vi.fn();
|
||||
const entitiesSubscriber = vi.fn();
|
||||
|
||||
void EntitiesController.storage?.subscribe(rawStorageSubscriber);
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, entitiesSubscriber);
|
||||
|
||||
EntitiesController.storage?.write('otherStorage', {
|
||||
someField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(
|
||||
TestedEntity._entityName,
|
||||
new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(100_000),
|
||||
}),
|
||||
);
|
||||
|
||||
EntitiesController.storage?.write('otherStorage', {
|
||||
someField: randomString(),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(entitiesSubscriber).toHaveBeenCalledOnce();
|
||||
expect(rawStorageSubscriber).toHaveBeenCalledTimes(3);
|
||||
}, {timeout: 10, interval: 1});
|
||||
})
|
||||
});
|
||||
});
|
||||
129
tests/lib/extension/base/CachablePreferences.spec.ts
Normal file
129
tests/lib/extension/base/CachablePreferences.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
239
tests/lib/extension/base/StorageEntity.spec.ts
Normal file
239
tests/lib/extension/base/StorageEntity.spec.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import { randomString } from "$tests/utils";
|
||||
import { randomInt } from "crypto";
|
||||
import EntitiesController from "$lib/extension/EntitiesController";
|
||||
import { TestedEntity } from "$tests/stubs/Entity";
|
||||
|
||||
describe("StorageEntity", () => {
|
||||
let mockedStorageArea: ChromeStorageArea;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedStorageArea = new ChromeStorageArea();
|
||||
EntitiesController.storage = new StorageHelper(mockedStorageArea);
|
||||
});
|
||||
|
||||
describe("readAll", () => {
|
||||
it("should return empty array if no entities stored", async () => {
|
||||
const entities = await TestedEntity.readAll();
|
||||
|
||||
expect(entities).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should read all entities from storage", async () => {
|
||||
const entity1 = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
const entity2 = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity1.save();
|
||||
await entity2.save();
|
||||
|
||||
const entities = await TestedEntity.readAll();
|
||||
|
||||
expect(entities).toHaveLength(2);
|
||||
expect(entities[0].id).toBe(entity1.id);
|
||||
expect(entities[0].settings).toEqual(entity1.settings);
|
||||
expect(entities[1].id).toBe(entity2.id);
|
||||
expect(entities[1].settings).toEqual(entity2.settings);
|
||||
});
|
||||
|
||||
it("should build entities with correct class", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
const [savedEntity] = await TestedEntity.readAll();
|
||||
|
||||
expect(savedEntity).toBeInstanceOf(TestedEntity);
|
||||
});
|
||||
});
|
||||
|
||||
describe("save", () => {
|
||||
it("should save entity to storage", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
expect(mockedStorageArea.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[entity.id]: entity.settings,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should overwrite existing entity with same ID", async () => {
|
||||
const id = randomString();
|
||||
const originalSettings = {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
};
|
||||
|
||||
const entity1 = new TestedEntity(id, originalSettings);
|
||||
await entity1.save();
|
||||
|
||||
const updatedSettings = {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
};
|
||||
|
||||
const entity2 = new TestedEntity(id, updatedSettings);
|
||||
await entity2.save();
|
||||
|
||||
expect(mockedStorageArea.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: updatedSettings,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should delete entity from storage", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
expect(mockedStorageArea.mockedData[TestedEntity._entityName]).not.toEqual({});
|
||||
|
||||
await entity.delete();
|
||||
expect(mockedStorageArea.mockedData[TestedEntity._entityName]).toEqual({});
|
||||
});
|
||||
|
||||
it("should not fail if entity does not exist", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await expect(entity.delete()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribe", () => {
|
||||
it("should notify about new entities", async () => {
|
||||
const subscriber = vi.fn();
|
||||
void TestedEntity.subscribe(subscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
// Saving is not notified about immediately.
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
expect(subscriber).toHaveBeenCalledWith([entity]);
|
||||
}, {timeout: 10, interval: 1});
|
||||
});
|
||||
|
||||
it("should notify about entity updates", async () => {
|
||||
const subscriber = vi.fn();
|
||||
void TestedEntity.subscribe(subscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
subscriber.mockReset();
|
||||
|
||||
entity.settings.stringField = randomString();
|
||||
await entity.save();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
expect(subscriber).toHaveBeenCalledWith([entity]);
|
||||
}, {interval: 1, timeout: 10});
|
||||
});
|
||||
|
||||
it("should notify about entity deletion", async () => {
|
||||
const subscriber = vi.fn();
|
||||
void TestedEntity.subscribe(subscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
await entity.delete();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledTimes(2);
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
expect(subscriber).toHaveBeenCalledWith([entity]);
|
||||
expect(subscriber).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("should stop notifications after unsubscribe", async () => {
|
||||
const subscriber = vi.fn();
|
||||
const unsubscribe = TestedEntity.subscribe(subscriber);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("properties", () => {
|
||||
it("should expose id", () => {
|
||||
const id = randomString();
|
||||
const entity = new TestedEntity(id, {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
expect(entity.id).toBe(id);
|
||||
});
|
||||
|
||||
it("should expose settings", () => {
|
||||
const settings = {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
};
|
||||
|
||||
const entity = new TestedEntity(randomString(), settings);
|
||||
|
||||
expect(entity.settings).toEqual(settings);
|
||||
});
|
||||
|
||||
it("should expose type from _entityName", () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
expect(entity.type).toBe(TestedEntity._entityName);
|
||||
});
|
||||
});
|
||||
});
|
||||
111
tests/lib/philomena/search/QueryLexer.spec.ts
Normal file
111
tests/lib/philomena/search/QueryLexer.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
141
tests/lib/philomena/tag-utils.spec.ts
Normal file
141
tests/lib/philomena/tag-utils.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
17
tests/stubs/Entity.ts
Normal file
17
tests/stubs/Entity.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export interface TestedSettings {
|
||||
stringField: string;
|
||||
numberField: number;
|
||||
nested?: {
|
||||
field: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class TestedEntity extends StorageEntity<TestedSettings> {
|
||||
static readonly _entityName = "entity";
|
||||
|
||||
constructor(id: string, settings: TestedSettings) {
|
||||
super(id, settings);
|
||||
}
|
||||
}
|
||||
45
tests/stubs/Preferences.ts
Normal file
45
tests/stubs/Preferences.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
|
||||
export interface TestedFields {
|
||||
numberField: number;
|
||||
stringField: string;
|
||||
}
|
||||
|
||||
export class TestedPreferences extends CacheablePreferences<TestedFields> implements WithFields<TestedFields> {
|
||||
readonly defaults: TestedFields;
|
||||
readonly mockedSettingsNamespace: string;
|
||||
readonly mockedStorageArea: ChromeStorageArea;
|
||||
readonly mockedStorageHelper: StorageHelper;
|
||||
|
||||
numberField;
|
||||
stringField;
|
||||
|
||||
constructor(settingsNamespace: string, mockedDefaults: TestedFields) {
|
||||
const mockedStorageArea = new ChromeStorageArea();
|
||||
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
|
||||
const mockedConfigurationController = new ConfigurationController(
|
||||
settingsNamespace,
|
||||
mockedStorageHelper,
|
||||
);
|
||||
|
||||
super(settingsNamespace, mockedConfigurationController);
|
||||
|
||||
this.mockedSettingsNamespace = settingsNamespace;
|
||||
this.mockedStorageArea = mockedStorageArea;
|
||||
this.mockedStorageHelper = mockedStorageHelper;
|
||||
this.defaults = mockedDefaults;
|
||||
|
||||
this.numberField = new PreferenceField(this, {
|
||||
field: 'numberField',
|
||||
defaultValue: this.defaults.numberField,
|
||||
});
|
||||
|
||||
this.stringField = new PreferenceField(this, {
|
||||
field: 'stringField',
|
||||
defaultValue: this.defaults.stringField,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user