1
0
mirror of https://github.com/koloml/philomena-tagging-assistant.git synced 2026-06-23 18:22:20 +00:00

30 Commits

Author SHA1 Message Date
515485b3b8 Merge pull request #174 from koloml/release/0.7.2
Release: 0.7.2
2026-06-20 14:17:55 -04:00
f311676097 Merge pull request #177 from koloml/feature/test-preferences-api
Testing behavior of internal preferences class
2026-06-20 14:16:06 -04:00
a0e52955e6 Sonar: Added readonly to private members 2026-06-20 22:10:51 +04:00
74554bc9f6 Covering cacheable preferences into tests 2026-06-20 22:03:11 +04:00
4094babc82 Bumped version to 0.7.2 2026-06-14 04:16:17 +04:00
1de2f3f97a Updated dependencies (#176)
* Updated `vite` from 7.3.1 to 7.3.5
* Updated `vitest` and `@vitest/coverage-v8` from 4.1.0 to 4.1.8
* Updated `typescript` from 5.9.3 to 6.0.3
* Updated `@sveltejs/kit` from 2.55.0 to 2.65.0
* Updated `svelte` from 5.53.12 to 5.56.3
* Updated `@types/chrome` from 0.1.37 to 0.1.43
* Updated `@types/node` from 25.5.0 to 25.9.3
* Updated `jsdom` from 28.1.0 to 29.1.1
* Updated `svelte-check` from 4.4.5 to 4.6.0
* Updated `sass` from 1.98.0 to 1.101.0
2026-06-14 04:13:36 +04:00
c1c774f4bb Merge pull request #175 from koloml/feature/footer-author-head
Added avali head into the footer near the author name
2026-06-13 19:58:13 -04:00
2f5c37d21f Merge pull request #172 from koloml/bugfix/tags-in-forums-with-encoded-text
Fixed wrong parsing of tag links inside forum posts
2026-06-13 19:55:32 -04:00
3404877091 Sonar: Replacing string.match(regexp) with regexp.exec(string) 2026-06-14 03:19:54 +04:00
ed3db1240c Signaling to TypeScript that match is present when function returns true 2026-06-14 03:15:51 +04:00
9a6274c815 Sonar: Marked special characters and RegExps as readonly 2026-06-14 03:13:45 +04:00
b8043bace6 Merge pull request #173 from koloml/feature/update-repo-links
Updated link to repository inside the extension
2026-06-13 18:30:14 -04:00
3a31eb2519 Formatting, missing import 2026-06-14 02:05:00 +04:00
ab255e535c Testing word-like operators and escaping them inside quotes 2026-06-14 02:03:41 +04:00
f8758306b7 Added boost to the list of queries to test 2026-06-14 02:03:08 +04:00
7c462e1b5c Added missing coverage for "NOT" operator 2026-06-14 01:31:10 +04:00
a909180798 Fixed terms not trimming out the whitespaces 2026-06-14 01:29:17 +04:00
6ceeabe170 Added tests for search query lexer 2026-06-14 01:28:37 +04:00
40aa02ff70 Fixed quoted term not processing starting & ending quotes 2026-06-14 00:32:47 +04:00
8c4c32c4bf Covering quoted term decoding/encoding behavior 2026-06-14 00:29:33 +04:00
88ebcef18a Removed imports of already global functions 2026-06-13 22:57:37 +04:00
de57432594 Fixed wrong index used when checking for ) character 2026-06-13 22:46:08 +04:00
74f113412e Fixed space being mandatory before ) and ^ 2026-06-13 22:44:57 +04:00
3e0495a529 Moved parentheses check to run before dirty text content extraction 2026-06-13 22:42:57 +04:00
24d17416b5 Cover the case when first term is not a term or when nothing is there 2026-06-13 22:07:11 +04:00
736c0917c0 Reversed + conversion with dash-encoded characters conversion
This way encoded `+` character will properly decode after other `+` were
dealt with
2026-06-13 22:01:14 +04:00
f01bfe8ae0 Removed erroneous - conversion for tag links 2026-06-13 22:00:20 +04:00
726c4dfe58 Added tests for tag name extraction from tag links & search queries 2026-06-13 21:56:31 +04:00
4142c1053b Updated links to the repository 2026-04-15 16:46:13 +04:00
7559f0a802 Added avali head into the footer near the author name 2026-04-05 22:26:58 +04:00
15 changed files with 890 additions and 359 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
"version": "0.7.1",
"version": "0.7.2",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city",

583
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.7.1",
"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"
}
}

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

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

View File

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

View File

@@ -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);

View File

@@ -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 => {

View File

@@ -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;
}

View File

@@ -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);
}
/**

View File

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

View File

@@ -0,0 +1,173 @@
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
import ConfigurationController from "$lib/extension/ConfigurationController";
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
import StorageHelper from "$lib/browser/StorageHelper";
import { randomString } from "$tests/utils";
import { randomInt } from "crypto";
interface TestedFields {
numberField: number;
stringField: string;
}
class TestedPreferences extends CacheablePreferences<TestedFields> implements WithFields<TestedFields> {
readonly defaults: TestedFields;
readonly mockedSettingsNamespace: string;
readonly mockedStorageArea: ChromeStorageArea;
readonly mockedStorageHelper: StorageHelper;
numberField;
stringField;
constructor(settingsNamespace: string, mockedDefaults: TestedFields) {
const mockedStorageArea = new ChromeStorageArea();
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
const mockedConfigurationController = new ConfigurationController(
settingsNamespace,
mockedStorageHelper,
);
super(settingsNamespace, mockedConfigurationController);
this.mockedSettingsNamespace = settingsNamespace;
this.mockedStorageArea = mockedStorageArea;
this.mockedStorageHelper = mockedStorageHelper;
this.defaults = mockedDefaults;
this.numberField = new PreferenceField(this, {
field: 'numberField',
defaultValue: this.defaults.numberField,
});
this.stringField = new PreferenceField(this, {
field: 'stringField',
defaultValue: this.defaults.stringField,
});
}
}
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();
});
});

View 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);
});
});
});

View File

@@ -0,0 +1,76 @@
import { URL } from 'url';
import { resolveTagNameFromLink, slugEncodedCharacters } from '$lib/philomena/tag-utils';
describe('tag-utils', () => {
const origin = 'https://furbooru.org';
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);
});
});
});