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

Implementation of tags submission, added status icon to indicate state

This commit is contained in:
2024-04-09 03:47:54 +04:00
parent c6ae064c90
commit 941d33bb66
10 changed files with 350 additions and 71 deletions

View File

@@ -0,0 +1,34 @@
/**
* Build the map containing both real tags and their aliases.
*
* @param {string[]} realAndAliasedTags List combining aliases and tag names.
* @param {string[]} realTags List of actual tag names, excluding aliases.
*
* @return {Map<string, string>} Map where key is a tag or alias and value is an actual tag name.
*/
export function buildTagsAndAliasesMap(realAndAliasedTags, realTags) {
/** @type {Map<string, string>} */
const tagsAndAliasesMap = new Map();
for (let tagName of realTags) {
tagsAndAliasesMap.set(tagName, tagName);
}
let realTagName = null;
for (let tagNameOrAlias of realAndAliasedTags) {
if (tagsAndAliasesMap.has(tagNameOrAlias)) {
realTagName = tagNameOrAlias;
continue;
}
if (!realTagName) {
console.warn('No real tag found for the alias:', tagNameOrAlias);
continue;
}
tagsAndAliasesMap.set(tagNameOrAlias, realTagName);
}
return tagsAndAliasesMap;
}

View File

@@ -1,36 +0,0 @@
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,41 @@
import PostParser from "$lib/booru/scraped/parsing/PostParser.js";
export default class ScrapedAPI {
/**
* Update the tags of the image using callback.
* @param {number} imageId ID of the image.
* @param {function(Set<string>): Set<string>} callback Callback to call to change the content.
* @return {Promise<Map<string,string>|null>} Updated tags and aliases list for updating internal cached state.
*/
async updateImageTags(imageId, callback) {
const formData = await new PostParser(imageId)
.resolveTagEditorFormData();
const tagsList = new Set(
formData
.get(PostParser.tagsInputName)
.split(',')
.map(tagName => tagName.trim())
);
const updateTagsList = callback(tagsList);
if (!(updateTagsList instanceof Set)) {
throw new Error("Return value is not a set!");
}
formData.set(
PostParser.tagsInputName,
Array.from(updateTagsList).join(', ')
);
const tagsSubmittedResponse = await fetch(`/images/${imageId}/tags`, {
method: 'POST',
body: formData,
});
return PostParser.resolveTagsAndAliasesFromPost(
await PostParser.resolveFragmentFromResponse(tagsSubmittedResponse)
);
}
}

View File

@@ -2,7 +2,7 @@ export default class PageParser {
/** @type {string} */
#url;
/** @type {DocumentFragment|null} */
#fragment;
#fragment = null;
constructor(url) {
this.#url = url;
@@ -22,19 +22,31 @@ export default class PageParser {
throw new Error(`Failed to load page from ${this.#url}`);
}
this.#fragment = await PageParser.resolveFragmentFromResponse(response);
return this.#fragment;
}
clear() {
this.#fragment = null;
}
/**
* Create a document fragment from the following response.
*
* @param {Response} response Response to create a fragment from. Note, that this response will be used. If you need
* to use the same response somewhere else, then you need to pass a cloned version of the response.
*
* @return {Promise<DocumentFragment>} Resulting document fragment ready for processing.
*/
static async resolveFragmentFromResponse(response) {
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,70 @@
import PageParser from "$lib/booru/scraped/parsing/PageParser.js";
import {buildTagsAndAliasesMap} from "$lib/booru/TagsUtils.js";
export default class PostParser 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()
);
}
/**
* Resolve the list of tags and aliases from the post content.
*
* @param {DocumentFragment} documentFragment Real content to parse the data from.
*
* @return {Map<string, string>|null} Tags and aliases or null if failed to parse.
*/
static resolveTagsAndAliasesFromPost(documentFragment) {
const imageShowContainer = documentFragment.querySelector('.image-show-container');
const tagsForm = documentFragment.querySelector('#tags-form');
if (!imageShowContainer || !tagsForm) {
return null;
}
const tagsFormData = new FormData(tagsForm);
const tagsAndAliasesList = imageShowContainer.dataset.imageTagAliases
.split(',')
.map(tagName => tagName.trim());
const actualTagsList = tagsFormData.get(this.tagsInputName)
.split(',')
.map(tagName => tagName.trim());
return buildTagsAndAliasesMap(
tagsAndAliasesList,
actualTagsList,
);
}
static tagsInputName = 'image[tag_input]';
}

View File

