diff --git a/src/app.d.ts b/src/app.d.ts index 3735081..4462d62 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,7 @@ // See https://kit.svelte.dev/docs/types#app // for information about these interfaces import MaintenanceProfile from "$entities/MaintenanceProfile.ts"; +import type TagGroup from "$entities/TagGroup.ts"; declare global { namespace App { @@ -24,6 +25,7 @@ declare global { interface EntityNamesMap { profiles: MaintenanceProfile; + groups: TagGroup; } } } diff --git a/src/components/features/GroupView.svelte b/src/components/features/GroupView.svelte new file mode 100644 index 0000000..e984d2a --- /dev/null +++ b/src/components/features/GroupView.svelte @@ -0,0 +1,59 @@ + + +
+ Group Name: +
{group.settings.name}
+
+{#if sortedTagsList.length} +
+ Tags: + +
+ {#each sortedTagsList as tagName} + {tagName} + {/each} +
+
+
+{/if} +{#if sortedPrefixes.length} +
+ Prefixes: + +
+ {#each sortedPrefixes as prefixName} + {prefixName}* + {/each} +
+
+
+{/if} + + diff --git a/src/components/tags/TagsColorContainer.svelte b/src/components/tags/TagsColorContainer.svelte new file mode 100644 index 0000000..ba02c41 --- /dev/null +++ b/src/components/tags/TagsColorContainer.svelte @@ -0,0 +1,62 @@ + + +
+ +
+ + diff --git a/src/components/ui/forms/TagCategorySelectField.svelte b/src/components/ui/forms/TagCategorySelectField.svelte new file mode 100644 index 0000000..61040ee --- /dev/null +++ b/src/components/ui/forms/TagCategorySelectField.svelte @@ -0,0 +1,80 @@ + + + + + diff --git a/src/lib/booru/tag-categories.js b/src/lib/booru/tag-categories.js new file mode 100644 index 0000000..cbd33da --- /dev/null +++ b/src/lib/booru/tag-categories.js @@ -0,0 +1,12 @@ +export const categories = [ + 'rating', + 'spoiler', + 'origin', + 'oc', + 'error', + 'character', + 'content-official', + 'content-fanmade', + 'species', + 'body-type', +]; diff --git a/src/lib/components/TagDropdownWrapper.js b/src/lib/components/TagDropdownWrapper.js index 78961a3..cef710e 100644 --- a/src/lib/components/TagDropdownWrapper.js +++ b/src/lib/components/TagDropdownWrapper.js @@ -2,10 +2,12 @@ import {BaseComponent} from "$lib/components/base/BaseComponent.js"; import MaintenanceProfile from "$entities/MaintenanceProfile.ts"; import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts"; import {getComponent} from "$lib/components/base/ComponentUtils.js"; +import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver"; const isTagEditorProcessedKey = Symbol(); +const categoriesResolver = new CustomCategoriesResolver(); -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,7 @@ class TagDropdownWrapper extends BaseComponent { async #onAddToNewClicked() { const profile = new MaintenanceProfile(crypto.randomUUID(), { name: 'Temporary Profile (' + (new Date().toISOString()) + ')', - tags: [this.#tagName] + tags: [this.tagName] }); await profile.save(); @@ -121,7 +163,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 +237,10 @@ export function wrapTagDropdown(element) { return; } - new TagDropdownWrapper(element).initialize(); + const tagDropdown = new TagDropdownWrapper(element); + tagDropdown.initialize(); + + categoriesResolver.addElement(tagDropdown); } export function watchTagDropdownsInTagsEditor() { diff --git a/src/lib/extension/CustomCategoriesResolver.ts b/src/lib/extension/CustomCategoriesResolver.ts new file mode 100644 index 0000000..6021939 --- /dev/null +++ b/src/lib/extension/CustomCategoriesResolver.ts @@ -0,0 +1,115 @@ +import type {TagDropdownWrapper} from "$lib/components/TagDropdownWrapper"; +import TagGroup from "$entities/TagGroup.ts"; +import {escapeRegExp} from "$lib/utils"; + +export default class CustomCategoriesResolver { + #tagCategories = new Map(); + #compiledRegExps = new Map(); + #tagDropdowns: TagDropdownWrapper[] = []; + #lastProcessedIndex = -1; + #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() { + const startIndex = Math.max(0, this.#lastProcessedIndex); + + this.#tagDropdowns + .slice(startIndex) + .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(); + this.#lastProcessedIndex = -1; + + 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; +} diff --git a/src/lib/extension/entities/TagGroup.ts b/src/lib/extension/entities/TagGroup.ts new file mode 100644 index 0000000..95dc44a --- /dev/null +++ b/src/lib/extension/entities/TagGroup.ts @@ -0,0 +1,21 @@ +import StorageEntity from "$lib/extension/base/StorageEntity.ts"; + +export interface TagGroupSettings { + name: string; + tags: string[]; + prefixes: string[]; + category: string; +} + +export default class TagGroup extends StorageEntity { + constructor(id: string, settings: Partial) { + super(id, { + name: settings.name || '', + tags: settings.tags || [], + prefixes: settings.prefixes || [], + category: settings.category || '' + }); + } + + static _entityName = 'groups'; +} diff --git a/src/lib/extension/transporting/exporters.ts b/src/lib/extension/transporting/exporters.ts index c2386c4..468df8e 100644 --- a/src/lib/extension/transporting/exporters.ts +++ b/src/lib/extension/transporting/exporters.ts @@ -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, entityName: string): Record { diff --git a/src/lib/utils.js b/src/lib/utils.js index db17ea5..c7a6531 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -21,3 +21,23 @@ export function findDeepObject(targetObject, path) { return result; } + +/** + * Matches all the characters needing replacement. + * + * Gathered from right here: https://stackoverflow.com/a/3561711/16048617. Because I don't want to introduce some + * library for that. + * + * @type {RegExp} + */ +const unsafeRegExpCharacters = /[/\-\\^$*+?.()|[\]{}]/g; + +/** + * Escape all the RegExp syntax-related characters in the following value. + * @param {string} value Original value. + * @return {string} Resulting value with all needed characters escaped. + */ +export function escapeRegExp(value) { + unsafeRegExpCharacters.lastIndex = 0; + return value.replace(unsafeRegExpCharacters, "\\$&"); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7b1fa3a..49cdecb 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -22,6 +22,7 @@
{/if} Tagging Profiles + Tag Groups
Preferences About diff --git a/src/routes/features/groups/+page.svelte b/src/routes/features/groups/+page.svelte new file mode 100644 index 0000000..97fc1b7 --- /dev/null +++ b/src/routes/features/groups/+page.svelte @@ -0,0 +1,23 @@ + + + + Back + Create New + {#if groups.length} +
+ {#each groups as group} + {group.settings.name} + {/each} + {/if} +
+ Import Group +
diff --git a/src/routes/features/groups/[id]/+page.svelte b/src/routes/features/groups/[id]/+page.svelte new file mode 100644 index 0000000..fffcfdb --- /dev/null +++ b/src/routes/features/groups/[id]/+page.svelte @@ -0,0 +1,39 @@ + + + + Back +
+
+{#if group} + +{/if} + +
+ Edit Group + Export Group + Delete Group +
diff --git a/src/routes/features/groups/[id]/delete/+page.svelte b/src/routes/features/groups/[id]/delete/+page.svelte new file mode 100644 index 0000000..50f1b1b --- /dev/null +++ b/src/routes/features/groups/[id]/delete/+page.svelte @@ -0,0 +1,41 @@ + + + + Back +
+
+{#if targetGroup} +

+ Do you want to remove group "{targetGroup.settings.name}"? This action is irreversible. +

+ +
+ Yes + No +
+{:else} +

Loading...

+{/if} diff --git a/src/routes/features/groups/[id]/edit/+page.svelte b/src/routes/features/groups/[id]/edit/+page.svelte new file mode 100644 index 0000000..746c79d --- /dev/null +++ b/src/routes/features/groups/[id]/edit/+page.svelte @@ -0,0 +1,82 @@ + + + + Back +
+
+ + + + + + + + + + + + + + + + + + + +
+ Save Group +
diff --git a/src/routes/features/groups/[id]/export/+page.svelte b/src/routes/features/groups/[id]/export/+page.svelte new file mode 100644 index 0000000..1a2b891 --- /dev/null +++ b/src/routes/features/groups/[id]/export/+page.svelte @@ -0,0 +1,50 @@ + + + + Back +
+
+ + + + + + +
+ isEncodedGroupShown = !isEncodedGroupShown}> + Switch Format: + {#if isEncodedGroupShown} + Base64-Encoded + {:else} + Raw JSON + {/if} + +
diff --git a/src/routes/features/groups/import/+page.svelte b/src/routes/features/groups/import/+page.svelte new file mode 100644 index 0000000..01583bc --- /dev/null +++ b/src/routes/features/groups/import/+page.svelte @@ -0,0 +1,134 @@ + + + + Back +
+
+{#if errorMessage} +

Failed to import: {errorMessage}

+ +
+
+{/if} +{#if !candidateGroup} + + + + + + +
+ Import +
+{:else} + {#if existingGroup} +

+ This group will replace the existing "{existingGroup.settings.name}" group, since it have the same ID. +

+ {/if} + + +
+ {#if existingGroup} + Replace Existing Group + Save as New Group + {:else} + Import New Group + {/if} + candidateGroup = null}>Cancel +
+{/if} + + diff --git a/src/stores/tag-groups-store.js b/src/stores/tag-groups-store.js new file mode 100644 index 0000000..057becd --- /dev/null +++ b/src/stores/tag-groups-store.js @@ -0,0 +1,12 @@ +import {writable} from "svelte/store"; +import TagGroup from "$entities/TagGroup.ts"; + +/** @type {import('svelte/store').Writable} */ +export const tagGroupsStore = writable([]); + +TagGroup + .readAll() + .then(groups => tagGroupsStore.set(groups)) + .then(() => { + TagGroup.subscribe(groups => tagGroupsStore.set(groups)); + });