1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2026-02-06 23:32:58 +00:00

140 Commits
0.2.1 ... 0.4.0

Author SHA1 Message Date
c7919e0127 Merge pull request #76 from koloml/release/0.4.0
Release: 0.4
2025-01-17 03:54:44 +04:00
73ff913eb7 Merge pull request #77 from koloml/feature/updating-dependencies
Updated Svelte, Vite, SCSS and other internal tools, fixed related errors & warnings
2025-01-17 03:53:42 +04:00
dd312e170e Cover :is selectors into the :global as well 2025-01-17 03:46:58 +04:00
283629d64b Updated all @sveltejs/kit-related packages to latest versions 2025-01-17 03:39:13 +04:00
9af73f0598 Replaced @import with @use on popup stylesheets 2025-01-17 03:25:30 +04:00
bd85d165d3 Updated deprecated color adjustment methods 2025-01-17 03:22:27 +04:00
24e0937c6b Updated all @use calls to use the $styles alias 2025-01-17 02:59:29 +04:00
24cec58af5 Updated vite to 6.0.7 2025-01-17 02:55:02 +04:00
66f106364a Updated sass to 1.83.4 2025-01-17 02:54:02 +04:00
650c5714d0 Updated cheerio to 1.0.0 2025-01-17 02:49:51 +04:00
31e2993a12 Bumped version to 0.4.0 2025-01-17 02:45:23 +04:00
e8349ce9a3 Merge pull request #75 from koloml/feature/more-suggested-properties
Search Suggestions: Added category tags count & other missed properties described in the search docs
2025-01-13 22:05:27 +04:00
6b807db235 Merge pull request #74 from koloml/bugfix/incorrect-redirect
Fixed fallback redirect when visiting profile with ID "new"
2025-01-13 22:04:45 +04:00
9a8e3cc597 Added more documented searchable terms from Derpibooru docs 2025-01-13 21:50:20 +04:00
d2d02b06e4 Added search terms for category-based tag count 2025-01-13 21:45:21 +04:00
58b79531e4 Merge remote-tracking branch 'origin/release/0.4' into bugfix/incorrect-redirect
# Conflicts:
#	src/routes/features/maintenance/[id]/+page.svelte
2025-01-13 21:38:55 +04:00
927e4c157b Fixed incorrect fallback redirect for "new" profiles 2025-01-12 19:41:16 +04:00
5c534da875 Removing unused last index in category resolving (oops) 2025-01-05 01:36:05 +04:00
257c369b02 Merge pull request #73 from koloml/feature/update-active-profile-toggler
Tagging Profiles: Replaced simple link with checkbox for "Activate Profile" option
2025-01-05 01:32:01 +04:00
d559d16977 Replaced simple button with checkbox for profile activity 2025-01-05 01:18:36 +04:00
6cced5036c Bind checked values from MenuCheckboxItem to input 2025-01-05 01:17:51 +04:00
15e379c798 Merge pull request #72 from koloml/feature/auto-remove-temporary-profiles
Remove temporary profiles when last tag was removed through dropdown
2025-01-03 19:43:27 +04:00
a39099bb1e Merge remote-tracking branch 'origin/release/0.4' into feature/auto-remove-temporary-profiles
# Conflicts:
#	src/lib/components/TagDropdownWrapper.js
2025-01-03 19:35:58 +04:00
3af723e50d Merge pull request #70 from koloml/feature/fullscreen-controls
Fullscreen Viewer: Added image/video quality setting and more explicit "close" icon
2025-01-03 06:41:41 +04:00
3e028a1509 Merge pull request #69 from koloml/feature/tag-groups
Added new Tag Groups feature with ability to customize colors of any tag with specific category
2025-01-03 06:41:22 +04:00
ba10768496 Added visible close button 2025-01-03 06:28:09 +04:00
e5ffd59b9c Fixed missing gap between 2 form controls 2025-01-03 06:03:42 +04:00
9a73ad80dd Skip blocks if they're not filled up 2025-01-03 05:54:06 +04:00
cd10ad62f4 Implemented resolver for custom categories for all the tags 2025-01-03 05:14:06 +04:00
b7a829ff12 Added tag category, storing the original category in the component class 2025-01-03 05:13:29 +04:00
e06359a24a Export the tag dropdown class, make tag name getter public 2025-01-03 03:43:02 +04:00
bb2065cf07 Added escaping utility function for RegExp 2025-01-03 03:42:03 +04:00
309dd15598 Keep the current URIs and switch them when size is changed 2024-12-30 23:08:06 +04:00
757526ab52 Fixed fullscreen button hiding itself on settings update 2024-12-30 22:57:43 +04:00
112d60ac78 Fixed selector appearing behind the media and not being accessible 2024-12-30 22:52:48 +04:00
3d1e0d6f06 Updated viewer to receive all the image URLs and apply selected size 2024-12-30 22:43:49 +04:00
98d3b1c696 Prevent viewer from closing when selecting sizes 2024-12-30 22:40:17 +04:00
52a8b6e778 Rendering selector for preview size in the fullscreen viewer element 2024-12-30 21:57:35 +04:00
c9c441a8ae Added size setting to the misc settings controller 2024-12-30 21:53:35 +04:00
c241bfb25c Mark profile as no longer temporary once it's edited 2024-12-23 21:46:36 +04:00
ac85938355 Added temporary flag, auto-remove profile when tags list is empty 2024-12-23 21:45:09 +04:00
a8a27654fc Merge pull request #65 from koloml/release/0.3.4
Release: 0.3.4
2024-12-16 16:43:16 +04:00
f58c4aa818 Bumped version to 0.3.4 2024-12-16 16:41:05 +04:00
eda58cd2ca Merge pull request #67 from koloml/feature/option-to-auto-remove-invalid-tags
Detect invalid blacklisted tags from Furbooru and allow to remove them manually or automatically
2024-12-16 16:32:44 +04:00
8f3020bc7b Merge pull request #66 from koloml/feature/property-icons-in-autocomplete
Added icon for the properties to differentiate between properties & tags
2024-12-15 02:46:17 +04:00
abbfcf2e34 Merge pull request #64 from koloml/feature/typescriptify-settings-classes
Refactoring settings classes to TypeScript
2024-12-15 02:41:39 +04:00
c4f00c4905 Fixed hover colors for error-tags 2024-12-14 22:04:31 +04:00
71039ee657 Adding default theme colors for categories 2024-12-14 22:02:10 +04:00
fa8ff3b718 Auto-removing or simply revealing tags when detected 2024-12-14 22:00:35 +04:00
90562f3878 Added settings menu for tags with auto-remove option 2024-12-14 21:59:47 +04:00
d1e22eaa0c Added flag for auto-removing invalid tags 2024-12-14 21:48:35 +04:00
ca3c4f6618 Added $config alias for config directory 2024-12-14 21:25:36 +04:00
3123ce1c0f Added list of blacklisted tags from Furbooru 2024-12-14 20:18:33 +04:00
6775a2175a Moving settings classes to TypeScript 2024-12-14 19:55:45 +04:00
62bcba34da Editing tag category, applying category color on view 2024-12-08 22:59:13 +04:00
17396952c3 Added field for selection of category, added container for tags 2024-12-08 22:58:46 +04:00
e61f6af237 Added category field to tag group 2024-12-08 22:58:16 +04:00
27133077c8 Adding default theme colors for categories 2024-12-08 22:57:49 +04:00
f90e51b546 Added icon for the properties to differentiate between props & tags 2024-12-07 20:54:56 +04:00
9e9499c904 Group deletion confirmation 2024-12-04 23:25:04 +04:00
7d19693f5e Import & export views 2024-12-04 23:21:26 +04:00
4fcf83d732 Group detail view & editing form 2024-12-02 03:23:32 +04:00
93d6d3a174 Groups listing, added link on the main view 2024-12-02 03:19:58 +04:00
4bdf04f911 Added store for the groups 2024-12-02 03:17:58 +04:00
c9a9fe059c Group view element 2024-12-02 03:17:48 +04:00
e523ce4468 Added initial version of tag group entity with tags & prefixes 2024-12-02 03:12:09 +04:00
d5f6ed1a3e Merge pull request #60 from koloml/release/0.3.3
Release: 0.3.3
2024-11-30 22:10:25 +04:00
0eba277f48 Bumped version to 0.3.3 2024-11-30 22:09:11 +04:00
b562752778 Merge pull request #62 from koloml/feature/fullscreen-viewer-loading-spinner
Fullscreen Viewer: Show the loading spinner while loading the image or video
2024-11-30 06:12:57 +04:00
015e5d6ec4 Show spinner icon in the fullscreen viewer while loading 2024-11-30 06:05:02 +04:00
fffb915985 Merge pull request #61 from koloml/feature/typescriptify-storage-entities
Storage entities code cleanup, moving to TypeScript
2024-11-30 05:16:16 +04:00
8151d1519a Merge remote-tracking branch 'origin/release/0.3.3' into feature/typescriptify-storage-entities
# Conflicts:
#	src/app.d.ts
2024-11-30 05:02:11 +04:00
4f0f3142a1 Typing controller methods, moving subscribe to base class 2024-11-30 04:46:10 +04:00
be97ac5640 Marking the constructor protected 2024-11-30 04:23:09 +04:00
9f61f99548 Moving readAll static method to base class 2024-11-30 04:22:43 +04:00
59c958ab32 Switching the jsconfig & vite config to TypeScript 2024-11-30 04:20:27 +04:00
ee3fcd1b08 Renamed controller to TS 2024-11-30 03:49:53 +04:00
d14fc8ba7d Proper modifiers 2024-11-30 03:37:05 +04:00
b3a92653ad Converting the exporters to TypeScript, fixing type errors 2024-11-28 00:45:41 +04:00
c7f40e99b7 Added mapping of entities and their names 2024-11-28 00:43:55 +04:00
f6ab60d939 Making entity name public, trying to better support new types 2024-11-27 22:48:31 +04:00
666d374057 Moving Maintenance Profile to TypeScript for better types 2024-11-27 22:11:35 +04:00
1ecb15c986 Merge pull request #58 from koloml/feature/moving-delete-action-and-confirmation
Added confirmation for profile deletion, moved "delete" button to the profile view instead of the editor
2024-11-26 03:11:36 +04:00
b22749812f Placed trash icon on delete button 2024-11-26 03:03:00 +04:00
4b3414be47 Added trash icon 2024-11-26 02:51:34 +04:00
6e4aef519f Merge remote-tracking branch 'origin/release/0.3.3' into feature/moving-delete-action-and-confirmation 2024-11-26 02:46:04 +04:00
feb57eec38 Merge pull request #59 from koloml/feature/use-fontawesome-from-npm
Replace local icons with icons from FontAwesome's NPM package
2024-11-26 02:42:16 +04:00
2a451d18be Replaced icons stored in static with icons files from npm package 2024-11-26 02:28:27 +04:00
1420ad1ece Disabled assets inlining for files like SVG icons 2024-11-26 01:48:56 +04:00
c0590ae347 Installed FA6 2024-11-26 01:47:49 +04:00
39260f9c5d Moved deletion button to profile view, added confirmation view 2024-11-26 01:22:20 +04:00
97b79b0b0d Merge pull request #52 from koloml/release/0.3.2
Release: 0.3.2
2024-11-23 01:15:14 +04:00
b645a1ca7a Bumped version to 0.3.2 2024-11-23 01:09:31 +04:00
c0a00e0c05 Merge pull request #57 from koloml/bugfix/backward-sync-for-preferences
Fixed preferences not being sycnhronized back from browser storage to popup view
2024-11-23 00:58:46 +04:00
a8f0f16121 Fixed missing backward synchronization from browser storage to stores 2024-11-23 00:47:56 +04:00
e13d9054cc Merge pull request #51 from koloml/bugfix/autocomplete-duplication
Fixed duplicating of auto-complete popup, added missed mouse clicks handling
2024-11-14 05:34:52 +04:00
c0139d0638 Fixed properties not being clickable with mouse 2024-11-14 05:06:27 +04:00
80ba4671f5 Fixed autocomplete popup duplication 2024-11-14 04:40:43 +04:00
bab919f0f8 Merge pull request #45 from koloml/release/0.3.1
Release: 0.3.1
2024-11-12 16:35:19 +04:00
72f901a2b7 Bumped version to 0.3.1 2024-11-12 16:30:52 +04:00
fd8efccfb3 Merge pull request #50 from koloml/feature/moving-import-and-export-to-separate-class
Slightly reduced extension content scripts size by extracting import/export logic into separate class
2024-11-12 16:29:31 +04:00
3621bb9f0e Removed export/import logic from entities, using transporter in popup 2024-11-12 16:21:06 +04:00
c15fae7c3d Implemented separate class for importing/exporting the entities 2024-11-12 16:19:13 +04:00
01e538c5c2 Cloning JS formatting settings to the TS, allow importing TS 2024-11-12 15:47:21 +04:00
4375613768 Merge pull request #49 from koloml/bugfix/wrap-tags-inside-dynamic-tag-editor
Fixed the "Add to profile" option not showing up after submitting tag changes in tags editor
2024-11-12 14:26:23 +04:00
3e05b1964d Skip watching logic if there is no editor on the page 2024-11-12 14:24:04 +04:00
5092dc7f6d Catch and wrap new tags dropdowns inside fancy tags editor 2024-11-12 14:19:53 +04:00
64dfac310e Merge pull request #48 from koloml/bugfix/force-reload-active-profile-on-change
Fixed profile not being refreshed after initial page load
2024-11-12 13:55:14 +04:00
2da2716844 Fixed profile not being refreshed after initial page load 2024-11-12 13:50:44 +04:00
10b5bff377 Merge pull request #46 from koloml/feature/display-active-profile-in-index-view
Display currently active profile on the main view of the popup
2024-11-12 13:38:56 +04:00
198b9da407 Fixed shrinking of the inputs on radio & checkbox items 2024-11-10 20:45:42 +04:00
b5cdb0d81b Show the active profile directly in the index view 2024-11-10 20:41:45 +04:00
dc4f575576 Merge pull request #44 from koloml/bugfix/add-to-profile-in-tags-index
Fixed option to add tag to profile not showing up on the index tags page
2024-10-23 12:29:03 +04:00
0a947219d0 Fixed "Add to profile" button is not being added on Tags index page 2024-10-22 21:49:42 +04:00
e83d70fbd9 Merge pull request #42 from koloml/release/0.3
Release: 0.3
2024-10-20 04:15:24 +04:00
844853ff57 Applied npm audit fix, updated lock file with new version 2024-10-20 04:13:27 +04:00
774409aac6 Bumped vite from 5.0.3 to 5.4.9 2024-10-20 04:12:12 +04:00
917775c5cd Bumped svelte from 4.2.7 to 4.2.19 2024-10-20 04:11:46 +04:00
a68c261b52 Bumped version to 0.3.0 2024-10-20 04:02:19 +04:00
f3a9694b1b Merge pull request #43 from koloml/feature/fullscreen-component
Fullscreen Viewer: Moved viewer logic into separate component, added closing with swipe for touch devices, added scroll lock
2024-10-20 03:55:38 +04:00
2cb4c6b4b2 Refactoring fullscreen viewer, added close swipe action for mobile 2024-10-20 03:47:18 +04:00
bafdb68f1e Added return type 2024-10-20 00:38:22 +04:00
02f9f3b36e Merge pull request #41 from koloml/feature/color-tags-in-editor
Tag Editor: Automatically apply category colors to the tags in the tag editor
2024-10-12 20:49:59 +04:00
57c505bee9 Dynamically catch and refresh colors in tag editor 2024-10-12 20:15:59 +04:00
03512a6539 Copying tag colors into the tag editor using other tags on the page 2024-10-12 19:15:04 +04:00
f95eaacaaa Merge pull request #40 from koloml/feature/add-tag-to-profile
Tagging Profiles: Added option to quickly add the tag into the active profile from the dropdown context menu
2024-10-12 18:46:16 +04:00
38cb925fa4 Implemented option to add the tag into active profile from dropdown 2024-10-12 03:40:38 +04:00
7dd738d0e8 Merge pull request #39 from koloml/feature/renaming-settings-route
Renamed `/settings` path to `/features` to avoid confusion with `/preferences`
2024-09-30 17:49:40 +04:00
2f8a47b808 Moving /settings route to /features to avoid confusion 2024-09-30 17:46:29 +04:00
d0c910d5bb Merge pull request #31 from koloml/feature/allow-tags-popup-in-galleries-listing
Show the tagging profiles popup in galleries view
2024-09-30 17:40:54 +04:00
dc1e49e60c Merge pull request #30 from koloml/feature/storage-viewer
Added debug section with extension's storage viewer
2024-09-30 17:40:44 +04:00
5e7e92614d Merge pull request #35 from koloml/feature/npm-audit
Updating vulnerable dependencies via `npm audit fix`
2024-08-26 22:22:06 +04:00
727b2c81ff Updating vulnerable dependencies via npm audit fix
Details of report:

# npm audit report

braces  <3.0.3
Severity: high
Uncontrolled resource consumption in braces - https://github.com/advisories/GHSA-grv7-fg5c-xmjg
fix available via `npm audit fix`
node_modules/braces

micromatch  <4.0.8
Severity: moderate
Regular Expression Denial of Service (ReDoS) in micromatch - https://github.com/advisories/GHSA-952p-6rrq-rcjv
fix available via `npm audit fix`
node_modules/micromatch

vite  5.1.0 - 5.1.6
Severity: moderate
Vite's `server.fs.deny` did not deny requests for patterns with directories. - https://github.com/advisories/GHSA-8jhw-289h-jh2g
fix available via `npm audit fix`
node_modules/vite

3 vulnerabilities (2 moderate, 1 high)
2024-08-26 22:09:17 +04:00
8059c93baa Display types correctly for the value 2024-08-12 20:04:02 +04:00
2f8d608e6b Fixed missing returning statement when updating Writeable 2024-08-12 20:02:35 +04:00
4635ccdb2b Fixed breadcrumbs generation 2024-08-12 19:55:11 +04:00
68d1d726af Added debug section to inspect extension's local storage 2024-08-12 19:37:35 +04:00
e8c3e610eb Support tagging profiles on galleries 2024-08-11 17:53:18 +04:00
f9cb73bafc Added missing alt-text for the chrome installation link 2024-08-10 16:33:44 +04:00
6bb3e83684 Added installation buttons for the Chrome & Firefox 2024-08-10 16:32:10 +04:00
93 changed files with 3512 additions and 3532 deletions

View File

