1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2025-12-24 15:12:58 +00:00

First draft version with Svelte for popup and additional build steps

This commit is contained in:
2024-03-21 23:56:47 +04:00
parent f213aa4b8c
commit f7160ba70a
52 changed files with 4922 additions and 1 deletions

View File

@@ -0,0 +1,36 @@
import PageParser from "$lib/booru/parsing/PageParser.js";
export default class ImagePageParser extends PageParser {
/** @type {HTMLFormElement} */
#tagEditorForm;
constructor(imageId) {
super(`/images/${imageId}`);
}
/**
* @return {Promise<HTMLFormElement>}
*/
async resolveTagEditorForm() {
if (this.#tagEditorForm) {
return this.#tagEditorForm;
}
const documentFragment = await this.resolveFragment();
const tagsFormElement = documentFragment.querySelector("#tags-form");
if (!tagsFormElement) {
throw new Error("Failed to find the tag editor form");
}
this.#tagEditorForm = tagsFormElement;
return tagsFormElement;
}
async resolveTagEditorFormData() {
return new FormData(
await this.resolveTagEditorForm()
);
}
}

View File

@@ -0,0 +1,40 @@
export default class PageParser {
/** @type {string} */
#url;
/** @type {DocumentFragment|null} */
#fragment;
constructor(url) {
this.#url = url;
}
/**
* @return {Promise<DocumentFragment>}
*/
async resolveFragment() {
if (this.#fragment) {
return this.#fragment;
}
const response = await fetch(this.#url);
if (!response.ok) {
throw new Error(`Failed to load page from ${this.#url}`);
}
const documentFragment = document.createDocumentFragment();
const template = document.createElement('template');
template.innerHTML = await response.text();
documentFragment.append(...template.content.childNodes);
this.#fragment = documentFragment;
return documentFragment;
}
clear() {
this.#fragment = null;
}
}

View File

@@ -0,0 +1,57 @@
/**
* Helper class to read and write JSON objects to the local storage.
* @class
*/
class StorageHelper {
/**
* @type {import('@types/chrome').storage.StorageArea}
*/
#storageArea;
/**
* @param {import('@types/chrome').storage.StorageArea} storageArea
*/
constructor(storageArea) {
this.#storageArea = storageArea;
}
/**
* Read the following entry from the local storage as a JSON object.
*
* @param {string} key Key of the entry to read.
* @param {any} defaultValue Default value to return if the entry does not exist.
*
* @return {Promise<any>} The JSON object or the default value if the entry does not exist.
*/
async read(key, defaultValue = null) {
return (await this.#storageArea.get(key))?.[key] || defaultValue;
}
/**
* Write the following JSON object to the local storage.
*
* @param {string} key Key of the entry to write.
* @param {any} value JSON object to write.
*/
write(key, value) {
void this.#storageArea.set({[key]: value});
}
/**
* Subscribe to changes in the local storage.
* @param {function(Record<string, StorageChange>): void} callback
*/
subscribe(callback) {
this.#storageArea.onChanged.addListener(callback);
}
/**
* Unsubscribe from changes in the local storage.
* @param {function(Record<string, StorageChange>): void} callback
*/
unsubscribe(callback) {
this.#storageArea.onChanged.removeListener(callback);
}
}
export default StorageHelper;

View File

@@ -0,0 +1,93 @@
import StorageHelper from "$lib/chrome/StorageHelper.js";
export default class EntitiesController {
static #storageHelper = new StorageHelper(chrome.storage.local);
/**
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
*
* @template EntityClass
*
* @param {string} entityName Name of the entity to read.
* @param {EntityClass} entityClass Class of the entity to read. Must have a constructor that accepts the ID and the
* settings object.
*
* @return {Promise<InstanceType<EntityClass>[]>} List of entities of the given type.
*/
static async readAllEntities(entityName, entityClass) {
const rawEntities = await this.#storageHelper.read(entityName, {});
if (!rawEntities || Object.keys(rawEntities).length === 0) {
return [];
}
return Object
.entries(rawEntities)
.map(([id, settings]) => new entityClass(id, settings));
}
/**
* Update the single entity in the storage. If the entity with the given ID already exists, it will be overwritten.
*
* @param {string} entityName Name of the entity to update.
* @param {StorageEntity} entity Entity to update.
*
* @return {Promise<void>}
*/
static async updateEntity(entityName, entity) {
await this.#storageHelper.write(
entityName,
Object.assign(
await this.#storageHelper.read(
entityName, {}
),
{
[entity.id]: entity.settings
}
)
);
}
/**
* Delete the entity with the given ID.
*
* @param {string} entityName Name of the entity to delete.
* @param {string} entityId ID of the entity to delete.
*
* @return {Promise<void>}
*/
static async deleteEntity(entityName, entityId) {
const entities = await this.#storageHelper.read(entityName, {});
delete entities[entityId];
await this.#storageHelper.write(entityName, entities);
}
/**
* Subscribe to all changes made to the storage.
*
* @template EntityClass
*
* @param {string} entityName Name of the entity to subscribe to.
* @param {EntityClass} entityClass Class of the entity to subscribe to.
* @param {function(InstanceType<EntityClass>[]): any} callback Callback to call when the storage changes.
* @return {function(): void} Unsubscribe function.
*/
static subscribeToEntity(entityName, entityClass, callback) {
/**
* Watch the changes made to the storage and call the callback when the entity changes.
* @param {Object<string, StorageChange>} changes Changes made to the storage.
*/
const storageChangesSubscriber = changes => {
if (!changes[entityName]) {
return;
}
this.readAllEntities(entityName, entityClass)
.then(callback);
}
this.#storageHelper.subscribe(storageChangesSubscriber);
return () => this.#storageHelper.unsubscribe(storageChangesSubscriber);
}
}

