mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2025-12-23 23:02:58 +00:00
Implementation of tags submission, added status icon to indicate state
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import {createMaintenancePopup} from "$lib/components/MaintenancePopup.js";
|
||||
import {createMediaBoxTools} from "$lib/components/MediaBoxTools.js";
|
||||
import {initializeMediaBox} from "$lib/components/MediaBoxWrapper.js";
|
||||
import {createMaintenanceStatusIcon} from "$lib/components/MaintenanceStatusIcon.js";
|
||||
|
||||
document.querySelectorAll('.media-box').forEach(mediaBoxElement => {
|
||||
initializeMediaBox(mediaBoxElement, [
|
||||
createMediaBoxTools(
|
||||
createMaintenancePopup()
|
||||
createMaintenancePopup(),
|
||||
createMaintenanceStatusIcon(),
|
||||
)
|
||||
]);
|
||||
});
|
||||
|
||||
34
src/lib/booru/TagsUtils.js
Normal file
34
src/lib/booru/TagsUtils.js
Normal 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;
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/lib/booru/scraped/ScrapedAPI.js
Normal file
41
src/lib/booru/scraped/ScrapedAPI.js
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
70
src/lib/booru/scraped/parsing/PostParser.js
Normal file
70
src/lib/booru/scraped/parsing/PostParser.js
Normal 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]';
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
57
src/lib/components/MaintenanceStatusIcon.js
Normal file
57
src/lib/components/MaintenanceStatusIcon.js
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,6 +71,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.maintenance-status-icon {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.media-box-tools.has-active-profile {
|
||||
&:before, &:after {
|
||||
|
||||
Reference in New Issue
Block a user