@@ -231,6 +231,178 @@ ij_javascript_while_brace_force = never
ij_javascript_while_on_new_line = false
ij_javascript_wrap_comments = false
[{*.ts,*.tsx}]
indent_size = 2
tab_width = 2
ij_continuation_indent_size = 2
ij_typescript_align_imports = false
ij_typescript_align_multiline_array_initializer_expression = false
ij_typescript_align_multiline_binary_operation = false
ij_typescript_align_multiline_chained_methods = false
ij_typescript_align_multiline_extends_list = false
ij_typescript_align_multiline_for = true
ij_typescript_align_multiline_parameters = true
ij_typescript_align_multiline_parameters_in_calls = false
ij_typescript_align_multiline_ternary_operation = false
ij_typescript_align_object_properties = 0
ij_typescript_align_union_types = false
ij_typescript_align_var_statements = 0
ij_typescript_array_initializer_new_line_after_left_brace = false
ij_typescript_array_initializer_right_brace_on_new_line = false
ij_typescript_array_initializer_wrap = off
ij_typescript_assignment_wrap = off
ij_typescript_binary_operation_sign_on_next_line = false
ij_typescript_binary_operation_wrap = off
ij_typescript_blacklist_imports = rxjs/Rx, node_modules/**, **/node_modules/**, @angular/material, @angular/material/typings/**
ij_typescript_blank_lines_after_imports = 1
ij_typescript_blank_lines_around_class = 1
ij_typescript_blank_lines_around_field = 0
ij_typescript_blank_lines_around_function = 1
ij_typescript_blank_lines_around_method = 1
ij_typescript_block_brace_style = end_of_line
ij_typescript_block_comment_add_space = false
ij_typescript_block_comment_at_first_column = true
ij_typescript_call_parameters_new_line_after_left_paren = false
ij_typescript_call_parameters_right_paren_on_new_line = false
ij_typescript_call_parameters_wrap = off
ij_typescript_catch_on_new_line = false
ij_typescript_chained_call_dot_on_new_line = true
ij_typescript_class_brace_style = end_of_line
ij_typescript_comma_on_new_line = false
ij_typescript_do_while_brace_force = never
ij_typescript_else_on_new_line = false
ij_typescript_enforce_trailing_comma = keep
ij_typescript_extends_keyword_wrap = off
ij_typescript_extends_list_wrap = off
ij_typescript_field_prefix = _
ij_typescript_file_name_style = relaxed
ij_typescript_finally_on_new_line = false
ij_typescript_for_brace_force = never
ij_typescript_for_statement_new_line_after_left_paren = false
ij_typescript_for_statement_right_paren_on_new_line = false
ij_typescript_for_statement_wrap = off
ij_typescript_force_quote_style = false
ij_typescript_force_semicolon_style = false
ij_typescript_function_expression_brace_style = end_of_line
ij_typescript_if_brace_force = never
ij_typescript_import_merge_members = global
ij_typescript_import_prefer_absolute_path = global
ij_typescript_import_sort_members = true
ij_typescript_import_sort_module_name = false
ij_typescript_import_use_node_resolution = true
ij_typescript_imports_wrap = on_every_item
ij_typescript_indent_case_from_switch = true
ij_typescript_indent_chained_calls = true
ij_typescript_indent_package_children = 0
ij_typescript_jsx_attribute_value = braces
ij_typescript_keep_blank_lines_in_code = 2
ij_typescript_keep_first_column_comment = true
ij_typescript_keep_indents_on_empty_lines = false
ij_typescript_keep_line_breaks = true
ij_typescript_keep_simple_blocks_in_one_line = false
ij_typescript_keep_simple_methods_in_one_line = false
ij_typescript_line_comment_add_space = true
ij_typescript_line_comment_at_first_column = false
ij_typescript_method_brace_style = end_of_line
ij_typescript_method_call_chain_wrap = off
ij_typescript_method_parameters_new_line_after_left_paren = false
ij_typescript_method_parameters_right_paren_on_new_line = false
ij_typescript_method_parameters_wrap = off
ij_typescript_object_literal_wrap = on_every_item
ij_typescript_object_types_wrap = on_every_item
ij_typescript_parentheses_expression_new_line_after_left_paren = false
ij_typescript_parentheses_expression_right_paren_on_new_line = false
ij_typescript_place_assignment_sign_on_next_line = false
ij_typescript_prefer_as_type_cast = false
ij_typescript_prefer_explicit_types_function_expression_returns = false
ij_typescript_prefer_explicit_types_function_returns = false
ij_typescript_prefer_explicit_types_vars_fields = false
ij_typescript_prefer_parameters_wrap = false
ij_typescript_property_prefix =
ij_typescript_reformat_c_style_comments = false
ij_typescript_space_after_colon = true
ij_typescript_space_after_comma = true
ij_typescript_space_after_dots_in_rest_parameter = false
ij_typescript_space_after_generator_mult = true
ij_typescript_space_after_property_colon = true
ij_typescript_space_after_quest = true
ij_typescript_space_after_type_colon = true
ij_typescript_space_after_unary_not = false
ij_typescript_space_before_async_arrow_lparen = true
ij_typescript_space_before_catch_keyword = true
ij_typescript_space_before_catch_left_brace = true
ij_typescript_space_before_catch_parentheses = true
ij_typescript_space_before_class_lbrace = true
ij_typescript_space_before_class_left_brace = true
ij_typescript_space_before_colon = true
ij_typescript_space_before_comma = false
ij_typescript_space_before_do_left_brace = true
ij_typescript_space_before_else_keyword = true
ij_typescript_space_before_else_left_brace = true
ij_typescript_space_before_finally_keyword = true
ij_typescript_space_before_finally_left_brace = true
ij_typescript_space_before_for_left_brace = true
ij_typescript_space_before_for_parentheses = true
ij_typescript_space_before_for_semicolon = false
ij_typescript_space_before_function_left_parenth = true
ij_typescript_space_before_generator_mult = false
ij_typescript_space_before_if_left_brace = true
ij_typescript_space_before_if_parentheses = true
ij_typescript_space_before_method_call_parentheses = false
ij_typescript_space_before_method_left_brace = true
ij_typescript_space_before_method_parentheses = false
ij_typescript_space_before_property_colon = false
ij_typescript_space_before_quest = true
ij_typescript_space_before_switch_left_brace = true
ij_typescript_space_before_switch_parentheses = true
ij_typescript_space_before_try_left_brace = true
ij_typescript_space_before_type_colon = false
ij_typescript_space_before_unary_not = false
ij_typescript_space_before_while_keyword = true
ij_typescript_space_before_while_left_brace = true
ij_typescript_space_before_while_parentheses = true
ij_typescript_spaces_around_additive_operators = true
ij_typescript_spaces_around_arrow_function_operator = true
ij_typescript_spaces_around_assignment_operators = true
ij_typescript_spaces_around_bitwise_operators = true
ij_typescript_spaces_around_equality_operators = true
ij_typescript_spaces_around_logical_operators = true
ij_typescript_spaces_around_multiplicative_operators = true
ij_typescript_spaces_around_relational_operators = true
ij_typescript_spaces_around_shift_operators = true
ij_typescript_spaces_around_unary_operator = false
ij_typescript_spaces_within_array_initializer_brackets = false
ij_typescript_spaces_within_brackets = false
ij_typescript_spaces_within_catch_parentheses = false
ij_typescript_spaces_within_for_parentheses = false
ij_typescript_spaces_within_if_parentheses = false
ij_typescript_spaces_within_imports = false
ij_typescript_spaces_within_interpolation_expressions = false
ij_typescript_spaces_within_method_call_parentheses = false
ij_typescript_spaces_within_method_parentheses = false
ij_typescript_spaces_within_object_literal_braces = false
ij_typescript_spaces_within_object_type_braces = true
ij_typescript_spaces_within_parentheses = false
ij_typescript_spaces_within_switch_parentheses = false
ij_typescript_spaces_within_type_assertion = false
ij_typescript_spaces_within_union_types = true
ij_typescript_spaces_within_while_parentheses = false
ij_typescript_special_else_if_treatment = true
ij_typescript_ternary_operation_signs_on_next_line = false
ij_typescript_ternary_operation_wrap = off
ij_typescript_union_types_wrap = on_every_item
ij_typescript_use_chained_calls_group_indents = false
ij_typescript_use_double_quotes = true
ij_typescript_use_explicit_js_extension = auto
ij_typescript_use_import_type = auto
ij_typescript_use_path_mapping = always
ij_typescript_use_public_modifier = false
ij_typescript_use_semicolon_after_statement = true
ij_typescript_var_declaration_wrap = normal
ij_typescript_while_brace_force = never
ij_typescript_while_on_new_line = false
ij_typescript_wrap_comments = false
[{*.htm,*.html,*.sht,*.shtm,*.shtml}]
ij_html_add_new_line_before_tags = body, div, p, form, h1, h2, h3
ij_html_align_attributes = true

BIN
.github/assets/chrome.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
.github/assets/firefox.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -48,6 +48,7 @@ function wrapScriptIntoIIFE() {
*/
function makeAliases(rootDir) {
return {
"$config": path.resolve(rootDir, 'src/config'),
"$lib": path.resolve(rootDir, 'src/lib'),
"$entities": path.resolve(rootDir, 'src/lib/extension/entities'),
"$styles": path.resolve(rootDir, 'src/styles'),

View File

@@ -1,5 +1,8 @@
# Furbooru Tagging Assistant
[![Get the Add-on on Firefox](.github/assets/firefox.png)](https://addons.mozilla.org/en-US/firefox/addon/furbooru-tagging-assistant/)
[![Get the extension on Chrome](.github/assets/chrome.png)](https://chromewebstore.google.com/detail/kpgaphaooaaodgodmnkamhmoedjcnfkj)
This is a browser extension written for the [Furbooru](https://furbooru.org) image-board. It gives you the ability to
tag the images more easily and quickly.

View File

@@ -1,18 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias and https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

View File

@@ -1,7 +1,7 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
"version": "0.2.1",
"version": "0.4.0",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city"
@@ -23,7 +23,8 @@
"*://*.furbooru.org/",
"*://*.furbooru.org/images?*",
"*://*.furbooru.org/search?*",
"*://*.furbooru.org/tags/*"
"*://*.furbooru.org/tags/*",
"*://*.furbooru.org/galleries/*"
],
"js": [
"src/content/listing.js"
@@ -38,6 +39,35 @@
],
"js": [
"src/content/header.js"
],
"css": [
"src/styles/content/header.scss"
]
},
{
"matches": [
"*://*.furbooru.org/images?*",
"*://*.furbooru.org/images/*",
"*://*.furbooru.org/images/*/tag_changes",
"*://*.furbooru.org/images/*/tag_changes?*",
"*://*.furbooru.org/search?*",
"*://*.furbooru.org/tags",
"*://*.furbooru.org/tags?*",
"*://*.furbooru.org/tags/*",
"*://*.furbooru.org/profiles/*/tag_changes",
"*://*.furbooru.org/profiles/*/tag_changes?*",
"*://*.furbooru.org/filters/*"
],
"js": [
"src/content/tags.js"
]
},
{
"matches": [
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/tags-editor.js"
]
}
],

3285
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,30 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.2.1",
"version": "0.4.0",
"private": true,
"scripts": {
"build": "npm run build:popup && npm run build:extension",
"build:popup": "vite build",
"build:extension": "node build-extension.js",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.15.3",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.262",
"cheerio": "^1.0.0-rc.12",
"sass": "^1.71.0",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"typescript": "^5.0.0",
"vite": "^5.0.3"
"cheerio": "^1.0.0",
"sass": "^1.83.4",
"svelte": "^5.18.0",
"svelte-check": "^4.1.4",
"typescript": "^5.7.3",
"vite": "^6.0.7"
},
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.1",
"lz-string": "^1.5.0"
}
}

50
src/app.d.ts vendored
View File

@@ -1,24 +1,40 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import type TagGroup from "$entities/TagGroup.ts";
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
type LinkTarget = "_blank" | "_self" | "_parent" | "_top";
type IconName = (
"tag"
| "paint-brush"
| "arrow-left"
| "info-circle"
| "wrench"
| "globe"
| "plus"
| "file-export"
);
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
type LinkTarget = "_blank" | "_self" | "_parent" | "_top";
type IconName = (
"tag"
| "paint-brush"
| "arrow-left"
| "info-circle"
| "wrench"
| "globe"
| "plus"
| "file-export"
| "trash"
);
interface EntityNamesMap {
profiles: MaintenanceProfile;
groups: TagGroup;
}
interface ImageURIs {
full: string;
large: string;
medium: string;
small: string;
}
}
}
export {};

View File