View File

@@ -0,0 +1,56 @@
import EntitiesController from "$lib/extension/EntitiesController.js";
class StorageEntity {
/**
* @type {string}
*/
#id;
/**
* @type {Object}
*/
#settings;
/**
* @param {string} id
* @param {Object} settings
*/
constructor(id, settings = {}) {
this.#id = id;
this.#settings = settings;
}
/**
* @return {string}
*/
get id() {
return this.#id;
}
/**
* @return {Object}
*/
get settings() {
return this.#settings;
}
static _entityName = "entity";
async save() {
await EntitiesController.updateEntity(this.constructor._entityName, this);
}
async delete() {
await EntitiesController.deleteEntity(this.constructor._entityName, this.id);
}
/**
* Static function to read all entities of this type from the storage. Must be implemented in the child class.
* @return {Promise<array>}
*/
static async readAll() {
throw new Error("Not implemented");
}
}
export default StorageEntity;

View File

@@ -0,0 +1,63 @@
import StorageEntity from "$lib/extension/base/StorageEntity.js";
import EntitiesController from "$lib/extension/EntitiesController.js";
/**
* @typedef {Object} MaintenanceProfileSettings
* @property {string} name
* @property {string[]} tags
*/
/**
* Class representing the maintenance profile entity.
*/
class MaintenanceProfile extends StorageEntity {
/**
* @param {string} id ID of the entity.
* @param {Partial<MaintenanceProfileSettings>} settings Maintenance profile settings object.
*/
constructor(id, settings) {
super(id, {
name: settings.name || '',
tags: settings.tags || []
});
}
/**
* @return {MaintenanceProfileSettings}
*/
get settings() {
return super.settings;
}
static _entityName = "profiles";
/**
* Read all maintenance profiles from the storage.
*
* @return {Promise<InstanceType<MaintenanceProfile>[]>}
*/
static async readAll() {
return await EntitiesController.readAllEntities(
this._entityName,
MaintenanceProfile
);
}
/**
* Subscribe to the changes and receive the new list of profiles when they change.
*
* @param {function(MaintenanceProfile[]): void} callback Callback to call when the profiles change. The new list of
* profiles is passed as an argument.
*
* @return {function(): void} Unsubscribe function.
*/
static subscribe(callback) {
return EntitiesController.subscribeToEntity(
this._entityName,
MaintenanceProfile,
callback
);
}
}
export default MaintenanceProfile;

View File