@@ -2,6 +2,7 @@ import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js"
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI.js";
export class MaintenancePopup extends BaseComponent {
/** @type {HTMLElement} */
@@ -16,6 +17,21 @@ export class MaintenancePopup extends BaseComponent {
/** @type {import('$lib/components/MediaBoxTools.js').MediaBoxTools} */
#mediaBoxTools = null;
/** @type {Set<string>} */
#tagsToRemove = new Set();
/** @type {Set<string>} */
#tagsToAdd = new Set();
/** @type {boolean} */
#isPlanningToSubmit = false;
/** @type {boolean} */
#isSubmitting = false;
/** @type {number|null} */
#tagsSubmissionTimer = null;
/**
* @protected
*/
@@ -52,6 +68,11 @@ export class MaintenancePopup extends BaseComponent {
MaintenancePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
this.#tagsListElement.addEventListener('click', this.#handleTagClick.bind(this));
const mediaBox = this.#mediaBoxTools.mediaBox;
mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this));
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
}
/**
@@ -107,15 +128,84 @@ export class MaintenancePopup extends BaseComponent {
return;
}
const tagName = tagElement.dataset.name;
if (tagElement.classList.contains('is-present')) {
tagElement.classList.toggle('is-removed');
const isToBeRemoved = tagElement.classList.toggle('is-removed');
if (isToBeRemoved) {
this.#tagsToRemove.add(tagName);
} else {
this.#tagsToRemove.remove(tagName);
}
}
if (tagElement.classList.contains('is-missing')) {
tagElement.classList.toggle('is-added');
const isToBeAdded = tagElement.classList.toggle('is-added');
if (isToBeAdded) {
this.#tagsToAdd.add(tagName);
} else {
this.#tagsToAdd.remove(tagName);
}
}
// TODO: Execute the submission on timeout or after user moved the mouse away from the popup.
if (this.#tagsToAdd.size || this.#tagsToRemove.size) {
this.#isPlanningToSubmit = true;
this.emit('maintenance-state-change', 'waiting');
}
}
#onMouseEnteredArea() {
if (this.#tagsSubmissionTimer) {
clearTimeout(this.#tagsSubmissionTimer);
}
}
#onMouseLeftArea() {
if (this.#isPlanningToSubmit && !this.#isSubmitting) {
this.#tagsSubmissionTimer = setTimeout(
this.#onSubmissionTimerPassed.bind(this),
MaintenancePopup.#delayBeforeSubmissionMs
);
}
}
async #onSubmissionTimerPassed() {
if (!this.#isPlanningToSubmit || this.#isSubmitting) {
return;
}
this.#isPlanningToSubmit = false;
this.#isSubmitting = true;
this.emit('maintenance-state-change', 'processing');
const maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
this.#mediaBoxTools.mediaBox.imageId,
tagsList => {
for (let tagName of this.#tagsToRemove) {
tagsList.delete(tagName);
}
for (let tagName of this.#tagsToAdd) {
tagsList.add(tagName);
}
return tagsList;
}
);
if (maybeTagsAndAliasesAfterUpdate) {
this.emit('tags-updated', maybeTagsAndAliasesAfterUpdate);
}
this.emit('maintenance-state-change', 'complete');
this.#tagsToAdd.clear();
this.#tagsToRemove.clear();
this.#isSubmitting = false;
}
/**
@@ -183,6 +273,10 @@ export class MaintenancePopup extends BaseComponent {
unsubscribeFromMaintenanceSettings();
}
}
static #scrapedAPI = new ScrapedAPI();
static #delayBeforeSubmissionMs = 500;
}
export function createMaintenancePopup() {

View File

@@ -0,0 +1,57 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
export class MaintenanceStatusIcon extends BaseComponent {
/** @type {import('MediaBoxTools.js').MediaBoxTools} */
#mediaBoxTools;
build() {
this.container.innerText = '🔧';
}
init() {
this.#mediaBoxTools = getComponent(this.container.parentElement);
if (!this.#mediaBoxTools) {
throw new Error('Status icon element initialized outside of the media box!');
}
this.#mediaBoxTools.on('maintenance-state-change', this.#onMaintenanceStateChanged.bind(this));
}
/**
* @param {CustomEvent<string>} stateChangeEvent
*/
#onMaintenanceStateChanged(stateChangeEvent) {
// TODO Replace those with FontAwesome icons later. Those icons can probably be sourced from the website itself.
switch (stateChangeEvent.detail) {
case "ready":
this.container.innerText = '🔧';
break;
case "waiting":
this.container.innerText = '⏳';
break;
case "processing":
this.container.innerText = '📤';
break;
case "complete":
this.container.innerText = '✅'
break;
default:
this.container.innerText = '❓';
}
}
}
export function createMaintenanceStatusIcon() {
const element = document.createElement('div');
element.classList.add('maintenance-status-icon');
new MaintenanceStatusIcon(element);
return element;
}

View File

@@ -1,5 +1,6 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import {buildTagsAndAliasesMap} from "$lib/booru/TagsUtils.js";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer = null;
@@ -11,6 +12,21 @@ export class MediaBoxWrapper extends BaseComponent {
init() {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer.querySelector('a');
this.on('tags-updated', this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
/**
* @param {CustomEvent<Map<string,string>>} tagsUpdatedEvent
*/
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent) {
const updatedMap = tagsUpdatedEvent.detail;
if (!(updatedMap instanceof Map)) {
throw new TypeError("Tags and aliases should be stored as Map!");
}
this.#tagsAndAliases = updatedMap;
}
#calculateMediaBoxTags() {
@@ -19,30 +35,7 @@ export class MediaBoxWrapper extends BaseComponent {
tagAliases = this.#thumbnailContainer.dataset.imageTagAliases?.split(', ') || [],
actualTags = this.#imageLinkElement.title.split(' | Tagged: ')[1]?.split(', ') || [];
/** @type {Map<string, string>} */
const tagAliasesMap = new Map();
for (let tagName of actualTags) {
tagAliasesMap.set(tagName, tagName);
}
let currentRealTagName = null;
for (let tagName of tagAliases) {
if (tagAliasesMap.has(tagName)) {
currentRealTagName = tagName;
continue;
}
if (!currentRealTagName) {
console.warn('No real tag found for the alias:', tagName);
continue;
}
tagAliasesMap.set(tagName, currentRealTagName);
}
return tagAliasesMap;
return buildTagsAndAliasesMap(tagAliases, actualTags);
}
/**
@@ -55,6 +48,12 @@ export class MediaBoxWrapper extends BaseComponent {
return this.#tagsAndAliases;
}
get imageId() {
return parseInt(
this.container.dataset.imageId
);
}
}
/**