@@ -0,0 +1,93 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import {storagesCollection} from "$stores/debug.js";
import {goto} from "$app/navigation";
import {findDeepObject} from "$lib/utils.js";
/** @type {string} */
export let storage;
/** @type {string[]} */
export let path;
/** @type {Object|null} */
let targetStorage = null;
/** @type {[string, string][]} */
let breadcrumbs = [];
/** @type {Object<string, any>|null} */
let targetObject = null;
let targetPathString = '';
$: {
/** @type {[string, string][]} */
const builtBreadcrumbs = [];
breadcrumbs = path.reduce((resultCrumbs, entry) => {
let entryPath = entry;
if (resultCrumbs.length) {
entryPath = resultCrumbs[resultCrumbs.length - 1][1] + "/" + entryPath;
}
resultCrumbs.push([entry, entryPath]);
return resultCrumbs;
}, builtBreadcrumbs);
targetPathString = path.join("/");
if (targetPathString.length) {
targetPathString += "/";
}
}
$: {
targetStorage = $storagesCollection[storage];
if (!targetStorage) {
goto("/preferences/debug/storage");
}
}
$: {
targetObject = targetStorage
? findDeepObject(targetStorage, path)
: null;
}
</script>
<Menu>
<MenuItem href="/preferences/debug/storage" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<p class="path">
<span>/ <a href="/preferences/debug/storage/{storage}">{storage}</a></span>
{#each breadcrumbs as [name, entryPath]}
<span>/ <a href="/preferences/debug/storage/{storage}/{entryPath}/">{name}</a></span>
{/each}
</p>
{#if targetObject}
<Menu>
<hr>
{#each Object.entries(targetObject) as [key, value]}
{#if targetObject[key] && typeof targetObject[key] === 'object'}
<MenuItem href="/preferences/debug/storage/{storage}/{targetPathString}{key}">
{key}: Object
</MenuItem>
{:else}
<MenuItem>
{key}: {typeof targetObject[key]} = {targetObject[key]}
</MenuItem>
{/if}
{/each}
</Menu>
{/if}
<style lang="scss">
.path {
display: flex;
flex-wrap: wrap;
column-gap: .5em;
}
</style>

View File

@@ -0,0 +1,59 @@
<script>
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
/**
* @type {import('$entities/TagGroup.ts').default}
*/
export let group;
let sortedTagsList, sortedPrefixes;
$: sortedTagsList = group.settings.tags.sort((a, b) => a.localeCompare(b));
$: sortedPrefixes = group.settings.prefixes.sort((a, b) => a.localeCompare(b));
</script>
<div class="block">
<strong>Group Name:</strong>
<div>{group.settings.name}</div>
</div>
{#if sortedTagsList.length}
<div class="block">
<strong>Tags:</strong>
<TagsColorContainer targetCategory="{group.settings.category}">
<div class="tags-list">
{#each sortedTagsList as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
</TagsColorContainer>
</div>
{/if}
{#if sortedPrefixes.length}
<div class="block">
<strong>Prefixes:</strong>
<TagsColorContainer targetCategory="{group.settings.category}">
<div class="tags-list">
{#each sortedPrefixes as prefixName}
<span class="tag">{prefixName}*</span>
{/each}
</div>
</TagsColorContainer>
</div>
{/if}
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.block + .block {
margin-top: .5em;
strong {
display: block;
margin-bottom: .25em;
}
}
</style>

View File

@@ -10,7 +10,7 @@
</footer>
<style lang="scss">
@use 'src/styles/colors';
@use '$styles/colors';
footer {
display: flex;

View File

@@ -3,7 +3,7 @@
</header>
<style lang="scss">
@use "src/styles/colors";
@use "$styles/colors";
header {
background: colors.$header;

View File

@@ -1,5 +1,5 @@
<script>
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default} */
/** @type {import('$entities/MaintenanceProfile.ts').default} */
export let profile;
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));

View File

@@ -0,0 +1,62 @@
<script>
/** @type {string} */
export let targetCategory = '';
</script>
<div class="tag-color-container tag-color-container--{targetCategory || 'default'}">
<slot></slot>
</div>
<style lang="scss">
@use '$styles/colors';
.tag-color-container:is(.tag-color-container--rating) :global(.tag) {
background-color: colors.$tag-rating-background;
color: colors.$tag-rating-text;
}
.tag-color-container:is(.tag-color-container--spoiler) :global(.tag) {
background-color: colors.$tag-spoiler-background;
color: colors.$tag-spoiler-text;
}
.tag-color-container:is(.tag-color-container--origin) :global(.tag) {
background-color: colors.$tag-origin-background;
color: colors.$tag-origin-text;
}
.tag-color-container:is(.tag-color-container--oc) :global(.tag) {
background-color: colors.$tag-oc-background;
color: colors.$tag-oc-text;
}
.tag-color-container:is(.tag-color-container--error) :global(.tag) {
background-color: colors.$tag-error-background;
color: colors.$tag-error-text;
}
.tag-color-container:is(.tag-color-container--character) :global(.tag) {
background-color: colors.$tag-character-background;
color: colors.$tag-character-text;
}
.tag-color-container:is(.tag-color-container--content-official) :global(.tag) {
background-color: colors.$tag-content-official-background;
color: colors.$tag-content-official-text;
}
.tag-color-container:is(.tag-color-container--content-fanmade) :global(.tag) {
background-color: colors.$tag-content-fanmade-background;
color: colors.$tag-content-fanmade-text;
}
.tag-color-container:is(.tag-color-container--species) :global(.tag) {
background-color: colors.$tag-species-background;
color: colors.$tag-species-text;
}
.tag-color-container:is(.tag-color-container--body-type) :global(.tag) {
background-color: colors.$tag-body-type-background;
color: colors.$tag-body-type-text;
}
</style>

View File

@@ -0,0 +1,80 @@
<script>
import SelectField from "$components/ui/forms/SelectField.svelte";
import { categories } from "$lib/booru/tag-categories.js";
/** @type {string} */
export let value = '';
/** @type {Record<string, string>} */
let tagCategoriesOptions = {
'': 'Default'
};
tagCategoriesOptions = categories.reduce((options, category) => {
options[category] = category
.replace('-', ' ')
.replace(/(?<=\s|^)\w/g, (matchedCharacter) => matchedCharacter.toUpperCase());
return options;
}, tagCategoriesOptions);
</script>
<SelectField bind:value={value} options={tagCategoriesOptions} name="tag_color"/>
<style lang="scss">
@use '$styles/colors';
:global(select[name=tag_color]) {
:global(option) {
&:is(:global([value=rating])) {
background-color: colors.$tag-rating-background;
color: colors.$tag-rating-text;
}
&:is(:global([value=spoiler])) {
background-color: colors.$tag-spoiler-background;
color: colors.$tag-spoiler-text;
}
&:is(:global([value=origin])) {
background-color: colors.$tag-origin-background;
color: colors.$tag-origin-text;
}
&:is(:global([value=oc])) {
background-color: colors.$tag-oc-background;
color: colors.$tag-oc-text;
}
&:is(:global([value=error])) {
background-color: colors.$tag-error-background;
color: colors.$tag-error-text;
}
&:is(:global([value=character])) {
background-color: colors.$tag-character-background;
color: colors.$tag-character-text;
}
&:is(:global([value=content-official])) {
background-color: colors.$tag-content-official-background;
color: colors.$tag-content-official-text;
}
&:is(:global([value=content-fanmade])) {
background-color: colors.$tag-content-fanmade-background;
color: colors.$tag-content-fanmade-text;
}
&:is(:global([value=species])) {
background-color: colors.$tag-species-background;
color: colors.$tag-species-text;
}
&:is(:global([value=body-type])) {
background-color: colors.$tag-body-type-background;
color: colors.$tag-body-type-text;
}
}
}
</style>

View File

@@ -3,7 +3,7 @@
</nav>
<style lang="scss">
@use 'src/styles/colors';
@use '$styles/colors';
nav {
display: flex;

View File

@@ -0,0 +1,37 @@
<script>
import MenuLink from "$components/ui/menu/MenuItem.svelte";
/**
* @type {boolean}
*/
export let checked;
/**
* @type {string|undefined}
*/
export let name = undefined;
/**
* @type {string|undefined}
*/
export let value = undefined;
/**
* @type {string|null}
*/
export let href = null;
</script>
<MenuLink {href}>
<input type="checkbox" {name} {value} bind:checked={checked} on:input on:click|stopPropagation>
<slot></slot>
</MenuLink>
<style lang="scss">
:global(.menu-item) input {
width: 16px;
height: 16px;
margin-right: 6px;
flex-shrink: 0;
}
</style>

View File

@@ -23,7 +23,7 @@
</svelte:element>
<style lang="scss">
@use '../../../styles/colors';
@use '$styles/colors';
.menu-item {
display: flex;

View File

@@ -32,5 +32,6 @@
width: 16px;
height: 16px;
margin-right: 6px;
flex-shrink: 0;
}
</style>

66
src/config/tags.ts Normal file
View File

@@ -0,0 +1,66 @@
export const tagsBlacklist: string[] = [
"anthro art",
"anthro artist",
"anthro cute",
"anthro furry",
"anthro nsfw",
"anthro oc",
"anthroart",
"anthroartist",
"anthrofurry",
"anthronsfw",
"anthrooc",
"art",
"artist",
"artwork",
"cringe",
"cringeworthy",
"cute art",
"cute artwork",
"cute furry",
"downvotes galore",
"drama in comments",
"drama in the comments",
"fandom",
"furries",
"furry anthro",
"furry art",
"furry artist",
"furry artwork",
"furry character",
"furry community",
"furry cute",
"furry fandom",
"furry nsfw",
"furry oc",
"furryanthro",
"furryart",
"furryartist",
"furryartwork",
"furrynsfw",
"furryoc",
"image",
"no tag",
"not tagged",
"notag",
"notags",
"nsfw anthro",
"nsfw art",
"nsfw artist",
"nsfw artwork",
"nsfw",
"nsfwanthro",
"nsfwart",
"nsfwartist",
"nsfwartwork",
"paywall",
"rcf community",
"sfw",
"solo oc",
"tag me",
"tag needed",
"tag your shit",
"tagme",
"upvotes galore",
"wall of faves"
];

View File

@@ -0,0 +1,3 @@
import {TagsForm} from "$lib/components/TagsForm.js";
TagsForm.watchForEditors();

7
src/content/tags.js Normal file
View File

@@ -0,0 +1,7 @@
import {watchTagDropdownsInTagsEditor, wrapTagDropdown} from "$lib/components/TagDropdownWrapper.js";
for (let tagDropdownElement of document.querySelectorAll('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
watchTagDropdownsInTagsEditor();

View File

@@ -0,0 +1,12 @@
export const categories = [
'rating',
'spoiler',
'origin',
'oc',
'error',
'character',
'content-official',
'content-fanmade',
'species',
'body-type',
];

View File

@@ -0,0 +1,338 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import MiscSettings from "$lib/extension/settings/MiscSettings.ts";
export class FullscreenViewer extends BaseComponent {
/** @type {HTMLVideoElement} */
#videoElement = document.createElement('video');
/** @type {HTMLImageElement} */
#imageElement = document.createElement('img');
#spinnerElement = document.createElement('i');
#sizeSelectorElement = document.createElement('select');
#closeButtonElement = document.createElement('i');
/** @type {number|null} */
#touchId = null;
/** @type {number|null} */
#startX = null;
/** @type {number|null} */
#startY = null;
/** @type {boolean|null} */
#isClosingSwipeStarted = null;
#isSizeFetched = false;
/** @type {App.ImageURIs|null} */
#currentURIs = null;
/**
* @protected
*/
build() {
this.container.classList.add('fullscreen-viewer');
this.container.append(
this.#spinnerElement,
this.#sizeSelectorElement,
this.#closeButtonElement,
);
this.#spinnerElement.classList.add('spinner', 'fa', 'fa-circle-notch', 'fa-spin');
this.#closeButtonElement.classList.add('close', 'fa', 'fa-xmark');
this.#sizeSelectorElement.classList.add('size-selector', 'input');
for (const [sizeKey, sizeName] of Object.entries(FullscreenViewer.#previewSizes)) {
const sizeOptionElement = document.createElement('option');
sizeOptionElement.value = sizeKey;
sizeOptionElement.innerText = sizeName;
this.#sizeSelectorElement.append(sizeOptionElement);
}
}
/**
* @protected
*/
init() {
document.addEventListener('keydown', this.#onDocumentKeyPressed.bind(this));
this.on('click', this.#close.bind(this));
this.on('touchstart', this.#onTouchStart.bind(this));
this.on('touchmove', this.#onTouchMove.bind(this));
this.on('touchend', this.#onTouchEnd.bind(this));
this.#videoElement.addEventListener('loadeddata', this.#onLoaded.bind(this));
this.#imageElement.addEventListener('load', this.#onLoaded.bind(this));
this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation());
FullscreenViewer.#miscSettings
.resolveFullscreenViewerPreviewSize()
.then(this.#onSizeResolved.bind(this))
.then(this.#watchForSizeSelectionChanges.bind(this));
}
#onLoaded() {
this.container.classList.remove('loading');
}
/**
* @param {TouchEvent} event
*/
#onTouchStart(event) {
if (this.#touchId !== null) {
return;
}
const firstTouch = event.touches.item(0);
if (!firstTouch) {
return;
}
this.#touchId = firstTouch.identifier;
this.#startX = firstTouch.clientX;
this.#startY = firstTouch.clientY;
this.container.classList.add(FullscreenViewer.#swipeState);
}
/**
* @param {TouchEvent} event
*/
#onTouchEnd(event) {
if (this.#touchId === null) {
return;
}
const endedTouch = Array.from(event.changedTouches)
.find(touch => touch.identifier === this.#touchId);
if (!endedTouch) {
return;
}
const verticalDistance = Math.abs(endedTouch.clientY - this.#startY);
const requiredClosingDistance = window.innerHeight / 3;
if (this.#isClosingSwipeStarted && verticalDistance > requiredClosingDistance) {
this.#close();
}
this.#touchId = null;
this.#startX = null;
this.#startY = null;
this.#isClosingSwipeStarted = null;
this.container.classList.remove(FullscreenViewer.#swipeState);
requestAnimationFrame(() => {
this.container.style.removeProperty(FullscreenViewer.#offsetProperty);
this.container.style.removeProperty(FullscreenViewer.#opacityProperty);
});
}
/**
* @param {TouchEvent} event
*/
#onTouchMove(event) {
if (this.#touchId === null) {
return;
}
if (this.#isClosingSwipeStarted === false) {
return;
}
for (const changedTouch of event.changedTouches) {
if (changedTouch.identifier !== this.#touchId) {
continue;
}
const verticalDistance = changedTouch.clientY - this.#startY;
if (this.#isClosingSwipeStarted === null) {
const horizontalDistance = changedTouch.clientX - this.#startX;
if (Math.abs(verticalDistance) >= FullscreenViewer.#minRequiredDistance) {
this.#isClosingSwipeStarted = true;
} else if (Math.abs(horizontalDistance) >= FullscreenViewer.#minRequiredDistance) {
this.#isClosingSwipeStarted = false;
break;
} else {
break;
}
}
this.container.style.setProperty(
FullscreenViewer.#offsetProperty,
verticalDistance.toString().concat('px')
);
const maxDistance = window.innerHeight * 2;
let opacity = 1;
if (verticalDistance !== 0) {
opacity -= Math.min(1, Math.abs(verticalDistance) / maxDistance);
}
this.container.style.setProperty(
FullscreenViewer.#opacityProperty,
opacity.toString()
);
break;
}
}
/**
* @param {KeyboardEvent} event
*/
#onDocumentKeyPressed(event) {
if (event.code === 'Escape' || event.code === 'Esc') {
this.#close();
}
}
/**
* @param {import("$lib/extension/settings/MiscSettings.js").FullscreenViewerSize} size
*/
#onSizeResolved(size) {
this.#sizeSelectorElement.value = size;
this.#isSizeFetched = true;
this.emit('size-loaded');
}
#watchForSizeSelectionChanges() {
let lastActiveSize = this.#sizeSelectorElement.value;
FullscreenViewer.#miscSettings.subscribe(settings => {
const targetSize = settings.fullscreenViewerSize;
if (!targetSize || lastActiveSize === targetSize) {
return;
}
lastActiveSize = targetSize;
this.#sizeSelectorElement.value = targetSize;
});
this.#sizeSelectorElement.addEventListener('input', () => {
const targetSize = this.#sizeSelectorElement.value;
if (this.#currentURIs) {
void this.show(this.#currentURIs);
}
if (!targetSize || targetSize === lastActiveSize || !(targetSize in FullscreenViewer.#previewSizes)) {
return;
}
lastActiveSize = targetSize;
void FullscreenViewer.#miscSettings.setFullscreenViewerPreviewSize(targetSize);
});
}
#close() {
this.#currentURIs = null;
this.container.classList.remove(FullscreenViewer.#shownState);
document.body.style.overflow = null;
requestAnimationFrame(() => {
this.#videoElement.volume = 0;
this.#videoElement.pause();
this.#videoElement.remove();
});
}
/**
* @param {App.ImageURIs} imageUris
* @return {Promise<string|null>}
*/
async #resolveCurrentSelectedSizeUrl(imageUris) {
if (!this.#isSizeFetched) {
await new Promise(resolve => this.on('size-loaded', resolve))
}
let targetSize = this.#sizeSelectorElement.value;
if (!imageUris.hasOwnProperty(targetSize)) {
targetSize = FullscreenViewer.#fallbackSize;
}
if (!imageUris.hasOwnProperty(targetSize)) {
targetSize = Object.keys(imageUris)[0];
}
if (!targetSize) {
return null;
}
return imageUris[targetSize];
}
/**
* @param {App.ImageURIs} imageUris
*/
async show(imageUris) {
this.#currentURIs = imageUris;
const url = await this.#resolveCurrentSelectedSizeUrl(imageUris);
if (!url) {
console.warn('Failed to resolve media for the viewer!');
return;
}
this.container.classList.add('loading');
requestAnimationFrame(() => {
this.container.classList.add(FullscreenViewer.#shownState);
document.body.style.overflow = 'hidden';
});
if (FullscreenViewer.#isVideoUrl(url)) {
this.#imageElement.remove();
this.#videoElement.src = url;
this.#videoElement.volume = 0;
this.#videoElement.autoplay = true;
this.#videoElement.loop = true;
this.#videoElement.controls = true;
this.container.append(this.#videoElement);
return;
}
this.#videoElement.remove();
this.#imageElement.src = url;
this.container.append(this.#imageElement);
}
/**
* @param {string} url
* @return {boolean}
*/
static #isVideoUrl(url) {
return url.endsWith('.mp4') || url.endsWith('.webm');
}
static #miscSettings = new MiscSettings();
static #offsetProperty = '--offset';
static #opacityProperty = '--opacity';
static #shownState = 'shown';
static #swipeState = 'swiped';
static #minRequiredDistance = 50;
/**
* @type {Record<import("$lib/extension/settings/MiscSettings.js").FullscreenViewerSize, string>}
*/
static #previewSizes = {
full: 'Full',
large: 'Large',
medium: 'Medium',
small: 'Small'
}
static #fallbackSize = 'large';
}

View File

@@ -1,6 +1,7 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import MiscSettings from "$lib/extension/settings/MiscSettings.js";
import MiscSettings from "$lib/extension/settings/MiscSettings.ts";
import {FullscreenViewer} from "$lib/components/FullscreenViewer.js";
export class ImageShowFullscreenButton extends BaseComponent {
/**
@@ -11,7 +12,6 @@ export class ImageShowFullscreenButton extends BaseComponent {
build() {
this.container.innerText = '🔍';
ImageShowFullscreenButton.#resolveFullscreenViewer();
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
}
@@ -33,7 +33,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
})
.then(() => {
ImageShowFullscreenButton.#miscSettings.subscribe(settings => {
this.#isFullscreenButtonEnabled = settings.fullscreenViewer;
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
this.#updateFullscreenButtonVisibility();
})
})
@@ -45,95 +45,36 @@ export class ImageShowFullscreenButton extends BaseComponent {
}
#onButtonClicked() {
const imageViewer = ImageShowFullscreenButton.#resolveFullscreenViewer();
const largeSourceUrl = this.#mediaBoxTools.mediaBox.imageLinks.large;
let imageElement = imageViewer.querySelector('img');
let videoElement = imageViewer.querySelector('video');
if (imageElement) {
imageElement.remove();
}
if (videoElement) {
videoElement.remove();
}
if (largeSourceUrl.endsWith('.webm') || largeSourceUrl.endsWith('.mp4')) {
videoElement ??= document.createElement('video');
videoElement.src = largeSourceUrl;
videoElement.volume = 0;
videoElement.autoplay = true;
videoElement.loop = true;
videoElement.controls = true;
imageViewer.appendChild(videoElement);
} else {
imageElement ??= document.createElement('img');
imageElement.src = largeSourceUrl;
imageViewer.appendChild(imageElement);
}
imageViewer.classList.add('shown');
ImageShowFullscreenButton
.#resolveViewer()
.show(this.#mediaBoxTools.mediaBox.imageLinks);
}
/**
* @type {HTMLElement|null}
* @type {FullscreenViewer|null}
*/
static #fullscreenViewerElement = null;
static #viewer = null;
/**
* @return {HTMLElement}
* @return {FullscreenViewer}
*/
static #resolveFullscreenViewer() {
this.#fullscreenViewerElement ??= this.#buildFullscreenViewer();
return this.#fullscreenViewerElement;
static #resolveViewer() {
this.#viewer ??= this.#buildViewer();
return this.#viewer;
}
/**
* @return {HTMLElement}
* @return {FullscreenViewer}
*/
static #buildFullscreenViewer() {
static #buildViewer() {
const element = document.createElement('div');
element.classList.add('fullscreen-viewer');
const viewer = new FullscreenViewer(element);
viewer.initialize();
document.body.append(element);
document.addEventListener('keydown', event => {
// When ESC pressed
if (event.code === 'Escape' || event.code === 'Esc') {
this.#closeFullscreenViewer(element);
}
});
element.addEventListener('click', () => {
this.#closeFullscreenViewer(element);
});
return element;
}
/**
* @param {HTMLElement} [viewerElement]
*/
static #closeFullscreenViewer(viewerElement = null) {
viewerElement ??= this.#resolveFullscreenViewer();
viewerElement.classList.remove('shown');
/** @type {HTMLVideoElement} */
const videoElement = viewerElement.querySelector('video');
if (!videoElement) {
return;
}
// Stopping and muting the video
requestAnimationFrame(() => {
videoElement.volume = 0;
videoElement.pause();
videoElement.remove();
})
return viewer;
}
/**

View File

@@ -1,8 +1,18 @@
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js";
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts";
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI.js";
import {tagsBlacklist} from "$config/tags.ts";
class BlackListedTagsEncounteredError extends Error {
/**
* @param {string} tagName
*/
constructor(tagName) {
super(`This tag is blacklisted and prevents submission: ${tagName}`);
}
}
export class MaintenancePopup extends BaseComponent {
/** @type {HTMLElement} */
@@ -11,6 +21,9 @@ export class MaintenancePopup extends BaseComponent {
/** @type {HTMLElement[]} */
#tagsList = [];
/** @type {Map<string, HTMLElement>} */
#suggestedInvalidTags = new Map();
/** @type {MaintenanceProfile|null} */
#activeProfile = null;
@@ -89,11 +102,16 @@ export class MaintenancePopup extends BaseComponent {
/** @type {string[]} */
const activeProfileTagsList = this.#activeProfile?.settings.tags || [];
for (let tagElement of this.#tagsList) {
for (const tagElement of this.#tagsList) {
tagElement.remove();
}
for (const tagElement of this.#suggestedInvalidTags.values()) {
tagElement.remove();
}
this.#tagsList = new Array(activeProfileTagsList.length);
this.#suggestedInvalidTags.clear();
const currentPostTags = this.#mediaBoxTools.mediaBox.tagsAndAliases;
@@ -109,6 +127,12 @@ export class MaintenancePopup extends BaseComponent {
tagElement.classList.toggle('is-present', isPresent);
tagElement.classList.toggle('is-missing', !isPresent);
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags.get(tagName) !== tagName);
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
if (tagsBlacklist.includes(tagName)) {
MaintenancePopup.#markTagAsInvalid(tagElement);
this.#suggestedInvalidTags.set(tagName, tagElement);
}
});
}
@@ -188,6 +212,8 @@ export class MaintenancePopup extends BaseComponent {
let maybeTagsAndAliasesAfterUpdate;
const shouldAutoRemove = await MaintenancePopup.#maintenanceSettings.resolveStripBlacklistedTags();
try {
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
this.#mediaBoxTools.mediaBox.imageId,
@@ -200,11 +226,27 @@ export class MaintenancePopup extends BaseComponent {
tagsList.add(tagName);
}
if (shouldAutoRemove) {
for (let tagName of tagsBlacklist) {
tagsList.delete(tagName);
}
} else {
for (let tagName of tagsList) {
if (tagsBlacklist.includes(tagName)) {
throw new BlackListedTagsEncounteredError(tagName);
}
}
}
return tagsList;
}
);
} catch (e) {
console.warn('Tags submission failed:', e);
if (e instanceof BlackListedTagsEncounteredError) {
this.#revealInvalidTags();
} else {
console.warn('Tags submission failed:', e);
}
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.emit('maintenance-state-change', 'failed');
@@ -228,6 +270,36 @@ export class MaintenancePopup extends BaseComponent {
this.#isSubmitting = false;
}
#revealInvalidTags() {
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
if (!tagsAndAliases) {
return;
}
const firstTagInList = this.#tagsList[0];
for (let tagName of tagsBlacklist) {
if (tagsAndAliases.has(tagName)) {
if (this.#suggestedInvalidTags.has(tagName)) {
continue;
}
const tagElement = MaintenancePopup.#buildTagElement(tagName);
MaintenancePopup.#markTagAsInvalid(tagElement);
tagElement.classList.add('is-present');
this.#suggestedInvalidTags.set(tagName, tagElement);
if (firstTagInList && firstTagInList.isConnected) {
this.#tagsListElement.insertBefore(tagElement, firstTagInList);
} else {
this.#tagsListElement.appendChild(tagElement);
}
}
}
}
/**
* @return {boolean}
*/
@@ -248,6 +320,15 @@ export class MaintenancePopup extends BaseComponent {
return tagElement;
}
/**
* Marks the tag with red color.
* @param {HTMLElement} tagElement Element to mark.
*/
static #markTagAsInvalid(tagElement) {
tagElement.dataset.tagCategory = 'error';
tagElement.setAttribute('data-tag-category', 'error');
}
/**
* Controller with maintenance settings.
* @type {MaintenanceSettings}
@@ -286,7 +367,13 @@ export class MaintenancePopup extends BaseComponent {
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(callback);
.then(profileOrNull => {
if (profileOrNull) {
lastActiveProfileId = profileOrNull.id;
}
callback(profileOrNull);
});
return () => {
unsubscribeFromProfilesChanges();

View File

@@ -56,7 +56,7 @@ export class MediaBoxWrapper extends BaseComponent {
}
/**
* @return {ImageURIs}
* @return {App.ImageURIs}
*/
get imageLinks() {
return JSON.parse(this.#thumbnailContainer.dataset.uris);
@@ -100,10 +100,3 @@ export function calculateMediaBoxesPositions(mediaBoxesList) {
}
})
}
/**
* @typedef {Object} ImageURIs
* @property {string} full
* @property {string} large
* @property {string} small
*/

View File

@@ -1,6 +1,6 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {QueryLexer, QuotedTermToken, TermToken, Token} from "$lib/booru/search/QueryLexer.js";
import SearchSettings from "$lib/extension/settings/SearchSettings.js";
import SearchSettings from "$lib/extension/settings/SearchSettings.ts";
export class SearchWrapper extends BaseComponent {
/** @type {HTMLInputElement|null} */
@@ -13,6 +13,10 @@ export class SearchWrapper extends BaseComponent {
#arePropertiesSuggestionsEnabled = false;
/** @type {"start"|"end"} */
#propertiesSuggestionsPosition = "start";
/** @type {HTMLElement|null} */
#cachedAutocompleteContainer = null;
/** @type {TermToken|QuotedTermToken|null} */
#lastTermToken = null;
build() {
this.#searchField = this.container.querySelector('input[name=q]');
@@ -94,6 +98,7 @@ export class SearchWrapper extends BaseComponent {
let searchValue = this.#searchField.value;
if (!searchValue) {
this.#lastTermToken = null;
return null;
}
@@ -103,16 +108,37 @@ export class SearchWrapper extends BaseComponent {
);
if (token instanceof TermToken) {
this.#lastTermToken = token;
return token.value;
}
if (token instanceof QuotedTermToken) {
this.#lastTermToken = token;
return token.decodedValue;
}
this.#lastTermToken = null;
return searchValue;
}
/**
* Resolve the autocomplete container from the document. Once resolved, it can be safely reused without breaking
* anything. Assuming refactored autocomplete handler is still implemented the way it is.
*
* This means, that properties will only be suggested once actual autocomplete logic was activated.
*
* @return {HTMLElement|null} Resolved element or nothing.
*/
#resolveAutocompleteContainer() {
if (this.#cachedAutocompleteContainer) {
return this.#cachedAutocompleteContainer;
}
this.#cachedAutocompleteContainer = document.querySelector('.autocomplete');
return this.#cachedAutocompleteContainer;
}
/**
* Render the list of suggestions into the existing popup or create and populate a new one.
* @param {string[]} suggestions List of suggestion to render the popup from.
@@ -121,12 +147,24 @@ export class SearchWrapper extends BaseComponent {
#renderSuggestions(suggestions, targetInput) {
/** @type {HTMLElement[]} */
const suggestedListItems = suggestions
.map(suggestedTerm => SearchWrapper.#renderTermSuggestion(suggestedTerm));
.map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm));
requestAnimationFrame(() => {
const autocompleteContainer = document.querySelector('.autocomplete') ?? SearchWrapper.#renderAutocompleteContainer();
const autocompleteContainer = this.#resolveAutocompleteContainer();
for (let existingTerm of autocompleteContainer.querySelectorAll('.autocomplete__item--property')) {
if (!autocompleteContainer) {
return;
}
// Since the autocomplete popup was refactored to re-use the same element over and over again, we need to remove
// the options from the popup manually when autocomplete was removed from the DOM, since site is not doing that.
const termsToRemove = autocompleteContainer.isConnected
// Only removing properties when element is still connected to the DOM (popup is used by the website)
? autocompleteContainer.querySelectorAll('.autocomplete__item--property')
// Remove everything if popup was disconnected from the DOM.
: autocompleteContainer.querySelectorAll('.autocomplete__item')
for (let existingTerm of termsToRemove) {
existingTerm.remove();
}
@@ -239,47 +277,60 @@ export class SearchWrapper extends BaseComponent {
return suggestionsList;
}
/**
* Render a new autocomplete container similar to the one generated by website. Might be sensitive to the updates
* made to the Philomena.
* @return {HTMLElement}
*/
static #renderAutocompleteContainer() {
const autocompleteContainer = document.createElement('div');
autocompleteContainer.className = 'autocomplete';
const innerListContainer = document.createElement('ul');
innerListContainer.className = 'autocomplete__list';
autocompleteContainer.append(innerListContainer);
return autocompleteContainer;
}
/**
* Render a single suggestion item and connect required events to interact with the user.
* @param {string} suggestedTerm Term to use for suggestion item.
* @return {HTMLElement} Resulting element.
*/
static #renderTermSuggestion(suggestedTerm) {
#renderTermSuggestion(suggestedTerm) {
/** @type {HTMLElement} */
const suggestionItem = document.createElement('li');
suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property');
suggestionItem.dataset.value = suggestedTerm;
suggestionItem.innerText = suggestedTerm;
const propertyIcon = document.createElement('i');
propertyIcon.classList.add('fa', 'fa-info-circle');
suggestionItem.insertAdjacentElement('afterbegin', propertyIcon);
suggestionItem.addEventListener('mouseover', () => {
this.#findAndResetSelectedSuggestion(suggestionItem);
SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem);
suggestionItem.classList.add('autocomplete__item--selected');
});
suggestionItem.addEventListener('mouseout', () => {
this.#findAndResetSelectedSuggestion(suggestionItem);
})
SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem);
});
suggestionItem.addEventListener('click', () => {
this.#replaceLastActiveTokenWithSuggestion(suggestedTerm);
});
return suggestionItem;
}
/**
* Automatically replace the last active token stored in the variable with the new value.
* @param {string} suggestedTerm Term to replace the value with.
*/
#replaceLastActiveTokenWithSuggestion(suggestedTerm) {
if (!this.#lastTermToken) {
return;
}
const searchQuery = this.#searchField.value;
const beforeToken = searchQuery.substring(0, this.#lastTermToken.index);
const afterToken = searchQuery.substring(this.#lastTermToken.index + this.#lastTermToken.value.length);
let replacementValue = suggestedTerm;
if (replacementValue.includes('"')) {
replacementValue = `"${QuotedTermToken.encode(replacementValue)}"`
}
this.#searchField.value = beforeToken + replacementValue + afterToken;
}
/**
* Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's
* front-end.
@@ -300,24 +351,42 @@ export class SearchWrapper extends BaseComponent {
static #typeDate = Symbol();
static #typeLiteral = Symbol();
static #typePersonal = Symbol();
static #typeBoolean = Symbol();
static #properties = new Map([
['animated', SearchWrapper.#typeBoolean],
['aspect_ratio', SearchWrapper.#typeNumeric],
['body_type_tag_count', SearchWrapper.#typeNumeric],
['character_tag_count', SearchWrapper.#typeNumeric],
['comment_count', SearchWrapper.#typeNumeric],
['content_fanmade_tag_count', SearchWrapper.#typeNumeric],
['content_official_tag_count', SearchWrapper.#typeNumeric],
['created_at', SearchWrapper.#typeDate],
['description', SearchWrapper.#typeLiteral],
['downvotes', SearchWrapper.#typeNumeric],
['duration', SearchWrapper.#typeNumeric],
['error_tag_count', SearchWrapper.#typeNumeric],
['faved_by', SearchWrapper.#typeLiteral],
['faved_by_id', SearchWrapper.#typeNumeric],
['faves', SearchWrapper.#typeNumeric],
['file_name', SearchWrapper.#typeLiteral],
['first_seen_at', SearchWrapper.#typeDate],
['height', SearchWrapper.#typeNumeric],
['id', SearchWrapper.#typeNumeric],
['oc_tag_count', SearchWrapper.#typeNumeric],
['orig_sha512_hash', SearchWrapper.#typeLiteral],
['original_format', SearchWrapper.#typeLiteral],
['pixels', SearchWrapper.#typeNumeric],
['rating_tag_count', SearchWrapper.#typeNumeric],
['score', SearchWrapper.#typeNumeric],
['sha512_hash', SearchWrapper.#typeLiteral],
['size', SearchWrapper.#typeNumeric],
['source_count', SearchWrapper.#typeNumeric],
['source_url', SearchWrapper.#typeLiteral],
['species_tag_count', SearchWrapper.#typeNumeric],
['spoiler_tag_count', SearchWrapper.#typeNumeric],
['tag_count', SearchWrapper.#typeNumeric],
['updated_at', SearchWrapper.#typeDate],
['uploader', SearchWrapper.#typeLiteral],
['uploader_id', SearchWrapper.#typeNumeric],
['upvotes', SearchWrapper.#typeNumeric],
@@ -341,6 +410,10 @@ export class SearchWrapper extends BaseComponent {
'uploads',
'upvotes',
'watched',
]],
[SearchWrapper.#typeBoolean, [
'true',
'false',
]]
]);
}

View File

@@ -0,0 +1,276 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
const isTagEditorProcessedKey = Symbol();
const categoriesResolver = new CustomCategoriesResolver();
export class TagDropdownWrapper extends BaseComponent {
/**
* Container with dropdown elements to insert options into.
* @type {HTMLElement}
*/
#dropdownContainer;
/**
* Button to add or remove the current tag into/from the active profile.
* @type {HTMLAnchorElement|null}
*/
#toggleOnExistingButton = null;
/**
* Button to create a new profile, make it active and add the current tag into the active profile.
* @type {HTMLAnchorElement|null}
*/
#addToNewButton = null;
/**
* Local clone of the currently active profile used for updating the list of tags.
* @type {MaintenanceProfile|null}
*/
#activeProfile = null;
/**
* Is cursor currently entered the dropdown.
* @type {boolean}
*/
#isEntered = false;
/**
* @type {string|undefined|null}
*/
#originalCategory = null;
build() {
this.#dropdownContainer = this.container.querySelector('.dropdown__content');
}
init() {
this.on('mouseenter', this.#onDropdownEntered.bind(this));
this.on('mouseleave', this.#onDropdownLeft.bind(this));
TagDropdownWrapper.#watchActiveProfile(activeProfileOrNull => {
this.#activeProfile = activeProfileOrNull;
if (this.#isEntered) {
this.#updateButtons();
}
});
}
get tagName() {
return this.container.dataset.tagName;
}
/**
* @return {string|undefined}
*/
get tagCategory() {
return this.container.dataset.tagCategory;
}
/**
* @param {string|undefined} targetCategory
*/
set tagCategory(targetCategory) {
// Make sure original category is properly stored.
this.originalCategory;
this.container.dataset.tagCategory = targetCategory;
if (targetCategory) {
this.container.setAttribute('data-tag-category', targetCategory);
return;
}
this.container.removeAttribute('data-tag-category');
}
/**
* @return {string|undefined}
*/
get originalCategory() {
if (this.#originalCategory === null) {
this.#originalCategory = this.tagCategory;
}
return this.#originalCategory;
}
#onDropdownEntered() {
this.#isEntered = true;
this.#updateButtons();
}
#onDropdownLeft() {
this.#isEntered = false;
}
#updateButtons() {
if (!this.#activeProfile) {
this.#addToNewButton ??= TagDropdownWrapper.#createDropdownLink(
'Add to new tagging profile',
this.#onAddToNewClicked.bind(this)
);
if (!this.#addToNewButton.isConnected) {
this.#dropdownContainer.append(this.#addToNewButton);
}
} else {
this.#addToNewButton?.remove();
}
if (this.#activeProfile) {
this.#toggleOnExistingButton ??= TagDropdownWrapper.#createDropdownLink(
'Add to existing tagging profile',
this.#onToggleInExistingClicked.bind(this)
);
const profileName = this.#activeProfile.settings.name;
let profileSpecificButtonText = `Add to profile "${profileName}"`;
if (this.#activeProfile.settings.tags.includes(this.tagName)) {
profileSpecificButtonText = `Remove from profile "${profileName}"`;
}
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
if (!this.#toggleOnExistingButton.isConnected) {
this.#dropdownContainer.append(this.#toggleOnExistingButton);
}
return;
}
this.#toggleOnExistingButton?.remove();
}
async #onAddToNewClicked() {
const profile = new MaintenanceProfile(crypto.randomUUID(), {
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
tags: [this.tagName],
temporary: true,
});
await profile.save();
await TagDropdownWrapper.#maintenanceSettings.setActiveProfileId(profile.id);
}
async #onToggleInExistingClicked() {
if (!this.#activeProfile) {
return;
}
const tagsList = new Set(this.#activeProfile.settings.tags);
const targetTagName = this.tagName;
if (tagsList.has(targetTagName)) {
tagsList.delete(targetTagName);
} else {
tagsList.add(targetTagName);
}
this.#activeProfile.settings.tags = Array.from(tagsList.values());
await this.#activeProfile.save();
}
static #maintenanceSettings = new MaintenanceSettings();
/**
* Watch for changes to active profile.
* @param {(profile: MaintenanceProfile|null) => void} onActiveProfileChange Callback to call when profile was
* changed.
*/
static #watchActiveProfile(onActiveProfileChange) {
let lastActiveProfile;
this.#maintenanceSettings.subscribe((settings) => {
lastActiveProfile = settings.activeProfile;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(onActiveProfileChange);
});
MaintenanceProfile.subscribe(profiles => {
const activeProfile = profiles
.find(profile => profile.id === lastActiveProfile);
onActiveProfileChange(activeProfile);
});
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(activeProfile => {
lastActiveProfile = activeProfile?.id ?? null;
onActiveProfileChange(activeProfile);
});
}
/**
* Create element for dropdown.
* @param {string} text Base text for the option.
* @param {(event: MouseEvent) => void} onClickHandler Click handler. Event will be prevented by default.
* @return {HTMLAnchorElement}
*/
static #createDropdownLink(text, onClickHandler) {
/** @type {HTMLAnchorElement} */
const dropdownLink = document.createElement('a');
dropdownLink.href = '#';
dropdownLink.innerText = text;
dropdownLink.className = 'tag__dropdown__link';
dropdownLink.addEventListener('click', event => {
event.preventDefault();
onClickHandler(event);
});
return dropdownLink;
}
}
export function wrapTagDropdown(element) {
// Skip initialization when tag component is already wrapped
if (getComponent(element)) {
return;
}
const tagDropdown = new TagDropdownWrapper(element);
tagDropdown.initialize();
categoriesResolver.addElement(tagDropdown);
}
export function watchTagDropdownsInTagsEditor() {
// We only need to watch for new editor elements if there is a tag editor present on the page
if (!document.querySelector('#image_tags_and_source')) {
return;
}
document.body.addEventListener('mouseover', event => {
/** @type {HTMLElement} */
const targetElement = event.target;
if (targetElement[isTagEditorProcessedKey]) {
return;
}
/** @type {HTMLElement|null} */
const closestTagEditor = targetElement.closest('#image_tags_and_source');
if (!closestTagEditor || closestTagEditor[isTagEditorProcessedKey]) {
targetElement[isTagEditorProcessedKey] = true;
return;
}
targetElement[isTagEditorProcessedKey] = true;
closestTagEditor[isTagEditorProcessedKey] = true;
for (const tagDropdownElement of closestTagEditor.querySelectorAll('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
})
}

View File

@@ -0,0 +1,81 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
export class TagsForm extends BaseComponent {
/**
* Collect all the tag categories available on the page and color the tags in the editor according to them.
*/
refreshTagColors() {
const tagCategories = this.#gatherTagCategories();
const editableTags = this.container.querySelectorAll('.tag');
for (let tagElement of editableTags) {
// Tag name is stored in the "remove" link and not in the tag itself.
const removeLink = tagElement.querySelector('a');
if (!removeLink) {
continue;
}
const tagName = removeLink.dataset.tagName;
if (!tagCategories.has(tagName)) {
continue;
}
const categoryName = tagCategories.get(tagName);
tagElement.dataset.tagCategory = categoryName;
tagElement.setAttribute('data-tag-category', categoryName);
}
}
/**
* Collect list of categories from the tags on the page.
* @return {Map<string, string>}
*/
#gatherTagCategories() {
/** @type {Map<string, string>} */
const tagCategories = new Map();
for (let tagElement of document.querySelectorAll('.tag[data-tag-name][data-tag-category]')) {
tagCategories.set(tagElement.dataset.tagName, tagElement.dataset.tagCategory);
}
return tagCategories;
}
static watchForEditors() {
document.body.addEventListener('click', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
if (!tagEditorWrapper) {
return;
}
const refreshTrigger = targetElement.closest('.js-taginput-show, #edit-tags')
if (!refreshTrigger) {
return;
}
const tagFormElement = tagEditorWrapper.querySelector('#tags-form');
/** @type {TagsForm|null} */
let tagEditor = getComponent(tagFormElement);
if (!tagEditor || (!tagEditor instanceof TagsForm)) {
tagEditor = new TagsForm(tagFormElement);
tagEditor.initialize();
}
tagEditor.refreshTagColors();
});
}
}

