mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2026-02-06 23:32:58 +00:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 62bcba34da | |||
| 17396952c3 | |||
| e61f6af237 | |||
| 27133077c8 | |||
| 9e9499c904 | |||
| 7d19693f5e | |||
| 4fcf83d732 | |||
| 93d6d3a174 | |||
| 4bdf04f911 | |||
| c9a9fe059c | |||
| e523ce4468 |
@@ -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,7 @@ 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
|
||||
|
||||
@@ -112,6 +112,9 @@ export async function buildStyle(buildOptions) {
|
||||
}
|
||||
},
|
||||
emptyOutDir: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: makeAliases(buildOptions.rootDir)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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.4",
|
||||
"version": "0.4.1",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "furbooru-tagging-assistant@thecore.city"
|
||||
|
||||
3298
package-lock.json
generated
3298
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.3.4",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "npm run build:popup && npm run build:extension",
|
||||
@@ -10,21 +10,21 @@
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.1",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@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-auto": "^4.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.17.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/chrome": "^0.0.304",
|
||||
"cheerio": "^1.0.0",
|
||||
"sass": "^1.85.0",
|
||||
"svelte": "^5.20.1",
|
||||
"svelte-check": "^4.1.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.1",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"lz-string": "^1.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
11
src/app.d.ts
vendored
11
src/app.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
@@ -24,6 +25,14 @@ declare global {
|
||||
|
||||
interface EntityNamesMap {
|
||||
profiles: MaintenanceProfile;
|
||||
groups: TagGroup;
|
||||
}
|
||||
|
||||
interface ImageURIs {
|
||||
full: string;
|
||||
large: string;
|
||||
medium: string;
|
||||
small: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<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";
|
||||
import { storagesCollection } from "$stores/debug";
|
||||
import { goto } from "$app/navigation";
|
||||
import { findDeepObject } from "$lib/utils";
|
||||
|
||||
/** @type {string} */
|
||||
export let storage;
|
||||
|
||||
59
src/components/features/GroupView.svelte
Normal file
59
src/components/features/GroupView.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script>
|
||||
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
|
||||
|
||||
/**
|
||||
* @type {import('$entities/TagGroup').default}
|
||||
*/
|
||||
export let group;
|
||||
|
||||
let sortedTagsList, sortedPrefixes;
|
||||
|
||||
$: sortedTagsList = group.settings.tags.sort((a, b) => a.localeCompare(b));
|
||||
$: sortedPrefixes = group.settings.prefixes.sort((a, b) => a.localeCompare(b));
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<strong>Group Name:</strong>
|
||||
<div>{group.settings.name}</div>
|
||||
</div>
|
||||
{#if sortedTagsList.length}
|
||||
<div class="block">
|
||||
<strong>Tags:</strong>
|
||||
<TagsColorContainer targetCategory="{group.settings.category}">
|
||||
<div class="tags-list">
|
||||
{#each sortedTagsList as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</TagsColorContainer>
|
||||
</div>
|
||||
{/if}
|
||||
{#if sortedPrefixes.length}
|
||||
<div class="block">
|
||||
<strong>Prefixes:</strong>
|
||||
<TagsColorContainer targetCategory="{group.settings.category}">
|
||||
<div class="tags-list">
|
||||
{#each sortedPrefixes as prefixName}
|
||||
<span class="tag">{prefixName}*</span>
|
||||
{/each}
|
||||
</div>
|
||||
</TagsColorContainer>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
/** @type {import('$entities/MaintenanceProfile.ts').default} */
|
||||
/** @type {import('$entities/MaintenanceProfile').default} */
|
||||
export let profile;
|
||||
|
||||
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import {version} from "$app/environment";
|
||||
import { version } from "$app/environment";
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
@@ -10,7 +10,7 @@
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'src/styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
@use "src/styles/colors";
|
||||
@use "$styles/colors";
|
||||
|
||||
header {
|
||||
background: colors.$header;
|
||||
|
||||
62
src/components/tags/TagsColorContainer.svelte
Normal file
62
src/components/tags/TagsColorContainer.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
/** @type {string} */
|
||||
export let targetCategory = '';
|
||||
</script>
|
||||
|
||||
<div class="tag-color-container tag-color-container--{targetCategory || 'default'}">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
|
||||
.tag-color-container:is(.tag-color-container--rating) :global(.tag) {
|
||||
background-color: colors.$tag-rating-background;
|
||||
color: colors.$tag-rating-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--spoiler) :global(.tag) {
|
||||
background-color: colors.$tag-spoiler-background;
|
||||
color: colors.$tag-spoiler-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--origin) :global(.tag) {
|
||||
background-color: colors.$tag-origin-background;
|
||||
color: colors.$tag-origin-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--oc) :global(.tag) {
|
||||
background-color: colors.$tag-oc-background;
|
||||
color: colors.$tag-oc-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--error) :global(.tag) {
|
||||
background-color: colors.$tag-error-background;
|
||||
color: colors.$tag-error-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--character) :global(.tag) {
|
||||
background-color: colors.$tag-character-background;
|
||||
color: colors.$tag-character-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--content-official) :global(.tag) {
|
||||
background-color: colors.$tag-content-official-background;
|
||||
color: colors.$tag-content-official-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--content-fanmade) :global(.tag) {
|
||||
background-color: colors.$tag-content-fanmade-background;
|
||||
color: colors.$tag-content-fanmade-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--species) :global(.tag) {
|
||||
background-color: colors.$tag-species-background;
|
||||
color: colors.$tag-species-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--body-type) :global(.tag) {
|
||||
background-color: colors.$tag-body-type-background;
|
||||
color: colors.$tag-body-type-text;
|
||||
}
|
||||
</style>
|
||||
80
src/components/ui/forms/TagCategorySelectField.svelte
Normal file
80
src/components/ui/forms/TagCategorySelectField.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script>
|
||||
import SelectField from "$components/ui/forms/SelectField.svelte";
|
||||
import { categories } from "$lib/booru/tag-categories";
|
||||
|
||||
/** @type {string} */
|
||||
export let value = '';
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
let tagCategoriesOptions = {
|
||||
'': 'Default'
|
||||
};
|
||||
|
||||
tagCategoriesOptions = categories.reduce((options, category) => {
|
||||
options[category] = category
|
||||
.replace('-', ' ')
|
||||
.replace(/(?<=\s|^)\w/g, (matchedCharacter) => matchedCharacter.toUpperCase());
|
||||
|
||||
return options;
|
||||
}, tagCategoriesOptions);
|
||||
</script>
|
||||
|
||||
<SelectField bind:value={value} options={tagCategoriesOptions} name="tag_color"/>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
|
||||
:global(select[name=tag_color]) {
|
||||
:global(option) {
|
||||
&:is(:global([value=rating])) {
|
||||
background-color: colors.$tag-rating-background;
|
||||
color: colors.$tag-rating-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=spoiler])) {
|
||||
background-color: colors.$tag-spoiler-background;
|
||||
color: colors.$tag-spoiler-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=origin])) {
|
||||
background-color: colors.$tag-origin-background;
|
||||
color: colors.$tag-origin-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=oc])) {
|
||||
background-color: colors.$tag-oc-background;
|
||||
color: colors.$tag-oc-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=error])) {
|
||||
background-color: colors.$tag-error-background;
|
||||
color: colors.$tag-error-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=character])) {
|
||||
background-color: colors.$tag-character-background;
|
||||
color: colors.$tag-character-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=content-official])) {
|
||||
background-color: colors.$tag-content-official-background;
|
||||
color: colors.$tag-content-official-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=content-fanmade])) {
|
||||
background-color: colors.$tag-content-fanmade-background;
|
||||
color: colors.$tag-content-fanmade-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=species])) {
|
||||
background-color: colors.$tag-species-background;
|
||||
color: colors.$tag-species-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=body-type])) {
|
||||
background-color: colors.$tag-body-type-background;
|
||||
color: colors.$tag-body-type-text;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
</nav>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'src/styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</script>
|
||||
|
||||
<MenuLink {href}>
|
||||
<input type="checkbox" {name} {value} {checked} on:input on:click|stopPropagation>
|
||||
<input type="checkbox" {name} {value} bind:checked={checked} on:input on:click|stopPropagation>
|
||||
<slot></slot>
|
||||
</MenuLink>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</svelte:element>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../../styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {initializeSiteHeader} from "$lib/components/SiteHeaderWrapper.js";
|
||||
import { initializeSiteHeader } from "$lib/components/SiteHeaderWrapper";
|
||||
|
||||
const siteHeader = document.querySelector('.header');
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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";
|
||||
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";
|
||||
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
const mediaBoxes = document.querySelectorAll('.media-box');
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import {TagsForm} from "$lib/components/TagsForm.js";
|
||||
import { TagsForm } from "$lib/components/TagsForm";
|
||||
|
||||
TagsForm.watchForEditors();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {watchTagDropdownsInTagsEditor, wrapTagDropdown} from "$lib/components/TagDropdownWrapper.js";
|
||||
import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$lib/components/TagDropdownWrapper";
|
||||
|
||||
for (let tagDropdownElement of document.querySelectorAll('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import PostParser from "$lib/booru/scraped/parsing/PostParser.js";
|
||||
import PostParser from "$lib/booru/scraped/parsing/PostParser";
|
||||
|
||||
export default class ScrapedAPI {
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageParser from "$lib/booru/scraped/parsing/PageParser.js";
|
||||
import {buildTagsAndAliasesMap} from "$lib/booru/TagsUtils.js";
|
||||
import PageParser from "$lib/booru/scraped/parsing/PageParser";
|
||||
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
|
||||
|
||||
export default class PostParser extends PageParser {
|
||||
/** @type {HTMLFormElement} */
|
||||
|
||||
@@ -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
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
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
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,13 +1,14 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings";
|
||||
|
||||
export class FullscreenViewer extends BaseComponent {
|
||||
/** @type {HTMLVideoElement} */
|
||||
#videoElement = document.createElement('video');
|
||||
/** @type {HTMLImageElement} */
|
||||
#imageElement = document.createElement('img');
|
||||
|
||||
#spinnerElement = document.createElement('i');
|
||||
|
||||
#sizeSelectorElement = document.createElement('select');
|
||||
#closeButtonElement = document.createElement('i');
|
||||
/** @type {number|null} */
|
||||
#touchId = null;
|
||||
/** @type {number|null} */
|
||||
@@ -16,15 +17,33 @@ export class FullscreenViewer extends BaseComponent {
|
||||
#startY = null;
|
||||
/** @type {boolean|null} */
|
||||
#isClosingSwipeStarted = null;
|
||||
#isSizeFetched = false;
|
||||
/** @type {App.ImageURIs|null} */
|
||||
#currentURIs = null;
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
build() {
|
||||
this.container.classList.add('fullscreen-viewer');
|
||||
this.container.append(this.#spinnerElement);
|
||||
|
||||
this.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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +59,12 @@ export class FullscreenViewer extends BaseComponent {
|
||||
|
||||
this.#videoElement.addEventListener('loadeddata', this.#onLoaded.bind(this));
|
||||
this.#imageElement.addEventListener('load', this.#onLoaded.bind(this));
|
||||
this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation());
|
||||
|
||||
FullscreenViewer.#miscSettings
|
||||
.resolveFullscreenViewerPreviewSize()
|
||||
.then(this.#onSizeResolved.bind(this))
|
||||
.then(this.#watchForSizeSelectionChanges.bind(this));
|
||||
}
|
||||
|
||||
#onLoaded() {
|
||||
@@ -163,7 +188,49 @@ export class FullscreenViewer extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("$lib/extension/settings/MiscSettings").FullscreenViewerSize} size
|
||||
*/
|
||||
#onSizeResolved(size) {
|
||||
this.#sizeSelectorElement.value = size;
|
||||
this.#isSizeFetched = true;
|
||||
|
||||
this.emit('size-loaded');
|
||||
}
|
||||
|
||||
#watchForSizeSelectionChanges() {
|
||||
let lastActiveSize = this.#sizeSelectorElement.value;
|
||||
|
||||
FullscreenViewer.#miscSettings.subscribe(settings => {
|
||||
const targetSize = settings.fullscreenViewerSize;
|
||||
|
||||
if (!targetSize || lastActiveSize === targetSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActiveSize = targetSize;
|
||||
this.#sizeSelectorElement.value = targetSize;
|
||||
});
|
||||
|
||||
this.#sizeSelectorElement.addEventListener('input', () => {
|
||||
const targetSize = this.#sizeSelectorElement.value;
|
||||
|
||||
if (this.#currentURIs) {
|
||||
void this.show(this.#currentURIs);
|
||||
}
|
||||
|
||||
if (!targetSize || targetSize === lastActiveSize || !(targetSize in FullscreenViewer.#previewSizes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActiveSize = targetSize;
|
||||
void FullscreenViewer.#miscSettings.setFullscreenViewerPreviewSize(targetSize);
|
||||
});
|
||||
}
|
||||
|
||||
#close() {
|
||||
this.#currentURIs = null;
|
||||
|
||||
this.container.classList.remove(FullscreenViewer.#shownState);
|
||||
document.body.style.overflow = null;
|
||||
|
||||
@@ -175,9 +242,44 @@ export class FullscreenViewer extends BaseComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {App.ImageURIs} imageUris
|
||||
* @return {Promise<string|null>}
|
||||
*/
|
||||
show(url) {
|
||||
async #resolveCurrentSelectedSizeUrl(imageUris) {
|
||||
if (!this.#isSizeFetched) {
|
||||
await new Promise(resolve => this.on('size-loaded', resolve))
|
||||
}
|
||||
|
||||
let targetSize = this.#sizeSelectorElement.value;
|
||||
|
||||
if (!imageUris.hasOwnProperty(targetSize)) {
|
||||
targetSize = FullscreenViewer.#fallbackSize;
|
||||
}
|
||||
|
||||
if (!imageUris.hasOwnProperty(targetSize)) {
|
||||
targetSize = Object.keys(imageUris)[0];
|
||||
}
|
||||
|
||||
if (!targetSize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return imageUris[targetSize];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {App.ImageURIs} imageUris
|
||||
*/
|
||||
async show(imageUris) {
|
||||
this.#currentURIs = imageUris;
|
||||
|
||||
const url = await this.#resolveCurrentSelectedSizeUrl(imageUris);
|
||||
|
||||
if (!url) {
|
||||
console.warn('Failed to resolve media for the viewer!');
|
||||
return;
|
||||
}
|
||||
|
||||
this.container.classList.add('loading');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
@@ -214,9 +316,23 @@ export class FullscreenViewer extends BaseComponent {
|
||||
return url.endsWith('.mp4') || url.endsWith('.webm');
|
||||
}
|
||||
|
||||
static #miscSettings = new MiscSettings();
|
||||
|
||||
static #offsetProperty = '--offset';
|
||||
static #opacityProperty = '--opacity';
|
||||
static #shownState = 'shown';
|
||||
static #swipeState = 'swiped';
|
||||
static #minRequiredDistance = 50;
|
||||
|
||||
/**
|
||||
* @type {Record<import("$lib/extension/settings/MiscSettings").FullscreenViewerSize, string>}
|
||||
*/
|
||||
static #previewSizes = {
|
||||
full: 'Full',
|
||||
large: 'Large',
|
||||
medium: 'Medium',
|
||||
small: 'Small'
|
||||
}
|
||||
|
||||
static #fallbackSize = 'large';
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings.ts";
|
||||
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";
|
||||
|
||||
export class ImageShowFullscreenButton extends BaseComponent {
|
||||
/**
|
||||
* @type {MediaBoxTools}
|
||||
* @type {import('./MediaBoxTools').MediaBoxTools|null}
|
||||
*/
|
||||
#mediaBoxTools;
|
||||
#mediaBoxTools= null;
|
||||
#isFullscreenButtonEnabled = false;
|
||||
|
||||
build() {
|
||||
@@ -33,7 +33,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
})
|
||||
.then(() => {
|
||||
ImageShowFullscreenButton.#miscSettings.subscribe(settings => {
|
||||
this.#isFullscreenButtonEnabled = settings.fullscreenViewer;
|
||||
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
})
|
||||
@@ -47,7 +47,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
#onButtonClicked() {
|
||||
ImageShowFullscreenButton
|
||||
.#resolveViewer()
|
||||
.show(this.#mediaBoxTools.mediaBox.imageLinks.large);
|
||||
.show(this.#mediaBoxTools.mediaBox.imageLinks);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI.js";
|
||||
import {tagsBlacklist} from "$config/tags.ts";
|
||||
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 {
|
||||
eventActiveProfileChanged,
|
||||
eventMaintenanceStateChanged,
|
||||
eventTagsUpdated
|
||||
} from "$lib/components/events/maintenance-popup-events";
|
||||
|
||||
class BlackListedTagsEncounteredError extends Error {
|
||||
/**
|
||||
@@ -27,7 +33,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
/** @type {MaintenanceProfile|null} */
|
||||
#activeProfile = null;
|
||||
|
||||
/** @type {import('$lib/components/MediaBoxTools.js').MediaBoxTools} */
|
||||
/** @type {import('$lib/components/MediaBoxTools').MediaBoxTools} */
|
||||
#mediaBoxTools = null;
|
||||
|
||||
/** @type {Set<string>} */
|
||||
@@ -45,6 +51,8 @@ export class MaintenancePopup extends BaseComponent {
|
||||
/** @type {number|null} */
|
||||
#tagsSubmissionTimer = null;
|
||||
|
||||
#emitter = emitterAt(this);
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
@@ -95,7 +103,8 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.#activeProfile = activeProfile;
|
||||
this.container.classList.toggle('is-active', activeProfile !== null);
|
||||
this.#refreshTagsList();
|
||||
this.emit('active-profile-changed', activeProfile);
|
||||
|
||||
this.#emitter.emit(eventActiveProfileChanged, activeProfile);
|
||||
}
|
||||
|
||||
#refreshTagsList() {
|
||||
@@ -181,7 +190,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = true;
|
||||
this.emit('maintenance-state-change', 'waiting');
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'waiting');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +217,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.#isPlanningToSubmit = false;
|
||||
this.#isSubmitting = true;
|
||||
|
||||
this.emit('maintenance-state-change', 'processing');
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'processing');
|
||||
|
||||
let maybeTagsAndAliasesAfterUpdate;
|
||||
|
||||
@@ -249,17 +258,18 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
this.emit('maintenance-state-change', 'failed');
|
||||
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'failed');
|
||||
this.#isSubmitting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (maybeTagsAndAliasesAfterUpdate) {
|
||||
this.emit('tags-updated', maybeTagsAndAliasesAfterUpdate);
|
||||
this.#emitter.emit(eventTagsUpdated, maybeTagsAndAliasesAfterUpdate);
|
||||
}
|
||||
|
||||
this.emit('maintenance-state-change', 'complete');
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'complete');
|
||||
|
||||
this.#tagsToAdd.clear();
|
||||
this.#tagsToRemove.clear();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { eventMaintenanceStateChanged } from "$lib/components/events/maintenance-popup-events";
|
||||
|
||||
export class MaintenanceStatusIcon extends BaseComponent {
|
||||
/** @type {import('MediaBoxTools.js').MediaBoxTools} */
|
||||
/** @type {import('./MediaBoxTools').MediaBoxTools} */
|
||||
#mediaBoxTools;
|
||||
|
||||
build() {
|
||||
@@ -16,7 +18,7 @@ export class MaintenanceStatusIcon extends BaseComponent {
|
||||
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, eventMaintenanceStateChanged, this.#onMaintenanceStateChanged.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import {MaintenancePopup} from "$lib/components/MaintenancePopup.js";
|
||||
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 { eventActiveProfileChanged } from "$lib/components/events/maintenance-popup-events";
|
||||
|
||||
export class MediaBoxTools extends BaseComponent {
|
||||
/** @type {import('MediaBoxWrapper.js').MediaBoxWrapper|null} */
|
||||
/** @type {import('./MediaBoxWrapper').MediaBoxWrapper|null} */
|
||||
#mediaBox;
|
||||
|
||||
/** @type {MaintenancePopup|null} */
|
||||
@@ -34,11 +36,11 @@ export class MediaBoxTools extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
this.on('active-profile-changed', this.#onActiveProfileChanged.bind(this));
|
||||
on(this, eventActiveProfileChanged, this.#onActiveProfileChanged.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<MaintenanceProfile|null>} profileChangedEvent
|
||||
* @param {CustomEvent<import('$entities/MaintenanceProfile').default|null>} profileChangedEvent
|
||||
*/
|
||||
#onActiveProfileChanged(profileChangedEvent) {
|
||||
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
|
||||
@@ -52,7 +54,7 @@ export class MediaBoxTools extends BaseComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {import('MediaBoxWrapper.js').MediaBoxWrapper|null}
|
||||
* @return {import('./MediaBoxWrapper').MediaBoxWrapper|null}
|
||||
*/
|
||||
get mediaBox() {
|
||||
return this.#mediaBox;
|
||||
@@ -61,7 +63,7 @@ export class MediaBoxTools extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Create a maintenance popup element.
|
||||
* @param {HTMLElement} childrenElements List of children elements to append to the component.
|
||||
* @param {HTMLElement[]} childrenElements List of children elements to append to the component.
|
||||
* @return {HTMLElement} The maintenance popup element.
|
||||
*/
|
||||
export function createMediaBoxTools(...childrenElements) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import {buildTagsAndAliasesMap} from "$lib/booru/TagsUtils.js";
|
||||
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 { eventTagsUpdated } from "$lib/components/events/maintenance-popup-events";
|
||||
|
||||
export class MediaBoxWrapper extends BaseComponent {
|
||||
#thumbnailContainer = null;
|
||||
@@ -13,11 +15,11 @@ export class MediaBoxWrapper extends BaseComponent {
|
||||
this.#thumbnailContainer = this.container.querySelector('.image-container');
|
||||
this.#imageLinkElement = this.#thumbnailContainer.querySelector('a');
|
||||
|
||||
this.on('tags-updated', this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
on(this, eventTagsUpdated, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<Map<string,string>>} tagsUpdatedEvent
|
||||
* @param {CustomEvent<Map<string,string>|null>} tagsUpdatedEvent
|
||||
*/
|
||||
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent) {
|
||||
const updatedMap = tagsUpdatedEvent.detail;
|
||||
@@ -56,7 +58,7 @@ export class MediaBoxWrapper extends BaseComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ImageURIs}
|
||||
* @return {App.ImageURIs}
|
||||
*/
|
||||
get imageLinks() {
|
||||
return JSON.parse(this.#thumbnailContainer.dataset.uris);
|
||||
@@ -100,10 +102,3 @@ export function calculateMediaBoxesPositions(mediaBoxesList) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} ImageURIs
|
||||
* @property {string} full
|
||||
* @property {string} large
|
||||
* @property {string} small
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {QueryLexer, QuotedTermToken, TermToken, Token} from "$lib/booru/search/QueryLexer.js";
|
||||
import SearchSettings from "$lib/extension/settings/SearchSettings.ts";
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { QueryLexer, QuotedTermToken, TermToken, Token } from "$lib/booru/search/QueryLexer";
|
||||
import SearchSettings from "$lib/extension/settings/SearchSettings";
|
||||
|
||||
export class SearchWrapper extends BaseComponent {
|
||||
/** @type {HTMLInputElement|null} */
|
||||
@@ -351,24 +351,42 @@ export class SearchWrapper extends BaseComponent {
|
||||
static #typeDate = Symbol();
|
||||
static #typeLiteral = Symbol();
|
||||
static #typePersonal = Symbol();
|
||||
static #typeBoolean = Symbol();
|
||||
|
||||
static #properties = new Map([
|
||||
['animated', SearchWrapper.#typeBoolean],
|
||||
['aspect_ratio', SearchWrapper.#typeNumeric],
|
||||
['body_type_tag_count', SearchWrapper.#typeNumeric],
|
||||
['character_tag_count', SearchWrapper.#typeNumeric],
|
||||
['comment_count', SearchWrapper.#typeNumeric],
|
||||
['content_fanmade_tag_count', SearchWrapper.#typeNumeric],
|
||||
['content_official_tag_count', SearchWrapper.#typeNumeric],
|
||||
['created_at', SearchWrapper.#typeDate],
|
||||
['description', SearchWrapper.#typeLiteral],
|
||||
['downvotes', SearchWrapper.#typeNumeric],
|
||||
['duration', SearchWrapper.#typeNumeric],
|
||||
['error_tag_count', SearchWrapper.#typeNumeric],
|
||||
['faved_by', SearchWrapper.#typeLiteral],
|
||||
['faved_by_id', SearchWrapper.#typeNumeric],
|
||||
['faves', SearchWrapper.#typeNumeric],
|
||||
['file_name', SearchWrapper.#typeLiteral],
|
||||
['first_seen_at', SearchWrapper.#typeDate],
|
||||
['height', SearchWrapper.#typeNumeric],
|
||||
['id', SearchWrapper.#typeNumeric],
|
||||
['oc_tag_count', SearchWrapper.#typeNumeric],
|
||||
['orig_sha512_hash', SearchWrapper.#typeLiteral],
|
||||
['original_format', SearchWrapper.#typeLiteral],
|
||||
['pixels', SearchWrapper.#typeNumeric],
|
||||
['rating_tag_count', SearchWrapper.#typeNumeric],
|
||||
['score', SearchWrapper.#typeNumeric],
|
||||
['sha512_hash', SearchWrapper.#typeLiteral],
|
||||
['size', SearchWrapper.#typeNumeric],
|
||||
['source_count', SearchWrapper.#typeNumeric],
|
||||
['source_url', SearchWrapper.#typeLiteral],
|
||||
['species_tag_count', SearchWrapper.#typeNumeric],
|
||||
['spoiler_tag_count', SearchWrapper.#typeNumeric],
|
||||
['tag_count', SearchWrapper.#typeNumeric],
|
||||
['updated_at', SearchWrapper.#typeDate],
|
||||
['uploader', SearchWrapper.#typeLiteral],
|
||||
['uploader_id', SearchWrapper.#typeNumeric],
|
||||
['upvotes', SearchWrapper.#typeNumeric],
|
||||
@@ -392,6 +410,10 @@ export class SearchWrapper extends BaseComponent {
|
||||
'uploads',
|
||||
'upvotes',
|
||||
'watched',
|
||||
]],
|
||||
[SearchWrapper.#typeBoolean, [
|
||||
'true',
|
||||
'false',
|
||||
]]
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {SearchWrapper} from "$lib/components/SearchWrapper.js";
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { SearchWrapper } from "$lib/components/SearchWrapper";
|
||||
|
||||
class SiteHeaderWrapper extends BaseComponent {
|
||||
/** @type {SearchWrapper|null} */
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import { 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";
|
||||
|
||||
const isTagEditorProcessedKey = Symbol();
|
||||
const categoriesResolver = new CustomCategoriesResolver();
|
||||
|
||||
class TagDropdownWrapper extends BaseComponent {
|
||||
export class TagDropdownWrapper extends BaseComponent {
|
||||
/**
|
||||
* Container with dropdown elements to insert options into.
|
||||
* @type {HTMLElement}
|
||||
@@ -36,6 +38,11 @@ class TagDropdownWrapper extends BaseComponent {
|
||||
*/
|
||||
#isEntered = false;
|
||||
|
||||
/**
|
||||
* @type {string|undefined|null}
|
||||
*/
|
||||
#originalCategory = null;
|
||||
|
||||
build() {
|
||||
this.#dropdownContainer = this.container.querySelector('.dropdown__content');
|
||||
}
|
||||
@@ -53,10 +60,45 @@ class TagDropdownWrapper extends BaseComponent {
|
||||
});
|
||||
}
|
||||
|
||||
get #tagName() {
|
||||
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();
|
||||
@@ -89,7 +131,7 @@ class TagDropdownWrapper extends BaseComponent {
|
||||
const profileName = this.#activeProfile.settings.name;
|
||||
let profileSpecificButtonText = `Add to profile "${profileName}"`;
|
||||
|
||||
if (this.#activeProfile.settings.tags.includes(this.#tagName)) {
|
||||
if (this.#activeProfile.settings.tags.includes(this.tagName)) {
|
||||
profileSpecificButtonText = `Remove from profile "${profileName}"`;
|
||||
}
|
||||
|
||||
@@ -108,7 +150,8 @@ class TagDropdownWrapper extends BaseComponent {
|
||||
async #onAddToNewClicked() {
|
||||
const profile = new MaintenanceProfile(crypto.randomUUID(), {
|
||||
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
|
||||
tags: [this.#tagName]
|
||||
tags: [this.tagName],
|
||||
temporary: true,
|
||||
});
|
||||
|
||||
await profile.save();
|
||||
@@ -121,7 +164,7 @@ class TagDropdownWrapper extends BaseComponent {
|
||||
}
|
||||
|
||||
const tagsList = new Set(this.#activeProfile.settings.tags);
|
||||
const targetTagName = this.#tagName;
|
||||
const targetTagName = this.tagName;
|
||||
|
||||
if (tagsList.has(targetTagName)) {
|
||||
tagsList.delete(targetTagName);
|
||||
@@ -195,7 +238,10 @@ export function wrapTagDropdown(element) {
|
||||
return;
|
||||
}
|
||||
|
||||
new TagDropdownWrapper(element).initialize();
|
||||
const tagDropdown = new TagDropdownWrapper(element);
|
||||
tagDropdown.initialize();
|
||||
|
||||
categoriesResolver.addElement(tagDropdown);
|
||||
}
|
||||
|
||||
export function watchTagDropdownsInTagsEditor() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
export class TagsForm extends BaseComponent {
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {bindComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import { bindComponent } from "$lib/components/base/component-utils";
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
@@ -45,7 +45,6 @@ export class BaseComponent {
|
||||
|
||||
/**
|
||||
* @return {HTMLElement}
|
||||
* @protected
|
||||
*/
|
||||
get container() {
|
||||
return this.#container;
|
||||
|
||||
@@ -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
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('instance');
|
||||
|
||||
interface ElementWithComponent extends HTMLElement {
|
||||
[instanceSymbol]?: BaseComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the component from the element, if there is one.
|
||||
* @param {HTMLElement} element
|
||||
* @return
|
||||
*/
|
||||
export function getComponent(element: ElementWithComponent): BaseComponent | 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(element: ElementWithComponent, instance: BaseComponent): void {
|
||||
if (element[instanceSymbol]) {
|
||||
throw new Error('The element is already bound to a component.');
|
||||
}
|
||||
|
||||
element[instanceSymbol] = instance;
|
||||
}
|
||||
92
src/lib/components/events/comms.ts
Normal file
92
src/lib/components/events/comms.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events";
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
|
||||
interface EventsMapping extends MaintenancePopupEventsMap {
|
||||
}
|
||||
|
||||
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
|
||||
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);
|
||||
}
|
||||
13
src/lib/components/events/maintenance-popup-events.ts
Normal file
13
src/lib/components/events/maintenance-popup-events.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
export const eventActiveProfileChanged = 'active-profile-changed';
|
||||
export const eventMaintenanceStateChanged = 'maintenance-state-change';
|
||||
export const eventTagsUpdated = 'tags-updated';
|
||||
|
||||
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
|
||||
|
||||
export interface MaintenancePopupEventsMap {
|
||||
[eventActiveProfileChanged]: MaintenanceProfile | null;
|
||||
[eventMaintenanceStateChanged]: MaintenanceState;
|
||||
[eventTagsUpdated]: Map<string, string> | null;
|
||||
}
|
||||
@@ -1,25 +1,24 @@
|
||||
import StorageHelper from "$lib/browser/StorageHelper.js";
|
||||
import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/StorageHelper";
|
||||
|
||||
export default class ConfigurationController {
|
||||
/** @type {string} */
|
||||
#configurationName;
|
||||
readonly #configurationName: string;
|
||||
|
||||
/**
|
||||
* @param {string} configurationName Name of the configuration to work with.
|
||||
*/
|
||||
constructor(configurationName) {
|
||||
constructor(configurationName: string) {
|
||||
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`.
|
||||
* @param settingName Setting name.
|
||||
* @param [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.
|
||||
* @return The setting value or the default value if the setting does not exist.
|
||||
*/
|
||||
async readSetting(settingName, defaultValue = null) {
|
||||
async readSetting<Type = any, DefaultType = any>(settingName: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
|
||||
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
|
||||
return settings[settingName] ?? defaultValue;
|
||||
}
|
||||
@@ -27,12 +26,12 @@ export default class ConfigurationController {
|
||||
/**
|
||||
* Write the given value to the setting.
|
||||
*
|
||||
* @param {string} settingName Setting name.
|
||||
* @param {any} value Value to write.
|
||||
* @param settingName Setting name.
|
||||
* @param value Value to write.
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async writeSetting(settingName, value) {
|
||||
async writeSetting(settingName: string, value: any): Promise<void> {
|
||||
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
|
||||
|
||||
settings[settingName] = value;
|
||||
@@ -44,10 +43,8 @@ export default class ConfigurationController {
|
||||
* Delete the specific setting.
|
||||
*
|
||||
* @param {string} settingName Setting name to delete.
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async deleteSetting(settingName) {
|
||||
async deleteSetting(settingName: string): Promise<void> {
|
||||
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
|
||||
|
||||
delete settings[settingName];
|
||||
@@ -63,9 +60,8 @@ export default class ConfigurationController {
|
||||
*
|
||||
* @return {function(): void} Unsubscribe function.
|
||||
*/
|
||||
subscribeToChanges(callback) {
|
||||
/** @param {Record<string, StorageChange>} changes */
|
||||
const changesSubscriber = changes => {
|
||||
subscribeToChanges(callback: (record: Record<string, any>) => void): () => void {
|
||||
const subscriber: StorageChangeSubscriber = changes => {
|
||||
if (!changes[this.#configurationName]) {
|
||||
return;
|
||||
}
|
||||
@@ -73,9 +69,9 @@ export default class ConfigurationController {
|
||||
callback(changes[this.#configurationName].newValue);
|
||||
}
|
||||
|
||||
ConfigurationController.#storageHelper.subscribe(changesSubscriber);
|
||||
ConfigurationController.#storageHelper.subscribe(subscriber);
|
||||
|
||||
return () => ConfigurationController.#storageHelper.unsubscribe(changesSubscriber);
|
||||
return () => ConfigurationController.#storageHelper.unsubscribe(subscriber);
|
||||
}
|
||||
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
110
src/lib/extension/CustomCategoriesResolver.ts
Normal file
110
src/lib/extension/CustomCategoriesResolver.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { escapeRegExp } from "$lib/utils";
|
||||
|
||||
export default class CustomCategoriesResolver {
|
||||
#tagCategories = new Map<string, string>();
|
||||
#compiledRegExps = new Map<RegExp, string>();
|
||||
#tagDropdowns: TagDropdownWrapper[] = [];
|
||||
#nextQueuedUpdate = -1;
|
||||
|
||||
constructor() {
|
||||
TagGroup.subscribe(this.#onTagGroupsReceived.bind(this));
|
||||
TagGroup.readAll().then(this.#onTagGroupsReceived.bind(this));
|
||||
}
|
||||
|
||||
public addElement(tagDropdown: TagDropdownWrapper): void {
|
||||
this.#tagDropdowns.push(tagDropdown);
|
||||
|
||||
if (!this.#tagCategories.size && !this.#compiledRegExps.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#queueUpdatingTags();
|
||||
}
|
||||
|
||||
#queueUpdatingTags() {
|
||||
clearTimeout(this.#nextQueuedUpdate);
|
||||
|
||||
this.#nextQueuedUpdate = setTimeout(
|
||||
this.#updateUnprocessedTags.bind(this),
|
||||
CustomCategoriesResolver.#unprocessedTagsTimeout
|
||||
);
|
||||
}
|
||||
|
||||
#updateUnprocessedTags() {
|
||||
this.#tagDropdowns
|
||||
.filter(CustomCategoriesResolver.#skipTagsWithOriginalCategory)
|
||||
.filter(this.#applyCustomCategoryForExactMatches.bind(this))
|
||||
.filter(this.#matchCustomCategoryByRegExp.bind(this))
|
||||
.forEach(CustomCategoriesResolver.#resetToOriginalCategory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply custom categories for the exact tag names.
|
||||
* @param tagDropdown Element to try applying the category for.
|
||||
* @return {boolean} Will return false when tag is processed and true when it is not found.
|
||||
* @private
|
||||
*/
|
||||
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
|
||||
const tagName = tagDropdown.tagName!;
|
||||
|
||||
if (!this.#tagCategories.has(tagName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
tagDropdown.tagCategory = this.#tagCategories.get(tagName)!;
|
||||
return false;
|
||||
}
|
||||
|
||||
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
|
||||
const tagName = tagDropdown.tagName!;
|
||||
|
||||
for (const targetRegularExpression of this.#compiledRegExps.keys()) {
|
||||
if (!targetRegularExpression.test(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tagDropdown.tagCategory = this.#compiledRegExps.get(targetRegularExpression)!;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#onTagGroupsReceived(tagGroups: TagGroup[]) {
|
||||
this.#tagCategories.clear();
|
||||
this.#compiledRegExps.clear();
|
||||
|
||||
if (!tagGroups.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const tagGroup of tagGroups) {
|
||||
const categoryName = tagGroup.settings.category;
|
||||
|
||||
for (const tagName of tagGroup.settings.tags) {
|
||||
this.#tagCategories.set(tagName, categoryName);
|
||||
}
|
||||
|
||||
for (const tagPrefix of tagGroup.settings.prefixes) {
|
||||
this.#compiledRegExps.set(
|
||||
new RegExp(`^${escapeRegExp(tagPrefix)}`),
|
||||
categoryName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.#queueUpdatingTags();
|
||||
}
|
||||
|
||||
static #skipTagsWithOriginalCategory(tagDropdown: TagDropdownWrapper): boolean {
|
||||
return !tagDropdown.originalCategory;
|
||||
}
|
||||
|
||||
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
|
||||
tagDropdown.tagCategory = tagDropdown.originalCategory;
|
||||
}
|
||||
|
||||
static #unprocessedTagsTimeout = 0;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import StorageHelper from "$lib/browser/StorageHelper.js";
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity.ts";
|
||||
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);
|
||||
@@ -71,7 +71,7 @@ export default class EntitiesController {
|
||||
/**
|
||||
* Watch the changes made to the storage and call the callback when the entity changes.
|
||||
*/
|
||||
const storageChangesSubscriber = (changes: Record<string, chrome.storage.StorageChange>) => {
|
||||
const subscriber: StorageChangeSubscriber = changes => {
|
||||
if (!changes[entityName]) {
|
||||
return;
|
||||
}
|
||||
@@ -80,8 +80,8 @@ export default class EntitiesController {
|
||||
.then(callback);
|
||||
}
|
||||
|
||||
this.#storageHelper.subscribe(storageChangesSubscriber);
|
||||
this.#storageHelper.subscribe(subscriber);
|
||||
|
||||
return () => this.#storageHelper.unsubscribe(storageChangesSubscriber);
|
||||
return () => this.#storageHelper.unsubscribe(subscriber);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {validateImportedEntity} from "$lib/extension/transporting/validators.js";
|
||||
import {exportEntityToObject} from "$lib/extension/transporting/exporters.ts";
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
|
||||
import {compressToEncodedURIComponent, decompressFromEncodedURIComponent} from "lz-string";
|
||||
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";
|
||||
|
||||
export default class EntitiesTransporter<EntityType> {
|
||||
readonly #targetEntityConstructor: new (...any: any[]) => EntityType;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController.js";
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
|
||||
export default class CacheableSettings<Fields> {
|
||||
#controller: ConfigurationController;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import EntitiesController from "$lib/extension/EntitiesController.js";
|
||||
import EntitiesController from "$lib/extension/EntitiesController";
|
||||
|
||||
export default abstract class StorageEntity<SettingsType extends Object = {}> {
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
|
||||
import EntitiesController from "$lib/extension/EntitiesController.ts";
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export interface MaintenanceProfileSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
temporary: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,9 +17,18 @@ export default class MaintenanceProfile extends StorageEntity<MaintenanceProfile
|
||||
constructor(id: string, settings: Partial<MaintenanceProfileSettings>) {
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || []
|
||||
tags: settings.tags || [],
|
||||
temporary: settings.temporary ?? false
|
||||
});
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (this.settings.temporary && !this.settings.tags?.length) {
|
||||
return this.delete();
|
||||
}
|
||||
|
||||
return super.save();
|
||||
}
|
||||
|
||||
public static readonly _entityName = "profiles";
|
||||
}
|
||||
|
||||
21
src/lib/extension/entities/TagGroup.ts
Normal file
21
src/lib/extension/entities/TagGroup.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export interface TagGroupSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
prefixes: string[];
|
||||
category: string;
|
||||
}
|
||||
|
||||
export default class TagGroup extends StorageEntity<TagGroupSettings> {
|
||||
constructor(id: string, settings: Partial<TagGroupSettings>) {
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || [],
|
||||
prefixes: settings.prefixes || [],
|
||||
category: settings.category || ''
|
||||
});
|
||||
}
|
||||
|
||||
static _entityName = 'groups';
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings.ts";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings";
|
||||
|
||||
interface MaintenanceSettingsFields {
|
||||
activeProfile: string | null;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings.ts";
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings";
|
||||
|
||||
export type FullscreenViewerSize = 'small' | 'medium' | 'large' | 'full';
|
||||
|
||||
interface MiscSettingsFields {
|
||||
fullscreenViewer: boolean;
|
||||
fullscreenViewerSize: FullscreenViewerSize;
|
||||
}
|
||||
|
||||
export default class MiscSettings extends CacheableSettings<MiscSettingsFields> {
|
||||
@@ -13,7 +16,15 @@ export default class MiscSettings extends CacheableSettings<MiscSettingsFields>
|
||||
return this._resolveSetting("fullscreenViewer", true);
|
||||
}
|
||||
|
||||
async resolveFullscreenViewerPreviewSize() {
|
||||
return this._resolveSetting('fullscreenViewerSize', 'large');
|
||||
}
|
||||
|
||||
async setFullscreenViewerEnabled(isEnabled: boolean) {
|
||||
return this._writeSetting("fullscreenViewer", isEnabled);
|
||||
}
|
||||
|
||||
async setFullscreenViewerPreviewSize(size: FullscreenViewerSize | string) {
|
||||
return this._writeSetting('fullscreenViewerSize', size as FullscreenViewerSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings.ts";
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings";
|
||||
|
||||
interface SearchSettingsFields {
|
||||
suggestProperties: boolean;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
type ExportersMap = {
|
||||
[EntityName in keyof App.EntityNamesMap]: (entity: App.EntityNamesMap[EntityName]) => Record<string, any>
|
||||
@@ -13,6 +13,15 @@ const entitiesExporters: ExportersMap = {
|
||||
tags: entity.settings.tags,
|
||||
}
|
||||
},
|
||||
groups: entity => {
|
||||
return {
|
||||
v: 1,
|
||||
id: entity.id,
|
||||
name: entity.settings.name,
|
||||
tags: entity.settings.tags,
|
||||
prefixes: entity.settings.prefixes,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function exportEntityToObject(entityInstance: StorageEntity<any>, entityName: string): Record<string, any> {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Map of validators for each entity. Function should throw the error if validation failed.
|
||||
* @type {Map<keyof App.EntityNamesMap|string, ((importedObject: Object) => void)>}
|
||||
*/
|
||||
const entitiesValidators = new Map([
|
||||
['profiles', importedObject => {
|
||||
if (importedObject.v !== 1) {
|
||||
throw new Error('Unsupported version!');
|
||||
}
|
||||
|
||||
if (
|
||||
!importedObject.id
|
||||
|| typeof importedObject.id !== "string"
|
||||
|| !importedObject.name
|
||||
|| typeof importedObject.name !== "string"
|
||||
|| !importedObject.tags
|
||||
|| !Array.isArray(importedObject.tags)
|
||||
) {
|
||||
throw new Error('Invalid profile format detected!');
|
||||
}
|
||||
}]
|
||||
])
|
||||
|
||||
/**
|
||||
* Validate the structure of the entity.
|
||||
* @param {Object} importedObject Object imported from JSON.
|
||||
* @param {string} entityName Name of the entity to validate. Should be loaded from the entity class.
|
||||
* @throws {Error} Error in case validation failed with the reason stored in the message.
|
||||
*/
|
||||
export function validateImportedEntity(importedObject, entityName) {
|
||||
if (!entitiesValidators.has(entityName)) {
|
||||
console.error(`Trying to validate entity without the validator present! Entity name: ${entityName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
entitiesValidators
|
||||
.get(entityName)
|
||||
.call(null, importedObject);
|
||||
}
|
||||
74
src/lib/extension/transporting/validators.ts
Normal file
74
src/lib/extension/transporting/validators.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
/**
|
||||
* Base information on the object which should be present on every entity.
|
||||
*/
|
||||
interface BaseImportableObject {
|
||||
/**
|
||||
* Numeric version of the entity for upgrading.
|
||||
*/
|
||||
v: number;
|
||||
/**
|
||||
* Unique ID of the entity.
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility type which combines base importable object and the entity type interfaces together. It strips away any types
|
||||
* defined for the properties, since imported object can not be trusted and should be type-checked by the validators.
|
||||
*/
|
||||
type ImportableObject<EntityType extends StorageEntity> = { [ObjectKey in keyof BaseImportableObject]: any }
|
||||
& { [SettingKey in keyof EntityType["settings"]]: any };
|
||||
|
||||
/**
|
||||
* Function for validating the entities.
|
||||
* @todo Probably would be better to replace the throw-catch method with some kind of result-error returning type.
|
||||
* Errors are only properly definable in the JSDoc.
|
||||
*/
|
||||
type ValidationFunction<EntityType extends StorageEntity> = (importedObject: ImportableObject<EntityType>) => void;
|
||||
|
||||
/**
|
||||
* Mapping of validation functions for all entities present in the extension. Key is a name of entity and value is a
|
||||
* function which throws an error when validation is failed.
|
||||
*/
|
||||
type EntitiesValidationMap = {
|
||||
[EntityKey in keyof App.EntityNamesMap]?: ValidationFunction<App.EntityNamesMap[EntityKey]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map of validators for each entity. Function should throw the error if validation failed.
|
||||
*/
|
||||
const entitiesValidators: EntitiesValidationMap = {
|
||||
profiles: importedObject => {
|
||||
if (importedObject.v !== 1) {
|
||||
throw new Error('Unsupported version!');
|
||||
}
|
||||
|
||||
if (
|
||||
!importedObject.id
|
||||
|| typeof importedObject.id !== "string"
|
||||
|| !importedObject.name
|
||||
|| typeof importedObject.name !== "string"
|
||||
|| !importedObject.tags
|
||||
|| !Array.isArray(importedObject.tags)
|
||||
) {
|
||||
throw new Error('Invalid profile format detected!');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the structure of the entity.
|
||||
* @param importedObject Object imported from JSON.
|
||||
* @param entityName Name of the entity to validate. Should be loaded from the entity class.
|
||||
* @throws {Error} Error in case validation failed with the reason stored in the message.
|
||||
*/
|
||||
export function validateImportedEntity(importedObject: any, entityName: string) {
|
||||
if (!entitiesValidators.hasOwnProperty(entityName)) {
|
||||
console.error(`Trying to validate entity without the validator present! Entity name: ${entityName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
entitiesValidators[entityName as keyof EntitiesValidationMap]!.call(null, importedObject);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Traverse and find the object using the key path.
|
||||
* @param {Object} targetObject Target object to traverse into.
|
||||
* @param {string[]} path Path of keys to traverse deep into the object.
|
||||
* @return {Object|null} Resulting object or null if nothing found (or target entry is not an object.
|
||||
*/
|
||||
export function findDeepObject(targetObject, path) {
|
||||
let result = targetObject;
|
||||
|
||||
for (let key of path) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
result = result[key];
|
||||
}
|
||||
|
||||
if (!result || typeof result !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
41
src/lib/utils.ts
Normal file
41
src/lib/utils.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Traverse and find the object using the key path.
|
||||
* @param targetObject Target object to traverse into.
|
||||
* @param path Path of keys to traverse deep into the object.
|
||||
* @return Resulting object or null if nothing found (or target entry is not an object).
|
||||
*/
|
||||
export function findDeepObject(targetObject: Record<string, any>, path: string[]): Object|null {
|
||||
let result = targetObject;
|
||||
|
||||
for (const key of path) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
result = result[key];
|
||||
}
|
||||
|
||||
if (!result || typeof result !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches all the characters needing replacement.
|
||||
*
|
||||
* Gathered from right here: https://stackoverflow.com/a/3561711/16048617. Because I don't want to introduce some
|
||||
* library for that.
|
||||
*/
|
||||
const unsafeRegExpCharacters: RegExp = /[/\-\\^$*+?.()|[\]{}]/g;
|
||||
|
||||
/**
|
||||
* Escape all the RegExp syntax-related characters in the following value.
|
||||
* @param value Original value.
|
||||
* @return Resulting value with all needed characters escaped.
|
||||
*/
|
||||
export function escapeRegExp(value: string): string {
|
||||
unsafeRegExpCharacters.lastIndex = 0;
|
||||
return value.replace(unsafeRegExpCharacters, "\\$&");
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store.js";
|
||||
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
|
||||
/** @type {import('$entities/MaintenanceProfile.ts').default|undefined} */
|
||||
/** @type {import('$entities/MaintenanceProfile').default|undefined} */
|
||||
let activeProfile;
|
||||
|
||||
$: activeProfile = $maintenanceProfilesStore.find(profile => profile.id === $activeProfileStore);
|
||||
@@ -22,6 +22,7 @@
|
||||
<hr>
|
||||
{/if}
|
||||
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
|
||||
<MenuItem href="/features/groups">Tag Groups</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences">Preferences</MenuItem>
|
||||
<MenuItem href="/about">About</MenuItem>
|
||||
|
||||
23
src/routes/features/groups/+page.svelte
Normal file
23
src/routes/features/groups/+page.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store";
|
||||
|
||||
/** @type {import('$entities/TagGroup').default[]} */
|
||||
let groups = [];
|
||||
|
||||
$: groups = $tagGroupsStore.sort((a, b) => a.settings.name.localeCompare(b.settings.name));
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/groups/new/edit" icon="plus">Create New</MenuItem>
|
||||
{#if groups.length}
|
||||
<hr>
|
||||
{#each groups as group}
|
||||
<MenuItem href="/features/groups/{group.id}">{group.settings.name}</MenuItem>
|
||||
{/each}
|
||||
{/if}
|
||||
<hr>
|
||||
<MenuItem href="/features/groups/import">Import Group</MenuItem>
|
||||
</Menu>
|
||||
39
src/routes/features/groups/[id]/+page.svelte
Normal file
39
src/routes/features/groups/[id]/+page.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import GroupView from "$components/features/GroupView.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store";
|
||||
|
||||
const groupId = $page.params.id;
|
||||
/** @type {import('$entities/TagGroup').default|null} */
|
||||
let group = null;
|
||||
|
||||
if (groupId === 'new') {
|
||||
goto('/features/groups/new/edit');
|
||||
}
|
||||
|
||||
$: {
|
||||
group = $tagGroupsStore.find(group => group.id === groupId) || null;
|
||||
|
||||
if (!group) {
|
||||
console.warn(`Group ${groupId} not found.`);
|
||||
goto('/features/groups');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/groups" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if group}
|
||||
<GroupView {group}/>
|
||||
{/if}
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem href="/features/groups/{groupId}/edit" icon="wrench">Edit Group</MenuItem>
|
||||
<MenuItem href="/features/groups/{groupId}/export" icon="file-export">Export Group</MenuItem>
|
||||
<MenuItem href="/features/groups/{groupId}/delete" icon="trash">Delete Group</MenuItem>
|
||||
</Menu>
|
||||
41
src/routes/features/groups/[id]/delete/+page.svelte
Normal file
41
src/routes/features/groups/[id]/delete/+page.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store";
|
||||
|
||||
const groupId = $page.params.id;
|
||||
const targetGroup = $tagGroupsStore.find(group => group.id === groupId);
|
||||
|
||||
if (!targetGroup) {
|
||||
void goto('/features/groups');
|
||||
}
|
||||
|
||||
async function deleteGroup() {
|
||||
if (!targetGroup) {
|
||||
console.warn('Attempting to delete the group, but the group is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
await targetGroup.delete();
|
||||
await goto('/features/groups');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/features/groups/{groupId}">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if targetGroup}
|
||||
<p>
|
||||
Do you want to remove group "{targetGroup.settings.name}"? This action is irreversible.
|
||||
</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={deleteGroup}>Yes</MenuItem>
|
||||
<MenuItem href="/features/groups/{groupId}">No</MenuItem>
|
||||
</Menu>
|
||||
{:else}
|
||||
<p>Loading...</p>
|
||||
{/if}
|
||||
82
src/routes/features/groups/[id]/edit/+page.svelte
Normal file
82
src/routes/features/groups/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import TagCategorySelectField from "$components/ui/forms/TagCategorySelectField.svelte";
|
||||
import TextField from "$components/ui/forms/TextField.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import TagsEditor from "$components/tags/TagsEditor.svelte";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store";
|
||||
|
||||
const groupId = $page.params.id;
|
||||
/** @type {TagGroup|null} */
|
||||
let targetGroup = null;
|
||||
|
||||
let groupName = '';
|
||||
/** @type {string[]} */
|
||||
let tagsList = [];
|
||||
/** @type {string[]} */
|
||||
let prefixesList = [];
|
||||
let tagCategory = '';
|
||||
|
||||
if (groupId === 'new') {
|
||||
targetGroup = new TagGroup(crypto.randomUUID(), {});
|
||||
} else {
|
||||
targetGroup = $tagGroupsStore.find(group => group.id === groupId) || null;
|
||||
|
||||
if (targetGroup) {
|
||||
groupName = targetGroup.settings.name;
|
||||
tagsList = [...targetGroup.settings.tags].sort((a, b) => a.localeCompare(b));
|
||||
prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b));
|
||||
tagCategory = targetGroup.settings.category;
|
||||
} else {
|
||||
goto('/features/groups');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGroup() {
|
||||
if (!targetGroup) {
|
||||
console.warn('Attempting to save group, but group is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
targetGroup.settings.name = groupName;
|
||||
targetGroup.settings.tags = [...tagsList];
|
||||
targetGroup.settings.prefixes = [...prefixesList];
|
||||
targetGroup.settings.category = tagCategory;
|
||||
|
||||
await targetGroup.save();
|
||||
await goto(`/features/groups/${targetGroup.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/groups/${groupId}" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Group Name">
|
||||
<TextField bind:value={groupName} placeholder="Group Name"></TextField>
|
||||
</FormControl>
|
||||
<FormControl label="Group Color">
|
||||
<TagCategorySelectField bind:value={tagCategory}/>
|
||||
</FormControl>
|
||||
<TagsColorContainer targetCategory="{tagCategory}">
|
||||
<FormControl label="Tags">
|
||||
<TagsEditor bind:tags={tagsList}/>
|
||||
</FormControl>
|
||||
</TagsColorContainer>
|
||||
<TagsColorContainer targetCategory="{tagCategory}">
|
||||
<FormControl label="Tag Prefixes">
|
||||
<TagsEditor bind:tags={prefixesList}/>
|
||||
</FormControl>
|
||||
</TagsColorContainer>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={saveGroup}>Save Group</MenuItem>
|
||||
</Menu>
|
||||
50
src/routes/features/groups/[id]/export/+page.svelte
Normal file
50
src/routes/features/groups/[id]/export/+page.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store";
|
||||
|
||||
const groupId = $page.params.id;
|
||||
const groupTransporter = new EntitiesTransporter(TagGroup);
|
||||
const group = $tagGroupsStore.find(group => group.id === groupId);
|
||||
|
||||
/** @type {string} */
|
||||
let rawExportedGroup;
|
||||
/** @type {string} */
|
||||
let encodedExportedGroup;
|
||||
|
||||
if (!group) {
|
||||
goto('/features/groups');
|
||||
} else {
|
||||
rawExportedGroup = groupTransporter.exportToJSON(group);
|
||||
encodedExportedGroup = groupTransporter.exportToCompressedJSON(group);
|
||||
}
|
||||
|
||||
let isEncodedGroupShown = true;
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Export string">
|
||||
<textarea readonly rows="6">{isEncodedGroupShown ? encodedExportedGroup : rawExportedGroup}</textarea>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={() => isEncodedGroupShown = !isEncodedGroupShown}>
|
||||
Switch Format:
|
||||
{#if isEncodedGroupShown}
|
||||
Base64-Encoded
|
||||
{:else}
|
||||
Raw JSON
|
||||
{/if}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
134
src/routes/features/groups/import/+page.svelte
Normal file
134
src/routes/features/groups/import/+page.svelte
Normal file
@@ -0,0 +1,134 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
import GroupView from "$components/features/GroupView.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store";
|
||||
|
||||
const groupTransporter = new EntitiesTransporter(TagGroup);
|
||||
|
||||
/** @type {string} */
|
||||
let importedString = '';
|
||||
/** @type {string} */
|
||||
let errorMessage = '';
|
||||
|
||||
/** @type {TagGroup|null} */
|
||||
let candidateGroup = null;
|
||||
/** @type {TagGroup|null} */
|
||||
let existingGroup = null;
|
||||
|
||||
function tryImportingGroup() {
|
||||
candidateGroup = null;
|
||||
existingGroup = null;
|
||||
|
||||
errorMessage = '';
|
||||
importedString = importedString.trim();
|
||||
|
||||
if (!importedString) {
|
||||
errorMessage = 'Nothing to import.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (importedString.trim().startsWith('{')) {
|
||||
candidateGroup = groupTransporter.importFromJSON(importedString);
|
||||
}
|
||||
|
||||
candidateGroup = groupTransporter.importFromCompressedJSON(importedString);
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error';
|
||||
}
|
||||
|
||||
if (candidateGroup) {
|
||||
existingGroup = $tagGroupsStore.find(group => group.id === candidateGroup?.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveGroup() {
|
||||
if (!candidateGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
candidateGroup.save().then(() => {
|
||||
goto(`/features/groups`);
|
||||
});
|
||||
}
|
||||
|
||||
function cloneAndSaveGroup() {
|
||||
if (!candidateGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clonedProfile = new TagGroup(crypto.randomUUID(), candidateGroup.settings);
|
||||
clonedProfile.settings.name += ` (Clone ${new Date().toISOString()})`;
|
||||
clonedProfile.save().then(() => {
|
||||
goto(`/features/groups`);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/features/groups">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if errorMessage}
|
||||
<p class="error">Failed to import: {errorMessage}</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
</Menu>
|
||||
{/if}
|
||||
{#if !candidateGroup}
|
||||
<FormContainer>
|
||||
<FormControl label="Import string">
|
||||
<textarea bind:value={importedString} rows="6"></textarea>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={tryImportingGroup}>Import</MenuItem>
|
||||
</Menu>
|
||||
{:else}
|
||||
{#if existingGroup}
|
||||
<p class="warning">
|
||||
This group will replace the existing "{existingGroup.settings.name}" group, since it have the same ID.
|
||||
</p>
|
||||
{/if}
|
||||
<GroupView group="{candidateGroup}"></GroupView>
|
||||
<Menu>
|
||||
<hr>
|
||||
{#if existingGroup}
|
||||
<MenuItem on:click={saveGroup}>Replace Existing Group</MenuItem>
|
||||
<MenuItem on:click={cloneAndSaveGroup}>Save as New Group</MenuItem>
|
||||
{:else}
|
||||
<MenuItem on:click={saveGroup}>Import New Group</MenuItem>
|
||||
{/if}
|
||||
<MenuItem on:click={() => candidateGroup = null}>Cancel</MenuItem>
|
||||
</Menu>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
|
||||
.error, .warning {
|
||||
padding: 5px 24px;
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background: colors.$error-background;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: colors.$warning-background;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
</style>
|
||||
@@ -2,9 +2,9 @@
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import MenuRadioItem from "$components/ui/menu/MenuRadioItem.svelte";
|
||||
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
|
||||
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
|
||||
/** @type {import('$entities/MaintenanceProfile.ts').default[]} */
|
||||
/** @type {import('$entities/MaintenanceProfile').default[]} */
|
||||
let profiles = [];
|
||||
|
||||
$: profiles = $maintenanceProfilesStore.sort((a, b) => a.settings.name.localeCompare(b.settings.name));
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import {page} from "$app/stores";
|
||||
import {goto} from "$app/navigation";
|
||||
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
|
||||
import ProfileView from "$components/maintenance/ProfileView.svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
import ProfileView from "$components/features/ProfileView.svelte";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
|
||||
const profileId = $page.params.id;
|
||||
/** @type {import('$entities/MaintenanceProfile.ts').default|null} */
|
||||
/** @type {import('$entities/MaintenanceProfile').default|null} */
|
||||
let profile = null;
|
||||
let isActiveProfile = false;
|
||||
|
||||
if (profileId === 'new') {
|
||||
goto('/maintenance/profiles/new/edit');
|
||||
goto('/features/maintenance/new/edit');
|
||||
}
|
||||
|
||||
$: {
|
||||
@@ -26,14 +26,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: isActiveProfile = $activeProfileStore === profileId;
|
||||
let isActiveProfile = $activeProfileStore === profileId;
|
||||
|
||||
function activateProfile() {
|
||||
if (isActiveProfile) {
|
||||
return;
|
||||
$: {
|
||||
if (isActiveProfile && $activeProfileStore !== profileId) {
|
||||
$activeProfileStore = profileId;
|
||||
}
|
||||
|
||||
$activeProfileStore = profileId;
|
||||
if (!isActiveProfile && $activeProfileStore === profileId) {
|
||||
$activeProfileStore = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -47,13 +49,9 @@
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem icon="wrench" href="/features/maintenance/{profileId}/edit">Edit Profile</MenuItem>
|
||||
<MenuItem icon="tag" href="#" on:click={activateProfile}>
|
||||
{#if isActiveProfile}
|
||||
<span>Profile is Active</span>
|
||||
{:else}
|
||||
<span>Activate Profile</span>
|
||||
{/if}
|
||||
</MenuItem>
|
||||
<MenuCheckboxItem bind:checked={isActiveProfile}>
|
||||
Activate Profile
|
||||
</MenuCheckboxItem>
|
||||
<MenuItem icon="file-export" href="/features/maintenance/{profileId}/export">
|
||||
Export Profile
|
||||
</MenuItem>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store.js";
|
||||
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
|
||||
const profileId = $page.params.id;
|
||||
const targetProfile = $maintenanceProfilesStore.find(profile => profile.id===profileId);
|
||||
const targetProfile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
|
||||
|
||||
if (!targetProfile) {
|
||||
void goto('/features/maintenance');
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import TagsEditor from "$components/web-components/TagsEditor.svelte";
|
||||
import TagsEditor from "$components/tags/TagsEditor.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import TextField from "$components/ui/forms/TextField.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import {page} from "$app/stores";
|
||||
import {goto} from "$app/navigation";
|
||||
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
/** @type {string} */
|
||||
let profileId = $page.params.id;
|
||||
@@ -42,6 +42,7 @@
|
||||
|
||||
targetProfile.settings.name = profileName;
|
||||
targetProfile.settings.tags = [...tagsList];
|
||||
targetProfile.settings.temporary = false;
|
||||
|
||||
await targetProfile.save();
|
||||
await goto('/features/maintenance/' + targetProfile.id);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script>
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store.js";
|
||||
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter.ts";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
const profileId = $page.params.id;
|
||||
const profile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import ProfileView from "$components/maintenance/ProfileView.svelte";
|
||||
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
|
||||
import {goto} from "$app/navigation";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter.ts";
|
||||
import ProfileView from "$components/features/ProfileView.svelte";
|
||||
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
import { goto } from "$app/navigation";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
|
||||
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../../../styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
.error, .warning {
|
||||
padding: 5px 24px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import {storagesCollection} from "$stores/debug.js";
|
||||
import { storagesCollection } from "$stores/debug";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import StorageViewer from "$components/debugging/StorageViewer.svelte";
|
||||
import {page} from "$app/stores";
|
||||
import {goto} from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
let pathString = '';
|
||||
/** @type {string[]} */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
|
||||
import {fullScreenViewerEnabled} from "$stores/misc-preferences.js";
|
||||
import { fullScreenViewerEnabled } from "$stores/misc-preferences";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import {
|
||||
searchPropertiesSuggestionsEnabled,
|
||||
searchPropertiesSuggestionsPosition
|
||||
} from "$stores/search-preferences.js";
|
||||
} from "$stores/search-preferences";
|
||||
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
|
||||
import SelectField from "$components/ui/forms/SelectField.svelte";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { stripBlacklistedTagsEnabled } from "$stores/maintenance-preferences.ts";
|
||||
import { stripBlacklistedTagsEnabled } from "$stores/maintenance-preferences";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {writable} from "svelte/store";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
/**
|
||||
* This is readable version of storages. Any changes made to these objects will not be sent to the local storage.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {writable} from "svelte/store";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts";
|
||||
import { writable } from "svelte/store";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
|
||||
export const stripBlacklistedTagsEnabled = writable(true);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {writable} from "svelte/store";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts";
|
||||
import { writable } from "svelte/store";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
|
||||
/**
|
||||
* Store for working with maintenance profiles in the Svelte popup.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {writable} from "svelte/store";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings.ts";
|
||||
import { writable } from "svelte/store";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings";
|
||||
|
||||
export const fullScreenViewerEnabled = writable(true);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {writable} from "svelte/store";
|
||||
import SearchSettings from "$lib/extension/settings/SearchSettings.ts";
|
||||
import { writable } from "svelte/store";
|
||||
import SearchSettings from "$lib/extension/settings/SearchSettings";
|
||||
|
||||
export const searchPropertiesSuggestionsEnabled = writable(false);
|
||||
|
||||
|
||||
12
src/stores/tag-groups-store.js
Normal file
12
src/stores/tag-groups-store.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { writable } from "svelte/store";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
|
||||
/** @type {import('svelte/store').Writable<TagGroup[]>} */
|
||||
export const tagGroupsStore = writable([]);
|
||||
|
||||
TagGroup
|
||||
.readAll()
|
||||
.then(groups => tagGroupsStore.set(groups))
|
||||
.then(() => {
|
||||
TagGroup.subscribe(groups => tagGroupsStore.set(groups));
|
||||
});
|
||||
8
src/styles/booru-vars.scss
Normal file
8
src/styles/booru-vars.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
$background-color: var(--background-color);
|
||||
$media-border: var(--media-border);
|
||||
$media-box-color: var(--media-box-color);
|
||||
|
||||
// These variables are defined dynamically based on the category of the tag
|
||||
$resolved-tag-background: var(--tag-background);
|
||||
$resolved-tag-border: var(--tag-border);
|
||||
$resolved-tag-color: var(--tag-color);
|
||||
@@ -1,3 +1,5 @@
|
||||
@use 'sass:color';
|
||||
|
||||
$background: #15121a;
|
||||
|
||||
$text: #dadada;
|
||||
@@ -26,25 +28,25 @@ $tag-count-background: #2d6236;
|
||||
$tag-text: #4aa158;
|
||||
|
||||
$tag-rating-text: #418dd9;
|
||||
$tag-rating-background: darken($tag-rating-text, 35%);
|
||||
$tag-rating-background: color.adjust($tag-rating-text, $lightness: -35%);
|
||||
$tag-spoiler-text: #d49b39;
|
||||
$tag-spoiler-background: darken($tag-spoiler-text, 34%);
|
||||
$tag-spoiler-background: color.adjust($tag-spoiler-text, $lightness: -34%);
|
||||
$tag-origin-text: #6f66d6;
|
||||
$tag-origin-background: darken($tag-origin-text, 40%);
|
||||
$tag-origin-background: color.adjust($tag-origin-text, $lightness: -40%);
|
||||
$tag-oc-text: #b157b7;
|
||||
$tag-oc-background: darken($tag-oc-text, 33%);
|
||||
$tag-oc-background: color.adjust($tag-oc-text, $lightness: -33%);
|
||||
$tag-error-text: #d45460;
|
||||
$tag-error-background: desaturate(darken($tag-error-text, 38%), 6%);
|
||||
$tag-error-background: color.adjust($tag-error-text, $lightness: -38%, $saturation: -6%, $space: hsl);
|
||||
$tag-character-text: #4aaabf;
|
||||
$tag-character-background: darken($tag-character-text, 33%);
|
||||
$tag-character-background: color.adjust($tag-character-text, $lightness: -33%);
|
||||
$tag-content-official-text: #b9b541;
|
||||
$tag-content-official-background: desaturate(darken($tag-content-official-text, 29%),2%);
|
||||
$tag-content-official-background: color.adjust($tag-content-official-text, $lightness: -29%, $saturation: -2%, $space: hsl);
|
||||
$tag-content-fanmade-text: #cc8eb5;
|
||||
$tag-content-fanmade-background: darken($tag-content-fanmade-text, 40%);
|
||||
$tag-content-fanmade-background: color.adjust($tag-content-fanmade-text, $lightness: -40%);
|
||||
$tag-species-text: #b16b50;
|
||||
$tag-species-background: darken($tag-species-text, 35%);
|
||||
$tag-species-background: color.adjust($tag-species-text, $lightness: -35%);
|
||||
$tag-body-type-text: #b8b8b8;
|
||||
$tag-body-type-background: desaturate(darken($tag-body-type-text, 35%), 10%);
|
||||
$tag-body-type-background: color.adjust($tag-body-type-text, $lightness: -35%, $saturation: -10%, $space: hsl);
|
||||
|
||||
$input-background: #26232d;
|
||||
$input-border: #5c5a61;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@use '../colors';
|
||||
@use '$styles/colors';
|
||||
@use '$styles/booru-vars';
|
||||
|
||||
// This will fix wierd misplacing of the modified media boxes in the listing.
|
||||
.js-resizable-media-container {
|
||||
@@ -18,22 +19,22 @@
|
||||
top: -1px;
|
||||
bottom: 0;
|
||||
z-index: 8;
|
||||
background: colors.$footer;
|
||||
border-top: 23px solid colors.$media-box-border;
|
||||
background: booru-vars.$background-color;
|
||||
border-top: 23px solid booru-vars.$media-box-color;
|
||||
}
|
||||
|
||||
&:before {
|
||||
right: calc(100% - 1px);
|
||||
left: -50%;
|
||||
border-left: 1px solid colors.$media-box-border;
|
||||
box-shadow: colors.$footer -10px 0 10px;
|
||||
border-left: booru-vars.$media-border;
|
||||
box-shadow: booru-vars.$background-color -10px 0 10px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
left: calc(100% - 1px);
|
||||
right: -50%;
|
||||
border-right: 1px solid colors.$media-box-border;
|
||||
box-shadow: colors.$footer 10px 0 10px;
|
||||
border-right: booru-vars.$media-border;
|
||||
box-shadow: booru-vars.$background-color 10px 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +46,12 @@
|
||||
left: -50%;
|
||||
right: -50%;
|
||||
z-index: 8;
|
||||
background: colors.$footer;
|
||||
border: 1px solid colors.$media-box-border;
|
||||
border-top: 0;
|
||||
background: booru-vars.$background-color;
|
||||
border: {
|
||||
left: booru-vars.$media-border;
|
||||
right: booru-vars.$media-border;
|
||||
bottom: booru-vars.$media-border;
|
||||
};
|
||||
|
||||
.tags-list {
|
||||
display: flex;
|
||||
@@ -62,17 +66,12 @@
|
||||
|
||||
.tag {
|
||||
cursor: pointer;
|
||||
padding: 0 6px;
|
||||
padding: 5px;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: colors.$tag-text;
|
||||
color: colors.$tag-background;
|
||||
}
|
||||
|
||||
&[data-tag-category=error]:hover {
|
||||
background: colors.$tag-error-text;
|
||||
color: colors.$tag-error-background;
|
||||
background: booru-vars.$resolved-tag-color;
|
||||
color: booru-vars.$resolved-tag-background;
|
||||
}
|
||||
|
||||
&.is-missing:not(.is-added),
|
||||
@@ -199,6 +198,30 @@
|
||||
transition: opacity .25s ease;
|
||||
}
|
||||
|
||||
.size-selector {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
z-index: 1;
|
||||
padding: 5px;
|
||||
background-color: colors.$text;
|
||||
color: colors.$background;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.shown {
|
||||
opacity: var(--opacity, 1);
|
||||
pointer-events: initial;
|
||||
|
||||
40
src/styles/injectable/base-styles.scss
Normal file
40
src/styles/injectable/base-styles.scss
Normal file
@@ -0,0 +1,40 @@
|
||||
@use '../colors';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 320px;
|
||||
|
||||
// Hacky class which is added by the JavaScript indicating that page was (probably) opened in the tab
|
||||
&.is-in-tab {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: colors.$background;
|
||||
color: colors.$text;
|
||||
font-size: 16px;
|
||||
font-family: verdana, arial, helvetica, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: colors.$link;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: colors.$link-hover;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,5 @@
|
||||
@use './colors';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 320px;
|
||||
|
||||
// Hacky class which is added by the JavaScript indicating that page was (probably) opened in the tab
|
||||
&.is-in-tab {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: colors.$background;
|
||||
color: colors.$text;
|
||||
font-size: 16px;
|
||||
font-family: verdana, arial, helvetica, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: colors.$link;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: colors.$link-hover;
|
||||
}
|
||||
}
|
||||
|
||||
@import "injectable/input";
|
||||
@import "injectable/tag";
|
||||
@import "injectable/icons";
|
||||
@use 'colors';
|
||||
@use 'injectable/base-styles';
|
||||
@use 'injectable/input';
|
||||
@use 'injectable/tag';
|
||||
@use 'injectable/icons';
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true
|
||||
"allowImportingTsExtensions": false,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user