Compare commits
313 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3d80b58b1 | |||
| d567ab4dec | |||
| e4322b3021 | |||
| 4907efdaab | |||
| c6b9250d71 | |||
| c330aa303a | |||
| 9ed3f6939d | |||
| 5584733b17 | |||
| 91947b8cc7 | |||
| df61c812fe | |||
| 65c420c36c | |||
| 79cd9bc44d | |||
| cf28d2d131 | |||
| 50238d8ef4 | |||
| 98b5311cfc | |||
| e60d20fd60 | |||
| 50a6d8ce32 | |||
| 7b532735ec | |||
| f139b76276 | |||
| eba81b72d4 | |||
| 5b93eb413b | |||
| 81247c1761 | |||
| 8d042a80a0 | |||
| 9c162bc2ce | |||
| 0deafb4a00 | |||
| 8822a2581b | |||
| 83d27cc966 | |||
| ae3c77031f | |||
| f39e060a6a | |||
| c811c13b70 | |||
| 234f80b992 | |||
| 4837184d40 | |||
| c37f680e9f | |||
| 2eefbf96ca | |||
| efd6522532 | |||
| b321c1049c | |||
| ce9a2b5f9b | |||
| 8fe1ca4914 | |||
| 48f278ae95 | |||
| 19ab302b54 | |||
| 189fda59c8 | |||
| 6bd7116df2 | |||
| 470021ee8c | |||
| e27257516d | |||
| 77293ba30c | |||
| b956b6f7bc | |||
| fcca26e128 | |||
| 69dc645de2 | |||
| 0781742dab | |||
| 71b067a77d | |||
| 6098a11115 | |||
| a87d8b94b8 | |||
| c283b96285 | |||
| 02478f0bf0 | |||
| 59c15f27eb | |||
| a58d8f0e15 | |||
| 2453bdf7b9 | |||
| 134e96bc4c | |||
| 1c05159ddf | |||
| 39c3f97846 | |||
| 9c19bd70c2 | |||
| d158b46dc6 | |||
| 966100d606 | |||
| 7cf2730402 | |||
| bdbe49b419 | |||
| ed779a8481 | |||
| bb14492578 | |||
| 30320e7283 | |||
| 8839373292 | |||
| 0e35d1d0ba | |||
| bca21da6d1 | |||
| 60491f57d4 | |||
| c26c4bcf62 | |||
| 1b4b646024 | |||
| 928fe5ddb0 | |||
| 6586141134 | |||
| d587bd2453 | |||
| e2eb8a0ca7 | |||
| 0876e5f001 | |||
| d5ad66d902 | |||
| cb6b5f4f9d | |||
| 193941b0ac | |||
| 562274b3d8 | |||
| 6faf5c8582 | |||
| e591751406 | |||
| c9347c375d | |||
| 68e134f2e4 | |||
| 338eb2bbb1 | |||
| 2933cd379e | |||
| 8fe2d718ff | |||
| b1ca67fc5b | |||
| 37095a2f22 | |||
| c1ed23dee5 | |||
| 8c51d2d482 | |||
| 16b72300a9 | |||
| 11af0f6484 | |||
| 4f302faf45 | |||
| bedb18a6aa | |||
| ea791838bf | |||
| ff16c62e26 | |||
| 45cc5b0eb3 | |||
| a2d884c969 | |||
| 74f987b5c9 | |||
| f687389516 | |||
| 92854f4d6b | |||
| 4ca9ff029b | |||
| 70e573ddc8 | |||
| 8e843c2b19 | |||
| 76e7bf1542 | |||
| d5ed86fb40 | |||
| dc0a9f0aa8 | |||
| 09edc44af8 | |||
| a9d53afdbe | |||
| ed263d2da4 | |||
| 9586d121e4 | |||
| 92afd10b81 | |||
| cf8be2589d | |||
| dbe164b444 | |||
| 5613c6fdca | |||
| dfab625999 | |||
| b25758c294 | |||
| 45a8c436be | |||
| 7aabb683cf | |||
| c4a904c046 | |||
| 3a010f9303 | |||
| 878f3eb878 | |||
| b9165302e7 | |||
| 6c02c14f5c | |||
| 8b2e0722f0 | |||
| 840202396e | |||
| f6504eeecf | |||
| faaef1305a | |||
| 91c44adbd3 | |||
| 68b68d3efd | |||
| 769a63ccff | |||
| 9d097436a5 | |||
| 6a34822f6a | |||
| ab5a6daa07 | |||
| 0bc4379491 | |||
| 3f245bb621 | |||
| a858888252 | |||
| 9d7f5c0f38 | |||
| f67a321a66 | |||
| 5dc41700b8 | |||
| c93c3c7bd5 | |||
| 459b1fa779 | |||
| 62dc38b35a | |||
| 07373e17d5 | |||
| 371bce133e | |||
| 898c82daf5 | |||
| fbecc5ccd5 | |||
| 48767d47f9 | |||
| c859c5b038 | |||
| 12e9054f59 | |||
| 23638b50ae | |||
| b31541ddf0 | |||
| 62857d44c2 | |||
| dff77391b0 | |||
| 5a07fa3d60 | |||
| 49986ec497 | |||
| 9f08eb2171 | |||
| 927fa21d95 | |||
| 620af3e622 | |||
| efdd9487ad | |||
| 762652f795 | |||
| 67d41ecf03 | |||
| 5975584905 | |||
| 566211d046 | |||
| 16f60ef9b5 | |||
| 09e912ffff | |||
| 8a3ef6b049 | |||
| 5392a17db5 | |||
| 0b4ff96fc1 | |||
| 2104922951 | |||
| f27157a0c5 | |||
| 729d0281ed | |||
| 461fce5c05 | |||
| d1a69437d1 | |||
| 062b04ca8a | |||
| 4d3023a641 | |||
| f7dff0968e | |||
| acacfe2027 | |||
| 90e75f1e38 | |||
| 805bb7543a | |||
| b6a96abadf | |||
| 06b9fefe8f | |||
| ff79e0b7fc | |||
| a99e4d286a | |||
| 26fae1dc4a | |||
| c834703781 | |||
| 33e1948a22 | |||
| 1b324f2829 | |||
| 22f158dda9 | |||
| f5dd2f7711 | |||
| d9affcf5a0 | |||
| c6d75e2b2a | |||
| 7bb71807bc | |||
| 9be8db85a2 | |||
| 392513f375 | |||
| d504ce3b04 | |||
| 08aa71c959 | |||
| bda2756779 | |||
| 92a0efaace | |||
| 01c08353f1 | |||
| d6487bbc2b | |||
| 011139d191 | |||
| 2eb824e54b | |||
| d439a69aab | |||
| b9a609a190 | |||
| 2c2c2acf3e | |||
| a8265e9baa | |||
| 4ea0e11ec1 | |||
| 2561cd19c9 | |||
| 1de6a89269 | |||
| 2db2a20803 | |||
| c7919e0127 | |||
| 73ff913eb7 | |||
| dd312e170e | |||
| 283629d64b | |||
| 9af73f0598 | |||
| bd85d165d3 | |||
| 24e0937c6b | |||
| 24cec58af5 | |||
| 66f106364a | |||
| 650c5714d0 | |||
| 31e2993a12 | |||
| e8349ce9a3 | |||
| 6b807db235 | |||
| 9a8e3cc597 | |||
| d2d02b06e4 | |||
| 58b79531e4 | |||
| 927e4c157b | |||
| 5c534da875 | |||
| 257c369b02 | |||
| d559d16977 | |||
| 6cced5036c | |||
| 15e379c798 | |||
| a39099bb1e | |||
| 3af723e50d | |||
| 3e028a1509 | |||
| ba10768496 | |||
| e5ffd59b9c | |||
| 9a73ad80dd | |||
| cd10ad62f4 | |||
| b7a829ff12 | |||
| e06359a24a | |||
| bb2065cf07 | |||
| 309dd15598 | |||
| 757526ab52 | |||
| 112d60ac78 | |||
| 3d1e0d6f06 | |||
| 98d3b1c696 | |||
| 52a8b6e778 | |||
| c9c441a8ae | |||
| c241bfb25c | |||
| ac85938355 | |||
| a8a27654fc | |||
| f58c4aa818 | |||
| eda58cd2ca | |||
| 8f3020bc7b | |||
| abbfcf2e34 | |||
| c4f00c4905 | |||
| 71039ee657 | |||
| fa8ff3b718 | |||
| 90562f3878 | |||
| d1e22eaa0c | |||
| ca3c4f6618 | |||
| 3123ce1c0f | |||
| 6775a2175a | |||
| 62bcba34da | |||
| 17396952c3 | |||
| e61f6af237 | |||
| 27133077c8 | |||
| f90e51b546 | |||
| 9e9499c904 | |||
| 7d19693f5e | |||
| 4fcf83d732 | |||
| 93d6d3a174 | |||
| 4bdf04f911 | |||
| c9a9fe059c | |||
| e523ce4468 | |||
| d5f6ed1a3e | |||
| 0eba277f48 | |||
| b562752778 | |||
| 015e5d6ec4 | |||
| fffb915985 | |||
| 8151d1519a | |||
| 4f0f3142a1 | |||
| be97ac5640 | |||
| 9f61f99548 | |||
| 59c958ab32 | |||
| ee3fcd1b08 | |||
| d14fc8ba7d | |||
| b3a92653ad | |||
| c7f40e99b7 | |||
| f6ab60d939 | |||
| 666d374057 | |||
| 1ecb15c986 | |||
| b22749812f | |||
| 4b3414be47 | |||
| 6e4aef519f | |||
| feb57eec38 | |||
| 2a451d18be | |||
| 1420ad1ece | |||
| c0590ae347 | |||
| 39260f9c5d | |||
| 97b79b0b0d | |||
| b645a1ca7a | |||
| c0a00e0c05 | |||
| a8f0f16121 | |||
| e13d9054cc | |||
| c0139d0638 | |||
| 80ba4671f5 |
367
.editorconfig
@@ -204,7 +204,7 @@ ij_javascript_spaces_within_brackets = false
|
||||
ij_javascript_spaces_within_catch_parentheses = false
|
||||
ij_javascript_spaces_within_for_parentheses = false
|
||||
ij_javascript_spaces_within_if_parentheses = false
|
||||
ij_javascript_spaces_within_imports = false
|
||||
ij_javascript_spaces_within_imports = true
|
||||
ij_javascript_spaces_within_interpolation_expressions = false
|
||||
ij_javascript_spaces_within_method_call_parentheses = false
|
||||
ij_javascript_spaces_within_method_parentheses = false
|
||||
@@ -221,7 +221,7 @@ ij_javascript_ternary_operation_wrap = off
|
||||
ij_javascript_union_types_wrap = on_every_item
|
||||
ij_javascript_use_chained_calls_group_indents = false
|
||||
ij_javascript_use_double_quotes = true
|
||||
ij_javascript_use_explicit_js_extension = auto
|
||||
ij_javascript_use_explicit_js_extension = never
|
||||
ij_javascript_use_import_type = auto
|
||||
ij_javascript_use_path_mapping = always
|
||||
ij_javascript_use_public_modifier = false
|
||||
@@ -376,7 +376,7 @@ 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_imports = true
|
||||
ij_typescript_spaces_within_interpolation_expressions = false
|
||||
ij_typescript_spaces_within_method_call_parentheses = false
|
||||
ij_typescript_spaces_within_method_parentheses = false
|
||||
@@ -393,7 +393,366 @@ 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_explicit_js_extension = never
|
||||
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
|
||||
|
||||
[*.svelte]
|
||||
indent_size = 2
|
||||
tab_width = 2
|
||||
ij_continuation_indent_size = 2
|
||||
ij_javascript_align_imports = false
|
||||
ij_javascript_align_multiline_array_initializer_expression = false
|
||||
ij_javascript_align_multiline_binary_operation = false
|
||||
ij_javascript_align_multiline_chained_methods = false
|
||||
ij_javascript_align_multiline_extends_list = false
|
||||
ij_javascript_align_multiline_for = true
|
||||
ij_javascript_align_multiline_parameters = true
|
||||
ij_javascript_align_multiline_parameters_in_calls = false
|
||||
ij_javascript_align_multiline_ternary_operation = false
|
||||
ij_javascript_align_object_properties = 0
|
||||
ij_javascript_align_union_types = false
|
||||
ij_javascript_align_var_statements = 0
|
||||
ij_javascript_array_initializer_new_line_after_left_brace = false
|
||||
ij_javascript_array_initializer_right_brace_on_new_line = false
|
||||
ij_javascript_array_initializer_wrap = off
|
||||
ij_javascript_assignment_wrap = off
|
||||
ij_javascript_binary_operation_sign_on_next_line = false
|
||||
ij_javascript_binary_operation_wrap = off
|
||||
ij_javascript_blacklist_imports = rxjs/Rx, node_modules/**, **/node_modules/**, @angular/material, @angular/material/typings/**
|
||||
ij_javascript_blank_lines_after_imports = 1
|
||||
ij_javascript_blank_lines_around_class = 1
|
||||
ij_javascript_blank_lines_around_field = 0
|
||||
ij_javascript_blank_lines_around_function = 1
|
||||
ij_javascript_blank_lines_around_method = 1
|
||||
ij_javascript_block_brace_style = end_of_line
|
||||
ij_javascript_block_comment_add_space = false
|
||||
ij_javascript_block_comment_at_first_column = true
|
||||
ij_javascript_call_parameters_new_line_after_left_paren = false
|
||||
ij_javascript_call_parameters_right_paren_on_new_line = false
|
||||
ij_javascript_call_parameters_wrap = off
|
||||
ij_javascript_catch_on_new_line = false
|
||||
ij_javascript_chained_call_dot_on_new_line = true
|
||||
ij_javascript_class_brace_style = end_of_line
|
||||
ij_javascript_comma_on_new_line = false
|
||||
ij_javascript_do_while_brace_force = never
|
||||
ij_javascript_else_on_new_line = false
|
||||
ij_javascript_enforce_trailing_comma = keep
|
||||
ij_javascript_extends_keyword_wrap = off
|
||||
ij_javascript_extends_list_wrap = off
|
||||
ij_javascript_field_prefix = _
|
||||
ij_javascript_file_name_style = relaxed
|
||||
ij_javascript_finally_on_new_line = false
|
||||
ij_javascript_for_brace_force = never
|
||||
ij_javascript_for_statement_new_line_after_left_paren = false
|
||||
ij_javascript_for_statement_right_paren_on_new_line = false
|
||||
ij_javascript_for_statement_wrap = off
|
||||
ij_javascript_force_quote_style = false
|
||||
ij_javascript_force_semicolon_style = false
|
||||
ij_javascript_function_expression_brace_style = end_of_line
|
||||
ij_javascript_if_brace_force = never
|
||||
ij_javascript_import_merge_members = global
|
||||
ij_javascript_import_prefer_absolute_path = global
|
||||
ij_javascript_import_sort_members = true
|
||||
ij_javascript_import_sort_module_name = false
|
||||
ij_javascript_import_use_node_resolution = true
|
||||
ij_javascript_imports_wrap = on_every_item
|
||||
ij_javascript_indent_case_from_switch = true
|
||||
ij_javascript_indent_chained_calls = true
|
||||
ij_javascript_indent_package_children = 0
|
||||
ij_javascript_jsx_attribute_value = braces
|
||||
ij_javascript_keep_blank_lines_in_code = 2
|
||||
ij_javascript_keep_first_column_comment = true
|
||||
ij_javascript_keep_indents_on_empty_lines = false
|
||||
ij_javascript_keep_line_breaks = true
|
||||
ij_javascript_keep_simple_blocks_in_one_line = false
|
||||
ij_javascript_keep_simple_methods_in_one_line = false
|
||||
ij_javascript_line_comment_add_space = true
|
||||
ij_javascript_line_comment_at_first_column = false
|
||||
ij_javascript_method_brace_style = end_of_line
|
||||
ij_javascript_method_call_chain_wrap = off
|
||||
ij_javascript_method_parameters_new_line_after_left_paren = false
|
||||
ij_javascript_method_parameters_right_paren_on_new_line = false
|
||||
ij_javascript_method_parameters_wrap = off
|
||||
ij_javascript_object_literal_wrap = on_every_item
|
||||
ij_javascript_object_types_wrap = on_every_item
|
||||
ij_javascript_parentheses_expression_new_line_after_left_paren = false
|
||||
ij_javascript_parentheses_expression_right_paren_on_new_line = false
|
||||
ij_javascript_place_assignment_sign_on_next_line = false
|
||||
ij_javascript_prefer_as_type_cast = false
|
||||
ij_javascript_prefer_explicit_types_function_expression_returns = false
|
||||
ij_javascript_prefer_explicit_types_function_returns = false
|
||||
ij_javascript_prefer_explicit_types_vars_fields = false
|
||||
ij_javascript_prefer_parameters_wrap = false
|
||||
ij_javascript_property_prefix =
|
||||
ij_javascript_reformat_c_style_comments = false
|
||||
ij_javascript_space_after_colon = true
|
||||
ij_javascript_space_after_comma = true
|
||||
ij_javascript_space_after_dots_in_rest_parameter = false
|
||||
ij_javascript_space_after_generator_mult = true
|
||||
ij_javascript_space_after_property_colon = true
|
||||
ij_javascript_space_after_quest = true
|
||||
ij_javascript_space_after_type_colon = true
|
||||
ij_javascript_space_after_unary_not = false
|
||||
ij_javascript_space_before_async_arrow_lparen = true
|
||||
ij_javascript_space_before_catch_keyword = true
|
||||
ij_javascript_space_before_catch_left_brace = true
|
||||
ij_javascript_space_before_catch_parentheses = true
|
||||
ij_javascript_space_before_class_lbrace = true
|
||||
ij_javascript_space_before_class_left_brace = true
|
||||
ij_javascript_space_before_colon = true
|
||||
ij_javascript_space_before_comma = false
|
||||
ij_javascript_space_before_do_left_brace = true
|
||||
ij_javascript_space_before_else_keyword = true
|
||||
ij_javascript_space_before_else_left_brace = true
|
||||
ij_javascript_space_before_finally_keyword = true
|
||||
ij_javascript_space_before_finally_left_brace = true
|
||||
ij_javascript_space_before_for_left_brace = true
|
||||
ij_javascript_space_before_for_parentheses = true
|
||||
ij_javascript_space_before_for_semicolon = false
|
||||
ij_javascript_space_before_function_left_parenth = true
|
||||
ij_javascript_space_before_generator_mult = false
|
||||
ij_javascript_space_before_if_left_brace = true
|
||||
ij_javascript_space_before_if_parentheses = true
|
||||
ij_javascript_space_before_method_call_parentheses = false
|
||||
ij_javascript_space_before_method_left_brace = true
|
||||
ij_javascript_space_before_method_parentheses = false
|
||||
ij_javascript_space_before_property_colon = false
|
||||
ij_javascript_space_before_quest = true
|
||||
ij_javascript_space_before_switch_left_brace = true
|
||||
ij_javascript_space_before_switch_parentheses = true
|
||||
ij_javascript_space_before_try_left_brace = true
|
||||
ij_javascript_space_before_type_colon = false
|
||||
ij_javascript_space_before_unary_not = false
|
||||
ij_javascript_space_before_while_keyword = true
|
||||
ij_javascript_space_before_while_left_brace = true
|
||||
ij_javascript_space_before_while_parentheses = true
|
||||
ij_javascript_spaces_around_additive_operators = true
|
||||
ij_javascript_spaces_around_arrow_function_operator = true
|
||||
ij_javascript_spaces_around_assignment_operators = true
|
||||
ij_javascript_spaces_around_bitwise_operators = true
|
||||
ij_javascript_spaces_around_equality_operators = true
|
||||
ij_javascript_spaces_around_logical_operators = true
|
||||
ij_javascript_spaces_around_multiplicative_operators = true
|
||||
ij_javascript_spaces_around_relational_operators = true
|
||||
ij_javascript_spaces_around_shift_operators = true
|
||||
ij_javascript_spaces_around_unary_operator = false
|
||||
ij_javascript_spaces_within_array_initializer_brackets = false
|
||||
ij_javascript_spaces_within_brackets = false
|
||||
ij_javascript_spaces_within_catch_parentheses = false
|
||||
ij_javascript_spaces_within_for_parentheses = false
|
||||
ij_javascript_spaces_within_if_parentheses = false
|
||||
ij_javascript_spaces_within_imports = true
|
||||
ij_javascript_spaces_within_interpolation_expressions = false
|
||||
ij_javascript_spaces_within_method_call_parentheses = false
|
||||
ij_javascript_spaces_within_method_parentheses = false
|
||||
ij_javascript_spaces_within_object_literal_braces = true
|
||||
ij_javascript_spaces_within_object_type_braces = true
|
||||
ij_javascript_spaces_within_parentheses = false
|
||||
ij_javascript_spaces_within_switch_parentheses = false
|
||||
ij_javascript_spaces_within_type_assertion = false
|
||||
ij_javascript_spaces_within_union_types = true
|
||||
ij_javascript_spaces_within_while_parentheses = false
|
||||
ij_javascript_special_else_if_treatment = true
|
||||
ij_javascript_ternary_operation_signs_on_next_line = false
|
||||
ij_javascript_ternary_operation_wrap = off
|
||||
ij_javascript_union_types_wrap = on_every_item
|
||||
ij_javascript_use_chained_calls_group_indents = false
|
||||
ij_javascript_use_double_quotes = true
|
||||
ij_javascript_use_explicit_js_extension = never
|
||||
ij_javascript_use_import_type = auto
|
||||
ij_javascript_use_path_mapping = always
|
||||
ij_javascript_use_public_modifier = false
|
||||
ij_javascript_use_semicolon_after_statement = true
|
||||
ij_javascript_var_declaration_wrap = normal
|
||||
ij_javascript_while_brace_force = never
|
||||
ij_javascript_while_on_new_line = false
|
||||
ij_javascript_wrap_comments = false
|
||||
ij_scss_align_closing_brace_with_properties = false
|
||||
ij_scss_blank_lines_around_nested_selector = 1
|
||||
ij_scss_blank_lines_between_blocks = 1
|
||||
ij_scss_block_comment_add_space = false
|
||||
ij_scss_brace_placement = 0
|
||||
ij_scss_enforce_quotes_on_format = false
|
||||
ij_scss_hex_color_long_format = false
|
||||
ij_scss_hex_color_lower_case = false
|
||||
ij_scss_hex_color_short_format = false
|
||||
ij_scss_hex_color_upper_case = false
|
||||
ij_scss_keep_blank_lines_in_code = 2
|
||||
ij_scss_keep_indents_on_empty_lines = false
|
||||
ij_scss_keep_single_line_blocks = false
|
||||
ij_scss_line_comment_add_space = false
|
||||
ij_scss_line_comment_at_first_column = false
|
||||
ij_scss_properties_order = font, font-family, font-size, font-weight, font-style, font-variant, font-size-adjust, font-stretch, line-height, position, z-index, top, right, bottom, left, display, visibility, float, clear, overflow, overflow-x, overflow-y, clip, zoom, align-content, align-items, align-self, flex, flex-flow, flex-basis, flex-direction, flex-grow, flex-shrink, flex-wrap, justify-content, order, box-sizing, width, min-width, max-width, height, min-height, max-height, margin, margin-top, margin-right, margin-bottom, margin-left, padding, padding-top, padding-right, padding-bottom, padding-left, table-layout, empty-cells, caption-side, border-spacing, border-collapse, list-style, list-style-position, list-style-type, list-style-image, content, quotes, counter-reset, counter-increment, resize, cursor, user-select, nav-index, nav-up, nav-right, nav-down, nav-left, transition, transition-delay, transition-timing-function, transition-duration, transition-property, transform, transform-origin, animation, animation-name, animation-duration, animation-play-state, animation-timing-function, animation-delay, animation-iteration-count, animation-direction, text-align, text-align-last, vertical-align, white-space, text-decoration, text-emphasis, text-emphasis-color, text-emphasis-style, text-emphasis-position, text-indent, text-justify, letter-spacing, word-spacing, text-outline, text-transform, text-wrap, text-overflow, text-overflow-ellipsis, text-overflow-mode, word-wrap, word-break, tab-size, hyphens, pointer-events, opacity, color, border, border-width, border-style, border-color, border-top, border-top-width, border-top-style, border-top-color, border-right, border-right-width, border-right-style, border-right-color, border-bottom, border-bottom-width, border-bottom-style, border-bottom-color, border-left, border-left-width, border-left-style, border-left-color, border-radius, border-top-left-radius, border-top-right-radius, border-bottom-right-radius, border-bottom-left-radius, border-image, border-image-source, border-image-slice, border-image-width, border-image-outset, border-image-repeat, outline, outline-width, outline-style, outline-color, outline-offset, background, background-color, background-image, background-repeat, background-attachment, background-position, background-position-x, background-position-y, background-clip, background-origin, background-size, box-decoration-break, box-shadow, text-shadow
|
||||
ij_scss_space_after_colon = true
|
||||
ij_scss_space_before_opening_brace = true
|
||||
ij_scss_use_double_quotes = true
|
||||
ij_scss_value_alignment = 0
|
||||
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 = true
|
||||
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 = true
|
||||
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 = never
|
||||
ij_typescript_use_import_type = auto
|
||||
ij_typescript_use_path_mapping = always
|
||||
ij_typescript_use_public_modifier = false
|
||||
|
||||
BIN
.github/assets/derpibooru-colors-in-editor.png
vendored
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
.github/assets/derpibooru-groups-showcase-0.png
vendored
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
.github/assets/derpibooru-groups-showcase-1.png
vendored
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
.github/assets/fullscreen-viewer-icon.png
vendored
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
.github/assets/fullscreen-viewer-showcase.png
vendored
Normal file
|
After Width: | Height: | Size: 343 KiB |
BIN
.github/assets/groups-showcase.png
vendored
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
.github/assets/profiles-showcase.png
vendored
Normal file
|
After Width: | Height: | Size: 156 KiB |
31
.github/workflows/build-and-tests.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Testing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- 'release/**'
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
name: 'Run Unit Tests'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Building the extension
|
||||
run: npm run build
|
||||
|
||||
- name: Running unit tests
|
||||
run: npm run test
|
||||
3
.gitignore
vendored
@@ -2,10 +2,11 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/coverage
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {build} from "vite";
|
||||
import {createHash} from "crypto";
|
||||
import { build } from "vite";
|
||||
import { createHash } from "crypto";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { SwapDefinedVariablesPlugin } from "../plugins/swap-defined-variables.js";
|
||||
import { ScssViteReadEnvVariableFunctionPlugin } from "../plugins/scss-read-env-variable-function.js";
|
||||
|
||||
/**
|
||||
* Create the result base file name for the file.
|
||||
@@ -48,6 +50,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'),
|
||||
@@ -55,66 +58,187 @@ function makeAliases(rootDir) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the selected script separately.
|
||||
* @param {AssetBuildOptions} buildOptions Building options for the script.
|
||||
* @return {Promise<string>} Result file path.
|
||||
* @param {import('rollup').OutputChunk} chunk
|
||||
* @param {import('rollup').OutputBundle} bundle
|
||||
* @param {Set<import('rollup').OutputChunk>} processedChunks
|
||||
* @return string[]
|
||||
*/
|
||||
export async function buildScript(buildOptions) {
|
||||
const outputBaseName = createOutputBaseName(buildOptions.input);
|
||||
function collectChunkDependencies(chunk, bundle, processedChunks = new Set()) {
|
||||
if (processedChunks.has(chunk) || !chunk.imports) {
|
||||
return [];
|
||||
}
|
||||
|
||||
processedChunks.add(chunk);
|
||||
|
||||
return chunk.imports.concat(
|
||||
chunk.imports
|
||||
.map(importedChunkName => {
|
||||
const module = bundle[importedChunkName];
|
||||
|
||||
if (module.type === 'chunk') {
|
||||
return collectChunkDependencies(module, bundle, processedChunks);
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
.flat()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(fileName: string, dependencies: string[]) => void} onDependencyResolvedCallback
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
function collectDependenciesForManifestBuilding(onDependencyResolvedCallback) {
|
||||
return {
|
||||
name: 'extract-dependencies-for-content-scripts',
|
||||
enforce: "post",
|
||||
/**
|
||||
* @param {any} options
|
||||
* @param {import('rollup').OutputBundle} bundle
|
||||
*/
|
||||
writeBundle(options, bundle) {
|
||||
Object.keys(bundle).forEach(fileName => {
|
||||
const chunk = bundle[fileName];
|
||||
|
||||
if (chunk.type !== "chunk" || !chunk.facadeModuleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dependencies = Array.from(
|
||||
new Set(
|
||||
collectChunkDependencies(chunk, bundle)
|
||||
)
|
||||
);
|
||||
|
||||
onDependencyResolvedCallback(fileName, dependencies);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Second revision of the building logic for the content scripts. This method tries to address duplication of
|
||||
* dependencies generated with the previous method, where every single content script was built separately.
|
||||
* @param {BatchBuildOptions} buildOptions
|
||||
* @returns {Promise<Map<string, string[]>>}
|
||||
*/
|
||||
export async function buildScriptsAndStyles(buildOptions) {
|
||||
/** @type {Map<string, string[]>} */
|
||||
const pathsReplacement = new Map();
|
||||
/** @type {Map<string, string[]>} */
|
||||
const pathsReplacementByOutputPath = new Map();
|
||||
|
||||
const amdScriptsInput = {};
|
||||
const libsAndStylesInput = {};
|
||||
|
||||
for (const inputPath of buildOptions.inputs) {
|
||||
let outputExtension = path.extname(inputPath);
|
||||
|
||||
if (outputExtension === '.scss') {
|
||||
outputExtension = '.css';
|
||||
}
|
||||
|
||||
if (outputExtension === '.ts') {
|
||||
outputExtension = '.js';
|
||||
}
|
||||
|
||||
const outputPath = createOutputBaseName(inputPath);
|
||||
const replacementsArray = [`${outputPath}${outputExtension}`];
|
||||
|
||||
pathsReplacement.set(inputPath, replacementsArray);
|
||||
|
||||
if (outputExtension === '.css' || inputPath.includes('/deps/')) {
|
||||
libsAndStylesInput[outputPath] = inputPath;
|
||||
continue;
|
||||
}
|
||||
|
||||
pathsReplacementByOutputPath.set(outputPath + '.js', replacementsArray);
|
||||
|
||||
amdScriptsInput[outputPath] = inputPath;
|
||||
}
|
||||
|
||||
const aliasesSettings = makeAliases(buildOptions.rootDir);
|
||||
const defineConstants = {
|
||||
__CURRENT_SITE__: JSON.stringify('furbooru'),
|
||||
__CURRENT_SITE_NAME__: JSON.stringify('Furbooru'),
|
||||
};
|
||||
|
||||
const derpibooruSwapPlugin = SwapDefinedVariablesPlugin({
|
||||
envVariable: 'SITE',
|
||||
expectedValue: 'derpibooru',
|
||||
define: {
|
||||
__CURRENT_SITE__: JSON.stringify('derpibooru'),
|
||||
__CURRENT_SITE_NAME__: JSON.stringify('Derpibooru'),
|
||||
}
|
||||
});
|
||||
|
||||
// Building all scripts together with AMD loader in mind
|
||||
await build({
|
||||
configFile: false,
|
||||
publicDir: false,
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
[outputBaseName]: buildOptions.input
|
||||
},
|
||||
input: amdScriptsInput,
|
||||
output: {
|
||||
dir: buildOptions.outputDir,
|
||||
entryFileNames: '[name].js'
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: 'chunks/[name]-[hash].js',
|
||||
// ManifestV3 doesn't allow to use modern ES modules syntax, so we build all content scripts as AMD modules.
|
||||
format: "amd",
|
||||
inlineDynamicImports: false,
|
||||
amd: {
|
||||
// amd-lite requires names even for the entry-point scripts, so we should make sure to add those.
|
||||
autoId: true,
|
||||
},
|
||||
// All these modules are not intended to be used outside of extension anyway
|
||||
minifyInternalExports: true,
|
||||
}
|
||||
},
|
||||
emptyOutDir: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: makeAliases(buildOptions.rootDir)
|
||||
alias: aliasesSettings,
|
||||
},
|
||||
plugins: [
|
||||
wrapScriptIntoIIFE()
|
||||
]
|
||||
wrapScriptIntoIIFE(),
|
||||
collectDependenciesForManifestBuilding((fileName, dependencies) => {
|
||||
pathsReplacementByOutputPath
|
||||
.get(fileName)
|
||||
?.push(...dependencies);
|
||||
}),
|
||||
derpibooruSwapPlugin,
|
||||
],
|
||||
define: defineConstants,
|
||||
});
|
||||
|
||||
return path.resolve(buildOptions.outputDir, `${outputBaseName}.js`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the selected stylesheet.
|
||||
* @param {AssetBuildOptions} buildOptions Build options for the stylesheet.
|
||||
* @return {Promise<string>} Result file path.
|
||||
*/
|
||||
export async function buildStyle(buildOptions) {
|
||||
const outputBaseName = createOutputBaseName(buildOptions.input);
|
||||
|
||||
// Build styles separately because AMD converts styles to JS files.
|
||||
await build({
|
||||
configFile: false,
|
||||
publicDir: false,
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
[outputBaseName]: buildOptions.input
|
||||
},
|
||||
input: libsAndStylesInput,
|
||||
output: {
|
||||
dir: buildOptions.outputDir,
|
||||
entryFileNames: '[name].js',
|
||||
assetFileNames: '[name].[ext]',
|
||||
}
|
||||
},
|
||||
emptyOutDir: false,
|
||||
}
|
||||
emptyOutDir: false
|
||||
},
|
||||
resolve: {
|
||||
alias: aliasesSettings,
|
||||
},
|
||||
plugins: [
|
||||
wrapScriptIntoIIFE(),
|
||||
ScssViteReadEnvVariableFunctionPlugin(),
|
||||
derpibooruSwapPlugin,
|
||||
],
|
||||
define: defineConstants,
|
||||
});
|
||||
|
||||
return path.resolve(buildOptions.outputDir, `${outputBaseName}.css`);
|
||||
return pathsReplacement;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,3 +247,11 @@ export async function buildStyle(buildOptions) {
|
||||
* @property {string} outputDir Destination folder for the script.
|
||||
* @property {string} rootDir Root directory of the repository.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BatchBuildOptions
|
||||
* @property {Set<string>} inputs Set of all scripts and styles to build.
|
||||
* @property {string} outputDir Destination folder for the assets.
|
||||
* @property {string} rootDir Root directory of the repository.
|
||||
* @property {(fileName: string, dependencies: string[]) => void} onDependenciesResolved Callback for dependencies.
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,38 @@ class ManifestProcessor {
|
||||
this.#manifestObject = parsedManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all the content scripts & stylesheets for single build action.
|
||||
*
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
collectContentScripts() {
|
||||
const contentScripts = this.#manifestObject.content_scripts;
|
||||
|
||||
if (!contentScripts) {
|
||||
console.info('No content scripts to collect.');
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const entryPoints = new Set();
|
||||
|
||||
for (let entry of contentScripts) {
|
||||
if (entry.js) {
|
||||
for (let jsPath of entry.js) {
|
||||
entryPoints.add(jsPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.css) {
|
||||
for (let cssPath of entry.css) {
|
||||
entryPoints.add(cssPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entryPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map over every content script defined in the manifest. If no content scripts defined, no calls will be made to the
|
||||
* callback.
|
||||
@@ -54,6 +86,53 @@ class ManifestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all patterns in content scripts and host permissions and replace the hostname to the different one.
|
||||
*
|
||||
* @param {string|string[]} singleOrMultipleHostnames One or multiple hostnames to replace the original hostname with.
|
||||
*/
|
||||
replaceHostTo(singleOrMultipleHostnames) {
|
||||
if (typeof singleOrMultipleHostnames === 'string') {
|
||||
singleOrMultipleHostnames = [singleOrMultipleHostnames];
|
||||
}
|
||||
|
||||
this.#manifestObject.host_permissions = singleOrMultipleHostnames.map(hostname => `*://*.${hostname}/`);
|
||||
|
||||
this.#manifestObject.content_scripts?.forEach(entry => {
|
||||
entry.matches = entry.matches.reduce((resultMatches, originalMatchPattern) => {
|
||||
for (const updatedHostname of singleOrMultipleHostnames) {
|
||||
resultMatches.push(
|
||||
originalMatchPattern.replace(
|
||||
/\*:\/\/\*\.[a-z]+\.[a-z]+\//,
|
||||
`*://*.${updatedHostname}/`
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return resultMatches;
|
||||
}, []);
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set different identifier for Gecko-based browsers (Firefox).
|
||||
*
|
||||
* @param {string} id ID of the extension to use.
|
||||
*/
|
||||
setGeckoIdentifier(id) {
|
||||
this.#manifestObject.browser_specific_settings.gecko.id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the different extension name.
|
||||
*
|
||||
* @param {string} booruName
|
||||
*/
|
||||
replaceBooruNameWith(booruName) {
|
||||
this.#manifestObject.name = this.#manifestObject.name.replaceAll('Furbooru', booruName);
|
||||
this.#manifestObject.description = this.#manifestObject.description.replaceAll('Furbooru', booruName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current state of the manifest into the selected file.
|
||||
*
|
||||
@@ -86,13 +165,27 @@ export function loadManifest(filePath) {
|
||||
|
||||
/**
|
||||
* @typedef {Object} Manifest
|
||||
* @property {string} name
|
||||
* @property {string} description
|
||||
* @property {string} version
|
||||
* @property {BrowserSpecificSettings} browser_specific_settings
|
||||
* @property {string[]} host_permissions
|
||||
* @property {ContentScriptsEntry[]|undefined} content_scripts
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BrowserSpecificSettings
|
||||
* @property {GeckoSettings} gecko
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} GeckoSettings
|
||||
* @property {string} id
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentScriptsEntry
|
||||
* @property {string[]} mathces
|
||||
* @property {string[]} matches
|
||||
* @property {string[]|undefined} js
|
||||
* @property {string[]|undefined} css
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {loadManifest} from "./lib/manifest.js";
|
||||
import { loadManifest } from "./lib/manifest.js";
|
||||
import path from "path";
|
||||
import {buildScript, buildStyle} from "./lib/content-scripts.js";
|
||||
import {normalizePath} from "vite";
|
||||
import {extractInlineScriptsFromIndex} from "./lib/index-file.js";
|
||||
import { buildScriptsAndStyles } from "./lib/content-scripts.js";
|
||||
import { extractInlineScriptsFromIndex } from "./lib/index-file.js";
|
||||
import { normalizePath } from "vite";
|
||||
|
||||
/**
|
||||
* Build addition assets required for the extension and pack it into the directory.
|
||||
@@ -11,45 +11,70 @@ import {extractInlineScriptsFromIndex} from "./lib/index-file.js";
|
||||
export async function packExtension(settings) {
|
||||
const manifest = loadManifest(path.resolve(settings.rootDir, 'manifest.json'));
|
||||
|
||||
// Since we CAN'T really build all scripts and stylesheets in a single build entry, we will run build for every single
|
||||
// one of them in a row. This way, no chunks will be generated. Thanks, ManifestV3!
|
||||
await manifest.mapContentScripts(async (entry) => {
|
||||
if (entry.js) {
|
||||
for (let scriptIndex = 0; scriptIndex < entry.js.length; scriptIndex++) {
|
||||
const builtScriptFilePath = await buildScript({
|
||||
input: path.resolve(settings.rootDir, entry.js[scriptIndex]),
|
||||
outputDir: settings.contentScriptsDir,
|
||||
rootDir: settings.rootDir,
|
||||
});
|
||||
const replacementMapping = await buildScriptsAndStyles({
|
||||
inputs: manifest.collectContentScripts(),
|
||||
outputDir: settings.contentScriptsDir,
|
||||
rootDir: settings.rootDir,
|
||||
});
|
||||
|
||||
entry.js[scriptIndex] = normalizePath(
|
||||
path.relative(
|
||||
settings.exportDir,
|
||||
builtScriptFilePath
|
||||
await manifest.mapContentScripts(async entry => {
|
||||
if (entry.js) {
|
||||
entry.js = entry.js
|
||||
.map(jsSourcePath => {
|
||||
if (!replacementMapping.has(jsSourcePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return replacementMapping.get(jsSourcePath);
|
||||
})
|
||||
.flat(1)
|
||||
.map(pathName => {
|
||||
return normalizePath(
|
||||
path.relative(
|
||||
settings.exportDir,
|
||||
path.join(
|
||||
settings.contentScriptsDir,
|
||||
pathName
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.css) {
|
||||
for (let styleIndex = 0; styleIndex < entry.css.length; styleIndex++) {
|
||||
const builtStylesheetFilePath = await buildStyle({
|
||||
input: path.resolve(settings.rootDir, entry.css[styleIndex]),
|
||||
outputDir: settings.contentScriptsDir,
|
||||
rootDir: settings.rootDir
|
||||
});
|
||||
entry.css = entry.css
|
||||
.map(jsSourcePath => {
|
||||
if (!replacementMapping.has(jsSourcePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
entry.css[styleIndex] = normalizePath(
|
||||
path.relative(
|
||||
settings.exportDir,
|
||||
builtStylesheetFilePath
|
||||
return replacementMapping.get(jsSourcePath);
|
||||
})
|
||||
.flat(1)
|
||||
.map(pathName => {
|
||||
return normalizePath(
|
||||
path.relative(
|
||||
settings.exportDir,
|
||||
path.join(
|
||||
settings.contentScriptsDir,
|
||||
pathName
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return entry;
|
||||
});
|
||||
})
|
||||
|
||||
if (process.env.SITE === 'derpibooru') {
|
||||
manifest.replaceHostTo([
|
||||
'derpibooru.org',
|
||||
'trixiebooru.org'
|
||||
]);
|
||||
manifest.replaceBooruNameWith('Derpibooru');
|
||||
manifest.setGeckoIdentifier('derpibooru-tagging-assistant@thecore.city');
|
||||
}
|
||||
|
||||
manifest.passVersionFromPackage(path.resolve(settings.rootDir, 'package.json'));
|
||||
manifest.saveTo(path.resolve(settings.exportDir, 'manifest.json'));
|
||||
|
||||
46
.vite/plugins/scss-read-env-variable-function.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { SassString, Value } from "sass";
|
||||
|
||||
/**
|
||||
* @return {import('vite').Plugin}
|
||||
*/
|
||||
export function ScssViteReadEnvVariableFunctionPlugin() {
|
||||
return {
|
||||
name: 'koloml:scss-read-env-variable-function',
|
||||
apply: 'build',
|
||||
enforce: 'post',
|
||||
|
||||
configResolved: config => {
|
||||
config.css.preprocessorOptions ??= {};
|
||||
config.css.preprocessorOptions.scss ??= {};
|
||||
config.css.preprocessorOptions.scss.functions ??= {};
|
||||
|
||||
/**
|
||||
* @param {Value[]} args
|
||||
* @return {SassString}
|
||||
*/
|
||||
config.css.preprocessorOptions.scss.functions['vite-read-env-variable($constant-name)'] = (args) => {
|
||||
const constName = args[0].assertString('constant-name').text;
|
||||
|
||||
if (config.define && config.define.hasOwnProperty(constName)) {
|
||||
let returnedValue = config.define[constName];
|
||||
|
||||
try {
|
||||
returnedValue = JSON.parse(returnedValue);
|
||||
} catch {
|
||||
returnedValue = null;
|
||||
}
|
||||
|
||||
if (typeof returnedValue !== 'string') {
|
||||
console.warn(`Attempting to read the constant with non-string type: ${constName}`);
|
||||
return new SassString('');
|
||||
}
|
||||
|
||||
return new SassString(returnedValue);
|
||||
}
|
||||
|
||||
console.warn(`Constant does not exist: ${constName}`);
|
||||
return new SassString('');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
.vite/plugins/swap-defined-variables.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @param {SwapDefinedVariablesSettings} settings
|
||||
* @return {import('vite').Plugin}
|
||||
*/
|
||||
export function SwapDefinedVariablesPlugin(settings) {
|
||||
return {
|
||||
name: 'koloml:swap-defined-variables',
|
||||
enforce: 'post',
|
||||
configResolved: (config) => {
|
||||
if (
|
||||
config.define
|
||||
&& process.env.hasOwnProperty(settings.envVariable)
|
||||
&& process.env[settings.envVariable] === settings.expectedValue
|
||||
) {
|
||||
for (const [key, value] of Object.entries(settings.define)) {
|
||||
config.define[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} SwapDefinedVariablesSettings
|
||||
* @property {string} envVariable
|
||||
* @property {string} expectedValue
|
||||
* @property {Record<string, string>} define
|
||||
*/
|
||||
57
README.md
@@ -1,10 +1,48 @@
|
||||
# Furbooru Tagging Assistant
|
||||
# Philomena Tagging Assistant
|
||||
|
||||
This is a browser extension written for the [Furbooru](https://furbooru.org) and [Derpibooru](https://derpibooru.org)
|
||||
image-boards. It gives you the ability to manually go over the list of images and apply tags to them without opening
|
||||
each individual image.
|
||||
|
||||
## Installation
|
||||
|
||||
This extension is available for both Chromium- and Firefox-based browsers. You can find the links to the extension pages
|
||||
below.
|
||||
|
||||
### Furbooru Tagging Assistant
|
||||
|
||||
[](https://addons.mozilla.org/en-US/firefox/addon/furbooru-tagging-assistant/)
|
||||
[](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.
|
||||
### Derpibooru Tagging Assistant
|
||||
|
||||
[](https://addons.mozilla.org/en-US/firefox/addon/derpibooru-tagging-assistant/)
|
||||
[](https://chromewebstore.google.com/detail/pnmbomcdbfcghgmegklfofncfigdielb)
|
||||
|
||||
## Features
|
||||
|
||||
### Tagging Profiles
|
||||
|
||||
Select a set of tags and add/remove them from images without opening them. Just hover over image, click on tags and
|
||||
you're done!
|
||||
|
||||

|
||||
|
||||
### Custom Tag Groups
|
||||
|
||||
Customize the list of tags with your own custom tag groups. Apply custom colors to different groups or even separate
|
||||
them from each other with group titles.
|
||||
|
||||

|
||||
|
||||
### Fullscreen Viewer
|
||||
|
||||
Open up the specific image or video in fullscreen mode by clicking 🔍 icon in the bottom left corner of the image. This
|
||||
feature is opt-in and should be enabled in the settings first.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Building
|
||||
|
||||
@@ -19,11 +57,18 @@ npm install --save-dev
|
||||
```
|
||||
|
||||
Second, you need to run the `build` command. It will first build the popup using SvelteKit and then build all the
|
||||
content scripts/stylesheets and copy the manifest afterward. Simply run:
|
||||
content scripts/stylesheets and copy the manifest afterward.
|
||||
|
||||
Extension can currently be built for 2 different imageboards using one of the following commands:
|
||||
|
||||
```shell
|
||||
# To build the extension for Furbooru, use:
|
||||
npm run build
|
||||
|
||||
# To build the extension for Derpbooru, use:
|
||||
npm run build:derpibooru
|
||||
```
|
||||
|
||||
When building is complete, resulting files can be found in the `/build` directory. These files can be either used
|
||||
directly in Chrome (via loading the extension as unpacked extension) or manually compressed into `*.zip` file.
|
||||
When build is complete, extension files can be found in the `/build` directory. These files can be either used
|
||||
directly in Chrome (via loading the extension as unpacked extension) or manually compressed into `*.zip` file and loaded
|
||||
into Firefox.
|
||||
|
||||
@@ -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.3.1",
|
||||
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
|
||||
"version": "0.5.1",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "furbooru-tagging-assistant@thecore.city"
|
||||
@@ -18,6 +18,14 @@
|
||||
"*://*.furbooru.org/"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/deps/amd.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/",
|
||||
@@ -27,7 +35,7 @@
|
||||
"*://*.furbooru.org/galleries/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/listing.js"
|
||||
"src/content/listing.ts"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/listing.scss"
|
||||
@@ -35,10 +43,13 @@
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/*"
|
||||
"*://*.furbooru.org/images/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/header.js"
|
||||
"src/content/tags-editor.ts"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/tags-editor.scss"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -56,15 +67,7 @@
|
||||
"*://*.furbooru.org/filters/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/tags.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/images/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/tags-editor.js"
|
||||
"src/content/tags.ts"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
4712
package-lock.json
generated
37
package.json
@@ -1,29 +1,38 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "npm run build:popup && npm run build:extension",
|
||||
"build:derpibooru": "cross-env SITE=derpibooru npm run build",
|
||||
"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",
|
||||
"test": "vitest run --coverage",
|
||||
"test:watch": "vitest watch --coverage"
|
||||
},
|
||||
"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",
|
||||
"@types/chrome": "^0.0.262",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"sass": "^1.71.0",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte-check": "^3.6.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.4.9"
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.21.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/chrome": "^0.0.326",
|
||||
"@types/node": "^22.15.29",
|
||||
"@vitest/coverage-v8": "^3.2.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"cross-env": "^10.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"sass": "^1.89.1",
|
||||
"svelte": "^5.33.14",
|
||||
"svelte-check": "^4.2.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.2.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"amd-lite": "^1.0.1",
|
||||
"lz-string": "^1.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
63
src/app.d.ts
vendored
@@ -1,24 +1,53 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
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"
|
||||
);
|
||||
/**
|
||||
* Identifier of the current site this extension is built for.
|
||||
*/
|
||||
const __CURRENT_SITE__: string;
|
||||
|
||||
/**
|
||||
* Name of the site.
|
||||
*/
|
||||
const __CURRENT_SITE_NAME__: string;
|
||||
|
||||
// Helper type to not deal with differences between the setTimeout of @types/node and usual web browser's type.
|
||||
type Timeout = ReturnType<typeof setTimeout>;
|
||||
|
||||
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 {};
|
||||
|
||||
@@ -1,93 +1,123 @@
|
||||
<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";
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { storagesCollection } from "$stores/debug";
|
||||
import { goto } from "$app/navigation";
|
||||
import { findDeepObject } from "$lib/utils";
|
||||
|
||||
/** @type {string} */
|
||||
export let storage;
|
||||
interface StorageViewerProps {
|
||||
storage: string;
|
||||
path: string[];
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
export let path;
|
||||
type BreadcrumbsArray = [string, string][];
|
||||
|
||||
/** @type {Object|null} */
|
||||
let targetStorage = null;
|
||||
/** @type {[string, string][]} */
|
||||
let breadcrumbs = [];
|
||||
/** @type {Object<string, any>|null} */
|
||||
let targetObject = null;
|
||||
let targetPathString = '';
|
||||
let { storage, path }: StorageViewerProps = $props();
|
||||
|
||||
$: {
|
||||
/** @type {[string, string][]} */
|
||||
const builtBreadcrumbs = [];
|
||||
let breadcrumbs = $derived.by<BreadcrumbsArray>(() => {
|
||||
return path.reduce<BreadcrumbsArray>((resultCrumbs, entry) => {
|
||||
let entryPath = entry;
|
||||
|
||||
breadcrumbs = path.reduce((resultCrumbs, entry) => {
|
||||
let entryPath = entry;
|
||||
if (resultCrumbs.length) {
|
||||
entryPath = resultCrumbs[resultCrumbs.length - 1][1] + "/" + entryPath;
|
||||
}
|
||||
|
||||
if (resultCrumbs.length) {
|
||||
entryPath = resultCrumbs[resultCrumbs.length - 1][1] + "/" + entryPath;
|
||||
}
|
||||
resultCrumbs.push([entry, entryPath]);
|
||||
|
||||
resultCrumbs.push([entry, entryPath]);
|
||||
return resultCrumbs;
|
||||
}, [])
|
||||
});
|
||||
|
||||
return resultCrumbs;
|
||||
}, builtBreadcrumbs);
|
||||
let targetStorage = $derived.by<object|null>(() => {
|
||||
return $storagesCollection[storage];
|
||||
});
|
||||
|
||||
targetPathString = path.join("/");
|
||||
let targetObject = $derived.by<Record<string, any> | null>(() => {
|
||||
return targetStorage
|
||||
? findDeepObject(targetStorage, path)
|
||||
: null;
|
||||
});
|
||||
|
||||
if (targetPathString.length) {
|
||||
targetPathString += "/";
|
||||
}
|
||||
let targetPathString = $derived.by<string>(() => {
|
||||
let pathString = path.join("/");
|
||||
|
||||
if (pathString.length) {
|
||||
pathString += "/";
|
||||
}
|
||||
|
||||
$: {
|
||||
targetStorage = $storagesCollection[storage];
|
||||
return pathString;
|
||||
});
|
||||
|
||||
if (!targetStorage) {
|
||||
goto("/preferences/debug/storage");
|
||||
}
|
||||
$effect(() => {
|
||||
if (!targetStorage) {
|
||||
goto("/preferences/debug/storage");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to resolve type, including the null.
|
||||
* @param value Value to resolve type from.
|
||||
* @return Type of the value, including "null" for null.
|
||||
*/
|
||||
function resolveType(value: unknown): string {
|
||||
let typeName: string = typeof value;
|
||||
|
||||
if (typeName === 'object' && value === null) {
|
||||
typeName = 'null';
|
||||
}
|
||||
|
||||
$: {
|
||||
targetObject = targetStorage
|
||||
? findDeepObject(targetStorage, path)
|
||||
: null;
|
||||
return typeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to resolve value, including values like null or undefined.
|
||||
* @param value Value to resolve.
|
||||
* @return String representation of the value.
|
||||
*/
|
||||
function resolveValue(value: unknown): string {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
return "undefined";
|
||||
}
|
||||
|
||||
return value?.toString() ?? '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/preferences/debug/storage" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
<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}
|
||||
<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>
|
||||
<Menu>
|
||||
<hr>
|
||||
{#each Object.entries(targetObject) as [key, _]}
|
||||
{#if targetObject[key] && typeof targetObject[key] === 'object'}
|
||||
<MenuItem href="/preferences/debug/storage/{storage}/{targetPathString}{key}">
|
||||
{key}: Object
|
||||
</MenuItem>
|
||||
{:else}
|
||||
<MenuItem>
|
||||
{key}: {resolveType(targetObject[key])} = {resolveValue(targetObject[key])}
|
||||
</MenuItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Menu>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.path {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: .5em;
|
||||
}
|
||||
.path {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: .5em;
|
||||
}
|
||||
</style>
|
||||
|
||||
73
src/components/features/GroupView.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
interface GroupViewProps {
|
||||
group: TagGroup;
|
||||
}
|
||||
|
||||
let { group }: GroupViewProps = $props();
|
||||
|
||||
let sortedTagsList = $derived<string[]>(group.settings.tags.sort((a, b) => a.localeCompare(b))),
|
||||
sortedPrefixes = $derived<string[]>(group.settings.prefixes.sort((a, b) => a.localeCompare(b))),
|
||||
sortedSuffixes = $derived<string[]>(group.settings.suffixes.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}
|
||||
{#if sortedSuffixes.length}
|
||||
<div class="block">
|
||||
<strong>Suffixes:</strong>
|
||||
<TagsColorContainer targetCategory={group.settings.category}>
|
||||
<div class="tags-list">
|
||||
{#each sortedSuffixes as suffixName}
|
||||
<span class="tag">*{suffixName}</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>
|
||||
41
src/components/features/ProfileView.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
interface ProfileViewProps {
|
||||
profile: MaintenanceProfile;
|
||||
}
|
||||
|
||||
let { profile }: ProfileViewProps = $props();
|
||||
|
||||
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<strong>Profile:</strong>
|
||||
<div>{profile.settings.name}</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<strong>Tags:</strong>
|
||||
<div class="tags-list">
|
||||
{#each sortedTagsList as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +1,32 @@
|
||||
<script>
|
||||
import {version} from "$app/environment";
|
||||
import { version } from "$app/environment";
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
<a href="https://github.com/koloml/furbooru-tagging-assistant/releases/tag/{version}" target="_blank">
|
||||
v{version}
|
||||
</a>
|
||||
<span>, made with ♥ by KoloMl.</span>
|
||||
<a href="https://github.com/koloml/furbooru-tagging-assistant/releases/tag/{version}" target="_blank">
|
||||
v{version}
|
||||
</a>
|
||||
<span>, made with ♥ by KoloMl.</span>
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'src/styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background: colors.$footer;
|
||||
color: colors.$footer-text;
|
||||
padding: 0 24px;
|
||||
font-size: 12px;
|
||||
line-height: 36px;
|
||||
footer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background: colors.$footer;
|
||||
color: colors.$footer-text;
|
||||
padding: 0 24px;
|
||||
font-size: 12px;
|
||||
line-height: 36px;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
<header>
|
||||
<a href="/">Furbooru Tagging Assistant</a>
|
||||
<a href="/">{__CURRENT_SITE_NAME__} Tagging Assistant</a>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
@use "src/styles/colors";
|
||||
@use "$styles/colors";
|
||||
|
||||
header {
|
||||
background: colors.$header;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
header {
|
||||
background: colors.$header;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
|
||||
a {
|
||||
color: colors.$text;
|
||||
line-height: 36px;
|
||||
padding: 0 12px;
|
||||
margin-left: -12px;
|
||||
a {
|
||||
color: colors.$text;
|
||||
line-height: 36px;
|
||||
padding: 0 12px;
|
||||
margin-left: -12px;
|
||||
|
||||
&:hover {
|
||||
background: colors.$header-hover-background;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: colors.$header-hover-background;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<script>
|
||||
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default} */
|
||||
export let profile;
|
||||
|
||||
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<strong>Profile:</strong>
|
||||
<div>{profile.settings.name}</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<strong>Tags:</strong>
|
||||
<div class="tags-list">
|
||||
{#each sortedTagsList as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
78
src/components/tags/TagsColorContainer.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface TagColorContainerProps {
|
||||
targetCategory?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { targetCategory = '', children }: TagColorContainerProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="tag-color-container tag-color-container--{targetCategory || 'default'}">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--rating)) :global(.tag) {
|
||||
background-color: colors.$tag-rating-background;
|
||||
color: colors.$tag-rating-text;
|
||||
border-color: colors.$tag-rating-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--spoiler)) :global(.tag) {
|
||||
background-color: colors.$tag-spoiler-background;
|
||||
color: colors.$tag-spoiler-text;
|
||||
border-color: colors.$tag-spoiler-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--origin)) :global(.tag) {
|
||||
background-color: colors.$tag-origin-background;
|
||||
color: colors.$tag-origin-text;
|
||||
border-color: colors.$tag-origin-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--oc)) :global(.tag) {
|
||||
background-color: colors.$tag-oc-background;
|
||||
color: colors.$tag-oc-text;
|
||||
border-color: colors.$tag-oc-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--error)) :global(.tag) {
|
||||
background-color: colors.$tag-error-background;
|
||||
color: colors.$tag-error-text;
|
||||
border-color: colors.$tag-error-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--character)) :global(.tag) {
|
||||
background-color: colors.$tag-character-background;
|
||||
color: colors.$tag-character-text;
|
||||
border-color: colors.$tag-character-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--content-official)) :global(.tag) {
|
||||
background-color: colors.$tag-content-official-background;
|
||||
color: colors.$tag-content-official-text;
|
||||
border-color: colors.$tag-content-official-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--content-fanmade)) :global(.tag) {
|
||||
background-color: colors.$tag-content-fanmade-background;
|
||||
color: colors.$tag-content-fanmade-text;
|
||||
border-color: colors.$tag-content-fanmade-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--species)) :global(.tag) {
|
||||
background-color: colors.$tag-species-background;
|
||||
color: colors.$tag-species-text;
|
||||
border-color: colors.$tag-species-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--body-type)) :global(.tag) {
|
||||
background-color: colors.$tag-body-type-background;
|
||||
color: colors.$tag-body-type-text;
|
||||
border-color: colors.$tag-body-type-border;
|
||||
}
|
||||
</style>
|
||||
115
src/components/tags/TagsEditor.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import type { EventHandler } from "svelte/elements";
|
||||
|
||||
interface TagEditorProps {
|
||||
// List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
|
||||
tags?: string[];
|
||||
mapTagNames?: (tagName: string) => string;
|
||||
}
|
||||
|
||||
let {
|
||||
tags = $bindable([]),
|
||||
mapTagNames,
|
||||
}: TagEditorProps = $props();
|
||||
|
||||
let uniqueTags = $state<Set<string>>(new Set());
|
||||
|
||||
$effect.pre(() => {
|
||||
uniqueTags = new Set(tags);
|
||||
});
|
||||
|
||||
let addedTagName = $state<string>('');
|
||||
|
||||
/**
|
||||
* Create a callback function to pass into both mouse & keyboard events for tag removal.
|
||||
* @param tagName Name to remove when clicked.
|
||||
* @return Callback to pass as event listener.
|
||||
*/
|
||||
function createTagRemoveHandler(tagName: string): EventHandler<Event, HTMLElement> {
|
||||
return event => {
|
||||
if (event.type === 'click') {
|
||||
removeTag(tagName);
|
||||
}
|
||||
|
||||
if (event instanceof KeyboardEvent && (event.code === 'Enter' || event.code === 'Space')) {
|
||||
// To be more comfortable, automatically focus next available tag's remove button in the list.
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
const currenTagElement = event.currentTarget.closest('.tag');
|
||||
const nextTagElement = currenTagElement?.previousElementSibling ?? currenTagElement?.parentElement?.firstElementChild;
|
||||
const nextRemoveButton = nextTagElement?.querySelector('.remove');
|
||||
|
||||
if (nextRemoveButton instanceof HTMLElement) {
|
||||
nextRemoveButton.focus();
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(tagName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the tag from the set.
|
||||
* @param tagName Name of the tag to remove.
|
||||
*/
|
||||
function removeTag(tagName: string) {
|
||||
uniqueTags.delete(tagName);
|
||||
tags = Array.from(uniqueTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the tag to the set.
|
||||
* @param tagName Name of the tag to add.
|
||||
*/
|
||||
function addTag(tagName: string) {
|
||||
uniqueTags.add(tagName);
|
||||
tags = Array.from(uniqueTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle adding new tags to the list or removing them when backspace is pressed.
|
||||
*
|
||||
* Additional note: For some reason, mobile Chrome breaks the usual behaviour inside extension. `code` is becoming
|
||||
* empty, while usually it should contain proper button code.
|
||||
*
|
||||
* @param event
|
||||
*/
|
||||
function handleKeyPresses(event: KeyboardEvent) {
|
||||
if ((event.code === 'Enter' || event.key === 'Enter') && addedTagName.length) {
|
||||
addTag(addedTagName)
|
||||
addedTagName = '';
|
||||
}
|
||||
|
||||
if ((event.code === 'Backspace' || event.key === 'Backspace') && !addedTagName.length && tags?.length) {
|
||||
removeTag(tags[tags.length - 1]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tags-editor">
|
||||
{#each uniqueTags.values() as tagName}
|
||||
<div class="tag">
|
||||
{mapTagNames?.(tagName) ?? tagName}
|
||||
<span class="remove" onclick={createTagRemoveHandler(tagName)}
|
||||
onkeydown={createTagRemoveHandler(tagName)}
|
||||
role="button" tabindex="0">x</span>
|
||||
</div>
|
||||
{/each}
|
||||
<input autocapitalize="none"
|
||||
autocomplete="off"
|
||||
bind:value={addedTagName}
|
||||
onkeydown={handleKeyPresses}
|
||||
type="text"/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-editor {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,20 @@
|
||||
<script>
|
||||
/** @type {string|undefined} */
|
||||
export let name = undefined;
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
/** @type {boolean} */
|
||||
export let checked;
|
||||
interface CheckboxFieldProps {
|
||||
name?: string;
|
||||
checked: boolean;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
name = undefined,
|
||||
checked = $bindable(),
|
||||
children
|
||||
}: CheckboxFieldProps = $props();
|
||||
</script>
|
||||
|
||||
<input type="checkbox" {name} bind:checked={checked}>
|
||||
<input bind:checked={checked} {name} type="checkbox">
|
||||
<span>
|
||||
<slot></slot>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface FormContainerProps {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { children }: FormContainerProps = $props();
|
||||
</script>
|
||||
|
||||
<form>
|
||||
<slot></slot>
|
||||
{@render children?.()}
|
||||
</form>
|
||||
|
||||
<style lang="scss">
|
||||
form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let label = undefined;
|
||||
interface FormControlProps {
|
||||
label?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
label = undefined,
|
||||
children
|
||||
}: FormControlProps = $props();
|
||||
</script>
|
||||
|
||||
<label class="control">
|
||||
{#if label}
|
||||
<div class="label">{label}</div>
|
||||
{/if}
|
||||
<slot></slot>
|
||||
{#if label}
|
||||
<div class="label">{label}</div>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</label>
|
||||
|
||||
<style lang="scss">
|
||||
.label {
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
.label {
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
|
||||
.control {
|
||||
padding: 5px 0;
|
||||
.control {
|
||||
padding: 5px 0;
|
||||
|
||||
:global(textarea) {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
:global(textarea) {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,40 +1,45 @@
|
||||
<script>
|
||||
/**
|
||||
* @type {string[]|Record<string, string>}
|
||||
*/
|
||||
export let options = [];
|
||||
<script lang="ts">
|
||||
type SelectFieldOptionsObject = Record<string, string>;
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let name = undefined;
|
||||
interface SelectFieldProps {
|
||||
options?: string[] | SelectFieldOptionsObject;
|
||||
name?: string;
|
||||
id?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let id = undefined;
|
||||
let {
|
||||
options = [],
|
||||
name = undefined,
|
||||
id = undefined,
|
||||
value = $bindable(undefined)
|
||||
}: SelectFieldProps = $props();
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let value = undefined;
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const optionPairs = {};
|
||||
const optionPairs = $derived.by<SelectFieldOptionsObject>(() => {
|
||||
const resultPairs: SelectFieldOptionsObject = {};
|
||||
|
||||
if (Array.isArray(options)) {
|
||||
for (let option of options) {
|
||||
optionPairs[option] = option;
|
||||
}
|
||||
for (let optionName of options) {
|
||||
resultPairs[optionName] = optionName;
|
||||
}
|
||||
} else if (options && typeof options === 'object') {
|
||||
Object.keys(options).forEach((key) => {
|
||||
optionPairs[key] = options[key];
|
||||
})
|
||||
Object.keys(options).forEach(optionKey => {
|
||||
resultPairs[optionKey] = options[optionKey];
|
||||
})
|
||||
}
|
||||
|
||||
return resultPairs;
|
||||
});
|
||||
</script>
|
||||
|
||||
<select {name} {id} bind:value={value}>
|
||||
{#each Object.entries(optionPairs) as [value, label]}
|
||||
<option {value}>{label}</option>
|
||||
{/each}
|
||||
<select bind:value={value} {id} {name}>
|
||||
{#each Object.entries(optionPairs) as [value, label]}
|
||||
<option {value}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<style lang="scss">
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
84
src/components/ui/forms/TagCategorySelectField.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import SelectField from "$components/ui/forms/SelectField.svelte";
|
||||
import { categories } from "$lib/booru/tag-categories";
|
||||
|
||||
interface TagCategorySelectFieldProps {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable('')
|
||||
}: TagCategorySelectFieldProps = $props();
|
||||
|
||||
let tagCategoriesOptions = $derived.by<Record<string, string>>(() => {
|
||||
return categories.reduce<Record<string, string>>((options, category) => {
|
||||
options[category] = category
|
||||
.replace('-', ' ')
|
||||
.replace(/(?<=\s|^)\w/g, (matchedCharacter) => matchedCharacter.toUpperCase());
|
||||
|
||||
return options;
|
||||
}, {
|
||||
'': 'Default'
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<SelectField bind:value={value} name="tag_color" options={tagCategoriesOptions}/>
|
||||
|
||||
<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>
|
||||
@@ -1,18 +1,21 @@
|
||||
<script>
|
||||
/** @type {string|undefined} */
|
||||
export let name = undefined;
|
||||
<script lang="ts">
|
||||
interface TextFieldProps {
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let placeholder = undefined;
|
||||
|
||||
/** @type {string} */
|
||||
export let value = '';
|
||||
let {
|
||||
name = undefined,
|
||||
placeholder = undefined,
|
||||
value = $bindable('')
|
||||
}: TextFieldProps = $props();
|
||||
</script>
|
||||
|
||||
<input type="text" {name} {placeholder} bind:value={value}>
|
||||
<input bind:value={value} {name} {placeholder} type="text">
|
||||
|
||||
<style lang="scss">
|
||||
:global(.control) input {
|
||||
width: 100%;
|
||||
}
|
||||
:global(.control) input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,38 +1,46 @@
|
||||
<script lang="ts">
|
||||
interface MenuProps {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: MenuProps = $props();
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
<slot></slot>
|
||||
{@render children?.()}
|
||||
</nav>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'src/styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > :global(.menu-item) {
|
||||
padding: 5px 24px;
|
||||
}
|
||||
|
||||
:global(.menu-item) {
|
||||
color: colors.$text;
|
||||
|
||||
&:hover {
|
||||
background: colors.$header-mobile-link-hover;
|
||||
}
|
||||
}
|
||||
|
||||
:global(hr) {
|
||||
background: colors.$block-border;
|
||||
margin: .5em 24px;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
:global(main) > & {
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
& > :global(.menu-item) {
|
||||
padding: 5px 24px;
|
||||
}
|
||||
|
||||
:global(.menu-item) {
|
||||
color: colors.$text;
|
||||
|
||||
&:hover {
|
||||
background: colors.$header-mobile-link-hover;
|
||||
}
|
||||
}
|
||||
|
||||
:global(hr) {
|
||||
background: colors.$block-border;
|
||||
margin: .5em 24px;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
:global(main) > & {
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,37 +1,98 @@
|
||||
<script>
|
||||
import MenuLink from "$components/ui/menu/MenuItem.svelte";
|
||||
<script lang="ts">
|
||||
import MenuLink from "$components/ui/menu/MenuItem.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { FormEventHandler, MouseEventHandler } from "svelte/elements";
|
||||
|
||||
interface MenuCheckboxItemProps {
|
||||
checked: boolean;
|
||||
name?: string;
|
||||
value?: string;
|
||||
href?: string;
|
||||
children?: Snippet;
|
||||
/**
|
||||
* @type {boolean}
|
||||
* Click event received by the checkbox input element.
|
||||
*/
|
||||
export let checked;
|
||||
onclick?: MouseEventHandler<HTMLInputElement>;
|
||||
oninput?: FormEventHandler<HTMLInputElement>;
|
||||
/**
|
||||
* Click event received by the menu item instead of the checkbox element.
|
||||
*/
|
||||
onitemclick?: MouseEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {string|undefined}
|
||||
*/
|
||||
export let name = undefined;
|
||||
let checkboxElement: HTMLInputElement;
|
||||
|
||||
/**
|
||||
* @type {string|undefined}
|
||||
*/
|
||||
export let value = undefined;
|
||||
let {
|
||||
checked = $bindable(),
|
||||
name = undefined,
|
||||
value = undefined,
|
||||
href = undefined,
|
||||
children,
|
||||
onclick,
|
||||
oninput,
|
||||
onitemclick,
|
||||
}: MenuCheckboxItemProps = $props();
|
||||
|
||||
/**
|
||||
* @type {string|null}
|
||||
*/
|
||||
export let href = null;
|
||||
/**
|
||||
* Prevent clicks from getting sent to the menu link if user clicked directly on the checkbox.
|
||||
* @param originalEvent
|
||||
*/
|
||||
function stopPropagationAndPassCallback(originalEvent: MouseEvent) {
|
||||
originalEvent.stopPropagation();
|
||||
onclick?.(originalEvent as MouseEvent & { currentTarget: HTMLInputElement });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and try to toggle checkbox if href was not provided for the menu item.
|
||||
*/
|
||||
function maybeToggleCheckboxOnOuterLinkClicked(event: MouseEvent) {
|
||||
// Call the event handler if present.
|
||||
if (onitemclick) {
|
||||
onitemclick(event as MouseEvent & {currentTarget: HTMLElement});
|
||||
|
||||
// If it was prevented, then don't attempt to run checkbox toggling workaround.
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// When menu link does not contain any link, we should just treat clicks on it as toggle action on checkbox.
|
||||
if (!href) {
|
||||
checked = !checked;
|
||||
|
||||
// Since we've toggled it using the `checked` property and input does not trigger `onclick` when we do something
|
||||
// programmatically, we should create valid event and send it back to the parent component so it will handle
|
||||
// whatever it wants.
|
||||
if (oninput) {
|
||||
// Uhh, not sure if this is how it should be done, but we need `currentTarget` to point on the checkbox. Without
|
||||
// dispatching the event, we can't fill it normally. Also, input element does not return us untrusted input
|
||||
// events automatically. Probably should make the util function later in case I'd need something similar.
|
||||
checkboxElement.addEventListener('input', (inputEvent: Event) => {
|
||||
oninput(inputEvent as Event & { currentTarget: HTMLInputElement });
|
||||
}, { once: true })
|
||||
|
||||
checkboxElement.dispatchEvent(new InputEvent('input'));
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<MenuLink {href}>
|
||||
<input type="checkbox" {name} {value} {checked} on:input on:click|stopPropagation>
|
||||
<slot></slot>
|
||||
<MenuLink {href} onclick={maybeToggleCheckboxOnOuterLinkClicked}>
|
||||
<input bind:this={checkboxElement}
|
||||
bind:checked={checked}
|
||||
{name}
|
||||
onclick={stopPropagationAndPassCallback}
|
||||
{oninput}
|
||||
type="checkbox"
|
||||
{value}>
|
||||
{@render children?.()}
|
||||
</MenuLink>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.menu-item) input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
:global(.menu-item) input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,45 @@
|
||||
<script>
|
||||
/**
|
||||
* @type {string|null}
|
||||
*/
|
||||
export let href = null;
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { MouseEventHandler } from "svelte/elements";
|
||||
|
||||
/**
|
||||
* @type {App.IconName|null}
|
||||
*/
|
||||
export let icon = null;
|
||||
interface MenuItemProps {
|
||||
href?: string | null;
|
||||
icon?: App.IconName | null;
|
||||
target?: App.LinkTarget | undefined;
|
||||
children?: Snippet;
|
||||
onclick?: MouseEventHandler<HTMLAnchorElement | HTMLSpanElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {App.LinkTarget|undefined}
|
||||
*/
|
||||
export let target = undefined;
|
||||
let {
|
||||
href = null,
|
||||
icon = null,
|
||||
target = undefined,
|
||||
children,
|
||||
onclick
|
||||
}: MenuItemProps = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element this="{href ? 'a': 'span'}" class="menu-item" {href} {target} on:click role="link" tabindex="0">
|
||||
{#if icon}
|
||||
<i class="icon icon-{icon}"></i>
|
||||
{/if}
|
||||
<slot></slot>
|
||||
<svelte:element class="menu-item" {href} {onclick} role="link" tabindex="0" {target} this="{href ? 'a': 'span'}">
|
||||
{#if icon}
|
||||
<i class="icon icon-{icon}"></i>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../../styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: colors.$text;
|
||||
margin-right: 6px;
|
||||
}
|
||||
i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: colors.$text;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,37 +1,44 @@
|
||||
<script>
|
||||
import MenuLink from "$components/ui/menu/MenuItem.svelte";
|
||||
<script lang="ts">
|
||||
import MenuLink from "$components/ui/menu/MenuItem.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { FormEventHandler, MouseEventHandler } from "svelte/elements";
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
export let checked;
|
||||
interface MenuRadioItemProps {
|
||||
checked: boolean;
|
||||
name: string;
|
||||
value: string;
|
||||
href?: string | null;
|
||||
children?: Snippet;
|
||||
onclick?: MouseEventHandler<HTMLInputElement>;
|
||||
oninput?: FormEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
export let name;
|
||||
let {
|
||||
checked,
|
||||
name,
|
||||
value,
|
||||
href = null,
|
||||
children,
|
||||
onclick,
|
||||
oninput,
|
||||
}: MenuRadioItemProps = $props();
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
export let value;
|
||||
|
||||
/**
|
||||
* @type {string|null}
|
||||
*/
|
||||
export let href = null;
|
||||
function stopPropagationAndPassCallback(originalEvent: MouseEvent) {
|
||||
originalEvent.stopPropagation();
|
||||
onclick?.(originalEvent as MouseEvent & { currentTarget: HTMLInputElement });
|
||||
}
|
||||
</script>
|
||||
|
||||
<MenuLink {href}>
|
||||
<input type="radio" {name} {value} {checked} on:input on:click|stopPropagation>
|
||||
<slot></slot>
|
||||
<input {checked} {name} onclick={stopPropagationAndPassCallback} {oninput} type="radio" {value}>
|
||||
{@render children?.()}
|
||||
</MenuLink>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.menu-item) input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
:global(.menu-item) input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
<script>
|
||||
/**
|
||||
* List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
|
||||
* @type {string[]}
|
||||
*/
|
||||
export let tags = [];
|
||||
|
||||
/** @type {Set<string>} */
|
||||
let uniqueTags = new Set();
|
||||
|
||||
$: uniqueTags = new Set(tags);
|
||||
|
||||
/** @type {string} */
|
||||
let addedTagName = '';
|
||||
|
||||
/**
|
||||
* Create a callback function to pass into both mouse & keyboard events for tag removal.
|
||||
* @param {string} tagName
|
||||
* @return {function(Event)} Callback to pass as event listener.
|
||||
*/
|
||||
function createTagRemoveHandler(tagName) {
|
||||
return event => {
|
||||
if (event.type === 'click') {
|
||||
removeTag(tagName);
|
||||
}
|
||||
|
||||
if (event instanceof KeyboardEvent && (event.code === 'Enter' || event.code === 'Space')) {
|
||||
// To be more comfortable, automatically focus next available tag's remove button in the list.
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
const currenTagElement = event.currentTarget.closest('.tag');
|
||||
const nextTagElement = currenTagElement?.previousElementSibling ?? currenTagElement?.parentElement?.firstElementChild;
|
||||
const nextRemoveButton = nextTagElement?.querySelector('.remove');
|
||||
|
||||
if (nextRemoveButton instanceof HTMLElement) {
|
||||
nextRemoveButton.focus();
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(tagName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tagName
|
||||
*/
|
||||
function removeTag(tagName) {
|
||||
uniqueTags.delete(tagName);
|
||||
tags = Array.from(uniqueTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tagName
|
||||
*/
|
||||
function addTag(tagName) {
|
||||
uniqueTags.add(tagName);
|
||||
tags = Array.from(uniqueTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle adding new tags to the list or removing them when backspace is pressed.
|
||||
*
|
||||
* Additional note: For some reason, mobile Chrome breaks the usual behaviour inside extension. `code` is becoming
|
||||
* empty, while usually it should contain proper button code.
|
||||
*
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
function handleKeyPresses(event) {
|
||||
if ((event.code === 'Enter' || event.key === 'Enter') && addedTagName.length) {
|
||||
addTag(addedTagName)
|
||||
addedTagName = '';
|
||||
}
|
||||
|
||||
if ((event.code === 'Backspace' || event.key === 'Backspace') && !addedTagName.length && tags?.length) {
|
||||
removeTag(tags[tags.length - 1]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tags-editor">
|
||||
{#each uniqueTags.values() as tagName}
|
||||
<div class="tag">
|
||||
{tagName}
|
||||
<span class="remove" on:click={createTagRemoveHandler(tagName)}
|
||||
on:keydown={createTagRemoveHandler(tagName)}
|
||||
role="button" tabindex="0">x</span>
|
||||
</div>
|
||||
{/each}
|
||||
<input type="text"
|
||||
bind:value={addedTagName}
|
||||
on:keydown={handleKeyPresses}
|
||||
autocomplete="off"
|
||||
autocapitalize="none"/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-editor {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
83
src/config/tags.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export const tagsBlacklist: string[] = (__CURRENT_SITE__ === 'furbooru' ? [
|
||||
"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"
|
||||
] : [
|
||||
"tagme",
|
||||
"tag me",
|
||||
"not tagged",
|
||||
"no tag",
|
||||
"notag",
|
||||
"notags",
|
||||
"upvotes galore",
|
||||
"downvotes galore",
|
||||
"wall of faves",
|
||||
"drama in the comments",
|
||||
"drama in comments",
|
||||
"tag needed",
|
||||
"paywall",
|
||||
"cringeworthy",
|
||||
"solo oc",
|
||||
"tag your shit"
|
||||
]);
|
||||
53
src/content/deps/amd.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { amdLite } from "amd-lite";
|
||||
|
||||
const originalDefine = amdLite.define;
|
||||
|
||||
/**
|
||||
* Set of already defined modules. Used for deduplication.
|
||||
*/
|
||||
const definedModules = new Set<string>();
|
||||
|
||||
/**
|
||||
* Throttle timer to make sure only one attempt at loading modules will run for a batch of loaded scripts.
|
||||
*/
|
||||
let throttledAutoRunTimer: NodeJS.Timeout | number | undefined;
|
||||
|
||||
/**
|
||||
* Schedule the automatic resolving of all waiting modules on the next available frame.
|
||||
*/
|
||||
function scheduleModulesAutoRun() {
|
||||
clearTimeout(throttledAutoRunTimer);
|
||||
|
||||
throttledAutoRunTimer = setTimeout(() => {
|
||||
amdLite.resolveDependencies(Object.keys(amdLite.waitingModules));
|
||||
});
|
||||
}
|
||||
|
||||
amdLite.define = (name, dependencies, originalCallback) => {
|
||||
// Chrome doesn't run the same content script multiple times, while Firefox does. Since each content script and their
|
||||
// chunks are intended to be run only once, we should just ignore any attempts of running the same module more than
|
||||
// once. Names of the modules are assumed to be unique.
|
||||
if (definedModules.has(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
definedModules.add(name);
|
||||
|
||||
originalDefine(name, dependencies, function () {
|
||||
const callbackResult = originalCallback(...arguments);
|
||||
|
||||
// Workaround for the entry script not returning anything causing AMD-Lite to send warning about modules not
|
||||
// being loaded/not existing.
|
||||
return typeof callbackResult !== 'undefined' ? callbackResult : {};
|
||||
});
|
||||
|
||||
// Schedule the auto run on the next available frame. Firefox and Chromium have a lot of differences in how they
|
||||
// decide to execute content scripts. For example, Firefox might decide to skip a frame before attempting to load
|
||||
// different groups of them. Chromium on the other hand doesn't have that issue, but it doesn't allow us to, for
|
||||
// example, schedule a microtask to run the modules.
|
||||
scheduleModulesAutoRun();
|
||||
}
|
||||
|
||||
amdLite.init({
|
||||
publicScope: window
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import {initializeSiteHeader} from "$lib/components/SiteHeaderWrapper.js";
|
||||
|
||||
const siteHeader = document.querySelector('.header');
|
||||
|
||||
if (siteHeader) {
|
||||
initializeSiteHeader(siteHeader);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import {createMaintenancePopup} from "$lib/components/MaintenancePopup.js";
|
||||
import {createMediaBoxTools} from "$lib/components/MediaBoxTools.js";
|
||||
import {calculateMediaBoxesPositions, initializeMediaBox} from "$lib/components/MediaBoxWrapper.js";
|
||||
import {createMaintenanceStatusIcon} from "$lib/components/MaintenanceStatusIcon.js";
|
||||
import {createImageShowFullscreenButton} from "$lib/components/ImageShowFullscreenButton.js";
|
||||
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
const mediaBoxes = document.querySelectorAll('.media-box');
|
||||
|
||||
mediaBoxes.forEach(mediaBoxElement => {
|
||||
initializeMediaBox(mediaBoxElement, [
|
||||
createMediaBoxTools(
|
||||
createMaintenancePopup(),
|
||||
createMaintenanceStatusIcon(),
|
||||
createImageShowFullscreenButton(),
|
||||
)
|
||||
]);
|
||||
|
||||
// Attempt to fix misplacement of media boxes
|
||||
requestAnimationFrame(() => {
|
||||
window.dispatchEvent(new CustomEvent('resize'));
|
||||
})
|
||||
});
|
||||
|
||||
calculateMediaBoxesPositions(mediaBoxes);
|
||||
30
src/content/listing.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createMaintenancePopup } from "$lib/components/MaintenancePopup";
|
||||
import { createMediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
import { calculateMediaBoxesPositions, initializeMediaBox } from "$lib/components/MediaBoxWrapper";
|
||||
import { createMaintenanceStatusIcon } from "$lib/components/MaintenanceStatusIcon";
|
||||
import { createImageShowFullscreenButton } from "$lib/components/ImageShowFullscreenButton";
|
||||
import { initializeImageListContainer } from "$lib/components/listing/ImageListContainer";
|
||||
|
||||
const mediaBoxes = document.querySelectorAll<HTMLElement>('.media-box');
|
||||
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
|
||||
|
||||
mediaBoxes.forEach(mediaBoxElement => {
|
||||
initializeMediaBox(mediaBoxElement, [
|
||||
createMediaBoxTools(
|
||||
createMaintenancePopup(),
|
||||
createMaintenanceStatusIcon(),
|
||||
createImageShowFullscreenButton(),
|
||||
)
|
||||
]);
|
||||
|
||||
// Attempt to fix misplacement of media boxes
|
||||
requestAnimationFrame(() => {
|
||||
window.dispatchEvent(new CustomEvent('resize'));
|
||||
})
|
||||
});
|
||||
|
||||
calculateMediaBoxesPositions(mediaBoxes);
|
||||
|
||||
if (imageListContainer) {
|
||||
initializeImageListContainer(imageListContainer);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import {TagsForm} from "$lib/components/TagsForm.js";
|
||||
|
||||
TagsForm.watchForEditors();
|
||||
6
src/content/tags-editor.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { TagsForm } from "$lib/components/TagsForm";
|
||||
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$lib/components/TagsListBlock";
|
||||
|
||||
initializeAllTagsLists();
|
||||
watchForUpdatedTagLists();
|
||||
TagsForm.watchForEditors();
|
||||
@@ -1,7 +0,0 @@
|
||||
import {watchTagDropdownsInTagsEditor, wrapTagDropdown} from "$lib/components/TagDropdownWrapper.js";
|
||||
|
||||
for (let tagDropdownElement of document.querySelectorAll('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
|
||||
watchTagDropdownsInTagsEditor();
|
||||
7
src/content/tags.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$lib/components/TagDropdownWrapper";
|
||||
|
||||
for (let tagDropdownElement of document.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
|
||||
watchTagDropdownsInTagsEditor();
|
||||
@@ -1,8 +1,13 @@
|
||||
/** @type {import('@sveltejs/kit').Reroute} */
|
||||
export function reroute({url}) {
|
||||
import type { Reroute } from "@sveltejs/kit";
|
||||
|
||||
export const reroute: Reroute = ({url}) => {
|
||||
// Reroute index.html as just / for the root.
|
||||
// Browser extension starts from with the index.html file in the pathname which is not correct for the router.
|
||||
if (url.pathname === '/index.html') {
|
||||
if (url.searchParams.has('path')) {
|
||||
return url.searchParams.get('path')!;
|
||||
}
|
||||
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Build the map containing both real tags and their aliases.
|
||||
*
|
||||
* @param {string[]} realAndAliasedTags List combining aliases and tag names.
|
||||
* @param {string[]} realTags List of actual tag names, excluding aliases.
|
||||
*
|
||||
* @return {Map<string, string>} Map where key is a tag or alias and value is an actual tag name.
|
||||
*/
|
||||
export function buildTagsAndAliasesMap(realAndAliasedTags, realTags) {
|
||||
/** @type {Map<string, string>} */
|
||||
const tagsAndAliasesMap = new Map();
|
||||
|
||||
for (let tagName of realTags) {
|
||||
tagsAndAliasesMap.set(tagName, tagName);
|
||||
}
|
||||
|
||||
let realTagName = null;
|
||||
|
||||
for (let tagNameOrAlias of realAndAliasedTags) {
|
||||
if (tagsAndAliasesMap.has(tagNameOrAlias)) {
|
||||
realTagName = tagNameOrAlias;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!realTagName) {
|
||||
console.warn('No real tag found for the alias:', tagNameOrAlias);
|
||||
continue;
|
||||
}
|
||||
|
||||
tagsAndAliasesMap.set(tagNameOrAlias, realTagName);
|
||||
}
|
||||
|
||||
return tagsAndAliasesMap;
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
import PostParser from "$lib/booru/scraped/parsing/PostParser.js";
|
||||
import PostParser from "$lib/booru/scraped/parsing/PostParser";
|
||||
|
||||
type UpdaterFunction = (tags: Set<string>) => Set<string>;
|
||||
|
||||
export default class ScrapedAPI {
|
||||
/**
|
||||
* Update the tags of the image using callback.
|
||||
* @param {number} imageId ID of the image.
|
||||
* @param {function(Set<string>): Set<string>} callback Callback to call to change the content.
|
||||
* @return {Promise<Map<string,string>|null>} Updated tags and aliases list for updating internal cached state.
|
||||
* @param imageId ID of the image.
|
||||
* @param callback Callback to call to change the content.
|
||||
* @return Updated tags and aliases list for updating internal cached state.
|
||||
*/
|
||||
async updateImageTags(imageId, callback) {
|
||||
async updateImageTags(imageId: number, callback: UpdaterFunction): Promise<Map<string, string> | null> {
|
||||
const postParser = new PostParser(imageId);
|
||||
const formData = await postParser.resolveTagEditorFormData();
|
||||
const tagsFieldValue = formData.get(PostParser.tagsInputName);
|
||||
|
||||
if (typeof tagsFieldValue !== 'string') {
|
||||
throw new Error('Missing tags field!');
|
||||
}
|
||||
|
||||
const tagsList = new Set(
|
||||
formData
|
||||
.get(PostParser.tagsInputName)
|
||||
tagsFieldValue
|
||||
.split(',')
|
||||
.map(tagName => tagName.trim())
|
||||
);
|
||||
@@ -1,17 +1,12 @@
|
||||
export default class PageParser {
|
||||
/** @type {string} */
|
||||
#url;
|
||||
/** @type {DocumentFragment|null} */
|
||||
#fragment = null;
|
||||
readonly #url: string;
|
||||
#fragment: DocumentFragment | null = null;
|
||||
|
||||
constructor(url) {
|
||||
constructor(url: string) {
|
||||
this.#url = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<DocumentFragment>}
|
||||
*/
|
||||
async resolveFragment() {
|
||||
async resolveFragment(): Promise<DocumentFragment> {
|
||||
if (this.#fragment) {
|
||||
return this.#fragment;
|
||||
}
|
||||
@@ -34,12 +29,12 @@ export default class PageParser {
|
||||
/**
|
||||
* Create a document fragment from the following response.
|
||||
*
|
||||
* @param {Response} response Response to create a fragment from. Note, that this response will be used. If you need
|
||||
* to use the same response somewhere else, then you need to pass a cloned version of the response.
|
||||
* @param response Response to create a fragment from. Note, that this response will be used. If you need to use the
|
||||
* same response somewhere else, then you need to pass a cloned version of the response.
|
||||
*
|
||||
* @return {Promise<DocumentFragment>} Resulting document fragment ready for processing.
|
||||
* @return Resulting document fragment ready for processing.
|
||||
*/
|
||||
static async resolveFragmentFromResponse(response) {
|
||||
static async resolveFragmentFromResponse(response: Response): Promise<DocumentFragment> {
|
||||
const documentFragment = document.createDocumentFragment();
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = await response.text();
|
||||
@@ -1,81 +0,0 @@
|
||||
import PageParser from "$lib/booru/scraped/parsing/PageParser.js";
|
||||
import {buildTagsAndAliasesMap} from "$lib/booru/TagsUtils.js";
|
||||
|
||||
export default class PostParser extends PageParser {
|
||||
/** @type {HTMLFormElement} */
|
||||
#tagEditorForm;
|
||||
|
||||
constructor(imageId) {
|
||||
super(`/images/${imageId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<HTMLFormElement>}
|
||||
*/
|
||||
async resolveTagEditorForm() {
|
||||
if (this.#tagEditorForm) {
|
||||
return this.#tagEditorForm;
|
||||
}
|
||||
|
||||
const documentFragment = await this.resolveFragment();
|
||||
const tagsFormElement = documentFragment.querySelector("#tags-form");
|
||||
|
||||
if (!tagsFormElement) {
|
||||
throw new Error("Failed to find the tag editor form");
|
||||
}
|
||||
|
||||
this.#tagEditorForm = tagsFormElement;
|
||||
|
||||
return tagsFormElement;
|
||||
}
|
||||
|
||||
async resolveTagEditorFormData() {
|
||||
return new FormData(
|
||||
await this.resolveTagEditorForm()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the tags and aliases mapping from the post page.
|
||||
*
|
||||
* @return {Promise<Map<string, string>|null>}
|
||||
*/
|
||||
async resolveTagsAndAliases() {
|
||||
return PostParser.resolveTagsAndAliasesFromPost(
|
||||
await this.resolveFragment()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the list of tags and aliases from the post content.
|
||||
*
|
||||
* @param {DocumentFragment} documentFragment Real content to parse the data from.
|
||||
*
|
||||
* @return {Map<string, string>|null} Tags and aliases or null if failed to parse.
|
||||
*/
|
||||
static resolveTagsAndAliasesFromPost(documentFragment) {
|
||||
const imageShowContainer = documentFragment.querySelector('.image-show-container');
|
||||
const tagsForm = documentFragment.querySelector('#tags-form');
|
||||
|
||||
if (!imageShowContainer || !tagsForm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tagsFormData = new FormData(tagsForm);
|
||||
|
||||
const tagsAndAliasesList = imageShowContainer.dataset.imageTagAliases
|
||||
.split(',')
|
||||
.map(tagName => tagName.trim());
|
||||
|
||||
const actualTagsList = tagsFormData.get(this.tagsInputName)
|
||||
.split(',')
|
||||
.map(tagName => tagName.trim());
|
||||
|
||||
return buildTagsAndAliasesMap(
|
||||
tagsAndAliasesList,
|
||||
actualTagsList,
|
||||
);
|
||||
}
|
||||
|
||||
static tagsInputName = 'image[tag_input]';
|
||||
}
|
||||
82
src/lib/booru/scraped/parsing/PostParser.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import PageParser from "$lib/booru/scraped/parsing/PageParser";
|
||||
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
|
||||
|
||||
export default class PostParser extends PageParser {
|
||||
#tagEditorForm: HTMLFormElement | null = null;
|
||||
|
||||
constructor(imageId: number) {
|
||||
super(`/images/${imageId}`);
|
||||
}
|
||||
|
||||
async resolveTagEditorForm(): Promise<HTMLFormElement> {
|
||||
if (this.#tagEditorForm) {
|
||||
return this.#tagEditorForm;
|
||||
}
|
||||
|
||||
const documentFragment = await this.resolveFragment();
|
||||
const tagsFormElement = documentFragment.querySelector<HTMLFormElement>("#tags-form");
|
||||
|
||||
if (!tagsFormElement) {
|
||||
throw new Error("Failed to find the tag editor form");
|
||||
}
|
||||
|
||||
this.#tagEditorForm = tagsFormElement;
|
||||
|
||||
return tagsFormElement;
|
||||
}
|
||||
|
||||
async resolveTagEditorFormData() {
|
||||
return new FormData(
|
||||
await this.resolveTagEditorForm()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the tags and aliases mapping from the post page.
|
||||
*/
|
||||
async resolveTagsAndAliases(): Promise<Map<string, string> | null> {
|
||||
return PostParser.resolveTagsAndAliasesFromPost(
|
||||
await this.resolveFragment()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the list of tags and aliases from the post content.
|
||||
*
|
||||
* @param documentFragment Real content to parse the data from.
|
||||
*
|
||||
* @return Tags and aliases or null if failed to parse.
|
||||
*/
|
||||
static resolveTagsAndAliasesFromPost(documentFragment: DocumentFragment): Map<string, string> | null {
|
||||
const imageShowContainer = documentFragment.querySelector<HTMLElement>('.image-show-container');
|
||||
const tagsForm = documentFragment.querySelector<HTMLFormElement>('#tags-form');
|
||||
|
||||
if (!imageShowContainer || !tagsForm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tagsFormData = new FormData(tagsForm);
|
||||
const tagsAndAliasesValue = imageShowContainer.dataset.imageTagAliases;
|
||||
const tagsValue = tagsFormData.get(this.tagsInputName);
|
||||
|
||||
if (!tagsAndAliasesValue || !tagsValue || typeof tagsValue !== 'string') {
|
||||
console.warn('Failed to locate tags & aliases!');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tagsAndAliasesList = tagsAndAliasesValue
|
||||
.split(',')
|
||||
.map(tagName => tagName.trim());
|
||||
|
||||
const actualTagsList = tagsValue
|
||||
.split(',')
|
||||
.map(tagName => tagName.trim());
|
||||
|
||||
return buildTagsAndAliasesMap(
|
||||
tagsAndAliasesList,
|
||||
actualTagsList,
|
||||
);
|
||||
}
|
||||
|
||||
static tagsInputName = 'image[tag_input]';
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
export class Token {
|
||||
index;
|
||||
value;
|
||||
readonly index: number;
|
||||
readonly value: string;
|
||||
|
||||
constructor(index, value) {
|
||||
constructor(index: number, value: string) {
|
||||
this.index = index;
|
||||
this.value = value;
|
||||
}
|
||||
@@ -28,12 +28,9 @@ export class BoostToken extends Token {
|
||||
}
|
||||
|
||||
export class QuotedTermToken extends Token {
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
#quotedValue;
|
||||
readonly #quotedValue: string;
|
||||
|
||||
constructor(index, value, quotedValue) {
|
||||
constructor(index: number, value: string, quotedValue: string) {
|
||||
super(index, value);
|
||||
|
||||
this.#quotedValue = quotedValue;
|
||||
@@ -43,19 +40,11 @@ export class QuotedTermToken extends Token {
|
||||
return QuotedTermToken.decode(this.#quotedValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @return {string}
|
||||
*/
|
||||
static decode(value) {
|
||||
static decode(value: string): string {
|
||||
return value.replace(/\\([\\"])/g, "$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @return {string}
|
||||
*/
|
||||
static encode(value) {
|
||||
static encode(value: string): string {
|
||||
return value.replace(/[\\"]/g, "\\$&");
|
||||
}
|
||||
}
|
||||
@@ -63,6 +52,10 @@ export class QuotedTermToken extends Token {
|
||||
export class TermToken extends Token {
|
||||
}
|
||||
|
||||
type MatchResultCarry = {
|
||||
match?: RegExpMatchArray | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Search query tokenizer. Should mostly work for the cases of parsing and finding the selected term for
|
||||
* auto-completion. Follows the rules described in the Philomena booru engine.
|
||||
@@ -70,38 +63,28 @@ export class TermToken extends Token {
|
||||
export class QueryLexer {
|
||||
/**
|
||||
* The original value to be parsed.
|
||||
* @type {string}
|
||||
*/
|
||||
#value;
|
||||
readonly #value: string;
|
||||
|
||||
/**
|
||||
* Current position of the parser in the value.
|
||||
* @type {number}
|
||||
*/
|
||||
#index = 0;
|
||||
#index: number = 0;
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
constructor(value) {
|
||||
constructor(value: string) {
|
||||
this.#value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the query and get the list of tokens.
|
||||
*
|
||||
* @return {Token[]} List of tokens.
|
||||
* @return List of tokens.
|
||||
*/
|
||||
parse() {
|
||||
/** @type {Token[]} */
|
||||
const tokens = [];
|
||||
parse(): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
const result: MatchResultCarry = {};
|
||||
|
||||
/**
|
||||
* @type {{match: RegExpMatchArray|null}}
|
||||
*/
|
||||
const result = {};
|
||||
|
||||
let dirtyText;
|
||||
let dirtyText: string;
|
||||
|
||||
while (this.#index < this.#value.length) {
|
||||
if (this.#value[this.#index] === QueryLexer.#commaCharacter) {
|
||||
@@ -111,26 +94,26 @@ export class QueryLexer {
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#negotiationOperator, result)) {
|
||||
tokens.push(new NotToken(this.#index, result.match[0]));
|
||||
this.#index += result.match[0].length;
|
||||
tokens.push(new NotToken(this.#index, result.match![0]));
|
||||
this.#index += result.match![0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#andOperator, result)) {
|
||||
tokens.push(new AndToken(this.#index, result.match[0]));
|
||||
this.#index += result.match[0].length;
|
||||
tokens.push(new AndToken(this.#index, result.match![0]));
|
||||
this.#index += result.match![0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#orOperator, result)) {
|
||||
tokens.push(new OrToken(this.#index, result.match[0]));
|
||||
this.#index += result.match[0].length;
|
||||
tokens.push(new OrToken(this.#index, result.match![0]));
|
||||
this.#index += result.match![0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#notOperator, result)) {
|
||||
tokens.push(new NotToken(this.#index, result.match[0]));
|
||||
this.#index += result.match[0].length;
|
||||
tokens.push(new NotToken(this.#index, result.match![0]));
|
||||
this.#index += result.match![0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -147,19 +130,19 @@ export class QueryLexer {
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#boostOperator, result)) {
|
||||
tokens.push(new BoostToken(this.#index, result.match[0]));
|
||||
this.#index += result.match[0].length;
|
||||
tokens.push(new BoostToken(this.#index, result.match![0]));
|
||||
this.#index += result.match![0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#whitespaces, result)) {
|
||||
this.#index += result.match[0].length;
|
||||
this.#index += result.match![0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.#match(QueryLexer.#quotedText, result)) {
|
||||
tokens.push(new QuotedTermToken(this.#index, result.match[0], result.match[1]));
|
||||
this.#index += result.match[0].length;
|
||||
tokens.push(new QuotedTermToken(this.#index, result.match![0], result.match![1]));
|
||||
this.#index += result.match![0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -180,25 +163,25 @@ export class QueryLexer {
|
||||
/**
|
||||
* Match the provided regular expression on the string with the current parser position.
|
||||
*
|
||||
* @param {RegExp} targetRegExp Target RegExp to parse with.
|
||||
* @param {{match: any}} [resultCarrier] Object for passing the results into.
|
||||
* @param targetRegExp Target RegExp to parse with.
|
||||
* @param [resultCarrier] Object for passing the results into.
|
||||
*
|
||||
* @return {boolean} Is there a match?
|
||||
* @return Is there a match?
|
||||
*/
|
||||
#match(targetRegExp, resultCarrier = {}) {
|
||||
#match(targetRegExp: RegExp, resultCarrier: MatchResultCarry = {}): boolean {
|
||||
return this.#matchAt(targetRegExp, this.#index, resultCarrier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the provided regular expression in the string with the specific index.
|
||||
*
|
||||
* @param {RegExp} targetRegExp Target RegExp to parse with.
|
||||
* @param {number} index Index to match the expression from.
|
||||
* @param {{match: any}} [resultCarrier] Object for passing the results into.
|
||||
* @param targetRegExp Target RegExp to parse with.
|
||||
* @param index Index to match the expression from.
|
||||
* @param [resultCarrier] Object for passing the results into.
|
||||
*
|
||||
* @return {boolean} Is there a match?
|
||||
* @return Is there a match?
|
||||
*/
|
||||
#matchAt(targetRegExp, index, resultCarrier = {}) {
|
||||
#matchAt(targetRegExp: RegExp, index: number, resultCarrier: MatchResultCarry = {}): boolean {
|
||||
targetRegExp.lastIndex = index;
|
||||
resultCarrier.match = this.#value.match(targetRegExp);
|
||||
|
||||
@@ -212,11 +195,10 @@ export class QueryLexer {
|
||||
*
|
||||
* @return {string} Matched text.
|
||||
*/
|
||||
#parseDirtyText(index) {
|
||||
let resultValue = '';
|
||||
#parseDirtyText(index: number): string {
|
||||
let resultValue: string = '';
|
||||
|
||||
/** @type {{match: RegExpMatchArray|null}} */
|
||||
const result = {match: null};
|
||||
const result: MatchResultCarry = {match: null};
|
||||
|
||||
// Loop over
|
||||
while (index < this.#value.length) {
|
||||
@@ -226,8 +208,8 @@ export class QueryLexer {
|
||||
}
|
||||
|
||||
if (this.#matchAt(QueryLexer.#dirtyTextContent, index, result)) {
|
||||
resultValue += result.match[0];
|
||||
index += result.match[0].length;
|
||||
resultValue += result.match![0];
|
||||
index += result.match![0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
12
src/lib/booru/tag-categories.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const categories = [
|
||||
'rating',
|
||||
'spoiler',
|
||||
'origin',
|
||||
'oc',
|
||||
'error',
|
||||
'character',
|
||||
'content-official',
|
||||
'content-fanmade',
|
||||
'species',
|
||||
'body-type',
|
||||
];
|
||||
33
src/lib/booru/tag-utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Build the map containing both real tags and their aliases.
|
||||
*
|
||||
* @param realAndAliasedTags List combining aliases and tag names.
|
||||
* @param realTags List of actual tag names, excluding aliases.
|
||||
*
|
||||
* @return Map where key is a tag or alias and value is an actual tag name.
|
||||
*/
|
||||
export function buildTagsAndAliasesMap(realAndAliasedTags: string[], realTags: string[]): Map<string, string> {
|
||||
const tagsAndAliasesMap: Map<string, string> = new Map();
|
||||
|
||||
for (const tagName of realTags) {
|
||||
tagsAndAliasesMap.set(tagName, tagName);
|
||||
}
|
||||
|
||||
let realTagName: string | null = null;
|
||||
|
||||
for (const tagNameOrAlias of realAndAliasedTags) {
|
||||
if (tagsAndAliasesMap.has(tagNameOrAlias)) {
|
||||
realTagName = tagNameOrAlias;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!realTagName) {
|
||||
console.warn('No real tag found for the alias:', tagNameOrAlias);
|
||||
continue;
|
||||
}
|
||||
|
||||
tagsAndAliasesMap.set(tagNameOrAlias, realTagName);
|
||||
}
|
||||
|
||||
return tagsAndAliasesMap;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Helper class to read and write JSON objects to the local storage.
|
||||
* @class
|
||||
*/
|
||||
class StorageHelper {
|
||||
/**
|
||||
* @type {import('@types/chrome').storage.StorageArea}
|
||||
*/
|
||||
#storageArea;
|
||||
|
||||
/**
|
||||
* @param {import('@types/chrome').storage.StorageArea} storageArea
|
||||
*/
|
||||
constructor(storageArea) {
|
||||
this.#storageArea = storageArea;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the following entry from the local storage as a JSON object.
|
||||
*
|
||||
* @param {string} key Key of the entry to read.
|
||||
* @param {any} defaultValue Default value to return if the entry does not exist.
|
||||
*
|
||||
* @return {Promise<any>} The JSON object or the default value if the entry does not exist.
|
||||
*/
|
||||
async read(key, defaultValue = null) {
|
||||
return (await this.#storageArea.get(key))?.[key] || defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the following JSON object to the local storage.
|
||||
*
|
||||
* @param {string} key Key of the entry to write.
|
||||
* @param {any} value JSON object to write.
|
||||
*/
|
||||
write(key, value) {
|
||||
void this.#storageArea.set({[key]: value});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to changes in the local storage.
|
||||
* @param {function(Record<string, StorageChange>): void} callback
|
||||
*/
|
||||
subscribe(callback) {
|
||||
this.#storageArea.onChanged.addListener(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from changes in the local storage.
|
||||
* @param {function(Record<string, StorageChange>): void} callback
|
||||
*/
|
||||
unsubscribe(callback) {
|
||||
this.#storageArea.onChanged.removeListener(callback);
|
||||
}
|
||||
}
|
||||
|
||||
export default StorageHelper;
|
||||
53
src/lib/browser/StorageHelper.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Changes subscribe function. It receives changes with old and new value for keys of the storage.
|
||||
*/
|
||||
export type StorageChangeSubscriber = (changes: Record<string, chrome.storage.StorageChange>) => void;
|
||||
|
||||
/**
|
||||
* Helper class to read and write JSON objects to the local storage.
|
||||
*/
|
||||
export default class StorageHelper {
|
||||
readonly #storageArea: chrome.storage.StorageArea;
|
||||
|
||||
constructor(storageArea: chrome.storage.StorageArea) {
|
||||
this.#storageArea = storageArea;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the following entry from the local storage as a JSON object.
|
||||
*
|
||||
* @param key Key of the entry to read.
|
||||
* @param defaultValue Default value to return if the entry does not exist.
|
||||
*
|
||||
* @return The JSON object or the default value if the entry does not exist.
|
||||
*/
|
||||
async read<Type = any, DefaultType = any>(key: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
|
||||
return (await this.#storageArea.get(key))?.[key] ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the following JSON object to the local storage.
|
||||
*
|
||||
* @param key Key of the entry to write.
|
||||
* @param value Value to write.
|
||||
*/
|
||||
write(key: string, value: any): void {
|
||||
void this.#storageArea.set({[key]: value});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to changes in the local storage.
|
||||
* @param callback Listener function to receive changes.
|
||||
*/
|
||||
subscribe(callback: StorageChangeSubscriber): void {
|
||||
this.#storageArea.onChanged.addListener(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from changes in the local storage.
|
||||
* @param callback Reference to the callback for unsubscribing.
|
||||
*/
|
||||
unsubscribe(callback: StorageChangeSubscriber): void {
|
||||
this.#storageArea.onChanged.removeListener(callback);
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
|
||||
export class FullscreenViewer extends BaseComponent {
|
||||
/** @type {HTMLVideoElement} */
|
||||
#videoElement;
|
||||
/** @type {HTMLImageElement} */
|
||||
#imageElement;
|
||||
|
||||
/** @type {number|null} */
|
||||
#touchId = null;
|
||||
/** @type {number|null} */
|
||||
#startX = null;
|
||||
/** @type {number|null} */
|
||||
#startY = null;
|
||||
/** @type {boolean|null} */
|
||||
#isClosingSwipeStarted = null;
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
build() {
|
||||
this.container.classList.add('fullscreen-viewer');
|
||||
|
||||
this.#videoElement = document.createElement('video');
|
||||
this.#imageElement = document.createElement('img');
|
||||
}
|
||||
|
||||
/**
|
||||
* @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));
|
||||
}
|
||||
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
|
||||
#close() {
|
||||
this.container.classList.remove(FullscreenViewer.#shownState);
|
||||
document.body.style.overflow = null;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.#videoElement.volume = 0;
|
||||
this.#videoElement.pause();
|
||||
this.#videoElement.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
*/
|
||||
show(url) {
|
||||
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 #offsetProperty = '--offset';
|
||||
static #opacityProperty = '--opacity';
|
||||
static #shownState = 'shown';
|
||||
static #swipeState = 'swiped';
|
||||
static #minRequiredDistance = 50;
|
||||
}
|
||||
308
src/lib/components/FullscreenViewer.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
import { emit, on } from "$lib/components/events/comms";
|
||||
import { EVENT_SIZE_LOADED } from "$lib/components/events/fullscreen-viewer-events";
|
||||
|
||||
export class FullscreenViewer extends BaseComponent {
|
||||
#videoElement: HTMLVideoElement = document.createElement('video');
|
||||
#imageElement: HTMLImageElement = document.createElement('img');
|
||||
#spinnerElement: HTMLElement = document.createElement('i');
|
||||
#sizeSelectorElement: HTMLSelectElement = document.createElement('select');
|
||||
#closeButtonElement: HTMLElement = document.createElement('i');
|
||||
#touchId: number | null = null;
|
||||
#startX: number | null = null;
|
||||
#startY: number | null = null;
|
||||
#isClosingSwipeStarted: boolean | null = null;
|
||||
#isSizeFetched: boolean = false;
|
||||
#currentURIs: App.ImageURIs | null = null;
|
||||
|
||||
protected build() {
|
||||
this.container.classList.add('fullscreen-viewer');
|
||||
|
||||
this.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');
|
||||
}
|
||||
|
||||
#onTouchStart(event: TouchEvent) {
|
||||
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);
|
||||
}
|
||||
|
||||
#onTouchEnd(event: TouchEvent) {
|
||||
if (this.#touchId === null || this.#startY === 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);
|
||||
});
|
||||
}
|
||||
|
||||
#onTouchMove(event: TouchEvent) {
|
||||
if (this.#touchId === null || this.#startY === null || this.#startX === 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;
|
||||
}
|
||||
}
|
||||
|
||||
#onDocumentKeyPressed(event: KeyboardEvent) {
|
||||
if (event.code === 'Escape' || event.code === 'Esc') {
|
||||
this.#close();
|
||||
}
|
||||
}
|
||||
|
||||
#onSizeResolved(size: FullscreenViewerSize) {
|
||||
this.#sizeSelectorElement.value = size;
|
||||
this.#isSizeFetched = true;
|
||||
|
||||
emit(this.container, EVENT_SIZE_LOADED, size);
|
||||
}
|
||||
|
||||
#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.removeProperty('overflow');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.#videoElement.volume = 0;
|
||||
this.#videoElement.pause();
|
||||
this.#videoElement.remove();
|
||||
});
|
||||
}
|
||||
|
||||
async #resolveCurrentSelectedSizeUrl(imageUris: App.ImageURIs): Promise<string | null> {
|
||||
if (!this.#isSizeFetched) {
|
||||
await new Promise(
|
||||
resolve => on(
|
||||
this.container,
|
||||
EVENT_SIZE_LOADED,
|
||||
resolve
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let targetSize: FullscreenViewerSize | string = 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 as FullscreenViewerSize];
|
||||
}
|
||||
|
||||
async show(imageUris: App.ImageURIs): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
static #isVideoUrl(url: string): boolean {
|
||||
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;
|
||||
|
||||
static #previewSizes: Record<FullscreenViewerSize, string> = {
|
||||
full: 'Full',
|
||||
large: 'Large',
|
||||
medium: 'Medium',
|
||||
small: 'Small'
|
||||
}
|
||||
|
||||
static #fallbackSize = 'large';
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
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 {FullscreenViewer} from "$lib/components/FullscreenViewer.js";
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings";
|
||||
import { FullscreenViewer } from "$lib/components/FullscreenViewer";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
|
||||
export class ImageShowFullscreenButton extends BaseComponent {
|
||||
/**
|
||||
* @type {MediaBoxTools}
|
||||
*/
|
||||
#mediaBoxTools;
|
||||
#isFullscreenButtonEnabled = false;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
#isFullscreenButtonEnabled: boolean = false;
|
||||
|
||||
build() {
|
||||
protected build() {
|
||||
this.container.innerText = '🔍';
|
||||
|
||||
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
|
||||
}
|
||||
|
||||
init() {
|
||||
protected init() {
|
||||
if (!this.container.parentElement) {
|
||||
throw new Error('Missing parent element!');
|
||||
}
|
||||
|
||||
this.#mediaBoxTools = getComponent(this.container.parentElement);
|
||||
|
||||
if (!this.#mediaBoxTools) {
|
||||
@@ -32,8 +34,8 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
.then(() => {
|
||||
ImageShowFullscreenButton.#miscSettings.subscribe(settings => {
|
||||
this.#isFullscreenButtonEnabled = settings.fullscreenViewer;
|
||||
ImageShowFullscreenButton.#miscSettings?.subscribe(settings => {
|
||||
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
})
|
||||
@@ -45,28 +47,25 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
}
|
||||
|
||||
#onButtonClicked() {
|
||||
const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks;
|
||||
|
||||
if (!imageLinks) {
|
||||
throw new Error('Failed to resolve image links from media box tools!');
|
||||
}
|
||||
|
||||
ImageShowFullscreenButton
|
||||
.#resolveViewer()
|
||||
.show(this.#mediaBoxTools.mediaBox.imageLinks.large);
|
||||
?.show(imageLinks);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {FullscreenViewer|null}
|
||||
*/
|
||||
static #viewer = null;
|
||||
static #viewer: FullscreenViewer | null = null;
|
||||
|
||||
/**
|
||||
* @return {FullscreenViewer}
|
||||
*/
|
||||
static #resolveViewer() {
|
||||
static #resolveViewer(): FullscreenViewer {
|
||||
this.#viewer ??= this.#buildViewer();
|
||||
return this.#viewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {FullscreenViewer}
|
||||
*/
|
||||
static #buildViewer() {
|
||||
static #buildViewer(): FullscreenViewer {
|
||||
const element = document.createElement('div');
|
||||
const viewer = new FullscreenViewer(element);
|
||||
|
||||
@@ -77,10 +76,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
return viewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {MiscSettings|null}
|
||||
*/
|
||||
static #miscSettings = null;
|
||||
static #miscSettings: MiscSettings | null = null;
|
||||
}
|
||||
|
||||
export function createImageShowFullscreenButton() {
|
||||
@@ -1,36 +1,37 @@
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
|
||||
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 MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI";
|
||||
import { tagsBlacklist } from "$config/tags";
|
||||
import { emitterAt } from "$lib/components/events/comms";
|
||||
import {
|
||||
EVENT_ACTIVE_PROFILE_CHANGED,
|
||||
EVENT_MAINTENANCE_STATE_CHANGED,
|
||||
EVENT_TAGS_UPDATED
|
||||
} from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
|
||||
class BlackListedTagsEncounteredError extends Error {
|
||||
constructor(tagName: string) {
|
||||
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
|
||||
cause: tagName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class MaintenancePopup extends BaseComponent {
|
||||
/** @type {HTMLElement} */
|
||||
#tagsListElement = null;
|
||||
|
||||
/** @type {HTMLElement[]} */
|
||||
#tagsList = [];
|
||||
|
||||
/** @type {MaintenanceProfile|null} */
|
||||
#activeProfile = null;
|
||||
|
||||
/** @type {import('$lib/components/MediaBoxTools.js').MediaBoxTools} */
|
||||
#mediaBoxTools = null;
|
||||
|
||||
/** @type {Set<string>} */
|
||||
#tagsToRemove = new Set();
|
||||
|
||||
/** @type {Set<string>} */
|
||||
#tagsToAdd = new Set();
|
||||
|
||||
/** @type {boolean} */
|
||||
#isPlanningToSubmit = false;
|
||||
|
||||
/** @type {boolean} */
|
||||
#isSubmitting = false;
|
||||
|
||||
/** @type {number|null} */
|
||||
#tagsSubmissionTimer = null;
|
||||
#tagsListElement: HTMLElement = document.createElement('div');
|
||||
#tagsList: HTMLElement[] = [];
|
||||
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
|
||||
#activeProfile: MaintenanceProfile | null = null;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
#tagsToRemove: Set<string> = new Set();
|
||||
#tagsToAdd: Set<string> = new Set();
|
||||
#isPlanningToSubmit: boolean = false;
|
||||
#isSubmitting: boolean = false;
|
||||
#tagsSubmissionTimer: Timeout | null = null;
|
||||
#emitter = emitterAt(this);
|
||||
|
||||
/**
|
||||
* @protected
|
||||
@@ -39,7 +40,6 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.container.innerHTML = '';
|
||||
this.container.classList.add('maintenance-popup');
|
||||
|
||||
this.#tagsListElement = document.createElement('div');
|
||||
this.#tagsListElement.classList.add('tags-list');
|
||||
|
||||
this.container.append(
|
||||
@@ -51,14 +51,13 @@ export class MaintenancePopup extends BaseComponent {
|
||||
* @protected
|
||||
*/
|
||||
init() {
|
||||
const mediaBoxToolsElement = this.container.closest('.media-box-tools');
|
||||
const mediaBoxToolsElement = this.container.closest<HTMLElement>('.media-box-tools');
|
||||
|
||||
if (!mediaBoxToolsElement) {
|
||||
throw new Error('Maintenance popup initialized outside of the media box tools!');
|
||||
}
|
||||
|
||||
/** @type {MediaBoxTools|null} */
|
||||
const mediaBoxTools = getComponent(mediaBoxToolsElement);
|
||||
const mediaBoxTools = getComponent<MediaBoxTools>(mediaBoxToolsElement);
|
||||
|
||||
if (!mediaBoxTools) {
|
||||
throw new Error('Media box tools component not found!');
|
||||
@@ -71,29 +70,39 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
const mediaBox = this.#mediaBoxTools.mediaBox;
|
||||
|
||||
if (!mediaBox) {
|
||||
throw new Error('Media box component not found!');
|
||||
}
|
||||
|
||||
mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this));
|
||||
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MaintenanceProfile|null} activeProfile
|
||||
*/
|
||||
#onActiveProfileChanged(activeProfile) {
|
||||
#onActiveProfileChanged(activeProfile: MaintenanceProfile | null) {
|
||||
this.#activeProfile = activeProfile;
|
||||
this.container.classList.toggle('is-active', activeProfile !== null);
|
||||
this.#refreshTagsList();
|
||||
this.emit('active-profile-changed', activeProfile);
|
||||
|
||||
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
|
||||
}
|
||||
|
||||
#refreshTagsList() {
|
||||
/** @type {string[]} */
|
||||
const activeProfileTagsList = this.#activeProfile?.settings.tags || [];
|
||||
if (!this.#mediaBoxTools?.mediaBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let tagElement of this.#tagsList) {
|
||||
const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || [];
|
||||
|
||||
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;
|
||||
|
||||
@@ -104,27 +113,38 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.#tagsList[index] = tagElement;
|
||||
this.#tagsListElement.appendChild(tagElement);
|
||||
|
||||
const isPresent = currentPostTags.has(tagName);
|
||||
const isPresent = currentPostTags?.has(tagName);
|
||||
|
||||
tagElement.classList.toggle('is-present', isPresent);
|
||||
tagElement.classList.toggle('is-missing', !isPresent);
|
||||
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags.get(tagName) !== tagName);
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and process clicks made directly to the tags.
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
#handleTagClick(event) {
|
||||
/** @type {HTMLElement} */
|
||||
let tagElement = event.target;
|
||||
#handleTagClick(event: MouseEvent) {
|
||||
const targetObject = event.target;
|
||||
|
||||
if (!tagElement.classList.contains('tag')) {
|
||||
tagElement = tagElement.closest('.tag');
|
||||
|
||||
if (!targetObject || !(targetObject instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tagElement) {
|
||||
let tagElement: HTMLElement | null = targetObject;
|
||||
|
||||
if (!tagElement.classList.contains('tag')) {
|
||||
tagElement = tagElement.closest<HTMLElement>('.tag');
|
||||
}
|
||||
|
||||
if (!tagElement?.dataset.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,7 +177,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = true;
|
||||
this.emit('maintenance-state-change', 'waiting');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,17 +197,19 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
async #onSubmissionTimerPassed() {
|
||||
if (!this.#isPlanningToSubmit || this.#isSubmitting) {
|
||||
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = false;
|
||||
this.#isSubmitting = true;
|
||||
|
||||
this.emit('maintenance-state-change', 'processing');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
|
||||
|
||||
let maybeTagsAndAliasesAfterUpdate;
|
||||
|
||||
const shouldAutoRemove = await MaintenancePopup.#maintenanceSettings.resolveStripBlacklistedTags();
|
||||
|
||||
try {
|
||||
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
|
||||
this.#mediaBoxTools.mediaBox.imageId,
|
||||
@@ -200,24 +222,41 @@ 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');
|
||||
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
|
||||
this.#isSubmitting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (maybeTagsAndAliasesAfterUpdate) {
|
||||
this.emit('tags-updated', maybeTagsAndAliasesAfterUpdate);
|
||||
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
|
||||
}
|
||||
|
||||
this.emit('maintenance-state-change', 'complete');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
|
||||
|
||||
this.#tagsToAdd.clear();
|
||||
this.#tagsToRemove.clear();
|
||||
@@ -228,18 +267,45 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.#isSubmitting = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
#revealInvalidTags() {
|
||||
if (!this.#mediaBoxTools?.mediaBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return this.container.classList.contains('is-active');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tagName
|
||||
* @return {HTMLElement}
|
||||
*/
|
||||
static #buildTagElement(tagName) {
|
||||
static #buildTagElement(tagName: string): HTMLElement {
|
||||
const tagElement = document.createElement('span');
|
||||
tagElement.classList.add('tag');
|
||||
tagElement.innerText = tagName;
|
||||
@@ -248,21 +314,28 @@ export class MaintenancePopup extends BaseComponent {
|
||||
return tagElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the tag with red color.
|
||||
* @param tagElement Element to mark.
|
||||
*/
|
||||
static #markTagAsInvalid(tagElement: HTMLElement) {
|
||||
tagElement.dataset.tagCategory = 'error';
|
||||
tagElement.setAttribute('data-tag-category', 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller with maintenance settings.
|
||||
* @type {MaintenanceSettings}
|
||||
*/
|
||||
static #maintenanceSettings = new MaintenanceSettings();
|
||||
|
||||
/**
|
||||
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
|
||||
* at the very start to retrieve the currently active profile.
|
||||
* @param {function(MaintenanceProfile|null):void} callback Callback to execute whenever selection of active profile
|
||||
* or profile itself has been changed.
|
||||
* @return {function(): void} Unsubscribe function. Call it to stop watching for changes.
|
||||
* @param callback Callback to execute whenever selection of active profile or profile itself has been changed.
|
||||
* @return Unsubscribe function. Call it to stop watching for changes.
|
||||
*/
|
||||
static #watchActiveProfile(callback) {
|
||||
let lastActiveProfileId;
|
||||
static #watchActiveProfile(callback: (profile: MaintenanceProfile | null) => void): () => void {
|
||||
let lastActiveProfileId: string | null | undefined = null;
|
||||
|
||||
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
|
||||
if (lastActiveProfileId) {
|
||||
@@ -302,9 +375,9 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Notify the frontend about new pending submission started.
|
||||
* @param {boolean} isStarted True if started, false if ended.
|
||||
* @param isStarted True if started, false if ended.
|
||||
*/
|
||||
static #notifyAboutPendingSubmission(isStarted) {
|
||||
static #notifyAboutPendingSubmission(isStarted: boolean) {
|
||||
if (this.#pendingSubmissionCount === null) {
|
||||
this.#pendingSubmissionCount = 0;
|
||||
this.#initializeExitPromptHandler();
|
||||
@@ -333,9 +406,8 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Amount of pending submissions or NULL if logic was not yet initialized.
|
||||
* @type {number|null}
|
||||
*/
|
||||
static #pendingSubmissionCount = null;
|
||||
static #pendingSubmissionCount: number|null = null;
|
||||
}
|
||||
|
||||
export function createMaintenancePopup() {
|
||||
@@ -1,28 +1,31 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
|
||||
export class MaintenanceStatusIcon extends BaseComponent {
|
||||
/** @type {import('MediaBoxTools.js').MediaBoxTools} */
|
||||
#mediaBoxTools;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
|
||||
build() {
|
||||
this.container.innerText = '🔧';
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.container.parentElement) {
|
||||
throw new Error('Missing parent element for the maintenance status icon!');
|
||||
}
|
||||
|
||||
this.#mediaBoxTools = getComponent(this.container.parentElement);
|
||||
|
||||
if (!this.#mediaBoxTools) {
|
||||
throw new Error('Status icon element initialized outside of the media box!');
|
||||
}
|
||||
|
||||
this.#mediaBoxTools.on('maintenance-state-change', this.#onMaintenanceStateChanged.bind(this));
|
||||
on(this.#mediaBoxTools, EVENT_MAINTENANCE_STATE_CHANGED, this.#onMaintenanceStateChanged.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<string>} stateChangeEvent
|
||||
*/
|
||||
#onMaintenanceStateChanged(stateChangeEvent) {
|
||||
#onMaintenanceStateChanged(stateChangeEvent: CustomEvent<string>) {
|
||||
// TODO Replace those with FontAwesome icons later. Those icons can probably be sourced from the website itself.
|
||||
switch (stateChangeEvent.detail) {
|
||||
case "ready":
|
||||
@@ -1,78 +0,0 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import {MaintenancePopup} from "$lib/components/MaintenancePopup.js";
|
||||
|
||||
export class MediaBoxTools extends BaseComponent {
|
||||
/** @type {import('MediaBoxWrapper.js').MediaBoxWrapper|null} */
|
||||
#mediaBox;
|
||||
|
||||
/** @type {MaintenancePopup|null} */
|
||||
#maintenancePopup = null;
|
||||
|
||||
init() {
|
||||
const mediaBoxElement = this.container.closest('.media-box');
|
||||
|
||||
if (!mediaBoxElement) {
|
||||
throw new Error('Toolbox element initialized outside of the media box!');
|
||||
}
|
||||
|
||||
this.#mediaBox = getComponent(mediaBoxElement);
|
||||
|
||||
for (let childElement of this.container.children) {
|
||||
const component = getComponent(childElement);
|
||||
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!component.isInitialized) {
|
||||
component.initialize();
|
||||
}
|
||||
|
||||
if (!this.#maintenancePopup && component instanceof MaintenancePopup) {
|
||||
this.#maintenancePopup = component;
|
||||
}
|
||||
}
|
||||
|
||||
this.on('active-profile-changed', this.#onActiveProfileChanged.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<MaintenanceProfile|null>} profileChangedEvent
|
||||
*/
|
||||
#onActiveProfileChanged(profileChangedEvent) {
|
||||
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {MaintenancePopup|null}
|
||||
*/
|
||||
get maintenancePopup() {
|
||||
return this.#maintenancePopup;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {import('MediaBoxWrapper.js').MediaBoxWrapper|null}
|
||||
*/
|
||||
get mediaBox() {
|
||||
return this.#mediaBox;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a maintenance popup element.
|
||||
* @param {HTMLElement} childrenElements List of children elements to append to the component.
|
||||
* @return {HTMLElement} The maintenance popup element.
|
||||
*/
|
||||
export function createMediaBoxTools(...childrenElements) {
|
||||
const mediaBoxToolsContainer = document.createElement('div');
|
||||
mediaBoxToolsContainer.classList.add('media-box-tools');
|
||||
|
||||
if (childrenElements.length) {
|
||||
mediaBoxToolsContainer.append(...childrenElements);
|
||||
}
|
||||
|
||||
new MediaBoxTools(mediaBoxToolsContainer);
|
||||
|
||||
return mediaBoxToolsContainer;
|
||||
}
|
||||
74
src/lib/components/MediaBoxTools.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { MaintenancePopup } from "$lib/components/MaintenancePopup";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxWrapper } from "$lib/components/MediaBoxWrapper";
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
export class MediaBoxTools extends BaseComponent {
|
||||
#mediaBox: MediaBoxWrapper | null = null;
|
||||
#maintenancePopup: MaintenancePopup | null = null;
|
||||
|
||||
init() {
|
||||
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
|
||||
|
||||
if (!mediaBoxElement) {
|
||||
throw new Error('Toolbox element initialized outside of the media box!');
|
||||
}
|
||||
|
||||
this.#mediaBox = getComponent(mediaBoxElement);
|
||||
|
||||
for (let childElement of this.container.children) {
|
||||
if (!(childElement instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const component = getComponent(childElement);
|
||||
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!component.isInitialized) {
|
||||
component.initialize();
|
||||
}
|
||||
|
||||
if (!this.#maintenancePopup && component instanceof MaintenancePopup) {
|
||||
this.#maintenancePopup = component;
|
||||
}
|
||||
}
|
||||
|
||||
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
|
||||
}
|
||||
|
||||
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
|
||||
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
|
||||
}
|
||||
|
||||
get maintenancePopup(): MaintenancePopup | null {
|
||||
return this.#maintenancePopup;
|
||||
}
|
||||
|
||||
get mediaBox(): MediaBoxWrapper | null {
|
||||
return this.#mediaBox;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a maintenance popup element.
|
||||
* @param childrenElements List of children elements to append to the component.
|
||||
* @return The maintenance popup element.
|
||||
*/
|
||||
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
|
||||
const mediaBoxToolsContainer = document.createElement('div');
|
||||
mediaBoxToolsContainer.classList.add('media-box-tools');
|
||||
|
||||
if (childrenElements.length) {
|
||||
mediaBoxToolsContainer.append(...childrenElements);
|
||||
}
|
||||
|
||||
new MediaBoxTools(mediaBoxToolsContainer);
|
||||
|
||||
return mediaBoxToolsContainer;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import {buildTagsAndAliasesMap} from "$lib/booru/TagsUtils.js";
|
||||
|
||||
export class MediaBoxWrapper extends BaseComponent {
|
||||
#thumbnailContainer = null;
|
||||
#imageLinkElement = null;
|
||||
|
||||
/** @type {Map<string,string>|null} */
|
||||
#tagsAndAliases = null;
|
||||
|
||||
init() {
|
||||
this.#thumbnailContainer = this.container.querySelector('.image-container');
|
||||
this.#imageLinkElement = this.#thumbnailContainer.querySelector('a');
|
||||
|
||||
this.on('tags-updated', this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<Map<string,string>>} tagsUpdatedEvent
|
||||
*/
|
||||
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent) {
|
||||
const updatedMap = tagsUpdatedEvent.detail;
|
||||
|
||||
if (!(updatedMap instanceof Map)) {
|
||||
throw new TypeError("Tags and aliases should be stored as Map!");
|
||||
}
|
||||
|
||||
this.#tagsAndAliases = updatedMap;
|
||||
}
|
||||
|
||||
#calculateMediaBoxTags() {
|
||||
/** @type {string[]|string[]} */
|
||||
const
|
||||
tagAliases = this.#thumbnailContainer.dataset.imageTagAliases?.split(', ') || [],
|
||||
actualTags = this.#imageLinkElement.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
|
||||
return buildTagsAndAliasesMap(tagAliases, actualTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Map<string, string>|null}
|
||||
*/
|
||||
get tagsAndAliases() {
|
||||
if (!this.#tagsAndAliases) {
|
||||
this.#tagsAndAliases = this.#calculateMediaBoxTags();
|
||||
}
|
||||
|
||||
return this.#tagsAndAliases;
|
||||
}
|
||||
|
||||
get imageId() {
|
||||
return parseInt(
|
||||
this.container.dataset.imageId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ImageURIs}
|
||||
*/
|
||||
get imageLinks() {
|
||||
return JSON.parse(this.#thumbnailContainer.dataset.uris);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the media box element into the special wrapper.
|
||||
* @param {HTMLElement} mediaBoxContainer
|
||||
* @param {HTMLElement[]} childComponentElements
|
||||
*/
|
||||
export function initializeMediaBox(mediaBoxContainer, childComponentElements) {
|
||||
new MediaBoxWrapper(mediaBoxContainer)
|
||||
.initialize();
|
||||
|
||||
for (let childComponentElement of childComponentElements) {
|
||||
mediaBoxContainer.appendChild(childComponentElement);
|
||||
getComponent(childComponentElement)?.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {NodeListOf<HTMLElement>} mediaBoxesList
|
||||
*/
|
||||
export function calculateMediaBoxesPositions(mediaBoxesList) {
|
||||
window.addEventListener('resize', () => {
|
||||
/** @type {HTMLElement|null} */
|
||||
let lastMediaBox = null,
|
||||
/** @type {number|null} */
|
||||
lastMediaBoxPosition = null;
|
||||
|
||||
for (let mediaBoxElement of mediaBoxesList) {
|
||||
const yPosition = mediaBoxElement.getBoundingClientRect().y;
|
||||
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
|
||||
|
||||
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
|
||||
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
|
||||
|
||||
lastMediaBox = mediaBoxElement;
|
||||
lastMediaBoxPosition = yPosition;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} ImageURIs
|
||||
* @property {string} full
|
||||
* @property {string} large
|
||||
* @property {string} small
|
||||
*/
|
||||
99
src/lib/components/MediaBoxWrapper.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { EVENT_TAGS_UPDATED } from "$lib/components/events/maintenance-popup-events";
|
||||
|
||||
export class MediaBoxWrapper extends BaseComponent {
|
||||
#thumbnailContainer: HTMLElement | null = null;
|
||||
#imageLinkElement: HTMLAnchorElement | null = null;
|
||||
#tagsAndAliases: Map<string, string> | null = null;
|
||||
|
||||
init() {
|
||||
this.#thumbnailContainer = this.container.querySelector('.image-container');
|
||||
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
|
||||
|
||||
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
}
|
||||
|
||||
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
|
||||
const updatedMap = tagsUpdatedEvent.detail;
|
||||
|
||||
if (!(updatedMap instanceof Map)) {
|
||||
throw new TypeError("Tags and aliases should be stored as Map!");
|
||||
}
|
||||
|
||||
this.#tagsAndAliases = updatedMap;
|
||||
}
|
||||
|
||||
#calculateMediaBoxTags() {
|
||||
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
|
||||
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
|
||||
return buildTagsAndAliasesMap(tagAliases, actualTags);
|
||||
}
|
||||
|
||||
get tagsAndAliases(): Map<string, string> | null {
|
||||
if (!this.#tagsAndAliases) {
|
||||
this.#tagsAndAliases = this.#calculateMediaBoxTags();
|
||||
}
|
||||
|
||||
return this.#tagsAndAliases;
|
||||
}
|
||||
|
||||
get imageId(): number {
|
||||
const imageId = this.container.dataset.imageId;
|
||||
|
||||
if (!imageId) {
|
||||
throw new Error('Missing image ID');
|
||||
}
|
||||
|
||||
return parseInt(imageId);
|
||||
}
|
||||
|
||||
get imageLinks(): App.ImageURIs {
|
||||
const jsonUris = this.#thumbnailContainer?.dataset.uris;
|
||||
|
||||
if (!jsonUris) {
|
||||
throw new Error('Missing URIs!');
|
||||
}
|
||||
|
||||
return JSON.parse(jsonUris);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the media box element into the special wrapper.
|
||||
*/
|
||||
export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
|
||||
new MediaBoxWrapper(mediaBoxContainer)
|
||||
.initialize();
|
||||
|
||||
for (let childComponentElement of childComponentElements) {
|
||||
mediaBoxContainer.appendChild(childComponentElement);
|
||||
getComponent(childComponentElement)?.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElement>) {
|
||||
window.addEventListener('resize', () => {
|
||||
let lastMediaBox: HTMLElement | null = null;
|
||||
let lastMediaBoxPosition: number | null = null;
|
||||
|
||||
for (const mediaBoxElement of mediaBoxesList) {
|
||||
const yPosition = mediaBoxElement.getBoundingClientRect().y;
|
||||
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
|
||||
|
||||
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
|
||||
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
|
||||
|
||||
lastMediaBox = mediaBoxElement;
|
||||
lastMediaBoxPosition = yPosition;
|
||||
}
|
||||
|
||||
// Last-ever media box is checked separately
|
||||
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
|
||||
lastMediaBox.classList.add('media-box--last');
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
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";
|
||||
|
||||
export class SearchWrapper extends BaseComponent {
|
||||
/** @type {HTMLInputElement|null} */
|
||||
#searchField = null;
|
||||
/** @type {string|null} */
|
||||
#lastParsedSearchValue = null;
|
||||
/** @type {Token[]} */
|
||||
#cachedParsedQuery = [];
|
||||
#searchSettings = new SearchSettings();
|
||||
#arePropertiesSuggestionsEnabled = false;
|
||||
/** @type {"start"|"end"} */
|
||||
#propertiesSuggestionsPosition = "start";
|
||||
|
||||
build() {
|
||||
this.#searchField = this.container.querySelector('input[name=q]');
|
||||
}
|
||||
|
||||
init() {
|
||||
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this));
|
||||
|
||||
this.#searchSettings.resolvePropertiesSuggestionsEnabled()
|
||||
.then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled);
|
||||
this.#searchSettings.resolvePropertiesSuggestionsPosition()
|
||||
.then(position => this.#propertiesSuggestionsPosition = position);
|
||||
|
||||
this.#searchSettings.subscribe(settings => {
|
||||
this.#arePropertiesSuggestionsEnabled = settings.suggestProperties;
|
||||
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch the user input and execute suggestions logic.
|
||||
* @param {InputEvent} event Source event to find the input element from.
|
||||
*/
|
||||
#onInputFindProperties(event) {
|
||||
// Ignore events until option is enabled.
|
||||
if (!this.#arePropertiesSuggestionsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFragment = this.#findCurrentTagFragment();
|
||||
|
||||
if (!currentFragment) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#renderSuggestions(
|
||||
SearchWrapper.#resolveSuggestionsFromTerm(currentFragment),
|
||||
event.currentTarget
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selection position in the search field.
|
||||
* @return {number}
|
||||
*/
|
||||
#getInputUserSelection() {
|
||||
return Math.min(
|
||||
this.#searchField.selectionStart,
|
||||
this.#searchField.selectionEnd
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the search query and return the list of parsed tokens. Result will be cached for current search query.
|
||||
* @return {Token[]}
|
||||
*/
|
||||
#resolveQueryTokens() {
|
||||
const searchValue = this.#searchField.value;
|
||||
|
||||
if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) {
|
||||
return this.#cachedParsedQuery;
|
||||
}
|
||||
|
||||
this.#lastParsedSearchValue = searchValue;
|
||||
this.#cachedParsedQuery = new QueryLexer(searchValue).parse();
|
||||
|
||||
return this.#cachedParsedQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the currently selected term.
|
||||
* @return {string|null} Selected term or null if none found.
|
||||
*/
|
||||
#findCurrentTagFragment() {
|
||||
if (!this.#searchField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let searchValue = this.#searchField.value;
|
||||
|
||||
if (!searchValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = SearchWrapper.#findActiveSearchTermPosition(
|
||||
this.#resolveQueryTokens(),
|
||||
this.#getInputUserSelection(),
|
||||
);
|
||||
|
||||
if (token instanceof TermToken) {
|
||||
return token.value;
|
||||
}
|
||||
|
||||
if (token instanceof QuotedTermToken) {
|
||||
return token.decodedValue;
|
||||
}
|
||||
|
||||
return searchValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {HTMLInputElement} targetInput Target input to attach the popup to.
|
||||
*/
|
||||
#renderSuggestions(suggestions, targetInput) {
|
||||
/** @type {HTMLElement[]} */
|
||||
const suggestedListItems = suggestions
|
||||
.map(suggestedTerm => SearchWrapper.#renderTermSuggestion(suggestedTerm));
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const autocompleteContainer = document.querySelector('.autocomplete') ?? SearchWrapper.#renderAutocompleteContainer();
|
||||
|
||||
for (let existingTerm of autocompleteContainer.querySelectorAll('.autocomplete__item--property')) {
|
||||
existingTerm.remove();
|
||||
}
|
||||
|
||||
const listContainer = autocompleteContainer.querySelector('ul');
|
||||
|
||||
switch (this.#propertiesSuggestionsPosition) {
|
||||
case "start":
|
||||
listContainer.prepend(...suggestedListItems);
|
||||
break;
|
||||
|
||||
case "end":
|
||||
listContainer.append(...suggestedListItems);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn("Invalid position for property suggestions!");
|
||||
}
|
||||
|
||||
|
||||
autocompleteContainer.style.position = 'absolute';
|
||||
autocompleteContainer.style.left = `${targetInput.offsetLeft}px`;
|
||||
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - targetInput.parentElement.scrollTop}px`;
|
||||
|
||||
document.body.append(autocompleteContainer);
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Loosely estimate where current selected search term is located and return it if found.
|
||||
* @param {Token[]} tokens Search value to find the actively selected term from.
|
||||
* @param {number} userSelectionIndex The index of the user selection.
|
||||
* @return {Token|null} Search term object or NULL if nothing found.
|
||||
*/
|
||||
static #findActiveSearchTermPosition(tokens, userSelectionIndex) {
|
||||
return tokens.find(
|
||||
token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regular expression to search the properties' syntax.
|
||||
* @type {RegExp}
|
||||
*/
|
||||
static #propertySearchTermHeadingRegExp = /^(?<name>[a-z\d_]+)(?<op_syntax>\.(?<op>[a-z]*))?(?<value_syntax>:(?<value>.*))?$/;
|
||||
|
||||
/**
|
||||
* Create a list of suggested elements using the input received from the user.
|
||||
* @param {string} searchTermValue Original decoded term received from the user.
|
||||
* @return {string[]} List of suggestions. Could be empty.
|
||||
*/
|
||||
static #resolveSuggestionsFromTerm(searchTermValue) {
|
||||
/** @type {string[]} */
|
||||
const suggestionsList = [];
|
||||
|
||||
this.#propertySearchTermHeadingRegExp.lastIndex = 0;
|
||||
const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue);
|
||||
|
||||
if (!parsedResult) {
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
const propertyName = parsedResult.groups.name;
|
||||
const propertyType = this.#properties.get(propertyName);
|
||||
const hasOperatorSyntax = Boolean(parsedResult.groups.op_syntax);
|
||||
const hasValueSyntax = Boolean(parsedResult.groups.value_syntax);
|
||||
|
||||
// No suggestions for values for now, maybe could add suggestions for namespaces like my:*
|
||||
if (hasValueSyntax) {
|
||||
if (this.#typeValues.has(propertyType)) {
|
||||
const givenValue = parsedResult.groups.value;
|
||||
|
||||
for (let candidateValue of this.#typeValues.get(propertyType)) {
|
||||
if (givenValue && !candidateValue.startsWith(givenValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestionsList.push(`${propertyName}${parsedResult.groups.op_syntax ?? ''}:${candidateValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
// If at least one dot placed, start suggesting operators
|
||||
if (hasOperatorSyntax) {
|
||||
if (this.#typeOperators.has(propertyType)) {
|
||||
const operatorName = parsedResult.groups.op;
|
||||
|
||||
for (let candidateOperator of this.#typeOperators.get(propertyType)) {
|
||||
if (operatorName && !candidateOperator.startsWith(operatorName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestionsList.push(`${propertyName}.${candidateOperator}:`);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
// Otherwise, search for properties with names starting with the term
|
||||
for (let [candidateProperty] of this.#properties) {
|
||||
if (propertyName && !candidateProperty.startsWith(propertyName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestionsList.push(candidateProperty);
|
||||
}
|
||||
|
||||
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) {
|
||||
/** @type {HTMLElement} */
|
||||
const suggestionItem = document.createElement('li');
|
||||
suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property');
|
||||
suggestionItem.dataset.value = suggestedTerm;
|
||||
suggestionItem.innerText = suggestedTerm;
|
||||
|
||||
suggestionItem.addEventListener('mouseover', () => {
|
||||
this.#findAndResetSelectedSuggestion(suggestionItem);
|
||||
suggestionItem.classList.add('autocomplete__item--selected');
|
||||
});
|
||||
|
||||
suggestionItem.addEventListener('mouseout', () => {
|
||||
this.#findAndResetSelectedSuggestion(suggestionItem);
|
||||
})
|
||||
|
||||
return suggestionItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's
|
||||
* front-end.
|
||||
* @param {HTMLElement} suggestedElement Target element to search from. If element is disconnected from the DOM,
|
||||
* search will be halted.
|
||||
*/
|
||||
static #findAndResetSelectedSuggestion(suggestedElement) {
|
||||
if (!suggestedElement.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let selectedElement of suggestedElement.parentElement.querySelectorAll('.autocomplete__item--selected')) {
|
||||
selectedElement.classList.remove('autocomplete__item--selected');
|
||||
}
|
||||
}
|
||||
|
||||
static #typeNumeric = Symbol();
|
||||
static #typeDate = Symbol();
|
||||
static #typeLiteral = Symbol();
|
||||
static #typePersonal = Symbol();
|
||||
|
||||
static #properties = new Map([
|
||||
['aspect_ratio', SearchWrapper.#typeNumeric],
|
||||
['comment_count', SearchWrapper.#typeNumeric],
|
||||
['created_at', SearchWrapper.#typeDate],
|
||||
['description', SearchWrapper.#typeLiteral],
|
||||
['downvotes', SearchWrapper.#typeNumeric],
|
||||
['faved_by', SearchWrapper.#typeLiteral],
|
||||
['faved_by_id', SearchWrapper.#typeNumeric],
|
||||
['faves', SearchWrapper.#typeNumeric],
|
||||
['first_seen_at', SearchWrapper.#typeDate],
|
||||
['height', SearchWrapper.#typeNumeric],
|
||||
['id', SearchWrapper.#typeNumeric],
|
||||
['orig_sha512_hash', SearchWrapper.#typeLiteral],
|
||||
['score', SearchWrapper.#typeNumeric],
|
||||
['sha512_hash', SearchWrapper.#typeLiteral],
|
||||
['source_url', SearchWrapper.#typeLiteral],
|
||||
['tag_count', SearchWrapper.#typeNumeric],
|
||||
['uploader', SearchWrapper.#typeLiteral],
|
||||
['uploader_id', SearchWrapper.#typeNumeric],
|
||||
['upvotes', SearchWrapper.#typeNumeric],
|
||||
['width', SearchWrapper.#typeNumeric],
|
||||
['wilson_score', SearchWrapper.#typeNumeric],
|
||||
['my', SearchWrapper.#typePersonal],
|
||||
]);
|
||||
|
||||
static #comparisonOperators = ['gt', 'gte', 'lt', 'lte'];
|
||||
|
||||
static #typeOperators = new Map([
|
||||
[SearchWrapper.#typeNumeric, SearchWrapper.#comparisonOperators],
|
||||
[SearchWrapper.#typeDate, SearchWrapper.#comparisonOperators],
|
||||
]);
|
||||
|
||||
static #typeValues = new Map([
|
||||
[SearchWrapper.#typePersonal, [
|
||||
'comments',
|
||||
'faves',
|
||||
'posts',
|
||||
'uploads',
|
||||
'upvotes',
|
||||
'watched',
|
||||
]]
|
||||
]);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {SearchWrapper} from "$lib/components/SearchWrapper.js";
|
||||
|
||||
class SiteHeaderWrapper extends BaseComponent {
|
||||
/** @type {SearchWrapper|null} */
|
||||
#searchWrapper = null;
|
||||
|
||||
build() {
|
||||
const searchForm = this.container.querySelector('.header__search');
|
||||
this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.#searchWrapper) {
|
||||
this.#searchWrapper.initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeSiteHeader(siteHeaderElement) {
|
||||
new SiteHeaderWrapper(siteHeaderElement)
|
||||
.initialize();
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
|
||||
const isTagEditorProcessedKey = Symbol();
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#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]
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
new TagDropdownWrapper(element).initialize();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
})
|
||||
}
|
||||
326
src/lib/components/TagDropdownWrapper.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
const categoriesResolver = new CustomCategoriesResolver();
|
||||
|
||||
export class TagDropdownWrapper extends BaseComponent {
|
||||
/**
|
||||
* Container with dropdown elements to insert options into.
|
||||
*/
|
||||
#dropdownContainer: HTMLElement | null = null;
|
||||
|
||||
/**
|
||||
* Button to add or remove the current tag into/from the active profile.
|
||||
*/
|
||||
#toggleOnExistingButton: HTMLAnchorElement | null = null;
|
||||
|
||||
/**
|
||||
* Button to create a new profile, make it active and add the current tag into the active profile.
|
||||
*/
|
||||
#addToNewButton: HTMLAnchorElement | null = null;
|
||||
|
||||
/**
|
||||
* Local clone of the currently active profile used for updating the list of tags.
|
||||
*/
|
||||
#activeProfile: MaintenanceProfile | null = null;
|
||||
|
||||
/**
|
||||
* Is cursor currently entered the dropdown.
|
||||
*/
|
||||
#isEntered: boolean = false;
|
||||
|
||||
#originalCategory: string | undefined | null = 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();
|
||||
}
|
||||
});
|
||||
|
||||
on(this, EVENT_TAG_GROUP_RESOLVED, this.#onTagGroupResolved.bind(this));
|
||||
}
|
||||
|
||||
#onTagGroupResolved(resolvedGroupEvent: CustomEvent<TagGroup | null>) {
|
||||
if (this.originalCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeTagGroup = resolvedGroupEvent.detail;
|
||||
|
||||
if (!maybeTagGroup) {
|
||||
this.tagCategory = this.originalCategory;
|
||||
return;
|
||||
}
|
||||
|
||||
this.tagCategory = maybeTagGroup.settings.category;
|
||||
}
|
||||
|
||||
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}"`;
|
||||
const tagName = this.tagName;
|
||||
|
||||
if (tagName && this.#activeProfile.settings.tags.includes(tagName)) {
|
||||
profileSpecificButtonText = `Remove from profile "${profileName}"`;
|
||||
}
|
||||
|
||||
// Derpibooru has icons in dropdown. Make sure to only edit text and keep the icon untouched. Also, add the space
|
||||
// before the text to make space between text and icon.
|
||||
if (__CURRENT_SITE__ === 'derpibooru' && this.#toggleOnExistingButton.lastChild instanceof Text) {
|
||||
this.#toggleOnExistingButton.lastChild.textContent = ` ${profileSpecificButtonText}`;
|
||||
} else {
|
||||
this.#toggleOnExistingButton.textContent = profileSpecificButtonText;
|
||||
}
|
||||
|
||||
if (!this.#toggleOnExistingButton.isConnected) {
|
||||
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.#toggleOnExistingButton?.remove();
|
||||
}
|
||||
|
||||
async #onAddToNewClicked() {
|
||||
const tagName = this.tagName;
|
||||
|
||||
if (!tagName) {
|
||||
throw new Error('Missing tag name to create the profile!');
|
||||
}
|
||||
|
||||
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 (!targetTagName) {
|
||||
throw new Error('Missing tag name!');
|
||||
}
|
||||
|
||||
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 onActiveProfileChange Callback to call when profile was
|
||||
* changed.
|
||||
*/
|
||||
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) {
|
||||
let lastActiveProfile: string | null = null;
|
||||
|
||||
this.#maintenanceSettings.subscribe((settings) => {
|
||||
lastActiveProfile = settings.activeProfile ?? null;
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
.then(onActiveProfileChange);
|
||||
});
|
||||
|
||||
MaintenanceProfile.subscribe(profiles => {
|
||||
const activeProfile = profiles
|
||||
.find(profile => profile.id === lastActiveProfile);
|
||||
|
||||
onActiveProfileChange(activeProfile ?? null
|
||||
);
|
||||
});
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
.then(activeProfile => {
|
||||
lastActiveProfile = activeProfile?.id ?? null;
|
||||
onActiveProfileChange(activeProfile);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create element for dropdown.
|
||||
* @param text Base text for the option.
|
||||
* @param onClickHandler Click handler. Event will be prevented by default.
|
||||
* @return
|
||||
*/
|
||||
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
|
||||
const dropdownLink = document.createElement('a');
|
||||
dropdownLink.href = '#';
|
||||
dropdownLink.className = 'tag__dropdown__link';
|
||||
|
||||
// Derpibooru has an icon in dropdown item. Create the icon and place the text with additional space in front of it.
|
||||
if (__CURRENT_SITE__ === 'derpibooru') {
|
||||
const dropdownLinkIcon = document.createElement('i');
|
||||
dropdownLinkIcon.classList.add('fa', 'fa-tags');
|
||||
|
||||
dropdownLink.textContent = ` ${text}`;
|
||||
dropdownLink.insertAdjacentElement('afterbegin', dropdownLinkIcon);
|
||||
} else {
|
||||
dropdownLink.textContent = text;
|
||||
}
|
||||
|
||||
dropdownLink.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
onClickHandler(event);
|
||||
});
|
||||
|
||||
return dropdownLink;
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapTagDropdown(element: HTMLElement) {
|
||||
// Skip initialization when tag component is already wrapped
|
||||
if (getComponent(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagDropdown = new TagDropdownWrapper(element);
|
||||
tagDropdown.initialize();
|
||||
|
||||
categoriesResolver.addElement(tagDropdown);
|
||||
}
|
||||
|
||||
const processedElementsSet = new WeakSet<HTMLElement>();
|
||||
|
||||
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 => {
|
||||
const targetElement = event.target;
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (processedElementsSet.has(targetElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
|
||||
|
||||
if (!closestTagEditor || processedElementsSet.has(closestTagEditor)) {
|
||||
processedElementsSet.add(targetElement);
|
||||
return;
|
||||
}
|
||||
|
||||
processedElementsSet.add(targetElement);
|
||||
processedElementsSet.add(closestTagEditor);
|
||||
|
||||
for (const tagDropdownElement of closestTagEditor.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
});
|
||||
|
||||
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
|
||||
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
|
||||
for (const tagDropdownElement of event.detail.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
150
src/lib/components/TagsForm.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { emit, on, type UnsubscribeFunction } from "$lib/components/events/comms";
|
||||
import { EVENT_FETCH_COMPLETE } from "$lib/components/events/booru-events";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
|
||||
|
||||
export class TagsForm extends BaseComponent {
|
||||
protected init() {
|
||||
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
|
||||
const unsubscribe = on(
|
||||
this.container,
|
||||
EVENT_FETCH_COMPLETE,
|
||||
() => this.#waitAndDetectUpdatedForm(unsubscribe),
|
||||
);
|
||||
}
|
||||
|
||||
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
|
||||
const elementContainingTagEditor = this.container
|
||||
.closest('#image_tags_and_source')
|
||||
?.parentElement;
|
||||
|
||||
if (!elementContainingTagEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
|
||||
|
||||
if (!tagsFormElement || getComponent(tagsFormElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormComponent = new TagsForm(tagsFormElement);
|
||||
tagFormComponent.initialize();
|
||||
|
||||
const fullTagEditor = tagFormComponent.parentTagEditorElement;
|
||||
|
||||
if (fullTagEditor) {
|
||||
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
|
||||
} else {
|
||||
console.info('Tag form is not in the tag editor. Event is not sent.');
|
||||
}
|
||||
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
observer.observe(elementContainingTagEditor, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
|
||||
// Make sure to forcibly disconnect everything after a while.
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
get parentTagEditorElement(): HTMLElement | null {
|
||||
return this.container.closest<HTMLElement>('.js-tagsauce')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLElement>('.tag');
|
||||
|
||||
for (const 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 (!tagName || !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
|
||||
*/
|
||||
#gatherTagCategories(): Map<string, string> {
|
||||
const tagCategories: Map<string, string> = new Map();
|
||||
|
||||
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
|
||||
const tagName = tagElement.dataset.tagName;
|
||||
const tagCategory = tagElement.dataset.tagCategory;
|
||||
|
||||
if (!tagName || !tagCategory) {
|
||||
console.warn('Missing tag name or category!');
|
||||
continue;
|
||||
}
|
||||
|
||||
tagCategories.set(tagName, 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<HTMLElement>('.js-taginput-show, #edit-tags')
|
||||
|
||||
if (!refreshTrigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
|
||||
|
||||
if (!tagFormElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tagEditor = getComponent(tagFormElement);
|
||||
|
||||
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
|
||||
tagEditor = new TagsForm(tagFormElement);
|
||||
tagEditor.initialize();
|
||||
}
|
||||
|
||||
(tagEditor as TagsForm).refreshTagColors();
|
||||
});
|
||||
}
|
||||
}
|
||||
244
src/lib/components/TagsListBlock.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
|
||||
import TagSettings from "$lib/extension/settings/TagSettings";
|
||||
|
||||
export class TagsListBlock extends BaseComponent {
|
||||
#tagsListButtonsContainer: HTMLElement | null = null;
|
||||
#tagsListContainer: HTMLElement | null = null;
|
||||
|
||||
#toggleGroupingButton = document.createElement('a');
|
||||
#toggleGroupingButtonIcon = document.createElement('i');
|
||||
|
||||
#tagSettings = new TagSettings();
|
||||
|
||||
#shouldDisplaySeparation = false;
|
||||
|
||||
#separatedGroups = new Map<string, TagGroup>();
|
||||
#separatedHeaders = new Map<string, HTMLElement>();
|
||||
#groupsCount = new Map<string, number>();
|
||||
#lastTagGroup = new WeakMap<TagDropdownWrapper, TagGroup | null>;
|
||||
|
||||
#isReorderingPlanned = false;
|
||||
|
||||
protected build() {
|
||||
this.#tagsListButtonsContainer = this.container.querySelector('.block.tagsauce .block__header__buttons');
|
||||
this.#tagsListContainer = this.container.querySelector('.tag-list');
|
||||
|
||||
this.#toggleGroupingButton.innerText = ' Grouping';
|
||||
this.#toggleGroupingButton.href = 'javascript:void(0)';
|
||||
this.#toggleGroupingButton.classList.add('button', 'button--link', 'button--inline');
|
||||
this.#toggleGroupingButton.title = 'Toggle the global groups separation option. This will only toggle global ' +
|
||||
'setting without changing the separation of specific groups.';
|
||||
|
||||
this.#toggleGroupingButtonIcon.classList.add('fas', TagsListBlock.#iconGroupingDisabled);
|
||||
this.#toggleGroupingButton.prepend(this.#toggleGroupingButtonIcon);
|
||||
|
||||
if (this.#tagsListButtonsContainer) {
|
||||
this.#tagsListButtonsContainer.append(this.#toggleGroupingButton);
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.#tagSettings.resolveGroupSeparation().then(this.#onTagSeparationChange.bind(this));
|
||||
this.#tagSettings.subscribe(settings => {
|
||||
this.#onTagSeparationChange(Boolean(settings.groupSeparation))
|
||||
});
|
||||
|
||||
on(
|
||||
this,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
this.#onTagDropdownCustomGroupResolved.bind(this)
|
||||
);
|
||||
|
||||
this.#toggleGroupingButton.addEventListener('click', this.#onToggleGroupingClicked.bind(this));
|
||||
}
|
||||
|
||||
#onTagSeparationChange(isSeparationEnabled: boolean) {
|
||||
if (this.#shouldDisplaySeparation === isSeparationEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#shouldDisplaySeparation = isSeparationEnabled;
|
||||
this.#reorderSeparatedGroups();
|
||||
this.#updateToggleSeparationButton();
|
||||
}
|
||||
|
||||
#updateToggleSeparationButton() {
|
||||
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingEnabled, this.#shouldDisplaySeparation);
|
||||
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingDisabled, !this.#shouldDisplaySeparation);
|
||||
}
|
||||
|
||||
#onTagDropdownCustomGroupResolved(resolvedCustomGroupEvent: CustomEvent<TagGroup | null>) {
|
||||
const maybeDropdownElement = resolvedCustomGroupEvent.target;
|
||||
|
||||
if (!(maybeDropdownElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagDropdown = getComponent<TagDropdownWrapper>(maybeDropdownElement);
|
||||
|
||||
if (!tagDropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagGroup = resolvedCustomGroupEvent.detail;
|
||||
|
||||
if (tagGroup) {
|
||||
this.#handleTagGroupChanges(tagGroup);
|
||||
}
|
||||
|
||||
this.#handleResolvedTagGroup(tagGroup, tagDropdown);
|
||||
|
||||
if (!this.#isReorderingPlanned) {
|
||||
this.#isReorderingPlanned = true;
|
||||
|
||||
requestAnimationFrame(this.#reorderSeparatedGroups.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
#onToggleGroupingClicked(event: Event) {
|
||||
event.preventDefault();
|
||||
void this.#tagSettings.setGroupSeparation(!this.#shouldDisplaySeparation);
|
||||
}
|
||||
|
||||
#handleTagGroupChanges(tagGroup: TagGroup) {
|
||||
const groupId = tagGroup.id;
|
||||
const processedGroup = this.#separatedGroups.get(groupId);
|
||||
|
||||
if (!tagGroup.settings.separate && processedGroup) {
|
||||
this.#separatedGroups.delete(groupId);
|
||||
this.#separatedHeaders.get(groupId)?.remove();
|
||||
this.#separatedHeaders.delete(groupId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Every time group is updated, a new object is being initialized
|
||||
if (tagGroup !== processedGroup) {
|
||||
this.#createOrUpdateHeaderForGroup(tagGroup);
|
||||
this.#separatedGroups.set(groupId, tagGroup);
|
||||
}
|
||||
}
|
||||
|
||||
#createOrUpdateHeaderForGroup(group: TagGroup) {
|
||||
let heading = this.#separatedHeaders.get(group.id);
|
||||
|
||||
if (!heading) {
|
||||
heading = document.createElement('h2');
|
||||
|
||||
// Heading is hidden by default and shown on next frame if there are tags to show in the section.
|
||||
heading.style.display = 'none';
|
||||
heading.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(group.id)}, 0)`;
|
||||
heading.style.flexBasis = '100%';
|
||||
heading.classList.add('tag-category-headline');
|
||||
|
||||
// We're inserting heading to the top just to make sure that heading is always in front of the tags related to
|
||||
// this category.
|
||||
this.#tagsListContainer?.insertAdjacentElement('afterbegin', heading);
|
||||
|
||||
this.#separatedHeaders.set(group.id, heading);
|
||||
}
|
||||
|
||||
heading.innerText = group.settings.name;
|
||||
}
|
||||
|
||||
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdownWrapper) {
|
||||
const previousGroupId = this.#lastTagGroup.get(tagComponent)?.id;
|
||||
const currentGroupId = resolvedGroup?.id;
|
||||
const isDifferentId = currentGroupId !== previousGroupId;
|
||||
const isSeparationEnabled = resolvedGroup?.settings.separate;
|
||||
|
||||
if (isDifferentId) {
|
||||
// Make sure to subtract the element from counters if there was a count before.
|
||||
if (previousGroupId && this.#groupsCount.has(previousGroupId)) {
|
||||
this.#groupsCount.set(previousGroupId, this.#groupsCount.get(previousGroupId)! - 1);
|
||||
}
|
||||
|
||||
// We only need to count groups which have separation enabled.
|
||||
if (currentGroupId && isSeparationEnabled) {
|
||||
const count = this.#groupsCount.get(resolvedGroup.id) ?? 0;
|
||||
this.#groupsCount.set(currentGroupId, count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// We're adding the CSS order for the tag as the CSS variable. This variable is updated later.
|
||||
if (currentGroupId && isSeparationEnabled) {
|
||||
tagComponent.container.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(currentGroupId)}, 0)`;
|
||||
} else {
|
||||
tagComponent.container.style.removeProperty('order');
|
||||
}
|
||||
|
||||
// If separation is disabled in the new group, then we should remove the tag from map, so it can be recaptured
|
||||
// when tag group is getting enabled later.
|
||||
if (currentGroupId && !isSeparationEnabled) {
|
||||
this.#lastTagGroup.delete(tagComponent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark this tag component as related to the following group.
|
||||
this.#lastTagGroup.set(tagComponent, resolvedGroup);
|
||||
}
|
||||
|
||||
#reorderSeparatedGroups() {
|
||||
this.#isReorderingPlanned = false;
|
||||
|
||||
const tagGroups = Array.from(this.#separatedGroups.values())
|
||||
.toSorted((a, b) => a.settings.name.localeCompare(b.settings.name));
|
||||
|
||||
for (let index = 0; index < tagGroups.length; index++) {
|
||||
const tagGroup = tagGroups[index];
|
||||
const groupId = tagGroup.id;
|
||||
const usedCount = this.#groupsCount.get(groupId);
|
||||
const relatedHeading = this.#separatedHeaders.get(groupId);
|
||||
|
||||
if (this.#shouldDisplaySeparation) {
|
||||
this.container.style.setProperty(TagsListBlock.#orderCssVariableForGroup(groupId), (index + 1).toString());
|
||||
} else {
|
||||
this.container.style.removeProperty(TagsListBlock.#orderCssVariableForGroup(groupId));
|
||||
}
|
||||
|
||||
if (relatedHeading) {
|
||||
if (!this.#shouldDisplaySeparation || !usedCount || usedCount <= 0) {
|
||||
relatedHeading.style.display = 'none';
|
||||
} else {
|
||||
relatedHeading.style.removeProperty('display');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static #orderCssVariableForGroup(groupId: string): string {
|
||||
return `--ta-order-${groupId}`;
|
||||
}
|
||||
|
||||
static #iconGroupingDisabled = 'fa-folder';
|
||||
static #iconGroupingEnabled = 'fa-folder-tree';
|
||||
}
|
||||
|
||||
export function initializeAllTagsLists() {
|
||||
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
|
||||
if (getComponent(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
new TagsListBlock(element)
|
||||
.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
export function watchForUpdatedTagLists() {
|
||||
on(document, EVENT_FORM_EDITOR_UPDATED, event => {
|
||||
const tagsListElement = event.detail.closest<HTMLElement>('#image_tags_and_source');
|
||||
|
||||
if (!tagsListElement || getComponent(tagsListElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
new TagsListBlock(tagsListElement)
|
||||
.initialize();
|
||||
});
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import {bindComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
*/
|
||||
export class BaseComponent {
|
||||
/** @type {HTMLElement} */
|
||||
#container;
|
||||
|
||||
#isInitialized = false;
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
constructor(container) {
|
||||
this.#container = container;
|
||||
|
||||
bindComponent(container, this);
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.#isInitialized) {
|
||||
throw new Error('The component is already initialized.');
|
||||
}
|
||||
|
||||
this.#isInitialized = true;
|
||||
|
||||
this.build();
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
build() {
|
||||
// This method can be implemented by the component classes to modify or create the inner elements.
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
init() {
|
||||
// This method can be implemented by the component classes to initialize the component.
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {HTMLElement}
|
||||
* @protected
|
||||
*/
|
||||
get container() {
|
||||
return this.#container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the component is initialized already. If not checked, subsequent calls to the `initialize` method will
|
||||
* throw an error.
|
||||
* @return {boolean}
|
||||
*/
|
||||
get isInitialized() {
|
||||
return this.#isInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the custom event on the container element.
|
||||
* @param {keyof HTMLElementEventMap|string} event The event name.
|
||||
* @param {any} [detail] The event detail. Can be omitted.
|
||||
*/
|
||||
emit(event, detail = undefined) {
|
||||
this.#container.dispatchEvent(
|
||||
new CustomEvent(
|
||||
event,
|
||||
{
|
||||
detail,
|
||||
bubbles: true
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the DOM event on the container element.
|
||||
* @param {keyof HTMLElementEventMap|string} event The event name.
|
||||
* @param {function(Event): void} listener The event listener.
|
||||
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
|
||||
* @return {function(): void} The unsubscribe function.
|
||||
*/
|
||||
on(event, listener, options = undefined) {
|
||||
this.#container.addEventListener(event, listener, options);
|
||||
|
||||
return () => void this.#container.removeEventListener(event, listener, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the DOM event on the container element. The event listener will be called only once.
|
||||
* @param {keyof HTMLElementEventMap|string} event The event name.
|
||||
* @param {function(Event): void} listener The event listener.
|
||||
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
|
||||
* @return {function(): void} The unsubscribe function.
|
||||
*/
|
||||
once(event, listener, options = undefined) {
|
||||
options = options || {};
|
||||
options.once = true;
|
||||
|
||||
return this.on(event, listener, options);
|
||||
}
|
||||
}
|
||||
100
src/lib/components/base/BaseComponent.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { bindComponent } from "$lib/components/base/component-utils";
|
||||
|
||||
type ComponentEventListener<EventName extends keyof HTMLElementEventMap> =
|
||||
(this: HTMLElement, event: HTMLElementEventMap[EventName]) => void;
|
||||
|
||||
export class BaseComponent<ContainerType extends HTMLElement = HTMLElement> {
|
||||
readonly #container: ContainerType;
|
||||
|
||||
#isInitialized = false;
|
||||
|
||||
constructor(container: ContainerType) {
|
||||
this.#container = container;
|
||||
|
||||
bindComponent(container, this);
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.#isInitialized) {
|
||||
throw new Error('The component is already initialized.');
|
||||
}
|
||||
|
||||
this.#isInitialized = true;
|
||||
|
||||
this.build();
|
||||
this.init();
|
||||
}
|
||||
|
||||
protected build(): void {
|
||||
// This method can be implemented by the component classes to modify or create the inner elements.
|
||||
}
|
||||
|
||||
protected init(): void {
|
||||
// This method can be implemented by the component classes to initialize the component.
|
||||
};
|
||||
|
||||
get container(): ContainerType {
|
||||
return this.#container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the component is initialized already. If not checked, subsequent calls to the `initialize` method will
|
||||
* throw an error.
|
||||
* @return
|
||||
*/
|
||||
get isInitialized(): boolean {
|
||||
return this.#isInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the custom event on the container element.
|
||||
* @param event The event name.
|
||||
* @param [detail] The event detail. Can be omitted.
|
||||
*/
|
||||
emit(event: keyof HTMLElementEventMap | string, detail: any = undefined): void {
|
||||
this.#container.dispatchEvent(
|
||||
new CustomEvent(
|
||||
event,
|
||||
{
|
||||
detail,
|
||||
bubbles: true
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the DOM event on the container element.
|
||||
* @param event The event name.
|
||||
* @param listener The event listener.
|
||||
* @param [options] The event listener options. Can be omitted.
|
||||
* @return The unsubscribe function.
|
||||
*/
|
||||
on<EventName extends keyof HTMLElementEventMap>(
|
||||
event: EventName,
|
||||
listener: ComponentEventListener<EventName>,
|
||||
options?: AddEventListenerOptions,
|
||||
): () => void {
|
||||
this.#container.addEventListener(event, listener, options);
|
||||
|
||||
return () => void this.#container.removeEventListener(event, listener, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the DOM event on the container element. The event listener will be called only once.
|
||||
* @param event The event name.
|
||||
* @param listener The event listener.
|
||||
* @param [options] The event listener options. Can be omitted.
|
||||
* @return The unsubscribe function.
|
||||
*/
|
||||
once<EventName extends keyof HTMLElementEventMap>(
|
||||
event: EventName,
|
||||
listener: ComponentEventListener<EventName>,
|
||||
options?: AddEventListenerOptions,
|
||||
): () => void {
|
||||
options = options || {};
|
||||
options.once = true;
|
||||
|
||||
return this.on(event, listener, options);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
const instanceSymbol = Symbol('instance');
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @return {import('./BaseComponent.js').BaseComponent|null}
|
||||
*/
|
||||
export function getComponent(element) {
|
||||
return element[instanceSymbol] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the component to the selected element.
|
||||
* @param {HTMLElement} element The element to bind the component to.
|
||||
* @param {import('./BaseComponent.js').BaseComponent} instance The component instance.
|
||||
*/
|
||||
export function bindComponent(element, instance) {
|
||||
if (element[instanceSymbol]) {
|
||||
throw new Error('The element is already bound to a component.');
|
||||
}
|
||||
|
||||
element[instanceSymbol] = instance;
|
||||
}
|
||||
29
src/lib/components/base/component-utils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
|
||||
const instanceSymbol = Symbol.for('instance');
|
||||
|
||||
interface ElementWithComponent<T> extends HTMLElement {
|
||||
[instanceSymbol]?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the component from the element, if there is one.
|
||||
* @param {HTMLElement} element
|
||||
* @return
|
||||
*/
|
||||
export function getComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>): T | null {
|
||||
return element[instanceSymbol] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the component to the selected element.
|
||||
* @param element The element to bind the component to.
|
||||
* @param instance The component instance.
|
||||
*/
|
||||
export function bindComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>, instance: T): void {
|
||||
if (element[instanceSymbol]) {
|
||||
throw new Error('The element is already bound to a component.');
|
||||
}
|
||||
|
||||
element[instanceSymbol] = instance;
|
||||
}
|
||||
5
src/lib/components/events/booru-events.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
|
||||
|
||||
export interface BooruEventsMap {
|
||||
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
|
||||
}
|
||||
100
src/lib/components/events/comms.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events";
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events";
|
||||
import type { BooruEventsMap } from "$lib/components/events/booru-events";
|
||||
import type { TagsFormEventsMap } from "$lib/components/events/tags-form-events";
|
||||
import type { TagDropdownEvents } from "$lib/components/events/tag-dropdown-events";
|
||||
|
||||
type EventsMapping =
|
||||
MaintenancePopupEventsMap
|
||||
& FullscreenViewerEventsMap
|
||||
& BooruEventsMap
|
||||
& TagsFormEventsMap
|
||||
& TagDropdownEvents;
|
||||
|
||||
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
|
||||
export type UnsubscribeFunction = () => void;
|
||||
type ResolvableTarget = EventTarget | BaseComponent;
|
||||
|
||||
function resolveTarget(componentOrElement: ResolvableTarget): EventTarget {
|
||||
if (componentOrElement instanceof BaseComponent) {
|
||||
return componentOrElement.container;
|
||||
}
|
||||
|
||||
return componentOrElement;
|
||||
}
|
||||
|
||||
export function emit<Event extends keyof EventsMapping>(
|
||||
targetOrComponent: ResolvableTarget,
|
||||
event: Event,
|
||||
details: EventsMapping[Event]
|
||||
) {
|
||||
const target = resolveTarget(targetOrComponent);
|
||||
|
||||
target.dispatchEvent(
|
||||
new CustomEvent(event, {
|
||||
detail: details,
|
||||
bubbles: true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function on<Event extends keyof EventsMapping>(
|
||||
targetOrComponent: ResolvableTarget,
|
||||
eventName: Event,
|
||||
callback: EventCallback<EventsMapping[Event]>,
|
||||
options: AddEventListenerOptions | null = null
|
||||
): UnsubscribeFunction {
|
||||
const target = resolveTarget(targetOrComponent);
|
||||
const controller = new AbortController();
|
||||
|
||||
target.addEventListener(
|
||||
eventName,
|
||||
callback as EventListener,
|
||||
{
|
||||
signal: controller.signal,
|
||||
once: options?.once
|
||||
}
|
||||
);
|
||||
|
||||
return () => controller.abort();
|
||||
}
|
||||
|
||||
const onceOptions = {once: true};
|
||||
|
||||
export function once<Event extends keyof EventsMapping>(
|
||||
targetOrComponent: ResolvableTarget,
|
||||
eventName: Event,
|
||||
callback: EventCallback<EventsMapping[Event]>
|
||||
): UnsubscribeFunction {
|
||||
return on(
|
||||
targetOrComponent,
|
||||
eventName,
|
||||
callback,
|
||||
onceOptions
|
||||
);
|
||||
}
|
||||
|
||||
class TargetedEmitter {
|
||||
readonly #element: ResolvableTarget;
|
||||
|
||||
constructor(targetOrComponent: ResolvableTarget) {
|
||||
this.#element = targetOrComponent;
|
||||
}
|
||||
|
||||
emit<Event extends keyof EventsMapping>(eventName: Event, details: EventsMapping[Event]): void {
|
||||
emit(this.#element, eventName, details);
|
||||
}
|
||||
|
||||
on<Event extends keyof EventsMapping>(eventName: Event, callback: EventCallback<EventsMapping[Event]>, options: AddEventListenerOptions | null = null): UnsubscribeFunction {
|
||||
return on(this.#element, eventName, callback, options);
|
||||
}
|
||||
|
||||
once<Event extends keyof EventsMapping>(eventName: Event, callback: EventCallback<EventsMapping[Event]>): UnsubscribeFunction {
|
||||
return once(this.#element, eventName, callback);
|
||||
}
|
||||
}
|
||||
|
||||
export function emitterAt(targetOrComponent: ResolvableTarget): TargetedEmitter {
|
||||
return new TargetedEmitter(targetOrComponent);
|
||||
}
|
||||
7
src/lib/components/events/fullscreen-viewer-events.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
|
||||
export const EVENT_SIZE_LOADED = 'size-loaded';
|
||||
|
||||
export interface FullscreenViewerEventsMap {
|
||||
[EVENT_SIZE_LOADED]: FullscreenViewerSize;
|
||||
}
|
||||
13
src/lib/components/events/maintenance-popup-events.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed';
|
||||
export const EVENT_MAINTENANCE_STATE_CHANGED = 'maintenance-state-change';
|
||||
export const EVENT_TAGS_UPDATED = 'tags-updated';
|
||||
|
||||
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
|
||||
|
||||
export interface MaintenancePopupEventsMap {
|
||||
[EVENT_ACTIVE_PROFILE_CHANGED]: MaintenanceProfile | null;
|
||||
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
|
||||
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
|
||||
}
|
||||
7
src/lib/components/events/tag-dropdown-events.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
export const EVENT_TAG_GROUP_RESOLVED = 'tag-group-resolved';
|
||||
|
||||
export interface TagDropdownEvents {
|
||||
[EVENT_TAG_GROUP_RESOLVED]: TagGroup | null;
|
||||
}
|
||||
5
src/lib/components/events/tags-form-events.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const EVENT_FORM_EDITOR_UPDATED = 'tags-form-updated';
|
||||
|
||||
export interface TagsFormEventsMap {
|
||||
[EVENT_FORM_EDITOR_UPDATED]: HTMLElement;
|
||||
}
|
||||
19
src/lib/components/listing/ImageListContainer.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { ImageListInfo } from "$lib/components/listing/ImageListInfo";
|
||||
|
||||
export class ImageListContainer extends BaseComponent {
|
||||
#info: ImageListInfo | null = null;
|
||||
|
||||
protected build() {
|
||||
const imageListInfoContainer = this.container.querySelector<HTMLElement>('.js-imagelist-info');
|
||||
|
||||
if (imageListInfoContainer) {
|
||||
this.#info = new ImageListInfo(imageListInfoContainer);
|
||||
this.#info.initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeImageListContainer(element: HTMLElement) {
|
||||
new ImageListContainer(element).initialize();
|
||||
}
|
||||
75
src/lib/components/listing/ImageListInfo.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
|
||||
export class ImageListInfo extends BaseComponent {
|
||||
#tagElement: HTMLElement | null = null;
|
||||
#impliedTags: string[] = [];
|
||||
#showUntaggedImplicationsButton: HTMLAnchorElement = document.createElement('a');
|
||||
|
||||
protected build() {
|
||||
const sectionAfterImage = this.container.querySelector('.tag-info__image + .flex__grow');
|
||||
|
||||
this.#tagElement = sectionAfterImage?.querySelector<HTMLElement>('.tag.dropdown') ?? null;
|
||||
|
||||
const labels = this.container
|
||||
.querySelectorAll<HTMLElement>('.tag-info__image + .flex__grow strong');
|
||||
|
||||
let targetElementToInsertBefore: HTMLElement | null = null;
|
||||
|
||||
for (const potentialListStarter of labels) {
|
||||
if (potentialListStarter.innerText === ImageListInfo.#implicationsStarterText) {
|
||||
targetElementToInsertBefore = potentialListStarter;
|
||||
this.#collectImplicationsFromListStarter(potentialListStarter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#impliedTags.length && targetElementToInsertBefore) {
|
||||
this.#showUntaggedImplicationsButton.href = '#';
|
||||
this.#showUntaggedImplicationsButton.innerText = '(Q)';
|
||||
this.#showUntaggedImplicationsButton.title =
|
||||
'Query untagged implications\n\n' +
|
||||
'This will open the search results with all untagged implications for the current tag.';
|
||||
this.#showUntaggedImplicationsButton.classList.add('detail-link');
|
||||
|
||||
targetElementToInsertBefore.insertAdjacentElement('beforebegin', this.#showUntaggedImplicationsButton);
|
||||
}
|
||||
}
|
||||
|
||||
protected init() {
|
||||
this.#showUntaggedImplicationsButton.addEventListener('click', this.#onShowUntaggedImplicationsClicked.bind(this));
|
||||
}
|
||||
|
||||
#collectImplicationsFromListStarter(listStarter: HTMLElement) {
|
||||
let targetElement: Element | null = listStarter.nextElementSibling;
|
||||
|
||||
while (targetElement) {
|
||||
if (targetElement instanceof HTMLAnchorElement) {
|
||||
this.#impliedTags.push(targetElement.innerText.trim());
|
||||
}
|
||||
|
||||
// First line break is considered the end of the list.
|
||||
if (targetElement instanceof HTMLBRElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
targetElement = targetElement.nextElementSibling;
|
||||
}
|
||||
}
|
||||
|
||||
#onShowUntaggedImplicationsClicked(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
url.pathname = '/search';
|
||||
url.search = '';
|
||||
|
||||
const currentTagName = this.#tagElement?.dataset.tagName;
|
||||
|
||||
url.searchParams.set('q', `${currentTagName}, !(${this.#impliedTags.join(", ")})`);
|
||||
|
||||
location.assign(url.href);
|
||||
}
|
||||
|
||||
static #implicationsStarterText = 'Implies:';
|
||||
}
|
||||
120
src/lib/extension/BulkEntitiesTransporter.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
|
||||
import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables";
|
||||
import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
|
||||
type TransportersMapping = {
|
||||
[EntityName in keyof App.EntityNamesMap]: EntitiesTransporter<App.EntityNamesMap[EntityName]>;
|
||||
}
|
||||
|
||||
export default class BulkEntitiesTransporter {
|
||||
#lastSameSiteStatus: SameSiteStatus = null;
|
||||
|
||||
get lastImportSameSiteStatus() {
|
||||
return this.#lastSameSiteStatus;
|
||||
}
|
||||
|
||||
parseAndImportFromJSON(jsonString: string): StorageEntity[] {
|
||||
let parsedObject: any;
|
||||
|
||||
this.#lastSameSiteStatus = null;
|
||||
|
||||
try {
|
||||
parsedObject = JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
throw new TypeError('Invalid JSON!', {cause: e});
|
||||
}
|
||||
|
||||
if (!BulkEntitiesTransporter.isList(parsedObject)) {
|
||||
throw new TypeError('Invalid or unsupported object!');
|
||||
}
|
||||
|
||||
this.#lastSameSiteStatus = EntitiesTransporter.checkIsSameSiteImportedObject(parsedObject);
|
||||
|
||||
let hasDifferentStatuses = false;
|
||||
|
||||
const resultEntities = parsedObject.elements
|
||||
.map(importableObject => {
|
||||
if (!(importableObject.$type in BulkEntitiesTransporter.#transporters)) {
|
||||
console.warn('Attempting to import unsupported entity: ' + importableObject.$type);
|
||||
return null;
|
||||
}
|
||||
|
||||
const transporter = BulkEntitiesTransporter.#transporters[importableObject.$type as keyof App.EntityNamesMap];
|
||||
const resultEntity = transporter.importFromObject(importableObject);
|
||||
|
||||
if (transporter.lastImportSameSiteStatus !== this.#lastSameSiteStatus) {
|
||||
hasDifferentStatuses = true;
|
||||
}
|
||||
|
||||
return resultEntity;
|
||||
})
|
||||
.filter(maybeEntity => !!maybeEntity);
|
||||
|
||||
if (hasDifferentStatuses) {
|
||||
this.#lastSameSiteStatus = 'unknown';
|
||||
}
|
||||
|
||||
return resultEntities;
|
||||
}
|
||||
|
||||
parseAndImportFromCompressedJSON(compressedJsonString: string): StorageEntity[] {
|
||||
return this.parseAndImportFromJSON(
|
||||
decompressFromEncodedURIComponent(compressedJsonString)
|
||||
);
|
||||
}
|
||||
|
||||
exportToJSON(entities: StorageEntity[]): string {
|
||||
return JSON.stringify({
|
||||
$type: 'list',
|
||||
$site: __CURRENT_SITE__,
|
||||
elements: entities
|
||||
.map(entity => {
|
||||
switch (true) {
|
||||
case entity instanceof MaintenanceProfile:
|
||||
return BulkEntitiesTransporter.#transporters.profiles.exportToObject(entity);
|
||||
case entity instanceof TagGroup:
|
||||
return BulkEntitiesTransporter.#transporters.groups.exportToObject(entity);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter(value => !!value)
|
||||
} as ImportableElementsList<ImportableEntityObject<StorageEntity>>, null, 2);
|
||||
}
|
||||
|
||||
exportToCompressedJSON(entities: StorageEntity[]): string {
|
||||
return compressToEncodedURIComponent(
|
||||
this.exportToJSON(entities)
|
||||
);
|
||||
}
|
||||
|
||||
static isList(targetObject: any): targetObject is ImportableElementsList<ImportableEntityObject<StorageEntity>> {
|
||||
return targetObject.$type
|
||||
&& targetObject.$type === 'list'
|
||||
&& targetObject.elements
|
||||
&& Array.isArray(targetObject.elements);
|
||||
}
|
||||
|
||||
static #transporters: TransportersMapping = {
|
||||
profiles: new EntitiesTransporter(MaintenanceProfile),
|
||||
groups: new EntitiesTransporter(TagGroup),
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the imported object is created for the same site extension or not.
|
||||
* @param importedObject Object to check.
|
||||
* @private
|
||||
*/
|
||||
static #checkIsSameSiteImportedObject(importedObject: Record<string, any>): SameSiteStatus {
|
||||
if (!('$site' in importedObject)) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return importedObject.$site === __CURRENT_SITE__
|
||||
? "same"
|
||||
: "different";
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import StorageHelper from "$lib/browser/StorageHelper.js";
|
||||
|
||||
export default class ConfigurationController {
|
||||
/** @type {string} */
|
||||
#configurationName;
|
||||
|
||||
/**
|
||||
* @param {string} configurationName Name of the configuration to work with.
|
||||
*/
|
||||
constructor(configurationName) {
|
||||
this.#configurationName = configurationName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the setting with the given name.
|
||||
*
|
||||
* @param {string} settingName Setting name.
|
||||
* @param {any} [defaultValue] Default value to return if the setting does not exist. Defaults to `null`.
|
||||
*
|
||||
* @return {Promise<any|null>} The setting value or the default value if the setting does not exist.
|
||||
*/
|
||||
async readSetting(settingName, defaultValue = null) {
|
||||
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
|
||||
return settings[settingName] ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the given value to the setting.
|
||||
*
|
||||
* @param {string} settingName Setting name.
|
||||
* @param {any} value Value to write.
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async writeSetting(settingName, value) {
|
||||
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
|
||||
|
||||
settings[settingName] = value;
|
||||
|
||||
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the specific setting.
|
||||
*
|
||||
* @param {string} settingName Setting name to delete.
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async deleteSetting(settingName) {
|
||||
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
|
||||
|
||||
delete settings[settingName];
|
||||
|
||||
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to changes in the configuration.
|
||||
*
|
||||
* @param {function(Record<string, any>)} callback Callback to call when the configuration changes. The new
|
||||
* configuration is passed as an argument.
|
||||
*
|
||||
* @return {function(): void} Unsubscribe function.
|
||||
*/
|
||||
subscribeToChanges(callback) {
|
||||
/** @param {Record<string, StorageChange>} changes */
|
||||
const changesSubscriber = changes => {
|
||||
if (!changes[this.#configurationName]) {
|
||||
return;
|
||||
}
|
||||
|
||||
callback(changes[this.#configurationName].newValue);
|
||||
}
|
||||
|
||||
ConfigurationController.#storageHelper.subscribe(changesSubscriber);
|
||||
|
||||
return () => ConfigurationController.#storageHelper.unsubscribe(changesSubscriber);
|
||||
}
|
||||
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
}
|
||||
80
src/lib/extension/ConfigurationController.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/StorageHelper";
|
||||
|
||||
export default class ConfigurationController {
|
||||
readonly #configurationName: string;
|
||||
readonly #storage: StorageHelper;
|
||||
|
||||
/**
|
||||
* @param {string} configurationName Name of the configuration to work with.
|
||||
* @param {StorageHelper} [storage] Selected storage where the settings are stored in. If not provided, local storage
|
||||
* is used.
|
||||
*/
|
||||
constructor(configurationName: string, storage: StorageHelper = new StorageHelper(chrome.storage.local)) {
|
||||
this.#configurationName = configurationName;
|
||||
this.#storage = storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the setting with the given name.
|
||||
*
|
||||
* @param settingName Setting name.
|
||||
* @param [defaultValue] Default value to return if the setting does not exist. Defaults to `null`.
|
||||
*
|
||||
* @return The setting value or the default value if the setting does not exist.
|
||||
*/
|
||||
async readSetting<Type = any, DefaultType = any>(settingName: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
|
||||
const settings = await this.#storage.read(this.#configurationName, {});
|
||||
return settings[settingName] ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the given value to the setting.
|
||||
*
|
||||
* @param settingName Setting name.
|
||||
* @param value Value to write.
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async writeSetting(settingName: string, value: any): Promise<void> {
|
||||
const settings = await this.#storage.read(this.#configurationName, {});
|
||||
|
||||
settings[settingName] = value;
|
||||
|
||||
this.#storage.write(this.#configurationName, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the specific setting.
|
||||
*
|
||||
* @param {string} settingName Setting name to delete.
|
||||
*/
|
||||
async deleteSetting(settingName: string): Promise<void> {
|
||||
const settings = await this.#storage.read(this.#configurationName, {});
|
||||
|
||||
delete settings[settingName];
|
||||
|
||||
this.#storage.write(this.#configurationName, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to changes in the configuration.
|
||||
*
|
||||
* @param {function(Record<string, any>)} callback Callback to call when the configuration changes. The new
|
||||
* configuration is passed as an argument.
|
||||
*
|
||||
* @return {function(): void} Unsubscribe function.
|
||||
*/
|
||||
subscribeToChanges(callback: (record: Record<string, any>) => void): () => void {
|
||||
const subscriber: StorageChangeSubscriber = changes => {
|
||||
if (!changes[this.#configurationName]) {
|
||||
return;
|
||||
}
|
||||
|
||||
callback(changes[this.#configurationName].newValue);
|
||||
}
|
||||
|
||||
this.#storage.subscribe(subscriber);
|
||||
|
||||
return () => this.#storage.unsubscribe(subscriber);
|
||||
}
|
||||
}
|
||||
128
src/lib/extension/CustomCategoriesResolver.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { escapeRegExp } from "$lib/utils";
|
||||
import { emit } from "$lib/components/events/comms";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
|
||||
|
||||
export default class CustomCategoriesResolver {
|
||||
#exactGroupMatches = new Map<string, TagGroup>();
|
||||
#regExpGroupMatches = new Map<RegExp, TagGroup>();
|
||||
#tagDropdowns: TagDropdownWrapper[] = [];
|
||||
#nextQueuedUpdate: Timeout | null = null;
|
||||
|
||||
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.#exactGroupMatches.size && !this.#regExpGroupMatches.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#queueUpdatingTags();
|
||||
}
|
||||
|
||||
#queueUpdatingTags() {
|
||||
if (this.#nextQueuedUpdate) {
|
||||
clearTimeout(this.#nextQueuedUpdate);
|
||||
}
|
||||
|
||||
this.#nextQueuedUpdate = setTimeout(
|
||||
this.#updateUnprocessedTags.bind(this),
|
||||
CustomCategoriesResolver.#unprocessedTagsTimeout
|
||||
);
|
||||
}
|
||||
|
||||
#updateUnprocessedTags() {
|
||||
this.#tagDropdowns
|
||||
.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.#exactGroupMatches.has(tagName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
emit(
|
||||
tagDropdown,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
this.#exactGroupMatches.get(tagName)!
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
|
||||
const tagName = tagDropdown.tagName!;
|
||||
|
||||
for (const targetRegularExpression of this.#regExpGroupMatches.keys()) {
|
||||
if (!targetRegularExpression.test(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
emit(
|
||||
tagDropdown,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
this.#regExpGroupMatches.get(targetRegularExpression)!
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#onTagGroupsReceived(tagGroups: TagGroup[]) {
|
||||
this.#exactGroupMatches.clear();
|
||||
this.#regExpGroupMatches.clear();
|
||||
|
||||
if (!tagGroups.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const tagGroup of tagGroups) {
|
||||
for (const tagName of tagGroup.settings.tags) {
|
||||
this.#exactGroupMatches.set(tagName, tagGroup);
|
||||
}
|
||||
|
||||
for (const tagPrefix of tagGroup.settings.prefixes) {
|
||||
this.#regExpGroupMatches.set(
|
||||
new RegExp(`^${escapeRegExp(tagPrefix)}`),
|
||||
tagGroup,
|
||||
);
|
||||
}
|
||||
|
||||
for (let tagSuffix of tagGroup.settings.suffixes) {
|
||||
this.#regExpGroupMatches.set(
|
||||
new RegExp(`${escapeRegExp(tagSuffix)}$`),
|
||||
tagGroup,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.#queueUpdatingTags();
|
||||
}
|
||||
|
||||
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
|
||||
emit(
|
||||
tagDropdown,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
static #unprocessedTagsTimeout = 0;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import StorageHelper from "$lib/browser/StorageHelper.js";
|
||||
|
||||
export default class EntitiesController {
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
|
||||
/**
|
||||
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
|
||||
*
|
||||
* @template EntityClass
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
static async readAllEntities(entityName, entityClass) {
|
||||
const rawEntities = await this.#storageHelper.read(entityName, {});
|
||||
|
||||
if (!rawEntities || Object.keys(rawEntities).length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object
|
||||
.entries(rawEntities)
|
||||
.map(([id, settings]) => new entityClass(id, settings));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>}
|
||||
*/
|
||||
static async updateEntity(entityName, entity) {
|
||||
await this.#storageHelper.write(
|
||||
entityName,
|
||||
Object.assign(
|
||||
await this.#storageHelper.read(
|
||||
entityName, {}
|
||||
),
|
||||
{
|
||||
[entity.id]: entity.settings
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>}
|
||||
*/
|
||||
static async deleteEntity(entityName, entityId) {
|
||||
const entities = await this.#storageHelper.read(entityName, {});
|
||||
delete entities[entityId];
|
||||
await this.#storageHelper.write(entityName, entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to all changes made to the storage.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
static subscribeToEntity(entityName, entityClass, callback) {
|
||||
/**
|
||||
* 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 => {
|
||||
if (!changes[entityName]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.readAllEntities(entityName, entityClass)
|
||||
.then(callback);
|
||||
}
|
||||
|
||||
this.#storageHelper.subscribe(storageChangesSubscriber);
|
||||
|
||||
return () => this.#storageHelper.unsubscribe(storageChangesSubscriber);
|
||||
}
|
||||
}
|
||||
87
src/lib/extension/EntitiesController.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/StorageHelper";
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export default class EntitiesController {
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
|
||||
/**
|
||||
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* @return List of entities of the given type.
|
||||
*/
|
||||
static async readAllEntities<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type): Promise<Type[]> {
|
||||
const rawEntities = await this.#storageHelper.read(entityName, {});
|
||||
|
||||
if (!rawEntities || Object.keys(rawEntities).length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object
|
||||
.entries(rawEntities)
|
||||
.map(([id, settings]) => new entityClass(id, settings));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the single entity in the storage. If the entity with the given ID already exists, it will be overwritten.
|
||||
*
|
||||
* @param entityName Name of the entity to update.
|
||||
* @param entity Entity to update.
|
||||
*/
|
||||
static async updateEntity(entityName: string, entity: StorageEntity<Object>): Promise<void> {
|
||||
this.#storageHelper.write(
|
||||
entityName,
|
||||
Object.assign(
|
||||
await this.#storageHelper.read(
|
||||
entityName, {}
|
||||
),
|
||||
{
|
||||
[entity.id]: entity.settings
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the entity with the given ID.
|
||||
*
|
||||
* @param entityName Name of the entity to delete.
|
||||
* @param entityId ID of the entity to delete.
|
||||
*/
|
||||
static async deleteEntity(entityName: string, entityId: string): Promise<void> {
|
||||
const entities = await this.#storageHelper.read(entityName, {});
|
||||
delete entities[entityId];
|
||||
this.#storageHelper.write(entityName, entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to all changes made to the storage.
|
||||
*
|
||||
* @template EntityClass
|
||||
*
|
||||
* @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<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.
|
||||
*/
|
||||
const subscriber: StorageChangeSubscriber = changes => {
|
||||
if (!changes[entityName]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.readAllEntities(entityName, entityClass)
|
||||
.then(callback);
|
||||
}
|
||||
|
||||
this.#storageHelper.subscribe(subscriber);
|
||||
|
||||
return () => this.#storageHelper.unsubscribe(subscriber);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,91 @@
|
||||
import {validateImportedEntity} from "$lib/extension/transporting/validators.js";
|
||||
import {exportEntityToObject} from "$lib/extension/transporting/exporters.js";
|
||||
import StorageEntity from "./base/StorageEntity.js";
|
||||
import {compressToEncodedURIComponent, decompressFromEncodedURIComponent} from "lz-string";
|
||||
import { validateImportedEntity } from "$lib/extension/transporting/validators";
|
||||
import { exportEntityToObject } from "$lib/extension/transporting/exporters";
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
|
||||
import type { ImportableElement } from "$lib/extension/transporting/importables";
|
||||
|
||||
type EntityConstructor<T extends StorageEntity> =
|
||||
(new (id: string, settings: Record<string, any>) => T)
|
||||
& typeof StorageEntity;
|
||||
/**
|
||||
* Status of the last import.
|
||||
*
|
||||
* - `NULL` - no import was done yet or was unsuccessful.
|
||||
* - `"unknown"` — imported object was created before v0.5, when extension started to be built for multiple sites.
|
||||
* - `"same"` — imported object is marked as generated by the same type of extension.
|
||||
* - `"different"` — imported object is marked as generated by some other type of extension.
|
||||
*/
|
||||
export type SameSiteStatus = null | "unknown" | "same" | "different";
|
||||
|
||||
export default class EntitiesTransporter<EntityType extends StorageEntity> {
|
||||
readonly #targetEntityConstructor: EntityConstructor<EntityType>;
|
||||
export default class EntitiesTransporter<EntityType> {
|
||||
readonly #targetEntityConstructor: new (...any: any[]) => EntityType;
|
||||
|
||||
#lastSameSiteStatus: SameSiteStatus = null;
|
||||
|
||||
/**
|
||||
* Read the status of the last successful import. This flag could be used to determine if it was for the same site as
|
||||
* the current extension or when it's generated before site identity was passed to the importable object.
|
||||
*
|
||||
* @see {SameSiteStatus} For the list of possible statuses.
|
||||
*/
|
||||
get lastImportSameSiteStatus() {
|
||||
return this.#lastSameSiteStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Name of the entity, exported directly from the constructor.
|
||||
* @private
|
||||
*/
|
||||
get #entityName() {
|
||||
// How the hell should I even do this?
|
||||
const entityName = ((this.#targetEntityConstructor as any) as typeof StorageEntity)._entityName;
|
||||
|
||||
if (entityName === "entity") {
|
||||
throw new Error("Generic entity name encountered!");
|
||||
}
|
||||
|
||||
return 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!');
|
||||
}
|
||||
|
||||
constructor(entityConstructor: EntityConstructor<EntityType>) {
|
||||
this.#targetEntityConstructor = entityConstructor;
|
||||
}
|
||||
|
||||
isCorrectEntity(entityObject: unknown): entityObject is EntityType {
|
||||
return entityObject instanceof this.#targetEntityConstructor;
|
||||
}
|
||||
|
||||
importFromObject(importedObject: Record<string, any>): EntityType {
|
||||
this.#lastSameSiteStatus = null;
|
||||
|
||||
// TODO: There should be an auto-upgrader somewhere before the validation. So if even the older version of schema
|
||||
// was used, we still will will be able to pass the validation. For now we only have non-breaking changes.
|
||||
validateImportedEntity(
|
||||
this.#entityName,
|
||||
importedObject,
|
||||
);
|
||||
|
||||
this.#lastSameSiteStatus = EntitiesTransporter.checkIsSameSiteImportedObject(importedObject);
|
||||
|
||||
return new this.#targetEntityConstructor(
|
||||
importedObject.id,
|
||||
importedObject
|
||||
);
|
||||
}
|
||||
|
||||
importFromJSON(jsonString: string): EntityType {
|
||||
const importedObject = this.#tryParsingAsJSON(jsonString);
|
||||
|
||||
if (!importedObject) {
|
||||
this.#lastSameSiteStatus = null;
|
||||
throw new Error('Invalid JSON!');
|
||||
}
|
||||
|
||||
validateImportedEntity(
|
||||
importedObject,
|
||||
this.#targetEntityConstructor._entityName
|
||||
);
|
||||
|
||||
return new this.#targetEntityConstructor(
|
||||
importedObject.id,
|
||||
importedObject
|
||||
);
|
||||
return this.importFromObject(importedObject);
|
||||
}
|
||||
|
||||
importFromCompressedJSON(compressedJsonString: string): EntityType {
|
||||
@@ -38,17 +94,27 @@ export default class EntitiesTransporter<EntityType extends StorageEntity> {
|
||||
)
|
||||
}
|
||||
|
||||
exportToJSON(entityObject: EntityType): string {
|
||||
if (!(entityObject instanceof this.#targetEntityConstructor)) {
|
||||
exportToObject(entityObject: EntityType) {
|
||||
if (!this.isCorrectEntity(entityObject)) {
|
||||
throw new TypeError('Transporter should be connected to the same entity to export!');
|
||||
}
|
||||
|
||||
const exportableObject = exportEntityToObject(
|
||||
entityObject,
|
||||
this.#targetEntityConstructor._entityName
|
||||
);
|
||||
if (!(entityObject instanceof StorageEntity)) {
|
||||
throw new TypeError('Only storage entities could be exported!');
|
||||
}
|
||||
|
||||
return JSON.stringify(exportableObject, null, 2);
|
||||
return exportEntityToObject(
|
||||
this.#entityName,
|
||||
entityObject
|
||||
);
|
||||
}
|
||||
|
||||
exportToJSON(entityObject: EntityType): string {
|
||||
return JSON.stringify(
|
||||
this.exportToObject(entityObject),
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
exportToCompressedJSON(entityObject: EntityType): string {
|
||||
@@ -70,4 +136,18 @@ export default class EntitiesTransporter<EntityType extends StorageEntity> {
|
||||
|
||||
return jsonObject
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the imported object is created for the same site extension or not.
|
||||
* @param importedObject Object to check.
|
||||
*/
|
||||
static checkIsSameSiteImportedObject(importedObject: Record<string, any>): SameSiteStatus {
|
||||
if (!('$site' in importedObject)) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return importedObject.$site === __CURRENT_SITE__
|
||||
? "same"
|
||||
: "different";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController.js";
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
63
src/lib/extension/base/StorageEntity.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import EntitiesController from "$lib/extension/EntitiesController";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return (this.constructor as typeof StorageEntity)._entityName;
|
||||
}
|
||||
|
||||
public static readonly _entityName: keyof App.EntityNamesMap | "entity" = "entity";
|
||||
|
||||
async save() {
|
||||
await EntitiesController.updateEntity(
|
||||
this.type,
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
async delete() {
|
||||
await EntitiesController.deleteEntity(
|
||||
this.type,
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity.js";
|
||||
import EntitiesController from "$lib/extension/EntitiesController.js";
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MaintenanceProfile;
|
||||
34
src/lib/extension/entities/MaintenanceProfile.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
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: keyof App.EntityNamesMap = "profiles";
|
||||
}
|
||||
25
src/lib/extension/entities/TagGroup.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export interface TagGroupSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
prefixes: string[];
|
||||
suffixes: string[];
|
||||
category: string;
|
||||
separate: boolean;
|
||||
}
|
||||
|
||||
export default class TagGroup extends StorageEntity<TagGroupSettings> {
|
||||
constructor(id: string, settings: Partial<TagGroupSettings>) {
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || [],
|
||||
prefixes: settings.prefixes || [],
|
||||
suffixes: settings.suffixes || [],
|
||||
category: settings.category || '',
|
||||
separate: Boolean(settings.separate),
|
||||
});
|
||||
}
|
||||
|
||||
public static readonly _entityName: keyof App.EntityNamesMap = "groups";
|
||||
}
|
||||