View File

@@ -0,0 +1,110 @@
import type {TagDropdownWrapper} from "$lib/components/TagDropdownWrapper";
import TagGroup from "$entities/TagGroup.ts";
import {escapeRegExp} from "$lib/utils";
export default class CustomCategoriesResolver {
#tagCategories = new Map<string, string>();
#compiledRegExps = new Map<RegExp, string>();
#tagDropdowns: TagDropdownWrapper[] = [];
#nextQueuedUpdate = -1;
constructor() {
TagGroup.subscribe(this.#onTagGroupsReceived.bind(this));
TagGroup.readAll().then(this.#onTagGroupsReceived.bind(this));
}
public addElement(tagDropdown: TagDropdownWrapper): void {
this.#tagDropdowns.push(tagDropdown);
if (!this.#tagCategories.size && !this.#compiledRegExps.size) {
return;
}
this.#queueUpdatingTags();
}
#queueUpdatingTags() {
clearTimeout(this.#nextQueuedUpdate);
this.#nextQueuedUpdate = setTimeout(
this.#updateUnprocessedTags.bind(this),
CustomCategoriesResolver.#unprocessedTagsTimeout
);
}
#updateUnprocessedTags() {
this.#tagDropdowns
.filter(CustomCategoriesResolver.#skipTagsWithOriginalCategory)
.filter(this.#applyCustomCategoryForExactMatches.bind(this))
.filter(this.#matchCustomCategoryByRegExp.bind(this))
.forEach(CustomCategoriesResolver.#resetToOriginalCategory);
}
/**
* Apply custom categories for the exact tag names.
* @param tagDropdown Element to try applying the category for.
* @return {boolean} Will return false when tag is processed and true when it is not found.
* @private
*/
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
const tagName = tagDropdown.tagName!;
if (!this.#tagCategories.has(tagName)) {
return true;
}
tagDropdown.tagCategory = this.#tagCategories.get(tagName)!;
return false;
}
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
const tagName = tagDropdown.tagName!;
for (const targetRegularExpression of this.#compiledRegExps.keys()) {
if (!targetRegularExpression.test(tagName)) {
continue;
}
tagDropdown.tagCategory = this.#compiledRegExps.get(targetRegularExpression)!;
return false;
}
return true;
}
#onTagGroupsReceived(tagGroups: TagGroup[]) {
this.#tagCategories.clear();
this.#compiledRegExps.clear();
if (!tagGroups.length) {
return;
}
for (const tagGroup of tagGroups) {
const categoryName = tagGroup.settings.category;
for (const tagName of tagGroup.settings.tags) {
this.#tagCategories.set(tagName, categoryName);
}
for (const tagPrefix of tagGroup.settings.prefixes) {
this.#compiledRegExps.set(
new RegExp(`^${escapeRegExp(tagPrefix)}`),
categoryName
);
}
}
this.#queueUpdatingTags();
}
static #skipTagsWithOriginalCategory(tagDropdown: TagDropdownWrapper): boolean {
return !tagDropdown.originalCategory;
}
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
tagDropdown.tagCategory = tagDropdown.originalCategory;
}
static #unprocessedTagsTimeout = 0;
}

