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}
+
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 @@
+
+
+
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 @@
+
+
+
+{#if group}
+
+{/if}
+
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 @@
+
+
+
+{#if targetGroup}
+
+ Do you want to remove group "{targetGroup.settings.name}"? This action is irreversible.
+
+
+{: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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+{#if errorMessage}
+ Failed to import: {errorMessage}
+
+{/if}
+{#if !candidateGroup}
+
+
+
+
+
+
+{:else}
+ {#if existingGroup}
+
+ This group will replace the existing "{existingGroup.settings.name}" group, since it have the same ID.
+
+ {/if}
+
+
+{/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));
+ });