mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2026-03-24 23:02:58 +00:00
Added Tag Presets, popup editor for them, implemented presets image edit
This commit is contained in:
4
src/app.d.ts
vendored
4
src/app.d.ts
vendored
@@ -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 {
|
||||
|
||||
20
src/components/features/PresetView.svelte
Normal file
20
src/components/features/PresetView.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type TagEditorPreset from "$entities/TagEditorPreset";
|
||||
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
|
||||
import TagsList from "$components/tags/TagsList.svelte";
|
||||
|
||||
interface PresetViewProps {
|
||||
preset: TagEditorPreset;
|
||||
}
|
||||
|
||||
let { preset }: PresetViewProps = $props();
|
||||
|
||||
const sortedTagsList = $derived(preset.settings.tags.toSorted((a, b) => a.localeCompare(b)));
|
||||
</script>
|
||||
|
||||
<DetailsBlock title="Preset Name">
|
||||
{preset.settings.name}
|
||||
</DetailsBlock>
|
||||
<DetailsBlock title="Tags">
|
||||
<TagsList tags={sortedTagsList}></TagsList>
|
||||
</DetailsBlock>
|
||||
21
src/components/tags/TagsList.svelte
Normal file
21
src/components/tags/TagsList.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
interface TagsListProps {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
let { tags }: TagsListProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="tags-list">
|
||||
{#each tags as tagName}
|
||||
<div class="tag">{tagName}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
30
src/components/ui/DetailsBlock.svelte
Normal file
30
src/components/ui/DetailsBlock.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface DetailsBlockProps {
|
||||
title?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { title, children }: DetailsBlockProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
{#if title?.length}
|
||||
<strong>{title}:</strong>
|
||||
{/if}
|
||||
<div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.block strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<EventDetails> = (event: CustomEvent<EventDetails>) => void;
|
||||
export type UnsubscribeFunction = () => void;
|
||||
|
||||
10
src/content/components/events/preset-block-events.ts
Normal file
10
src/content/components/events/preset-block-events.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const EVENT_PRESET_TAG_CHANGE_APPLIED = 'preset-tag-changed';
|
||||
|
||||
export interface PresetTagChange {
|
||||
addedTags?: Set<string>;
|
||||
removedTags?: Set<string>;
|
||||
}
|
||||
|
||||
export interface PresetBlockEventsMap {
|
||||
[EVENT_PRESET_TAG_CHANGE_APPLIED]: PresetTagChange;
|
||||
}
|
||||
101
src/content/components/extension/presets/EditorPresetsBlock.ts
Normal file
101
src/content/components/extension/presets/EditorPresetsBlock.ts
Normal file
@@ -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<string> = 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<string>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
127
src/content/components/extension/presets/PresetTableRow.ts
Normal file
127
src/content/components/extension/presets/PresetTableRow.ts
Normal file
@@ -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<string>) {
|
||||
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';
|
||||
}
|
||||
@@ -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<string> = 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<PresetTagChange>) {
|
||||
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;
|
||||
|
||||
11
src/lib/dom-utils.ts
Normal file
11
src/lib/dom-utils.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<App.EntityNamesMap[EntityName]>;
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
17
src/lib/extension/entities/TagEditorPreset.ts
Normal file
17
src/lib/extension/entities/TagEditorPreset.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
interface TagEditorPresetSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export default class TagEditorPreset extends StorageEntity<TagEditorPresetSettings> {
|
||||
constructor(id: string, settings: Partial<TagEditorPresetSettings>) {
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || [],
|
||||
});
|
||||
}
|
||||
|
||||
public static readonly _entityName = 'presets';
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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!');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Fields extends Record<string, any>> = {
|
||||
[FieldKey in keyof Fields as Fields[FieldKey] extends string ? FieldKey : never]: string;
|
||||
};
|
||||
|
||||
export function sortEntitiesByField<Fields extends Object>(entities: StorageEntity<Fields>[], fieldName: keyof OnlyStringFields<Fields>) {
|
||||
return entities.toSorted(
|
||||
(a, b) => (a.settings[fieldName] as string)
|
||||
.localeCompare(b.settings[fieldName] as string)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
{/if}
|
||||
<MenuItem href="/features/profiles">Tagging Profiles</MenuItem>
|
||||
<MenuItem href="/features/groups">Tag Groups</MenuItem>
|
||||
<MenuItem href="/features/presets">Tag Presets</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/transporting">Import/Export</MenuItem>
|
||||
<MenuItem href="/preferences">Preferences</MenuItem>
|
||||
|
||||
19
src/routes/features/presets/+page.svelte
Normal file
19
src/routes/features/presets/+page.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
|
||||
import { sortEntitiesByField } from "$lib/utils";
|
||||
|
||||
let presets = $derived(sortEntitiesByField($tagEditorPresets, "name"))
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/presets/new/edit" icon="plus">Create New</MenuItem>
|
||||
{#if presets.length}
|
||||
<hr>
|
||||
{#each presets as preset}
|
||||
<MenuItem href="/features/presets/{preset.id}">{preset.settings.name}</MenuItem>
|
||||
{/each}
|
||||
{/if}
|
||||
</Menu>
|
||||
42
src/routes/features/presets/[id]/+page.svelte
Normal file
42
src/routes/features/presets/[id]/+page.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import TagEditorPreset from "$entities/TagEditorPreset";
|
||||
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
|
||||
import { goto } from "$app/navigation";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import PresetView from "$components/features/PresetView.svelte";
|
||||
|
||||
let presetId = $derived(page.params.id);
|
||||
let preset = $derived<TagEditorPreset|null>(
|
||||
$tagEditorPresets.find(preset => preset.id === presetId) || null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (presetId === 'new') {
|
||||
goto(`/features/presets/new/edit`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!preset) {
|
||||
console.warn(`Preset ${presetId} not found.`);
|
||||
goto('/features/presets');
|
||||
} else {
|
||||
$popupTitle = `Preset: ${preset.settings.name}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/presets" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if preset}
|
||||
<PresetView {preset}></PresetView>
|
||||
{/if}
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem href="/features/presets/{presetId}/edit" icon="wrench">Edit Preset</MenuItem>
|
||||
<MenuItem href="/features/presets/{presetId}/delete">Delete Preset</MenuItem>
|
||||
</Menu>
|
||||
74
src/routes/features/presets/[id]/edit/+page.svelte
Normal file
74
src/routes/features/presets/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import TagEditorPreset from "$entities/TagEditorPreset";
|
||||
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
import { goto } from "$app/navigation";
|
||||
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 TextField from "$components/ui/forms/TextField.svelte";
|
||||
import TagsEditor from "$components/tags/TagsEditor.svelte";
|
||||
|
||||
let presetId = $derived(page.params.id);
|
||||
|
||||
let targetPreset = $derived.by<TagEditorPreset | null>(() => {
|
||||
if (presetId === 'new') {
|
||||
return new TagEditorPreset(crypto.randomUUID(), {});
|
||||
}
|
||||
|
||||
return $tagEditorPresets.find(preset => preset.id === presetId) || null;
|
||||
});
|
||||
|
||||
let presetName = $state('');
|
||||
let tagsList = $state<string[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (presetId === 'new') {
|
||||
$popupTitle = 'Create New Preset';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetPreset) {
|
||||
goto('/features/presets');
|
||||
return;
|
||||
}
|
||||
|
||||
$popupTitle = `Edit Tagging Preset: ${targetPreset.settings.name}}`;
|
||||
|
||||
presetName = targetPreset.settings.name;
|
||||
tagsList = [...targetPreset.settings.tags].sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
async function savePreset() {
|
||||
if (!targetPreset) {
|
||||
console.warn('Attempting to save the preset, but the preset is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
targetPreset.settings.name = presetName;
|
||||
targetPreset.settings.tags = [...tagsList];
|
||||
|
||||
await targetPreset.save();
|
||||
await goto(`/features/presets/${targetPreset.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/presets{presetId === 'new' ? '' : '/' + presetId}" icon="arrow-left">
|
||||
Back
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Preset Name">
|
||||
<TextField bind:value={presetName} placeholder="Preset Name"></TextField>
|
||||
</FormControl>
|
||||
<FormControl label="Tags">
|
||||
<TagsEditor bind:tags={tagsList}></TagsEditor>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem href="#" onclick={savePreset}>Save Preset</MenuItem>
|
||||
</Menu>
|
||||
@@ -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<keyof App.EntityNamesMap, Record<string, boolean>> = $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}
|
||||
<hr>
|
||||
{/if}
|
||||
{#if $tagEditorPresets.length}
|
||||
<MenuCheckboxItem bind:checked={exportAllPresets} oninput={createToggleAllOnUserInput('presets')}>
|
||||
Export All Presets
|
||||
</MenuCheckboxItem>
|
||||
{#each $tagEditorPresets as preset}
|
||||
<MenuCheckboxItem bind:checked={exportedEntities.presets[preset.id]} oninput={refreshAreAllEntitiesChecked}>
|
||||
Preset: {preset.settings.name}
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
<hr>
|
||||
{/if}
|
||||
<MenuItem icon="file-export" onclick={toggleExportedStringDisplay}>Export Selected</MenuItem>
|
||||
</Menu>
|
||||
{:else}
|
||||
|
||||
@@ -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<TaggingProfile[]>([]);
|
||||
let importedGroups = $state<TagGroup[]>([]);
|
||||
let importedPresets = $state<TagEditorPreset[]>([]);
|
||||
|
||||
let saveAllProfiles = $state(false);
|
||||
let saveAllGroups = $state(false);
|
||||
let saveAllPresets = $state(false);
|
||||
|
||||
let isSaving = $state(false);
|
||||
|
||||
let selectedEntities: Record<keyof App.EntityNamesMap, Record<string, boolean>> = $state({
|
||||
profiles: {},
|
||||
groups: {},
|
||||
presets: {},
|
||||
});
|
||||
|
||||
let previewedEntity = $state<StorageEntity | null>(null);
|
||||
@@ -49,8 +54,15 @@
|
||||
}, new Map<string, TagGroup>())
|
||||
);
|
||||
|
||||
const existingPresetsMap = $derived(
|
||||
$tagEditorPresets.reduce((map, preset) => {
|
||||
map.set(preset.id, preset);
|
||||
return map;
|
||||
}, new Map<string, TagEditorPreset>())
|
||||
);
|
||||
|
||||
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");
|
||||
}
|
||||
</script>
|
||||
@@ -251,10 +280,7 @@
|
||||
{/if}
|
||||
{#if importedGroups.length}
|
||||
<hr>
|
||||
<MenuCheckboxItem
|
||||
bind:checked={saveAllGroups}
|
||||
oninput={createToggleAllOnUserInput('groups')}
|
||||
>
|
||||
<MenuCheckboxItem bind:checked={saveAllGroups} oninput={createToggleAllOnUserInput('groups')}>
|
||||
Import All Groups
|
||||
</MenuCheckboxItem>
|
||||
{#each importedGroups as candidateGroup}
|
||||
@@ -272,6 +298,26 @@
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if importedPresets.length}
|
||||
<hr>
|
||||
<MenuCheckboxItem bind:checked={saveAllPresets} oninput={createToggleAllOnUserInput('presets')}>
|
||||
Import All Presets
|
||||
</MenuCheckboxItem>
|
||||
{#each importedPresets as candidatePreset}
|
||||
<MenuCheckboxItem
|
||||
bind:checked={selectedEntities.presets[candidatePreset.id]}
|
||||
oninput={refreshAreAllEntitiesChecked}
|
||||
onitemclick={createShowPreviewForEntity(candidatePreset)}
|
||||
>
|
||||
{#if existingPresetsMap.has(candidatePreset.id)}
|
||||
Update:
|
||||
{:else}
|
||||
New:
|
||||
{/if}
|
||||
{candidatePreset.settings.name || 'Unnamed Preset'}
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
{/if}
|
||||
<hr>
|
||||
<MenuItem onclick={saveSelectedEntities}>
|
||||
Imported Selected
|
||||
|
||||
11
src/stores/entities/tag-editor-presets.ts
Normal file
11
src/stores/entities/tag-editor-presets.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { type Writable, writable } from "svelte/store";
|
||||
import TagEditorPreset from "$entities/TagEditorPreset";
|
||||
|
||||
export const tagEditorPresets: Writable<TagEditorPreset[]> = writable([]);
|
||||
|
||||
TagEditorPreset
|
||||
.readAll()
|
||||
.then(presets => tagEditorPresets.set(presets))
|
||||
.then(() => {
|
||||
TagEditorPreset.subscribe(presets => tagEditorPresets.set(presets))
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user