View File

@@ -1,4 +1,5 @@
import StorageHelper from "$lib/browser/StorageHelper.js";
import type StorageEntity from "$lib/extension/base/StorageEntity.ts";
export default class EntitiesController {
static #storageHelper = new StorageHelper(chrome.storage.local);
@@ -6,15 +7,13 @@ export default class EntitiesController {
/**
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
*
* @template EntityClass
* @param entityName Name of the entity to read.
* @param entityClass Class of the entity to read. Must have a constructor that accepts the ID and the settings
* object.
*
* @param {string} entityName Name of the entity to read.
* @param {EntityClass} entityClass Class of the entity to read. Must have a constructor that accepts the ID and the
* settings object.
*
* @return {Promise<InstanceType<EntityClass>[]>} List of entities of the given type.
* @return List of entities of the given type.
*/
static async readAllEntities(entityName, entityClass) {
static async readAllEntities<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type): Promise<Type[]> {
const rawEntities = await this.#storageHelper.read(entityName, {});
if (!rawEntities || Object.keys(rawEntities).length === 0) {
@@ -29,13 +28,11 @@ export default class EntitiesController {
/**
* Update the single entity in the storage. If the entity with the given ID already exists, it will be overwritten.
*
* @param {string} entityName Name of the entity to update.
* @param {StorageEntity} entity Entity to update.
*
* @return {Promise<void>}
* @param entityName Name of the entity to update.
* @param entity Entity to update.
*/
static async updateEntity(entityName, entity) {
await this.#storageHelper.write(
static async updateEntity(entityName: string, entity: StorageEntity<Object>): Promise<void> {
this.#storageHelper.write(
entityName,
Object.assign(
await this.#storageHelper.read(
@@ -51,15 +48,13 @@ export default class EntitiesController {
/**
* Delete the entity with the given ID.
*
* @param {string} entityName Name of the entity to delete.
* @param {string} entityId ID of the entity to delete.
*
* @return {Promise<void>}
* @param entityName Name of the entity to delete.
* @param entityId ID of the entity to delete.
*/
static async deleteEntity(entityName, entityId) {
static async deleteEntity(entityName: string, entityId: string): Promise<void> {
const entities = await this.#storageHelper.read(entityName, {});
delete entities[entityId];
await this.#storageHelper.write(entityName, entities);
this.#storageHelper.write(entityName, entities);
}
/**
@@ -67,17 +62,16 @@ export default class EntitiesController {
*
* @template EntityClass
*
* @param {string} entityName Name of the entity to subscribe to.
* @param {EntityClass} entityClass Class of the entity to subscribe to.
* @param {function(InstanceType<EntityClass>[]): any} callback Callback to call when the storage changes.
* @return {function(): void} Unsubscribe function.
* @param entityName Name of the entity to subscribe to.
* @param entityClass Class of the entity to subscribe to.
* @param callback Callback to call when the storage changes.
* @return Unsubscribe function.
*/
static subscribeToEntity(entityName, entityClass, callback) {
static subscribeToEntity<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type, callback: (entities: Type[]) => void): () => void {
/**
* Watch the changes made to the storage and call the callback when the entity changes.
* @param {Object<string, StorageChange>} changes Changes made to the storage.
*/
const storageChangesSubscriber = changes => {
const storageChangesSubscriber = (changes: Record<string, chrome.storage.StorageChange>) => {
if (!changes[entityName]) {
return;
}

View File

@@ -0,0 +1,89 @@
import {validateImportedEntity} from "$lib/extension/transporting/validators.js";
import {exportEntityToObject} from "$lib/extension/transporting/exporters.ts";
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
import {compressToEncodedURIComponent, decompressFromEncodedURIComponent} from "lz-string";
export default class EntitiesTransporter<EntityType> {
readonly #targetEntityConstructor: new (...any: any[]) => EntityType;
/**
* Name of the entity, exported directly from the constructor.
* @private
*/
get #entityName() {
// How the hell should I even do this?
return ((this.#targetEntityConstructor as any) as typeof StorageEntity)._entityName;
}
/**
* @param entityConstructor Class which should be used for import or export.
*/
constructor(entityConstructor: new (...any: any[]) => EntityType) {
if (!(entityConstructor.prototype instanceof StorageEntity)) {
throw new TypeError('Invalid class provided as the target for importing!');
}
this.#targetEntityConstructor = entityConstructor;
}
importFromJSON(jsonString: string): EntityType {
const importedObject = this.#tryParsingAsJSON(jsonString);
if (!importedObject) {
throw new Error('Invalid JSON!');
}
validateImportedEntity(
importedObject,
this.#entityName
);
return new this.#targetEntityConstructor(
importedObject.id,
importedObject
);
}
importFromCompressedJSON(compressedJsonString: string): EntityType {
return this.importFromJSON(
decompressFromEncodedURIComponent(compressedJsonString)
)
}
exportToJSON(entityObject: EntityType): string {
if (!(entityObject instanceof this.#targetEntityConstructor)) {
throw new TypeError('Transporter should be connected to the same entity to export!');
}
if (!(entityObject instanceof StorageEntity)) {
throw new TypeError('Only storage entities could be exported!');
}
const exportableObject = exportEntityToObject(
entityObject,
this.#entityName
);
return JSON.stringify(exportableObject, null, 2);
}
exportToCompressedJSON(entityObject: EntityType): string {
return compressToEncodedURIComponent(this.exportToJSON(entityObject));
}
#tryParsingAsJSON(jsonString: string): Record<string, any> | null {
let jsonObject: Record<string, any> | null = null;
try {
jsonObject = JSON.parse(jsonString);
} catch (e) {
}
if (typeof jsonObject !== "object") {
throw new TypeError("Should be an object!");
}
return jsonObject
}
}

View File

@@ -1,20 +1,20 @@
import ConfigurationController from "$lib/extension/ConfigurationController.js";
export default class CacheableSettings {
/** @type {ConfigurationController} */
#controller;
/** @type {Map<string, any>} */
#cachedValues = new Map();
/** @type {function[]} */
#disposables = [];
export default class CacheableSettings<Fields> {
#controller: ConfigurationController;
#cachedValues: Map<keyof Fields, any> = new Map();
#disposables: Function[] = [];
constructor(settingsNamespace) {
constructor(settingsNamespace: string) {
this.#controller = new ConfigurationController(settingsNamespace);
this.#disposables.push(
this.#controller.subscribeToChanges(settings => {
for (const key of Object.keys(settings)) {
this.#cachedValues.set(key, settings[key]);
this.#cachedValues.set(
key as keyof Fields,
settings[key]
);
}
})
);
@@ -27,12 +27,12 @@ export default class CacheableSettings {
* @return {Promise<SettingType>}
* @protected
*/
async _resolveSetting(settingName, defaultValue) {
protected async _resolveSetting<Key extends keyof Fields>(settingName: Key, defaultValue: Fields[Key]): Promise<Fields[Key]> {
if (this.#cachedValues.has(settingName)) {
return this.#cachedValues.get(settingName);
}
const settingValue = await this.#controller.readSetting(settingName, defaultValue);
const settingValue = await this.#controller.readSetting(settingName as string, defaultValue);
this.#cachedValues.set(settingName, settingValue);
@@ -40,13 +40,12 @@ export default class CacheableSettings {
}
/**
* @param {string} settingName Name of the setting to write.
* @param {*} value Value to pass.
* @param {boolean} [force=false] Ignore the cache and force the update.
* @return {Promise<void>}
* @param settingName Name of the setting to write.
* @param value Value to pass.
* @param force Ignore the cache and force the update.
* @protected
*/
async _writeSetting(settingName, value, force = false) {
async _writeSetting<Key extends keyof Fields>(settingName: Key, value: Fields[Key], force: boolean = false): Promise<void> {
if (
!force
&& this.#cachedValues.has(settingName)
@@ -55,7 +54,10 @@ export default class CacheableSettings {
return;
}
return this.#controller.writeSetting(settingName, value);
return this.#controller.writeSetting(
settingName as string,
value
);
}
/**
@@ -63,8 +65,8 @@ export default class CacheableSettings {
* @param {function(Object): void} callback Callback which will receive list of settings.
* @return {function(): void} Unsubscribe function.
*/
subscribe(callback) {
const unsubscribeCallback = this.#controller.subscribeToChanges(callback);
subscribe(callback: (settings: Partial<Fields>) => void): () => void {
const unsubscribeCallback = this.#controller.subscribeToChanges(callback as (fields: Record<string, any>) => void);
this.#disposables.push(unsubscribeCallback);

View File

@@ -1,56 +0,0 @@
import EntitiesController from "$lib/extension/EntitiesController.js";
class StorageEntity {
/**
* @type {string}
*/
#id;
/**
* @type {Object}
*/
#settings;
/**
* @param {string} id
* @param {Object} settings
*/
constructor(id, settings = {}) {
this.#id = id;
this.#settings = settings;
}
/**
* @return {string}
*/
get id() {
return this.#id;
}
/**
* @return {Object}
*/
get settings() {
return this.#settings;
}
static _entityName = "entity";
async save() {
await EntitiesController.updateEntity(this.constructor._entityName, this);
}
async delete() {
await EntitiesController.deleteEntity(this.constructor._entityName, this.id);
}
/**
* Static function to read all entities of this type from the storage. Must be implemented in the child class.
* @return {Promise<array>}
*/
static async readAll() {
throw new Error("Not implemented");
}
}
export default StorageEntity;

View File

@@ -0,0 +1,59 @@
import EntitiesController from "$lib/extension/EntitiesController.js";
export default abstract class StorageEntity<SettingsType extends Object = {}> {
/**
* @type {string}
*/
readonly #id: string;
/**
* @type {Object}
*/
readonly #settings: SettingsType;
protected constructor(id: string, settings: SettingsType) {
this.#id = id;
this.#settings = settings;
}
get id(): string {
return this.#id;
}
get settings(): SettingsType {
return this.#settings;
}
public static readonly _entityName: string = "entity";
async save() {
await EntitiesController.updateEntity(
(this.constructor as typeof StorageEntity)._entityName,
this
);
}
async delete() {
await EntitiesController.deleteEntity(
(this.constructor as typeof StorageEntity)._entityName,
this.id
);
}
public static async readAll<Type extends StorageEntity<any>>(this: new (...args: any[]) => Type): Promise<Type[]> {
return await EntitiesController.readAllEntities(
// Voodoo magic, once again.
((this as any) as typeof StorageEntity)._entityName,
this
)
}
public static subscribe<Type extends StorageEntity<any>>(this: new (...args: any[]) => Type, callback: (entities: Type[]) => void): () => void {
return EntitiesController.subscribeToEntity(
// And once more.
((this as any) as typeof StorageEntity)._entityName,
this,
callback
);
}
}

View File

@@ -1,140 +0,0 @@
import StorageEntity from "$lib/extension/base/StorageEntity.js";
import EntitiesController from "$lib/extension/EntitiesController.js";
import {compressToEncodedURIComponent, decompressFromEncodedURIComponent} from "lz-string";
/**
* @typedef {Object} MaintenanceProfileSettings
* @property {string} name
* @property {string[]} tags
*/
/**
* Class representing the maintenance profile entity.
*/
class MaintenanceProfile extends StorageEntity {
/**
* @param {string} id ID of the entity.
* @param {Partial<MaintenanceProfileSettings>} settings Maintenance profile settings object.
*/
constructor(id, settings) {
super(id, {
name: settings.name || '',
tags: settings.tags || []
});
}
/**
* @return {MaintenanceProfileSettings}
*/
get settings() {
return super.settings;
}
/**
* Export the profile to the formatted JSON.
*
* @type {string}
*/
toJSON() {
return JSON.stringify({
v: 1,
id: this.id,
name: this.settings.name,
tags: this.settings.tags,
}, null, 2);
}
toCompressedJSON() {
return compressToEncodedURIComponent(
this.toJSON()
);
}
static _entityName = "profiles";
/**
* Read all maintenance profiles from the storage.
*
* @return {Promise<InstanceType<MaintenanceProfile>[]>}
*/
static async readAll() {
return await EntitiesController.readAllEntities(
this._entityName,
MaintenanceProfile
);
}
/**
* Subscribe to the changes and receive the new list of profiles when they change.
*
* @param {function(MaintenanceProfile[]): void} callback Callback to call when the profiles change. The new list of
* profiles is passed as an argument.
*
* @return {function(): void} Unsubscribe function.
*/
static subscribe(callback) {
return EntitiesController.subscribeToEntity(
this._entityName,
MaintenanceProfile,
callback
);
}
/**
* Validate and import the profile from the JSON.
* @param {string} exportedString JSON for profile.
* @return {MaintenanceProfile} Maintenance profile imported from the JSON. Note that profile is not automatically
* saved.
* @throws {Error} When version is unsupported or format is invalid.
*/
static importFromJSON(exportedString) {
let importedObject;
try {
importedObject = JSON.parse(exportedString);
} catch (e) {
// Error will be sent later, since empty string could be parsed as nothing without raising the error.
}
if (!importedObject) {
throw new Error('Invalid JSON!');
}
if (importedObject.v !== 1) {
throw new Error('Unsupported version!');
}
if (
!importedObject.id
|| typeof importedObject.id !== "string"
|| !importedObject.name
|| typeof importedObject.name !== "string"
|| !importedObject.tags
|| !Array.isArray(importedObject.tags)
) {
throw new Error('Invalid profile format detected!');
}
return new MaintenanceProfile(
importedObject.id,
{
name: importedObject.name,
tags: importedObject.tags,
}
);
}
/**
* Validate and import the profile from the compressed JSON string.
* @param {string} compressedString
* @return {MaintenanceProfile}
* @throws {Error} When version is unsupported or format is invalid.
*/
static importFromCompressedJSON(compressedString) {
return this.importFromJSON(
decompressFromEncodedURIComponent(compressedString)
);
}
}
export default MaintenanceProfile;

View File

@@ -0,0 +1,35 @@
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
import EntitiesController from "$lib/extension/EntitiesController.ts";
export interface MaintenanceProfileSettings {
name: string;
tags: string[];
temporary: boolean;
}
/**
* Class representing the maintenance profile entity.
*/
export default class MaintenanceProfile extends StorageEntity<MaintenanceProfileSettings> {
/**
* @param id ID of the entity.
* @param settings Maintenance profile settings object.
*/
constructor(id: string, settings: Partial<MaintenanceProfileSettings>) {
super(id, {
name: settings.name || '',
tags: settings.tags || [],
temporary: settings.temporary ?? false
});
}
async save(): Promise<void> {
if (this.settings.temporary && !this.settings.tags?.length) {
return this.delete();
}
return super.save();
}
public static readonly _entityName = "profiles";
}

View File

@@ -0,0 +1,21 @@
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
export interface TagGroupSettings {
name: string;
tags: string[];
prefixes: string[];
category: string;
}
export default class TagGroup extends StorageEntity<TagGroupSettings> {
constructor(id: string, settings: Partial<TagGroupSettings>) {
super(id, {
name: settings.name || '',
tags: settings.tags || [],
prefixes: settings.prefixes || [],
category: settings.category || ''
});
}
static _entityName = 'groups';
}

View File

@@ -1,63 +0,0 @@
import ConfigurationController from "$lib/extension/ConfigurationController.js";
import MaintenanceProfile from "$lib/extension/entities/MaintenanceProfile.js";
import CacheableSettings from "$lib/extension/base/CacheableSettings.js";
export default class MaintenanceSettings extends CacheableSettings {
constructor() {
super("maintenance");
}
/**
* Set the active maintenance profile.
*
* @return {Promise<string|null>}
*/
async resolveActiveProfileId() {
return this._resolveSetting("activeProfile", null);
}
/**
* Get the active maintenance profile if it is set.
*
* @return {Promise<MaintenanceProfile|null>}
*/
async resolveActiveProfileAsObject() {
const resolvedProfileId = await this.resolveActiveProfileId();
return (await MaintenanceProfile.readAll())
.find(profile => profile.id === resolvedProfileId) || null;
}
/**
* Set the active maintenance profile.
*
* @param {string|null} profileId ID of the profile to set as active. If `null`, the active profile will be considered
* unset.
*
* @return {Promise<void>}
*/
async setActiveProfileId(profileId) {
await this._writeSetting("activeProfile", profileId);
}
/**
* Subscribe to the changes in the maintenance-related settings.
*
* @param {function(MaintenanceSettingsObject): void} callback Callback to call when the settings change. The new
* settings are passed as an argument.
*
* @return {function(): void} Unsubscribe function.
*/
subscribe(callback) {
return super.subscribe(settings => {
callback({
activeProfile: settings.activeProfile || null,
});
});
}
}
/**
* @typedef {Object} MaintenanceSettingsObject
* @property {string|null} activeProfile
*/

View File

@@ -0,0 +1,48 @@
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import CacheableSettings from "$lib/extension/base/CacheableSettings.ts";
interface MaintenanceSettingsFields {
activeProfile: string | null;
stripBlacklistedTags: boolean;
}
export default class MaintenanceSettings extends CacheableSettings<MaintenanceSettingsFields> {
constructor() {
super("maintenance");
}
/**
* Set the active maintenance profile.
*/
async resolveActiveProfileId() {
return this._resolveSetting("activeProfile", null);
}
/**
* Get the active maintenance profile if it is set.
*/
async resolveActiveProfileAsObject(): Promise<MaintenanceProfile | null> {
const resolvedProfileId = await this.resolveActiveProfileId();
return (await MaintenanceProfile.readAll())
.find(profile => profile.id === resolvedProfileId) || null;
}
async resolveStripBlacklistedTags() {
return this._resolveSetting('stripBlacklistedTags', false);
}
/**
* Set the active maintenance profile.
*
* @param profileId ID of the profile to set as active. If `null`, the active profile will be considered
* unset.
*/
async setActiveProfileId(profileId: string | null): Promise<void> {
await this._writeSetting("activeProfile", profileId);
}
async setStripBlacklistedTags(isEnabled: boolean) {
await this._writeSetting('stripBlacklistedTags', isEnabled);
}
}

View File

@@ -1,32 +0,0 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings.js";
export default class MiscSettings extends CacheableSettings {
constructor() {
super("misc");
}
async resolveFullscreenViewerEnabled() {
return this._resolveSetting("fullscreenViewer", true);
}
async setFullscreenViewerEnabled(isEnabled) {
return this._writeSetting("fullscreenViewer", isEnabled);
}
/**
* @param {function(MiscSettingsObject): void} callback
* @return {function(): void}
*/
subscribe(callback) {
return super.subscribe(settings => {
callback({
fullscreenViewer: settings.fullscreenViewer ?? true,
})
});
}
}
/**
* @typedef {Object} MiscSettingsObject
* @property {boolean} fullscreenViewer
*/

View File

@@ -0,0 +1,30 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings.ts";
export type FullscreenViewerSize = 'small' | 'medium' | 'large' | 'full';
interface MiscSettingsFields {
fullscreenViewer: boolean;
fullscreenViewerSize: FullscreenViewerSize;
}
export default class MiscSettings extends CacheableSettings<MiscSettingsFields> {
constructor() {
super("misc");
}
async resolveFullscreenViewerEnabled() {
return this._resolveSetting("fullscreenViewer", true);
}
async resolveFullscreenViewerPreviewSize() {
return this._resolveSetting('fullscreenViewerSize', 'large');
}
async setFullscreenViewerEnabled(isEnabled: boolean) {
return this._writeSetting("fullscreenViewer", isEnabled);
}
async setFullscreenViewerPreviewSize(size: FullscreenViewerSize | string) {
return this._writeSetting('fullscreenViewerSize', size as FullscreenViewerSize);
}
}

View File

@@ -1,42 +0,0 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings.js";
export default class SearchSettings extends CacheableSettings {
constructor() {
super("search");
}
async resolvePropertiesSuggestionsEnabled() {
return this._resolveSetting("suggestProperties", false);
}
async resolvePropertiesSuggestionsPosition() {
return this._resolveSetting("suggestPropertiesPosition", "start");
}
async setPropertiesSuggestions(isEnabled) {
return this._writeSetting("suggestProperties", isEnabled);
}
async setPropertiesSuggestionsPosition(position) {
return this._writeSetting("suggestPropertiesPosition", position);
}
/**
* @param {function(SearchSettingsObject): void} callback
* @return {function(): void}
*/
subscribe(callback) {
return super.subscribe(rawSettings => {
callback({
suggestProperties: rawSettings.suggestProperties ?? false,
suggestPropertiesPosition: rawSettings.suggestPropertiesPosition ?? "start",
});
});
}
}
/**
* @typedef {Object} SearchSettingsObject
* @property {boolean} suggestProperties
* @property {"start"|"end"} suggestPropertiesPosition
*/

View File

@@ -0,0 +1,28 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings.ts";
interface SearchSettingsFields {
suggestProperties: boolean;
suggestPropertiesPosition: "start" | "end";
}
export default class SearchSettings extends CacheableSettings<SearchSettingsFields> {
constructor() {
super("search");
}
async resolvePropertiesSuggestionsEnabled() {
return this._resolveSetting("suggestProperties", false);
}
async resolvePropertiesSuggestionsPosition() {
return this._resolveSetting("suggestPropertiesPosition", "start");
}
async setPropertiesSuggestions(isEnabled: boolean) {
return this._writeSetting("suggestProperties", isEnabled);
}
async setPropertiesSuggestionsPosition(position: "start" | "end") {
return this._writeSetting("suggestPropertiesPosition", position);
}
}

View File

@@ -0,0 +1,33 @@
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
type ExportersMap = {
[EntityName in keyof App.EntityNamesMap]: (entity: App.EntityNamesMap[EntityName]) => Record<string, any>
};
const entitiesExporters: ExportersMap = {
profiles: entity => {
return {
v: 1,
id: entity.id,
name: entity.settings.name,
tags: entity.settings.tags,
}
},
groups: entity => {
return {
v: 1,
id: entity.id,
name: entity.settings.name,
tags: entity.settings.tags,
prefixes: entity.settings.prefixes,
}
}
};
export function exportEntityToObject(entityInstance: StorageEntity<any>, entityName: string): Record<string, any> {
if (!(entityName in entitiesExporters) || !entitiesExporters.hasOwnProperty(entityName)) {
throw new Error(`Missing exporter for entity: ${entityName}`);
}
return entitiesExporters[entityName as keyof App.EntityNamesMap].call(null, entityInstance);
}

View File

@@ -0,0 +1,39 @@
/**
* Map of validators for each entity. Function should throw the error if validation failed.
* @type {Map<keyof App.EntityNamesMap|string, ((importedObject: Object) => void)>}
*/
const entitiesValidators = new Map([
['profiles', importedObject => {
if (importedObject.v !== 1) {
throw new Error('Unsupported version!');
}
if (
!importedObject.id
|| typeof importedObject.id !== "string"
|| !importedObject.name
|| typeof importedObject.name !== "string"
|| !importedObject.tags
|| !Array.isArray(importedObject.tags)
) {
throw new Error('Invalid profile format detected!');
}
}]
])
/**
* Validate the structure of the entity.
* @param {Object} importedObject Object imported from JSON.
* @param {string} entityName Name of the entity to validate. Should be loaded from the entity class.
* @throws {Error} Error in case validation failed with the reason stored in the message.
*/
export function validateImportedEntity(importedObject, entityName) {
if (!entitiesValidators.has(entityName)) {
console.error(`Trying to validate entity without the validator present! Entity name: ${entityName}`);
return;
}
entitiesValidators
.get(entityName)
.call(null, importedObject);
}

43
src/lib/utils.js Normal file
View File

@@ -0,0 +1,43 @@
/**
* Traverse and find the object using the key path.
* @param {Object} targetObject Target object to traverse into.
* @param {string[]} path Path of keys to traverse deep into the object.
* @return {Object|null} Resulting object or null if nothing found (or target entry is not an object.
*/
export function findDeepObject(targetObject, path) {
let result = targetObject;
for (let key of path) {
if (!result || typeof result !== 'object') {
return null;
}
result = result[key];
}
if (!result || typeof result !== "object") {
return null;
}
return result;
}
/**
* Matches all the characters needing replacement.
*
* Gathered from right here: https://stackoverflow.com/a/3561711/16048617. Because I don't want to introduce some
* library for that.
*
* @type {RegExp}
*/
const unsafeRegExpCharacters = /[/\-\\^$*+?.()|[\]{}]/g;
/**
* Escape all the RegExp syntax-related characters in the following value.
* @param {string} value Original value.
* @return {string} Resulting value with all needed characters escaped.
*/
export function escapeRegExp(value) {
unsafeRegExpCharacters.lastIndex = 0;
return value.replace(unsafeRegExpCharacters, "\\$&");
}

View File

@@ -1,10 +1,28 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store.js";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
/** @type {import('$entities/MaintenanceProfile.ts').default|undefined} */
let activeProfile;
$: activeProfile = $maintenanceProfilesStore.find(profile => profile.id === $activeProfileStore);
function turnOffActiveProfile() {
$activeProfileStore = null;
}
</script>
<Menu>
<MenuItem href="/settings/maintenance">Tagging Profiles</MenuItem>
{#if activeProfile}
<MenuCheckboxItem checked on:input={turnOffActiveProfile} href="/features/maintenance/{activeProfile.id}">
Active Profile: {activeProfile.settings.name}
</MenuCheckboxItem>
<hr>
{/if}
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
<MenuItem href="/features/groups">Tag Groups</MenuItem>
<hr>
<MenuItem href="/preferences">Preferences</MenuItem>
<MenuItem href="/about">About</MenuItem>

View File

@@ -6,5 +6,5 @@
<Menu>
<MenuItem href="/">Back</MenuItem>
<hr>
<MenuItem href="/settings/maintenance">Tagging Profiles</MenuItem>
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
</Menu>

View File

@@ -0,0 +1,23 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagGroupsStore } from "$stores/tag-groups-store.js";
/** @type {import('$entities/TagGroup.ts').default[]} */
let groups = [];
$: groups = $tagGroupsStore.sort((a, b) => a.settings.name.localeCompare(b.settings.name));
</script>
<Menu>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/groups/new/edit" icon="plus">Create New</MenuItem>
{#if groups.length}
<hr>
{#each groups as group}
<MenuItem href="/features/groups/{group.id}">{group.settings.name}</MenuItem>
{/each}
{/if}
<hr>
<MenuItem href="/features/groups/import">Import Group</MenuItem>
</Menu>

View File

@@ -0,0 +1,39 @@
<script>
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import GroupView from "$components/features/GroupView.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagGroupsStore } from "$stores/tag-groups-store.js";
const groupId = $page.params.id;
/** @type {import('$entities/TagGroup.ts').default|null} */
let group = null;
if (groupId==='new') {
goto('/features/groups/new/edit');
}
$: {
group = $tagGroupsStore.find(group => group.id===groupId) || null;
if (!group) {
console.warn(`Group ${ groupId } not found.`);
goto('/features/groups');
}
}
</script>
<Menu>
<MenuItem href="/features/groups" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if group}
<GroupView {group}/>
{/if}
<Menu>
<hr>
<MenuItem href="/features/groups/{groupId}/edit" icon="wrench">Edit Group</MenuItem>
<MenuItem href="/features/groups/{groupId}/export" icon="file-export">Export Group</MenuItem>
<MenuItem href="/features/groups/{groupId}/delete" icon="trash">Delete Group</MenuItem>
</Menu>

View File

@@ -0,0 +1,41 @@
<script>
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagGroupsStore } from "$stores/tag-groups-store.js";
const groupId = $page.params.id;
const targetGroup = $tagGroupsStore.find(group => group.id===groupId);
if (!targetGroup) {
void goto('/features/groups');
}
async function deleteGroup() {
if (!targetGroup) {
console.warn('Attempting to delete the group, but the group is not loaded yet.');
return;
}
await targetGroup.delete();
await goto('/features/groups');
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/features/groups/{groupId}">Back</MenuItem>
<hr>
</Menu>
{#if targetGroup}
<p>
Do you want to remove group "{targetGroup.settings.name}"? This action is irreversible.
</p>
<Menu>
<hr>
<MenuItem on:click={deleteGroup}>Yes</MenuItem>
<MenuItem href="/features/groups/{groupId}">No</MenuItem>
</Menu>
{:else}
<p>Loading...</p>
{/if}

View File

@@ -0,0 +1,82 @@
<script>
import {goto} from "$app/navigation";
import {page} from "$app/stores";
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import TagCategorySelectField from "$components/ui/forms/TagCategorySelectField.svelte";
import TextField from "$components/ui/forms/TextField.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagsEditor from "$components/web-components/TagsEditor.svelte";
import TagGroup from "$entities/TagGroup.ts";
import {tagGroupsStore} from "$stores/tag-groups-store.js";
const groupId = $page.params.id;
/** @type {TagGroup|null} */
let targetGroup = null;
let groupName = '';
/** @type {string[]} */
let tagsList = [];
/** @type {string[]} */
let prefixesList = [];
let tagCategory = '';
if (groupId==='new') {
targetGroup = new TagGroup(crypto.randomUUID(), {});
} else {
targetGroup = $tagGroupsStore.find(group => group.id===groupId) || null;
if (targetGroup) {
groupName = targetGroup.settings.name;
tagsList = [...targetGroup.settings.tags].sort((a, b) => a.localeCompare(b));
prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b));
tagCategory = targetGroup.settings.category;
} else {
goto('/features/groups');
}
}
async function saveGroup() {
if (!targetGroup) {
console.warn('Attempting to save group, but group is not loaded yet.');
return;
}
targetGroup.settings.name = groupName;
targetGroup.settings.tags = [...tagsList];
targetGroup.settings.prefixes = [...prefixesList];
targetGroup.settings.category = tagCategory;
await targetGroup.save();
await goto(`/features/groups/${targetGroup.id}`);
}
</script>
<Menu>
<MenuItem href="/features/groups/${groupId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Group Name">
<TextField bind:value={groupName} placeholder="Group Name"></TextField>
</FormControl>
<FormControl label="Group Color">
<TagCategorySelectField bind:value={tagCategory}/>
</FormControl>
<TagsColorContainer targetCategory="{tagCategory}">
<FormControl label="Tags">
<TagsEditor bind:tags={tagsList}/>
</FormControl>
</TagsColorContainer>
<TagsColorContainer targetCategory="{tagCategory}">
<FormControl label="Tag Prefixes">
<TagsEditor bind:tags={prefixesList}/>
</FormControl>
</TagsColorContainer>
</FormContainer>
<Menu>
<hr>
<MenuItem on:click={saveGroup}>Save Group</MenuItem>
</Menu>

View File

@@ -0,0 +1,50 @@
<script>
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagGroup from "$entities/TagGroup.ts";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter.ts";
import { tagGroupsStore } from "$stores/tag-groups-store.js";
const groupId = $page.params.id;
const groupTransporter = new EntitiesTransporter(TagGroup);
const group = $tagGroupsStore.find(group => group.id===groupId);
/** @type {string} */
let rawExportedGroup;
/** @type {string} */
let encodedExportedGroup;
if (!group) {
goto('/features/groups');
} else {
rawExportedGroup = groupTransporter.exportToJSON(group);
encodedExportedGroup = groupTransporter.exportToCompressedJSON(group);
}
let isEncodedGroupShown = true;
</script>
<Menu>
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Export string">
<textarea readonly rows="6">{isEncodedGroupShown ? encodedExportedGroup : rawExportedGroup}</textarea>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem on:click={() => isEncodedGroupShown = !isEncodedGroupShown}>
Switch Format:
{#if isEncodedGroupShown}
Base64-Encoded
{:else}
Raw JSON
{/if}
</MenuItem>
</Menu>

View File

@@ -0,0 +1,134 @@
<script>
import { goto } from "$app/navigation";
import GroupView from "$components/features/GroupView.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagGroup from "$entities/TagGroup.ts";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter.ts";
import { tagGroupsStore } from "$stores/tag-groups-store.js";
const groupTransporter = new EntitiesTransporter(TagGroup);
/** @type {string} */
let importedString = '';
/** @type {string} */
let errorMessage = '';
/** @type {TagGroup|null} */
let candidateGroup = null;
/** @type {TagGroup|null} */
let existingGroup = null;
function tryImportingGroup() {
candidateGroup = null;
existingGroup = null;
errorMessage = '';
importedString = importedString.trim();
if (!importedString) {
errorMessage = 'Nothing to import.';
return;
}
try {
if (importedString.trim().startsWith('{')) {
candidateGroup = groupTransporter.importFromJSON(importedString);
}
candidateGroup = groupTransporter.importFromCompressedJSON(importedString);
} catch (error) {
errorMessage = error instanceof Error
? error.message
:'Unknown error';
}
if (candidateGroup) {
existingGroup = $tagGroupsStore.find(group => group.id===candidateGroup?.id) ?? null;
}
}
function saveGroup() {
if (!candidateGroup) {
return;
}
candidateGroup.save().then(() => {
goto(`/features/groups`);
});
}
function cloneAndSaveGroup() {
if (!candidateGroup) {
return;
}
const clonedProfile = new TagGroup(crypto.randomUUID(), candidateGroup.settings);
clonedProfile.settings.name += ` (Clone ${ new Date().toISOString() })`;
clonedProfile.save().then(() => {
goto(`/features/groups`);
});
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/features/groups">Back</MenuItem>
<hr>
</Menu>
{#if errorMessage}
<p class="error">Failed to import: {errorMessage}</p>
<Menu>
<hr>
</Menu>
{/if}
{#if !candidateGroup}
<FormContainer>
<FormControl label="Import string">
<textarea bind:value={importedString} rows="6"></textarea>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem on:click={tryImportingGroup}>Import</MenuItem>
</Menu>
{:else}
{#if existingGroup}
<p class="warning">
This group will replace the existing "{existingGroup.settings.name}" group, since it have the same ID.
</p>
{/if}
<GroupView group="{candidateGroup}"></GroupView>
<Menu>
<hr>
{#if existingGroup}
<MenuItem on:click={saveGroup}>Replace Existing Group</MenuItem>
<MenuItem on:click={cloneAndSaveGroup}>Save as New Group</MenuItem>
{:else}
<MenuItem on:click={saveGroup}>Import New Group</MenuItem>
{/if}
<MenuItem on:click={() => candidateGroup = null}>Cancel</MenuItem>
</Menu>
{/if}
<style lang="scss">
@use '$styles/colors';
.error, .warning {
padding: 5px 24px;
margin: {
left: -24px;
right: -24px;
}
}
.error {
background: colors.$error-background;
}
.warning {
background: colors.$warning-background;
margin-bottom: .5em;
}
</style>

View File

@@ -4,7 +4,7 @@
import MenuRadioItem from "$components/ui/menu/MenuRadioItem.svelte";
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default[]} */
/** @type {import('$entities/MaintenanceProfile.ts').default[]} */
let profiles = [];
$: profiles = $maintenanceProfilesStore.sort((a, b) => a.settings.name.localeCompare(b.settings.name));
@@ -27,12 +27,12 @@
<Menu>
<MenuItem icon="arrow-left" href="/">Back</MenuItem>
<MenuItem icon="plus" href="/settings/maintenance/new/edit">Create New</MenuItem>
<MenuItem icon="plus" href="/features/maintenance/new/edit">Create New</MenuItem>
{#if profiles.length}
<hr>
{/if}
{#each profiles as profile}
<MenuRadioItem href="/settings/maintenance/{profile.id}"
<MenuRadioItem href="/features/maintenance/{profile.id}"
name="active-profile"
value="{profile.id}"
checked="{$activeProfileStore === profile.id}"
@@ -42,5 +42,5 @@
{/each}
<hr>
<MenuItem href="#" on:click={resetActiveProfile}>Reset Active Profile</MenuItem>
<MenuItem href="/settings/maintenance/import">Import Profile</MenuItem>
<MenuItem href="/features/maintenance/import">Import Profile</MenuItem>
</Menu>

View File

@@ -0,0 +1,64 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import {page} from "$app/stores";
import {goto} from "$app/navigation";
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
import ProfileView from "$components/maintenance/ProfileView.svelte";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
const profileId = $page.params.id;
/** @type {import('$entities/MaintenanceProfile.ts').default|null} */
let profile = null;
if (profileId==='new') {
goto('/features/maintenance/new/edit');
}
$: {
const resolvedProfile = $maintenanceProfilesStore.find(profile => profile.id===profileId);
if (resolvedProfile) {
profile = resolvedProfile;
} else {
console.warn(`Profile ${profileId} not found.`);
goto('/features/maintenance');
}
}
let isActiveProfile = $activeProfileStore===profileId;
$: {
if (isActiveProfile && $activeProfileStore!==profileId) {
$activeProfileStore = profileId;
}
if (!isActiveProfile && $activeProfileStore===profileId) {
$activeProfileStore = null;
}
}
</script>
<Menu>
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if profile}
<ProfileView {profile}/>
{/if}
<Menu>
<hr>
<MenuItem icon="wrench" href="/features/maintenance/{profileId}/edit">Edit Profile</MenuItem>
<MenuCheckboxItem bind:checked={isActiveProfile}>
Activate Profile
</MenuCheckboxItem>
<MenuItem icon="file-export" href="/features/maintenance/{profileId}/export">
Export Profile
</MenuItem>
<MenuItem icon="trash" href="/features/maintenance/{profileId}/delete">
Delete Profile
</MenuItem>
</Menu>
<style lang="scss">
</style>

View File

@@ -0,0 +1,41 @@
<script>
import { goto } from "$app/navigation";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { page } from "$app/stores";
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store.js";
const profileId = $page.params.id;
const targetProfile = $maintenanceProfilesStore.find(profile => profile.id===profileId);
if (!targetProfile) {
void goto('/features/maintenance');
}
async function deleteProfile() {
if (!targetProfile) {
console.warn('Attempting to delete the profile, but the profile is not loaded yet.');
return;
}
await targetProfile.delete();
await goto('/features/maintenance');
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/features/maintenance/{profileId}">Back</MenuItem>
<hr>
</Menu>
{#if targetProfile}
<p>
Do you want to remove profile "{targetProfile.settings.name}"? This action is irreversible.
</p>
<Menu>
<hr>
<MenuItem on:click={deleteProfile}>Yes</MenuItem>
<MenuItem href="/features/maintenance/{profileId}">No</MenuItem>
</Menu>
{:else}
<p>Loading...</p>
{/if}

View File

@@ -8,7 +8,7 @@
import {page} from "$app/stores";
import {goto} from "$app/navigation";
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
/** @type {string} */
let profileId = $page.params.id;
@@ -30,7 +30,7 @@
profileName = targetProfile.settings.name;
tagsList = [...targetProfile.settings.tags].sort((a, b) => a.localeCompare(b));
} else {
goto('/settings/maintenance');
goto('/features/maintenance');
}
}
@@ -42,24 +42,15 @@
targetProfile.settings.name = profileName;
targetProfile.settings.tags = [...tagsList];
targetProfile.settings.temporary = false;
await targetProfile.save();
await goto('/settings/maintenance/' + targetProfile.id);
}
async function deleteProfile() {
if (!targetProfile) {
console.warn('Attempting to delete the profile, but the profile is not loaded yet.');
return;
}
await targetProfile.delete();
await goto('/settings/maintenance');
await goto('/features/maintenance/' + targetProfile.id);
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/settings/maintenance{profileId === 'new' ? '' : '/' + profileId}">
<MenuItem icon="arrow-left" href="/features/maintenance{profileId === 'new' ? '' : '/' + profileId}">
Back
</MenuItem>
<hr>
@@ -75,7 +66,4 @@
<Menu>
<hr>
<MenuItem href="#" on:click={saveProfile}>Save Profile</MenuItem>
{#if profileId !== 'new'}
<MenuItem href="#" on:click={deleteProfile}>Delete Profile</MenuItem>
{/if}
</Menu>

View File

@@ -1,36 +1,35 @@
<script>
import {page} from "$app/stores";
import {goto} from "$app/navigation";
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store.js";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter.ts";
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
const profileId = $page.params.id;
/**
* @type {import('$lib/extension/entities/MaintenanceProfile.js').default|undefined}
*/
const profile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
/** @type {string} */
let exportedProfile = '';
/** @type {string} */
let compressedProfile = '';
if (!profile) {
goto('/settings/maintenance/');
goto('/features/maintenance/');
} else {
exportedProfile = profile.toJSON();
compressedProfile = profile.toCompressedJSON();
exportedProfile = profilesTransporter.exportToJSON(profile);
compressedProfile = profilesTransporter.exportToCompressedJSON(profile);
}
let isCompressedProfileShown = true;
</script>
<Menu>
<MenuItem href="/settings/maintenance/{profileId}" icon="arrow-left">
<MenuItem href="/features/maintenance/{profileId}" icon="arrow-left">
Back
</MenuItem>
<hr>

View File

@@ -2,11 +2,14 @@
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import FormControl from "$components/ui/forms/FormControl.svelte";
import ProfileView from "$components/maintenance/ProfileView.svelte";
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
import {goto} from "$app/navigation";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter.ts";
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
/** @type {string} */
let importedString = '';
@@ -32,10 +35,10 @@
try {
if (importedString.trim().startsWith('{')) {
candidateProfile = MaintenanceProfile.importFromJSON(importedString);
candidateProfile = profilesTransporter.importFromJSON(importedString);
}
candidateProfile = MaintenanceProfile.importFromCompressedJSON(importedString);
candidateProfile = profilesTransporter.importFromCompressedJSON(importedString);
} catch (error) {
errorMessage = error instanceof Error
? error.message
@@ -53,7 +56,7 @@
}
candidateProfile.save().then(() => {
goto(`/settings/maintenance`);
goto(`/features/maintenance`);
});
}
@@ -65,13 +68,13 @@
const clonedProfile = new MaintenanceProfile(crypto.randomUUID(), candidateProfile.settings);
clonedProfile.settings.name += ` (Clone ${new Date().toISOString()})`;
clonedProfile.save().then(() => {
goto(`/settings/maintenance`);
goto(`/features/maintenance`);
});
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/settings/maintenance">Back</MenuItem>
<MenuItem icon="arrow-left" href="/features/maintenance">Back</MenuItem>
<hr>
</Menu>
{#if errorMessage}
@@ -110,7 +113,7 @@
{/if}
<style lang="scss">
@use '../../../../styles/colors';
@use '$styles/colors';
.error, .warning {
padding: 5px 24px;

View File

@@ -6,6 +6,9 @@
<Menu>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/preferences/tags">Tagging</MenuItem>
<MenuItem href="/preferences/search">Search</MenuItem>
<MenuItem href="/preferences/misc">Misc & Tools</MenuItem>
<hr>
<MenuItem href="/preferences/debug">Debug</MenuItem>
</Menu>

View File

@@ -0,0 +1,10 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
</script>
<Menu>
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/preferences/debug/storage">Inspect Storages</MenuItem>
</Menu>

View File

@@ -0,0 +1,13 @@
<script>
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import {storagesCollection} from "$stores/debug.js";
</script>
<Menu>
<MenuItem href="/preferences/debug" icon="arrow-left">Back</MenuItem>
<hr>
{#each Object.keys($storagesCollection) as storageName}
<MenuItem href="/preferences/debug/storage/{storageName}/">Storage: {storageName}</MenuItem>
{/each}
</Menu>

View File

@@ -0,0 +1,29 @@
<script>
import StorageViewer from "$components/debugging/StorageViewer.svelte";
import {page} from "$app/stores";
import {goto} from "$app/navigation";
let pathString = '';
/** @type {string[]} */
let pathArray = [];
/** @type {string|undefined} */
let storageName = void 0;
$: {
pathString = $page.params.path;
pathArray = pathString.length ? pathString.split("/") : [];
storageName = pathArray.shift()
if (pathArray.length && pathArray[pathArray.length - 1] === '') {
pathArray.pop();
}
if (!storageName) {
goto("/preferences/debug/storage");
}
}
</script>
{#if storageName}
<StorageViewer storage="{storageName}" path="{pathArray}"></StorageViewer>
{/if}

View File

@@ -0,0 +1,20 @@
<script>
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { stripBlacklistedTagsEnabled } from "$stores/maintenance-preferences.ts";
</script>
<Menu>
<MenuItem icon="arrow-left" href="/preferences">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl>
<CheckboxField bind:checked={$stripBlacklistedTagsEnabled}>
Automatically remove black-listed tags from the images
</CheckboxField>
</FormControl>
</FormContainer>

View File

@@ -1,63 +0,0 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import {page} from "$app/stores";
import {goto} from "$app/navigation";
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
import ProfileView from "$components/maintenance/ProfileView.svelte";
const profileId = $page.params.id;
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default|null} */
let profile = null;
let isActiveProfile = false;
if (profileId === 'new') {
goto('/maintenance/profiles/new/edit');
}
$: {
const resolvedProfile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
if (resolvedProfile) {
profile = resolvedProfile;
} else {
console.warn(`Profile ${profileId} not found.`);
goto('/settings/maintenance');
}
}
$: isActiveProfile = $activeProfileStore === profileId;
function activateProfile() {
if (isActiveProfile) {
return;
}
$activeProfileStore = profileId;
}
</script>
<Menu>
<MenuItem href="/settings/maintenance" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if profile}
<ProfileView {profile}/>
{/if}
<Menu>
<hr>
<MenuItem icon="wrench" href="/settings/maintenance/{profileId}/edit">Edit Profile</MenuItem>
<MenuItem icon="tag" href="#" on:click={activateProfile}>
{#if isActiveProfile}
<span>Profile is Active</span>
{:else}
<span>Activate Profile</span>
{/if}
</MenuItem>
<MenuItem icon="file-export" href="/settings/maintenance/{profileId}/export">
Export Profile
</MenuItem>
</Menu>
<style lang="scss">
</style>

21
src/stores/debug.js Normal file
View File

@@ -0,0 +1,21 @@
import {writable} from "svelte/store";
/**
* This is readable version of storages. Any changes made to these objects will not be sent to the local storage.
* @type {Writable<Record<string, Object>>}
*/
export const storagesCollection = writable({});
chrome.storage.local.get(storages => {
storagesCollection.set(storages);
});
chrome.storage.local.onChanged.addListener(changes => {
storagesCollection.update(storages => {
for (let updatedStorageName of Object.keys(changes)) {
storages[updatedStorageName] = changes[updatedStorageName].newValue;
}
return storages;
})
});

View File

@@ -0,0 +1,18 @@
import {writable} from "svelte/store";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts";
export const stripBlacklistedTagsEnabled = writable(true);
const maintenanceSettings = new MaintenanceSettings();
Promise
.all([
maintenanceSettings.resolveStripBlacklistedTags().then(v => stripBlacklistedTagsEnabled.set(v ?? true))
])
.then(() => {
maintenanceSettings.subscribe(settings => {
stripBlacklistedTagsEnabled.set(typeof settings.stripBlacklistedTags === 'boolean' ? settings.stripBlacklistedTags : true);
});
stripBlacklistedTagsEnabled.subscribe(v => maintenanceSettings.setStripBlacklistedTags(v));
});

View File

@@ -1,6 +1,6 @@
import {writable} from "svelte/store";
import MaintenanceProfile from "$lib/extension/entities/MaintenanceProfile.js";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js";
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts";
/**
* Store for working with maintenance profiles in the Svelte popup.

View File

@@ -1,5 +1,5 @@
import {writable} from "svelte/store";
import MiscSettings from "$lib/extension/settings/MiscSettings.js";
import MiscSettings from "$lib/extension/settings/MiscSettings.ts";
export const fullScreenViewerEnabled = writable(true);
@@ -10,5 +10,9 @@ Promise.allSettled([
]).then(() => {
fullScreenViewerEnabled.subscribe(value => {
void miscSettings.setFullscreenViewerEnabled(value);
})
});
miscSettings.subscribe(settings => {
fullScreenViewerEnabled.set(Boolean(settings.fullscreenViewer));
});
});

View File

@@ -1,5 +1,5 @@
import {writable} from "svelte/store";
import SearchSettings from "$lib/extension/settings/SearchSettings.js";
import SearchSettings from "$lib/extension/settings/SearchSettings.ts";
export const searchPropertiesSuggestionsEnabled = writable(false);
@@ -21,4 +21,9 @@ Promise.allSettled([
searchPropertiesSuggestionsPosition.subscribe(value => {
void searchSettings.setPropertiesSuggestionsPosition(value);
});
searchSettings.subscribe(settings => {
searchPropertiesSuggestionsEnabled.set(Boolean(settings.suggestProperties));
searchPropertiesSuggestionsPosition.set(settings.suggestPropertiesPosition || 'start');
});
})

View File

@@ -0,0 +1,12 @@
import {writable} from "svelte/store";
import TagGroup from "$entities/TagGroup.ts";
/** @type {import('svelte/store').Writable<TagGroup[]>} */
export const tagGroupsStore = writable([]);
TagGroup
.readAll()
.then(groups => tagGroupsStore.set(groups))
.then(() => {
TagGroup.subscribe(groups => tagGroupsStore.set(groups));
});

View File

@@ -1,3 +1,5 @@
@use 'sass:color';
$background: #15121a;
$text: #dadada;
@@ -25,6 +27,27 @@ $tag-background: #1b3c21;
$tag-count-background: #2d6236;
$tag-text: #4aa158;
$tag-rating-text: #418dd9;
$tag-rating-background: color.adjust($tag-rating-text, $lightness: -35%);
$tag-spoiler-text: #d49b39;
$tag-spoiler-background: color.adjust($tag-spoiler-text, $lightness: -34%);
$tag-origin-text: #6f66d6;
$tag-origin-background: color.adjust($tag-origin-text, $lightness: -40%);
$tag-oc-text: #b157b7;
$tag-oc-background: color.adjust($tag-oc-text, $lightness: -33%);
$tag-error-text: #d45460;
$tag-error-background: color.adjust($tag-error-text, $lightness: -38%, $saturation: -6%, $space: hsl);
$tag-character-text: #4aaabf;
$tag-character-background: color.adjust($tag-character-text, $lightness: -33%);
$tag-content-official-text: #b9b541;
$tag-content-official-background: color.adjust($tag-content-official-text, $lightness: -29%, $saturation: -2%, $space: hsl);
$tag-content-fanmade-text: #cc8eb5;
$tag-content-fanmade-background: color.adjust($tag-content-fanmade-text, $lightness: -40%);
$tag-species-text: #b16b50;
$tag-species-background: color.adjust($tag-species-text, $lightness: -35%);
$tag-body-type-text: #b8b8b8;
$tag-body-type-background: color.adjust($tag-body-type-text, $lightness: -35%, $saturation: -10%, $space: hsl);
$input-background: #26232d;
$input-border: #5c5a61;

View File

@@ -0,0 +1,9 @@
.autocomplete {
&__item {
&--property {
i {
margin-right: .5em;
}
}
}
}

View File

@@ -70,6 +70,11 @@
color: colors.$tag-background;
}
&[data-tag-category=error]:hover {
background: colors.$tag-error-text;
color: colors.$tag-error-background;
}
&.is-missing:not(.is-added),
&.is-present.is-removed {
opacity: 0.5;
@@ -160,9 +165,9 @@
.fullscreen-viewer {
pointer-events: none;
z-index: 9999;
opacity: 0;
opacity: var(--opacity, 0);
background-color: black;
transition: opacity 0.1s;
transition: opacity 0.1s, transform 0.1s;
position: fixed;
left: 0;
right: 0;
@@ -171,15 +176,70 @@
display: flex;
justify-content: stretch;
align-items: stretch;
transform: translateY(var(--offset, 0));
img, video {
object-fit: contain;
width: 100%;
height: 100%;
opacity: 1;
}
.spinner {
position: fixed;
opacity: 0;
left: 50vw;
top: 50vh;
transform: translate(-50%, -50%);
font-size: 64px;
text-shadow: 0 0 15px black;
}
img, video, .spinner {
transition: opacity .25s ease;
}
.size-selector {
position: absolute;
top: 5px;
left: 5px;
z-index: 1;
}
.close {
position: absolute;
top: 5px;
right: 5px;
z-index: 1;
padding: 5px;
background-color: colors.$text;
color: colors.$background;
font-size: 20px;
line-height: 20px;
width: 20px;
height: 20px;
text-align: center;
display: block;
cursor: pointer;
}
&.shown {
opacity: 1;
opacity: var(--opacity, 1);
pointer-events: initial;
}
&.swiped {
opacity: var(--opacity, 1);
transition: none;
}
&.loading {
img, video {
opacity: 0.25;
}
.spinner {
opacity: 1;
}
}
}

View File

@@ -0,0 +1,40 @@
@use '../colors';
* {
box-sizing: border-box;
margin: 0;
padding: 0;
text-decoration: none;
color: inherit;
font-family: inherit;
font-size: inherit;
}
body {
width: 320px;
// Hacky class which is added by the JavaScript indicating that page was (probably) opened in the tab
&.is-in-tab {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
}
html, body {
background-color: colors.$background;
color: colors.$text;
font-size: 16px;
font-family: verdana, arial, helvetica, sans-serif;
margin: 0;
padding: 0;
}
a {
color: colors.$link;
text-decoration: none;
&:hover, &:focus {
color: colors.$link-hover;
}
}

View File

@@ -1,6 +1,6 @@
@mixin insert-icon($icon_src) {
mask-image: url($icon_src);
-webkit-mask-image: url($icon_src);
@mixin insert-icon($url) {
mask-image: $url;
-webkit-mask-image: $url;
}
.icon {
@@ -11,33 +11,37 @@
}
.icon.icon-tag {
@include insert-icon('/img/tag.svg');
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/tag.svg'));
}
.icon.icon-paint-brush {
@include insert-icon('/img/paint-brush.svg');
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/paintbrush.svg'));
}
.icon.icon-arrow-left {
@include insert-icon('/img/arrow-left.svg');
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/arrow-left.svg'));
}
.icon.icon-info-circle {
@include insert-icon('/img/info-circle.svg');
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/circle-info.svg'));
}
.icon.icon-wrench {
@include insert-icon('/img/wrench.svg');
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/wrench.svg'));
}
.icon.icon-globe {
@include insert-icon('/img/globe.svg');
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/globe.svg'));
}
.icon.icon-plus {
@include insert-icon('/img/plus.svg');
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/plus.svg'));
}
.icon.icon-file-export {
@include insert-icon('/img/file-export.svg');
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/file-export.svg'));
}
.icon.icon-trash {
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/trash.svg'));
}

View File

@@ -1,44 +1,5 @@
@use './colors';
* {
box-sizing: border-box;
margin: 0;
padding: 0;
text-decoration: none;
color: inherit;
font-family: inherit;
font-size: inherit;
}
body {
width: 320px;
// Hacky class which is added by the JavaScript indicating that page was (probably) opened in the tab
&.is-in-tab {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
}
html, body {
background-color: colors.$background;
color: colors.$text;
font-size: 16px;
font-family: verdana, arial, helvetica, sans-serif;
margin: 0;
padding: 0;
}
a {
color: colors.$link;
text-decoration: none;
&:hover, &:focus {
color: colors.$link-hover;
}
}
@import "injectable/input";
@import "injectable/tag";
@import "injectable/icons";
@use 'colors';
@use 'injectable/base-styles';
@use 'injectable/input';
@use 'injectable/tag';
@use 'injectable/icons';

View File

@@ -1,6 +0,0 @@
<svg width="32" height="32" version="1.1" viewBox="0 -256 1472 1558" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,-1,-64,1099)">
<path d="m1536 640v-128q0-53-32.5-90.5t-84.5-37.5h-704l293-294q38-36 38-90t-38-90l-75-76q-37-37-90-37-52 0-91 37l-651 652q-37 37-37 90 0 52 37 91l651 650q38 38 91 38 52 0 90-38l75-74q38-38 38-91t-38-91l-293-293h704q52 0 84.5-37.5t32.5-90.5z"
fill="currentColor"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 453 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
<path d="M384 121.9c0-6.3-2.5-12.4-7-16.9L279.1 7c-4.5-4.5-10.6-7-17-7H256v128h128v-6.1zM192 336v-32c0-8.84 7.16-16 16-16h176V160H248c-13.2 0-24-10.8-24-24V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V352H208c-8.84 0-16-7.16-16-16zm379.05-28.02l-95.7-96.43c-10.06-10.14-27.36-3.01-27.36 11.27V288H384v64h63.99v65.18c0 14.28 17.29 21.41 27.36 11.27l95.7-96.42c6.6-6.66 6.6-17.4 0-24.05z"/>
</svg>

Before

Width:  |  Height:  |  Size: 492 B

View File

@@ -1,6 +0,0 @@
<svg width="32" height="32" version="1.1" viewBox="0 -256 1536 1536" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,-1,0,1152)">
<path d="m1193 993q11 7 25 22v-1q0-2-9.5-10t-11.5-12q-1 1-4 1zm-6-1q-1 1-2.5 3t-1.5 3q3-2 10-5-6-4-6-1zm-459 183q-16 2-26 5 1 0 6.5-1t10.5-2 9-2zm45 37q7 4 13.5 2.5t7.5-7.5q-5 3-21 5zm-8-6-3 2q-2 3-5.5 5t-4.5 2q2-1 21-3-6-4-8-6zm-102 84v2q1-2 3-5.5t3-5.5zm-105-40q0-2-1-2l-1 2zm375-1044v-1zm-165 1202q209 0 385.5-103t279.5-279.5 103-385.5-103-385.5-279.5-279.5-385.5-103-385.5 103-279.5 279.5-103 385.5 103 385.5 279.5 279.5 385.5 103zm472-1246 5 5q-7 10-29 12 1 12-14 26.5t-27 15.5q0 4-10.5 11t-17.5 8q-9 2-27-9-7-3-4-5-3 3-12 11t-16 11q-2 1-7.5 1t-8.5 2q-1 1-6 4.5t-7 4.5-6.5 3-7.5 1.5-7.5-2.5-8.5-6-4.5-15.5-2.5-14.5q-8 6-0.5 20t1.5 20q-7 7-21 0.5t-21-15.5q-1-1-9.5-5.5t-11.5-7.5q-4-6-9-17.5t-6-13.5q0 2-2.5 6.5t-2.5 6.5q-12-2-16 3 5-16 8-17l-4 2q-1-6 3-15t4-11q1-5-1.5-13t-2.5-11q0-2 5-11 4-19-2-32 0-1-3.5-7t-6.5-11l-2-5-2 1q-1 1-2 0-1-6-9-13t-10-11q-15-23-9-38 3-8 10-10 3-1 3 2 1-9-11-27 1-1 4-3-17 0-10-14 202 36 352 181h-3zm-560 185q16 3 30.5-16t22.5-23q41-20 59-11 0-9 14-28 3-4 6.5-11.5t5.5-10.5q5-7 19-16t19-16q6 3 9 9 13-35 24-34 5 0 8 8 0-1-0.5-3t-1.5-3q7 15 5 26l6 4q5 4 5 5-6 6-9-3-30-14-48 22-2 3-4.5 8t-5 12-1.5 11.5 6 4.5q11 0 12.5 1.5t-2.5 6-4 7.5q-1 4-1.5 12.5t-1.5 12.5l-5 6q-5 6-11.5 13.5t-7.5 9.5q-4-10-16.5-8.5t-18.5 9.5q1-2-0.5-6.5t-1.5-6.5q-14 0-17 1 1 6 3 21t4 22q1 5 5.5 13.5t8 15.5 4.5 14-4.5 10.5-18.5 2.5q-20-1-29-22-1-3-3-11.5t-5-12.5-9-7q-8-3-27-2t-26 5q-14 8-24 30.5t-11 41.5q0 10 3 27.5t3 27-6 26.5q3 2 10 10.5t11 11.5q2 2 5 2h5t4 2 3 6q-1 1-4 3-3 3-4 3 4-3 19-1t19 2q0 1 22 0 17-13 24 2 0 1-2.5 10.5t-0.5 14.5q5-29 32-10 3-4 16.5-6t18.5-5q3-2 7-5.5t6-5 6-0.5 9 7q11-17 13-25 11-43 20-48 8-2 12.5-2t5 10.5 0 15.5-1.5 13l-2 37q-16 3-20 12.5t1.5 20 16.5 19.5q1 1 16.5 8t21.5 12q24 19 17 39 9-2 11 9l-5 3q-4 3-8 5.5t-5 1.5q11 7 2 18 5 3 8 11.5t9 11.5q9-14 22-3 8 9 2 18 5 8 22 11.5t20 9.5q5-1 7 0t2 4.5v7.5t1 8.5 3 7.5q4 6 16 10.5t14 5.5l19 12q4 4 0 4 18-2 32 11 13 12-5 23 2 7-4 10.5t-16 5.5q3 1 12 0.5t12 1.5q15 11-7 17-20 5-47-13-3-2-13-12t-17-11q15 18 5 22 8-1 22.5 9t15.5 11q4 2 10.5 2.5t8.5 1.5q71 25 92-1 8 11 11 15t9.5 9 15.5 8q21 7 23 9l1 23q-12-1-18 8t-7 22l-6-8q0 6-3.5 7.5t-7.5 0.5-9.5-2-7.5 0q-9 2-19.5 15.5t-14.5 16.5q9 0 9 5-2 5-10 8 1 6-2 8t-9 0q-2 12-1 13-6 1-11 11t-8 10q-2 0-4.5-2t-5-5.5l-5-7t-3.5-5.5l-2-2q-12 6-24-10-9 1-17-2 15 6 2 13-11 5-21 2 12 5 10 14t-12 16q1 0 4-1t4-1q-1 5-9.5 9.5t-19.5 9-14 6.5q-7 5-36 10.5t-36 1.5q-5-3-6-6t1.5-8.5 3.5-8.5q6-23 5-27-1-3-8.5-8t-5.5-12q1-4 11.5-10t12.5-12q5-13-4-25-4-5-15-11t-14-10q-5-5-3.5-11.5t0.5-9.5q1 1 1 2.5t1 2.5q0-13 11-22 8-6-16-18-20-11-20-4 1 8-7.5 16t-10.5 12-3.5 19-9.5 21q-6 4-19 4t-18-5q0 10-49 30-17 8-58 4 7 1 0 17-8 16-21 12-8 25-4 35 2 5 9 14t9 15q1 3 15.5 6t16.5 8q1 4-2.5 6.5t-9.5 4.5q53-6 63 18 5 9 3 14 0-1 2-1t2-1q12 3 7 17 19 8 26 8 5-1 11-6t10-5q17-3 21.5 10t-9.5 23q7-4 7 6-1 13-7 19-3 2-6.5 2.5t-6.5 0-7 0.5q-1 0-8 2-1-1-2-1h-8q-4-2-4-5v-1q-1-3 4-6l5-1 3-2q-1 0-2.5-2.5t-2.5-2.5q0-3 3-5-2-1-14-7.5t-17-10.5q-1-1-4-2.5t-4-2.5q-2-1-4 2t-4 9-4 11.5-4.5 10-5.5 4.5q-12 0-18-17 3 10-13 17.5t-25 7.5q20 15-9 30l-1 1q-30-4-45-7-2-6 3-12-1-7 6-9 0-1 0.5-1t0.5-1q0 1-0.5 1t-0.5 1q3-1 10.5-1.5t9.5-1.5q3-1 4.5-2l7.5-5t5.5-6-2.5-5q-2-1-9-4t-12.5-5.5-6.5-3.5q-3-5 0-16t-2-15q-5 5-10 18.5t-8 17.5q8-9-30-6l-8 1q-4 0-15-2t-16-1q-7 0-29 6 7 17 5 25 5 0 7 2l-6 3q-3-1-25-9 2-3 8-9.5t9-11.5q-22 6-27-2 0-1-9 0-25 1-24-7 1-4 9-12 0-9-1-9-27 22-30 23-172-83-276-248 1-2 2.5-11t3.5-8.5 11 4.5q9-9 3-21 2 2 36-21 56-40 22-53v5.5t1 6.5q-9-1-19 5-3-6 0.5-20t11.5-14q-8 0-10.5-17t-2.5-38.5-1-25.5l2-1q-3-13 6-37.5t24-20.5q-4-18 5-21-1-4 0-8t4.5-8.5 6-7l13.5-13.5q28-11 41-29 4-6 10.5-24.5t15.5-25.5q-2-6 10-21.5t11-25.5q-1 0-2.5-0.5t-2.5-0.5q3-8 16.5-16t16.5-14q2-3 2.5-10.5t3-12 8.5-2.5q3 24-26 68-16 27-18 31-3 5-5.5 16.5t-4.5 15.5q27-9 26-13-5-10 26-52 2-3 10-10t11-12q3-4 9.5-14.5t10.5-15.5q-1 0-3-2l-3-3q4-2 9-5t8-4.5 7.5-5 7.5-7.5q16-18 20-33 1-4 0.5-15.5t1.5-16.5q2-6 6-11t11.5-10 11.5-7 14.5-6.5 11.5-5.5q2-1 18-11t25-14q10-4 16.5-4.5t16 2.5 15.5 4z"
fill="currentColor"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1,3 +0,0 @@
<svg width="32" height="32" version="1.1" viewBox="0 0 496 496" xmlns="http://www.w3.org/2000/svg">
<path d="m248 0c-136.96 0-248 111.08-248 248 0 137 111.04 248 248 248s248-111 248-248c0-136.92-111.04-248-248-248zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12z"/>
</svg>

Before

Width:  |  Height:  |  Size: 514 B

View File

@@ -1,3 +0,0 @@
<svg width="32" height="32" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<path d="M167.02 309.34c-40.12 2.58-76.53 17.86-97.19 72.3-2.35 6.21-8 9.98-14.59 9.98-11.11 0-45.46-27.67-55.25-34.35C0 439.62 37.93 512 128 512c75.86 0 128-43.77 128-120.19 0-3.11-.65-6.08-.97-9.13l-88.01-73.34zM457.89 0c-15.16 0-29.37 6.71-40.21 16.45C213.27 199.05 192 203.34 192 257.09c0 13.7 3.25 26.76 8.73 38.7l63.82 53.18c7.21 1.8 14.64 3.03 22.39 3.03 62.11 0 98.11-45.47 211.16-256.46 7.38-14.35 13.9-29.85 13.9-45.99C512 20.64 486 0 457.89 0z"/>
</svg>

Before

Width:  |  Height:  |  Size: 554 B

View File

@@ -1,6 +0,0 @@
<svg width="1408" height="1408" version="1.1" viewBox="0 -256 1408 1408" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,-1,0,1152)">
<path d="m1408 800v-192q0-40-28-68t-68-28h-416v-416q0-40-28-68t-68-28h-192q-40 0-68 28t-28 68v416h-416q-40 0-68 28t-28 68v192q0 40 28 68t68 28h416v416q0 40 28 68t68 28h192q40 0 68-28t28-68v-416h416q40 0 68-28t28-68z"
fill="currentColor"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 430 B

View File

@@ -1,6 +0,0 @@
<svg width="32" height="32" version="1.1" viewBox="0 -256 1515 1515" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,-1,0,1152)">
<path d="m448 1088q0 53-37.5 90.5t-90.5 37.5-90.5-37.5-37.5-90.5 37.5-90.5 90.5-37.5 90.5 37.5 37.5 90.5zm1067-576q0-53-37-90l-491-492q-39-37-91-37-53 0-90 37l-715 716q-38 37-64.5 101t-26.5 117v416q0 52 38 90t90 38h416q53 0 117-26.5t102-64.5l715-714q37-39 37-91z"
fill="currentColor"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 473 B

View File

@@ -1,5 +0,0 @@
<svg width="32" height="32" version="1.1" viewBox="0 -256 1641 1643" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,-1,-21,1152)">
<path d="m384 64q0 26-19 45t-45 19-45-19-19-45 19-45 45-19 45 19 19 45zm644 420-682-682q-37-37-90-37-52 0-91 37l-106 108q-38 36-38 90 0 53 38 91l681 681q39-98 114.5-173.5t173.5-114.5zm634 435q0-39-23-106-47-134-164.5-217.5t-258.5-83.5q-185 0-316.5 131.5t-131.5 316.5 131.5 316.5 316.5 131.5q58 0 121.5-16.5t107.5-46.5q16-11 16-28t-16-28l-293-169v-224l193-107q5 3 79 48.5t135.5 81 70.5 35.5q15 0 23.5-10t8.5-25z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 589 B

View File

@@ -14,11 +14,18 @@ const config = {
name: Date.now().toString(36)
},
alias: {
"$config": "./src/config",
"$components": "./src/components",
"$styles": "./src/styles",
"$stores": "./src/stores",
"$entities": "./src/lib/extension/entities",
},
typescript: {
config: config => {
config.compilerOptions = config.compilerOptions || {};
config.compilerOptions.allowImportingTsExtension = true
}
}
},
preprocess: [
vitePreprocess({

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true
}
}

View File

@@ -2,7 +2,11 @@ import {sveltekit} from '@sveltejs/kit/vite';
import {defineConfig} from 'vite';
export default defineConfig({
build: {
// SVGs imported from the FA6 don't need to be inlined!
assetsInlineLimit: 0
},
plugins: [
sveltekit(),
]
],
});