@@ -0,0 +1,184 @@
export default class TagEditorComponent extends HTMLElement {
/**
* Array of elements representing tags.
* @type {HTMLElement[]}
*/
#tagElements = [];
/**
* Generated input for adding new tags to the tag list. Will be rendered on connecting.
* @type {HTMLInputElement|undefined}
*/
#tagInput;
/**
* Cached list of tag names. Changing this value will not automatically change the actual tags.
* @type {Set<string>}
*/
#tagsSet = new Set();
constructor() {
super();
}
connectedCallback() {
if (!this.#tagInput) {
this.#tagInput = document.createElement('input');
this.appendChild(this.#tagInput);
this.#tagInput.addEventListener('keydown', this.#onKeyDownDetectActions.bind(this));
}
if (!this.#tagElements.length) {
this.#renderTags();
}
this.addEventListener('click', this.#onClickDetectTagRemoval.bind(this));
}
/**
* Render the list of tag elements based on the tag attribute. Should be called every time tag attribute is changed.
*/
#renderTags() {
const tags = this.getAttribute(TagEditorComponent.#tagsAttribute) || '';
const updatedTagsSet = new Set(
tags.split(',')
.map(tagName => tagName.trim())
.filter(Boolean)
);
this.#tagsSet = new Set(updatedTagsSet.values());
this.#tagElements = this.#tagElements.filter(tagElement => {
const tagName = tagElement.dataset.tag;
if (!updatedTagsSet.has(tagName)) {
tagElement.remove();
return false;
}
updatedTagsSet.delete(tagName);
return true;
});
for (let tagName of updatedTagsSet) {
const tagElement = document.createElement('div');
tagElement.classList.add('tag');
tagElement.innerText = tagName;
tagElement.dataset.tag = tagName;
const tagRemoveElement = document.createElement("span");
tagRemoveElement.classList.add('remove');
tagRemoveElement.innerText = 'x';
tagElement.appendChild(tagRemoveElement);
this.#tagInput.insertAdjacentElement('beforebegin', tagElement);
this.#tagElements.push(tagElement);
}
}
/**
* Detect add/remove keyboard shortcuts on the input.
* @param {KeyboardEvent} event
*/
#onKeyDownDetectActions(event) {
const isTagSubmit = event.key === 'Enter';
const isTagRemove = event.key === 'Backspace' && !this.#tagInput.value.length;
if (!isTagSubmit && !isTagRemove) {
return;
}
if (isTagSubmit) {
event.preventDefault();
}
const providedTagName = this.#tagInput.value.trim();
if (providedTagName && isTagSubmit) {
if (!this.#tagsSet.has(providedTagName)) {
this.setAttribute(
TagEditorComponent.#tagsAttribute,
[...this.#tagsSet, providedTagName].join(',')
);
}
this.#tagInput.value = '';
return;
}
if (isTagRemove && this.#tagsSet.size) {
this.setAttribute(
TagEditorComponent.#tagsAttribute,
[...this.#tagsSet].slice(0, -1).join(',')
)
}
}
/**
* Detect clicks on the "remove" button inside tags.
* @param {MouseEvent} event
*/
#onClickDetectTagRemoval(event) {
/** @type {HTMLElement} */
const maybeRemoveTagElement = event.target;
if (!maybeRemoveTagElement.classList.contains('remove')) {
return;
}
/** @type {HTMLElement} */
const tagElement = maybeRemoveTagElement.closest('.tag');
if (!tagElement) {
return;
}
const tagName = tagElement.dataset.tag;
if (this.#tagsSet.has(tagName)) {
this.#tagsSet.delete(tagName);
this.setAttribute(
TagEditorComponent.#tagsAttribute,
[...this.#tagsSet].join(",")
);
}
}
/**
* @param {string} name
* @param {string} from
* @param {string} to
*/
attributeChangedCallback(name, from, to) {
if (!this.isConnected) {
return;
}
if (name === TagEditorComponent.#tagsAttribute) {
this.#renderTags();
this.dispatchEvent(
new CustomEvent(
'change',
{
detail: [...this.#tagsSet.values()]
}
)
);
}
}
static get observedAttributes() {
return [this.#tagsAttribute];
}
static #tagsAttribute = 'tags';
}
if (!customElements.get('tags-editor')) {
customElements.define('tags-editor', TagEditorComponent);
} else {
console.warn('Tags Component is attempting to initialize twice!');
}