From 74866949bb5de5187c0d2fa6d8fca27988b2d49a Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 12 Mar 2026 00:17:45 +0400 Subject: [PATCH] Added Tag Presets, popup editor for them, implemented presets image edit --- src/app.d.ts | 4 +- src/components/features/PresetView.svelte | 20 +++ src/components/tags/TagsList.svelte | 21 +++ src/components/ui/DetailsBlock.svelte | 30 +++++ src/content/components/events/booru-events.ts | 2 + src/content/components/events/comms.ts | 4 +- .../components/events/preset-block-events.ts | 10 ++ .../extension/presets/EditorPresetsBlock.ts | 101 ++++++++++++++ .../extension/presets/PresetTableRow.ts | 127 ++++++++++++++++++ src/content/components/philomena/TagsForm.ts | 98 +++++++++++++- src/lib/dom-utils.ts | 11 ++ src/lib/extension/BulkEntitiesTransporter.ts | 4 + src/lib/extension/entities/TagEditorPreset.ts | 17 +++ src/lib/extension/transporting/exporters.ts | 10 ++ src/lib/extension/transporting/validators.ts | 13 ++ src/lib/utils.ts | 14 ++ src/routes/+page.svelte | 1 + src/routes/features/presets/+page.svelte | 19 +++ src/routes/features/presets/[id]/+page.svelte | 42 ++++++ .../features/presets/[id]/edit/+page.svelte | 74 ++++++++++ src/routes/transporting/export/+page.svelte | 24 ++++ src/routes/transporting/import/+page.svelte | 56 +++++++- src/stores/entities/tag-editor-presets.ts | 11 ++ src/styles/content/tags-editor.scss | 15 +++ 24 files changed, 720 insertions(+), 8 deletions(-) create mode 100644 src/components/features/PresetView.svelte create mode 100644 src/components/tags/TagsList.svelte create mode 100644 src/components/ui/DetailsBlock.svelte create mode 100644 src/content/components/events/preset-block-events.ts create mode 100644 src/content/components/extension/presets/EditorPresetsBlock.ts create mode 100644 src/content/components/extension/presets/PresetTableRow.ts create mode 100644 src/lib/dom-utils.ts create mode 100644 src/lib/extension/entities/TagEditorPreset.ts create mode 100644 src/routes/features/presets/+page.svelte create mode 100644 src/routes/features/presets/[id]/+page.svelte create mode 100644 src/routes/features/presets/[id]/edit/+page.svelte create mode 100644 src/stores/entities/tag-editor-presets.ts diff --git a/src/app.d.ts b/src/app.d.ts index d56ae6a..8281fc6 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,7 +1,8 @@ // See https://kit.svelte.dev/docs/types#app // for information about these interfaces -import TaggingProfile from "$entities/TaggingProfile"; +import type TaggingProfile from "$entities/TaggingProfile"; import type TagGroup from "$entities/TagGroup"; +import type TagEditorPreset from "$entities/TagEditorPreset"; declare global { /** @@ -39,6 +40,7 @@ declare global { interface EntityNamesMap { profiles: TaggingProfile; groups: TagGroup; + presets: TagEditorPreset; } interface ImageURIs { diff --git a/src/components/features/PresetView.svelte b/src/components/features/PresetView.svelte new file mode 100644 index 0000000..18c24ca --- /dev/null +++ b/src/components/features/PresetView.svelte @@ -0,0 +1,20 @@ + + + + {preset.settings.name} + + + + diff --git a/src/components/tags/TagsList.svelte b/src/components/tags/TagsList.svelte new file mode 100644 index 0000000..8fb00b4 --- /dev/null +++ b/src/components/tags/TagsList.svelte @@ -0,0 +1,21 @@ + + +
+ {#each tags as tagName} +
{tagName}
+ {/each} +
+ + diff --git a/src/components/ui/DetailsBlock.svelte b/src/components/ui/DetailsBlock.svelte new file mode 100644 index 0000000..a02fc29 --- /dev/null +++ b/src/components/ui/DetailsBlock.svelte @@ -0,0 +1,30 @@ + + +
+ {#if title?.length} + {title}: + {/if} +
+ {@render children?.()} +
+
+ + diff --git a/src/content/components/events/booru-events.ts b/src/content/components/events/booru-events.ts index 05d9e57..65a429d 100644 --- a/src/content/components/events/booru-events.ts +++ b/src/content/components/events/booru-events.ts @@ -1,5 +1,7 @@ export const EVENT_FETCH_COMPLETE = 'fetchcomplete'; +export const EVENT_RELOAD = 'reload'; export interface BooruEventsMap { [EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation. + [EVENT_RELOAD]: null; } diff --git a/src/content/components/events/comms.ts b/src/content/components/events/comms.ts index c31bae0..7127a25 100644 --- a/src/content/components/events/comms.ts +++ b/src/content/components/events/comms.ts @@ -4,13 +4,15 @@ import type { FullscreenViewerEventsMap } from "$content/components/events/fulls import type { BooruEventsMap } from "$content/components/events/booru-events"; import type { TagsFormEventsMap } from "$content/components/events/tags-form-events"; import type { TagDropdownEvents } from "$content/components/events/tag-dropdown-events"; +import type { PresetBlockEventsMap } from "$content/components/events/preset-block-events"; type EventsMapping = MaintenancePopupEventsMap & FullscreenViewerEventsMap & BooruEventsMap & TagsFormEventsMap - & TagDropdownEvents; + & TagDropdownEvents + & PresetBlockEventsMap; type EventCallback = (event: CustomEvent) => void; export type UnsubscribeFunction = () => void; diff --git a/src/content/components/events/preset-block-events.ts b/src/content/components/events/preset-block-events.ts new file mode 100644 index 0000000..4723d61 --- /dev/null +++ b/src/content/components/events/preset-block-events.ts @@ -0,0 +1,10 @@ +export const EVENT_PRESET_TAG_CHANGE_APPLIED = 'preset-tag-changed'; + +export interface PresetTagChange { + addedTags?: Set; + removedTags?: Set; +} + +export interface PresetBlockEventsMap { + [EVENT_PRESET_TAG_CHANGE_APPLIED]: PresetTagChange; +} diff --git a/src/content/components/extension/presets/EditorPresetsBlock.ts b/src/content/components/extension/presets/EditorPresetsBlock.ts new file mode 100644 index 0000000..86bbe1e --- /dev/null +++ b/src/content/components/extension/presets/EditorPresetsBlock.ts @@ -0,0 +1,101 @@ +import { BaseComponent } from "$content/components/base/BaseComponent"; +import TagEditorPreset from "$entities/TagEditorPreset"; +import PresetTableRow from "$content/components/extension/presets/PresetTableRow"; +import { createFontAwesomeIcon } from "$lib/dom-utils"; + +export default class EditorPresetsBlock extends BaseComponent { + #presetsTable = document.createElement('table'); + #presetBlocks: PresetTableRow[] = []; + #tags: Set = new Set(); + + protected build() { + this.container.classList.add('block', 'hidden', 'tag-presets'); + this.container.style.marginTop = 'var(--block-spacing)'; + + const header = document.createElement('div'); + header.classList.add('block__header'); + + const headerTitle = document.createElement('div'); + headerTitle.classList.add('block__header__title') + headerTitle.textContent = ' Presets'; + + const content = document.createElement('div'); + content.classList.add('block__content'); + + this.#presetsTable.append( + document.createElement('thead'), + document.createElement('tbody'), + ); + + this.#presetsTable.tHead?.append( + EditorPresetsBlock.#createRowWithTableHeads([ + 'Name', + 'Tags', + 'Actions' + ]), + ); + + headerTitle.prepend(createFontAwesomeIcon('layer-group')); + header.append(headerTitle); + content.append(this.#presetsTable); + + this.container.append( + header, + content, + ); + } + + protected init() { + TagEditorPreset.readAll() + .then(this.#refreshPresets.bind(this)) + .then(() => TagEditorPreset.subscribe(this.#refreshPresets.bind(this))); + } + + toggleVisibility(shouldBeVisible: boolean | undefined = undefined) { + this.container.classList.toggle('hidden', shouldBeVisible); + } + + updateTags(tags: Set) { + this.#tags = tags; + + for (const presetBlock of this.#presetBlocks) { + presetBlock.updateTags(tags); + } + } + + #refreshPresets(presetsList: TagEditorPreset[]) { + if (this.#presetBlocks.length) { + for (const block of this.#presetBlocks) { + block.remove(); + } + } + + for (const preset of presetsList) { + const block = PresetTableRow.create(preset); + this.#presetsTable.tBodies[0]?.append(block.container); + block.initialize(); + block.updateTags(this.#tags); + + this.#presetBlocks.push(block); + } + } + + static create(): EditorPresetsBlock { + return new EditorPresetsBlock( + document.createElement('div') + ); + } + + static #createRowWithTableHeads(columnNames: string[]): HTMLTableRowElement { + const rowElement = document.createElement('tr'); + + for (const columnName of columnNames) { + const columnHeadElement = document.createElement('th'); + columnHeadElement.textContent = columnName; + + rowElement.append(columnHeadElement); + } + + return rowElement; + } +} diff --git a/src/content/components/extension/presets/PresetTableRow.ts b/src/content/components/extension/presets/PresetTableRow.ts new file mode 100644 index 0000000..0141a46 --- /dev/null +++ b/src/content/components/extension/presets/PresetTableRow.ts @@ -0,0 +1,127 @@ +import { BaseComponent } from "$content/components/base/BaseComponent"; +import type TagEditorPreset from "$entities/TagEditorPreset"; +import { emit } from "$content/components/events/comms"; +import { EVENT_PRESET_TAG_CHANGE_APPLIED } from "$content/components/events/preset-block-events"; +import { createFontAwesomeIcon } from "$lib/dom-utils"; + +export default class PresetTableRow extends BaseComponent { + #preset: TagEditorPreset; + #tagsList: HTMLElement[] = []; + #applyAllButton = document.createElement('button'); + #removeAllButton = document.createElement('button'); + + constructor(container: HTMLElement, preset: TagEditorPreset) { + super(container); + + this.#preset = preset; + } + + protected build() { + this.#tagsList = this.#preset.settings.tags.map(tagName => { + const tagElement = document.createElement('span'); + tagElement.classList.add('tag'); + tagElement.textContent = tagName; + tagElement.dataset.tagName = tagName; + + return tagElement; + }); + + const nameCell = document.createElement('td'); + nameCell.textContent = this.#preset.settings.name; + + const tagsCell = document.createElement('td'); + + const tagsListContainer = document.createElement('div'); + tagsListContainer.classList.add('tag-list'); + tagsListContainer.append(...this.#tagsList); + + tagsCell.append(tagsListContainer); + + const actionsCell = document.createElement('td'); + + const actionsContainer = document.createElement('div'); + actionsContainer.classList.add('flex', 'flex--gap-small'); + + this.#applyAllButton.classList.add('button', 'button--state-success', 'button--bold'); + this.#applyAllButton.append(createFontAwesomeIcon('circle-plus')); + this.#applyAllButton.title = 'Add all tags from this preset into the editor'; + + this.#removeAllButton.classList.add('button', 'button--state-danger', 'button--bold'); + this.#removeAllButton.append(createFontAwesomeIcon('circle-minus')); + this.#removeAllButton.title = 'Remove all tags from this preset from the editor'; + + actionsContainer.append( + this.#applyAllButton, + this.#removeAllButton, + ); + + actionsCell.append(actionsContainer); + + this.container.append( + nameCell, + tagsCell, + actionsCell, + ); + } + + protected init() { + for (const tagElement of this.#tagsList) { + tagElement.addEventListener('click', this.#onTagClicked.bind(this)); + } + + this.#applyAllButton.addEventListener('click', this.#onApplyAllClicked.bind(this)); + this.#removeAllButton.addEventListener('click', this.#onRemoveAllClicked.bind(this)); + } + + #onTagClicked(event: Event) { + const targetElement = event.currentTarget; + + if (!(targetElement instanceof HTMLElement)) { + return; + } + + const tagName = targetElement.dataset.tagName; + const isMissing = targetElement.classList.contains(PresetTableRow.#tagMissingClassName); + + emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, { + [isMissing ? 'addedTags' : 'removedTags']: new Set([tagName]) + }); + } + + #onApplyAllClicked(event: Event) { + event.preventDefault(); + event.stopPropagation(); + + emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, { + addedTags: new Set(this.#preset.settings.tags), + }); + } + + #onRemoveAllClicked(event: Event) { + event.preventDefault(); + event.stopPropagation(); + + emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, { + removedTags: new Set(this.#preset.settings.tags), + }); + } + + updateTags(tags: Set) { + for (const tagElement of this.#tagsList) { + tagElement.classList.toggle( + PresetTableRow.#tagMissingClassName, + !tags.has(tagElement.dataset.tagName || ''), + ); + } + } + + remove() { + this.container.remove(); + } + + static create(preset: TagEditorPreset) { + return new this(document.createElement('tr'), preset); + } + + static #tagMissingClassName = 'is-missing'; +} diff --git a/src/content/components/philomena/TagsForm.ts b/src/content/components/philomena/TagsForm.ts index ab79c8c..03095d5 100644 --- a/src/content/components/philomena/TagsForm.ts +++ b/src/content/components/philomena/TagsForm.ts @@ -1,10 +1,36 @@ import { BaseComponent } from "$content/components/base/BaseComponent"; import { getComponent } from "$content/components/base/component-utils"; import { emit, on, type UnsubscribeFunction } from "$content/components/events/comms"; -import { EVENT_FETCH_COMPLETE } from "$content/components/events/booru-events"; +import { EVENT_FETCH_COMPLETE, EVENT_RELOAD } from "$content/components/events/booru-events"; import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events"; +import EditorPresetsBlock from "$content/components/extension/presets/EditorPresetsBlock"; +import { EVENT_PRESET_TAG_CHANGE_APPLIED, type PresetTagChange } from "$content/components/events/preset-block-events"; export class TagsForm extends BaseComponent { + #togglePresetsButton: HTMLButtonElement = document.createElement('button'); + #presetsList = EditorPresetsBlock.create(); + #plainEditorTextarea: HTMLTextAreaElement|null = null; + #fancyEditorInput: HTMLInputElement|null = null; + #tagsSet: Set = new Set(); + + protected build() { + this.#togglePresetsButton.classList.add( + 'button', + 'button--state-primary', + 'button--bold', + 'button--separate-left', + ); + + this.#togglePresetsButton.textContent = 'Presets'; + + this.container + .querySelector('.fancy-tag-edit ~ button:last-of-type') + ?.after(this.#togglePresetsButton, this.#presetsList.container); + + this.#plainEditorTextarea = this.container.querySelector('textarea.tagsinput'); + this.#fancyEditorInput = this.container.querySelector('.js-taginput-fancy input'); + } + protected init() { // Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here. const unsubscribe = on( @@ -12,6 +38,22 @@ export class TagsForm extends BaseComponent { EVENT_FETCH_COMPLETE, () => this.#waitAndDetectUpdatedForm(unsubscribe), ); + + this.#togglePresetsButton.addEventListener('click', this.#togglePresetsList.bind(this)); + this.#presetsList.initialize(); + + this.#plainEditorTextarea?.addEventListener('input', this.#refreshTagsList.bind(this)); + this.#fancyEditorInput?.addEventListener('keydown', this.#refreshTagsList.bind(this)); + + this.#refreshTagsList(); + + on(this.#presetsList, EVENT_PRESET_TAG_CHANGE_APPLIED, this.#onTagChangeRequested.bind(this)); + + if (this.#plainEditorTextarea) { + // When reloaded, we should catch and refresh the colors. Extension reuses this event to force site to update + // list of tags in the fancy tag editor. + on(this.#plainEditorTextarea, EVENT_RELOAD, this.refreshTagColors.bind(this)); + } } #waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void { @@ -111,6 +153,60 @@ export class TagsForm extends BaseComponent { return tagCategories; } + #togglePresetsList(event: Event) { + event.stopPropagation(); + event.preventDefault(); + + this.#presetsList.toggleVisibility(); + this.#refreshTagsList(); + } + + #refreshTagsList() { + this.#tagsSet = new Set( + this.#plainEditorTextarea?.value + .split(',') + .map(tagName => tagName.trim()) + ); + + this.#presetsList.updateTags(this.#tagsSet); + } + + #onTagChangeRequested(event: CustomEvent) { + const { addedTags = null, removedTags = null } = event.detail; + let tagChangeList: string[] = []; + + if (addedTags) { + tagChangeList.push(...addedTags); + } + + if (removedTags) { + tagChangeList.push(...Array.from(removedTags).map(tagName => `-${tagName}`)); + } + + this.#applyTagChangesWithFancyTagEditor( + tagChangeList.join(',') + ); + } + + #applyTagChangesWithFancyTagEditor(tagsListWithChanges: string): void { + if (!this.#fancyEditorInput || !this.#plainEditorTextarea) { + return; + } + + const originalValue = this.#fancyEditorInput.value; + + // We have to tell plain text editor to also refresh the list of tags in the fancy editor, just in case user + // made changes to it in plain mode. + emit(this.#plainEditorTextarea, EVENT_RELOAD, null); + + this.#fancyEditorInput.value = tagsListWithChanges; + this.#fancyEditorInput.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Comma', + })); + + this.#fancyEditorInput.value = originalValue; + } + static watchForEditors() { document.body.addEventListener('click', event => { const targetElement = event.target; diff --git a/src/lib/dom-utils.ts b/src/lib/dom-utils.ts new file mode 100644 index 0000000..bfef56b --- /dev/null +++ b/src/lib/dom-utils.ts @@ -0,0 +1,11 @@ +/** + * Reusable function to create icons from FontAwesome. Usable only for website, since extension doesn't host its own + * copy of FA styles. Extension should use imports of SVGs inside CSS instead. + * @param iconSlug Slug of the icon to be added. + * @return Element with classes for FontAwesome icon added. + */ +export function createFontAwesomeIcon(iconSlug: string): HTMLElement { + const iconElement = document.createElement('i'); + iconElement.classList.add('fa-solid', `fa-${iconSlug}`); + return iconElement; +} diff --git a/src/lib/extension/BulkEntitiesTransporter.ts b/src/lib/extension/BulkEntitiesTransporter.ts index eba3452..08ed77c 100644 --- a/src/lib/extension/BulkEntitiesTransporter.ts +++ b/src/lib/extension/BulkEntitiesTransporter.ts @@ -4,6 +4,7 @@ import type { ImportableElementsList, ImportableEntityObject } from "$lib/extens import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter"; import TaggingProfile from "$entities/TaggingProfile"; import TagGroup from "$entities/TagGroup"; +import TagEditorPreset from "$entities/TagEditorPreset"; type TransportersMapping = { [EntityName in keyof App.EntityNamesMap]: EntitiesTransporter; @@ -77,6 +78,8 @@ export default class BulkEntitiesTransporter { return BulkEntitiesTransporter.#transporters.profiles.exportToObject(entity); case entity instanceof TagGroup: return BulkEntitiesTransporter.#transporters.groups.exportToObject(entity); + case entity instanceof TagEditorPreset: + return BulkEntitiesTransporter.#transporters.presets.exportToObject(entity); } return null; @@ -101,6 +104,7 @@ export default class BulkEntitiesTransporter { static #transporters: TransportersMapping = { profiles: new EntitiesTransporter(TaggingProfile), groups: new EntitiesTransporter(TagGroup), + presets: new EntitiesTransporter(TagEditorPreset), } /** diff --git a/src/lib/extension/entities/TagEditorPreset.ts b/src/lib/extension/entities/TagEditorPreset.ts new file mode 100644 index 0000000..d904d5f --- /dev/null +++ b/src/lib/extension/entities/TagEditorPreset.ts @@ -0,0 +1,17 @@ +import StorageEntity from "$lib/extension/base/StorageEntity"; + +interface TagEditorPresetSettings { + name: string; + tags: string[]; +} + +export default class TagEditorPreset extends StorageEntity { + constructor(id: string, settings: Partial) { + super(id, { + name: settings.name || '', + tags: settings.tags || [], + }); + } + + public static readonly _entityName = 'presets'; +} diff --git a/src/lib/extension/transporting/exporters.ts b/src/lib/extension/transporting/exporters.ts index a0aa6c7..bdd9d75 100644 --- a/src/lib/extension/transporting/exporters.ts +++ b/src/lib/extension/transporting/exporters.ts @@ -33,6 +33,16 @@ const entitiesExporters: ExportersMap = { category: entity.settings.category, separate: entity.settings.separate, } + }, + presets: entity => { + return { + $type: "presets", + $site: __CURRENT_SITE__, + v: 1, + id: entity.id, + name: entity.settings.name, + tags: entity.settings.tags, + } } }; diff --git a/src/lib/extension/transporting/validators.ts b/src/lib/extension/transporting/validators.ts index 2efec8b..6b3d13e 100644 --- a/src/lib/extension/transporting/validators.ts +++ b/src/lib/extension/transporting/validators.ts @@ -64,6 +64,19 @@ const entitiesValidators: EntitiesValidationMap = { throw new Error('Invalid group format detected!'); } }, + presets: importedObject => { + if (!importedObject.v || importedObject.v > 1) { + throw new Error('Unsupported preset version!'); + } + + if ( + !validateRequiredString(importedObject?.id) + || !validateRequiredString(importedObject?.name) + || !validateOptionalArray(importedObject?.tags) + ) { + throw new Error('Invalid preset format detected!'); + } + } }; /** diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 251fd43..e1c7d50 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,3 +1,6 @@ +import type StorageEntity from "$lib/extension/base/StorageEntity"; +import type TagGroup from "$entities/TagGroup"; + /** * Traverse and find the object using the key path. * @param targetObject Target object to traverse into. @@ -39,3 +42,14 @@ export function escapeRegExp(value: string): string { unsafeRegExpCharacters.lastIndex = 0; return value.replace(unsafeRegExpCharacters, "\\$&"); } + +type OnlyStringFields> = { + [FieldKey in keyof Fields as Fields[FieldKey] extends string ? FieldKey : never]: string; +}; + +export function sortEntitiesByField(entities: StorageEntity[], fieldName: keyof OnlyStringFields) { + return entities.toSorted( + (a, b) => (a.settings[fieldName] as string) + .localeCompare(b.settings[fieldName] as string) + ); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7886217..a806a3a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -26,6 +26,7 @@ {/if} Tagging Profiles Tag Groups + Tag Presets
Import/Export Preferences diff --git a/src/routes/features/presets/+page.svelte b/src/routes/features/presets/+page.svelte new file mode 100644 index 0000000..537caf1 --- /dev/null +++ b/src/routes/features/presets/+page.svelte @@ -0,0 +1,19 @@ + + + + Back + Create New + {#if presets.length} +
+ {#each presets as preset} + {preset.settings.name} + {/each} + {/if} +
diff --git a/src/routes/features/presets/[id]/+page.svelte b/src/routes/features/presets/[id]/+page.svelte new file mode 100644 index 0000000..36129c3 --- /dev/null +++ b/src/routes/features/presets/[id]/+page.svelte @@ -0,0 +1,42 @@ + + + + Back +
+
+{#if preset} + +{/if} + +
+ Edit Preset + Delete Preset +
diff --git a/src/routes/features/presets/[id]/edit/+page.svelte b/src/routes/features/presets/[id]/edit/+page.svelte new file mode 100644 index 0000000..6a23ea2 --- /dev/null +++ b/src/routes/features/presets/[id]/edit/+page.svelte @@ -0,0 +1,74 @@ + + + + + Back + + + + + + + + + + + +
+ Save Preset +
diff --git a/src/routes/transporting/export/+page.svelte b/src/routes/transporting/export/+page.svelte index ed63fc7..3c3c119 100644 --- a/src/routes/transporting/export/+page.svelte +++ b/src/routes/transporting/export/+page.svelte @@ -9,11 +9,13 @@ import FormControl from "$components/ui/forms/FormControl.svelte"; import FormContainer from "$components/ui/forms/FormContainer.svelte"; import { popupTitle } from "$stores/popup"; + import { tagEditorPresets } from "$stores/entities/tag-editor-presets"; const bulkTransporter = new BulkEntitiesTransporter(); let exportAllProfiles = $state(false); let exportAllGroups = $state(false); + let exportAllPresets = $state(false); let displayExportedString = $state(false); let shouldUseCompressed = $state(true); @@ -24,6 +26,7 @@ const exportedEntities: Record> = $state({ profiles: {}, groups: {}, + presets: {}, }); $effect(() => { @@ -42,6 +45,12 @@ } }); + $tagEditorPresets.forEach(preset => { + if (exportedEntities.presets[preset.id]) { + elementsToExport.push(preset); + } + }); + plainExport = bulkTransporter.exportToJSON(elementsToExport); compressedExport = bulkTransporter.exportToCompressedJSON(elementsToExport); } @@ -57,6 +66,7 @@ requestAnimationFrame(() => { exportAllProfiles = $taggingProfiles.every(profile => exportedEntities.profiles[profile.id]); exportAllGroups = $tagGroups.every(group => exportedEntities.groups[group.id]); + exportAllPresets = $tagEditorPresets.every(preset => exportedEntities.presets[preset.id]); }); } @@ -74,6 +84,9 @@ case "groups": $tagGroups.forEach(group => exportedEntities.groups[group.id] = exportAllGroups); break; + case "presets": + $tagEditorPresets.forEach(preset => exportedEntities.presets[preset.id] = exportAllPresets); + break; default: console.warn(`Trying to toggle unsupported entity type: ${targetEntity}`); } @@ -116,6 +129,17 @@ {/each}
{/if} + {#if $tagEditorPresets.length} + + Export All Presets + + {#each $tagEditorPresets as preset} + + Preset: {preset.settings.name} + + {/each} +
+ {/if} Export Selected {:else} diff --git a/src/routes/transporting/import/+page.svelte b/src/routes/transporting/import/+page.svelte index 0677607..82f2eee 100644 --- a/src/routes/transporting/import/+page.svelte +++ b/src/routes/transporting/import/+page.svelte @@ -16,21 +16,26 @@ import type { SameSiteStatus } from "$lib/extension/EntitiesTransporter"; import { popupTitle } from "$stores/popup"; import Notice from "$components/ui/Notice.svelte"; + import TagEditorPreset from "$entities/TagEditorPreset"; + import { tagEditorPresets } from "$stores/entities/tag-editor-presets"; let importedString = $state(''); let errorMessage = $state(''); let importedProfiles = $state([]); let importedGroups = $state([]); + let importedPresets = $state([]); let saveAllProfiles = $state(false); let saveAllGroups = $state(false); + let saveAllPresets = $state(false); let isSaving = $state(false); let selectedEntities: Record> = $state({ profiles: {}, groups: {}, + presets: {}, }); let previewedEntity = $state(null); @@ -49,8 +54,15 @@ }, new Map()) ); + const existingPresetsMap = $derived( + $tagEditorPresets.reduce((map, preset) => { + map.set(preset.id, preset); + return map; + }, new Map()) + ); + const hasImportedEntities = $derived( - Boolean(importedProfiles.length || importedGroups.length) + Boolean(importedProfiles.length || importedGroups.length || importedPresets.length) ); $effect(() => { @@ -70,6 +82,7 @@ function tryBulkImport() { importedProfiles = []; importedGroups = []; + importedPresets = []; errorMessage = ''; importedString = importedString.trim(); @@ -103,6 +116,9 @@ case "groups": importedGroups.push(targetImportedEntity as TagGroup); break; + case "presets": + importedPresets.push(targetImportedEntity as TagEditorPreset); + break; default: console.warn(`Unprocessed entity type detected: ${targetImportedEntity.type}`, targetImportedEntity); } @@ -115,12 +131,14 @@ function cancelImport() { importedProfiles = []; importedGroups = []; + importedPresets = []; } function refreshAreAllEntitiesChecked() { requestAnimationFrame(() => { saveAllProfiles = importedProfiles.every(profile => selectedEntities.profiles[profile.id]); saveAllGroups = importedGroups.every(group => selectedEntities.groups[group.id]); + saveAllPresets = importedPresets.every(preset => selectedEntities.presets[preset.id]); }); } @@ -134,6 +152,9 @@ case "groups": importedGroups.forEach(group => selectedEntities.groups[group.id] = saveAllGroups); break; + case "presets": + importedPresets.forEach(preset => selectedEntities.presets[preset.id] = saveAllPresets); + break; default: console.warn(`Trying to toggle unsupported entity type: ${entityType}`); } @@ -171,6 +192,14 @@ await group.save(); } + for (const preset of importedPresets) { + if (!selectedEntities.presets[preset.id]) { + continue; + } + + await preset.save(); + } + await goto("/transporting"); } @@ -251,10 +280,7 @@ {/if} {#if importedGroups.length}
- + Import All Groups {#each importedGroups as candidateGroup} @@ -272,6 +298,26 @@ {/each} {/if} + {#if importedPresets.length} +
+ + Import All Presets + + {#each importedPresets as candidatePreset} + + {#if existingPresetsMap.has(candidatePreset.id)} + Update: + {:else} + New: + {/if} + {candidatePreset.settings.name || 'Unnamed Preset'} + + {/each} + {/if}
Imported Selected diff --git a/src/stores/entities/tag-editor-presets.ts b/src/stores/entities/tag-editor-presets.ts new file mode 100644 index 0000000..079248c --- /dev/null +++ b/src/stores/entities/tag-editor-presets.ts @@ -0,0 +1,11 @@ +import { type Writable, writable } from "svelte/store"; +import TagEditorPreset from "$entities/TagEditorPreset"; + +export const tagEditorPresets: Writable = writable([]); + +TagEditorPreset + .readAll() + .then(presets => tagEditorPresets.set(presets)) + .then(() => { + TagEditorPreset.subscribe(presets => tagEditorPresets.set(presets)) + }); diff --git a/src/styles/content/tags-editor.scss b/src/styles/content/tags-editor.scss index c371073..9eb4b5b 100644 --- a/src/styles/content/tags-editor.scss +++ b/src/styles/content/tags-editor.scss @@ -11,3 +11,18 @@ h2.tag-category-headline { bottom: calc(#{$base-margin-bottom} - #{booru-vars.$padding-small}); } } + +.block.tag-presets { + .tag { + cursor: pointer; + + &.is-missing { + opacity: 0.5; + } + + &:hover { + color: booru-vars.$resolved-tag-background; + background: booru-vars.$resolved-tag-color; + } + } +}