From 941d33bb66fd0e7b2b8972f0c0d49f4049ba90fe Mon Sep 17 00:00:00 2001 From: KoloMl Date: Tue, 9 Apr 2024 03:47:54 +0400 Subject: [PATCH] Implementation of tags submission, added status icon to indicate state --- src/content/listing.js | 4 +- src/lib/booru/TagsUtils.js | 34 ++++++ src/lib/booru/parsing/ImagePageParser.js | 36 ------- src/lib/booru/scraped/ScrapedAPI.js | 41 +++++++ .../booru/{ => scraped}/parsing/PageParser.js | 26 +++-- src/lib/booru/scraped/parsing/PostParser.js | 70 ++++++++++++ src/lib/components/MaintenancePopup.js | 100 +++++++++++++++++- src/lib/components/MaintenanceStatusIcon.js | 57 ++++++++++ src/lib/components/MediaBoxWrapper.js | 47 ++++---- src/styles/content/listing.scss | 6 ++ 10 files changed, 350 insertions(+), 71 deletions(-) create mode 100644 src/lib/booru/TagsUtils.js delete mode 100644 src/lib/booru/parsing/ImagePageParser.js create mode 100644 src/lib/booru/scraped/ScrapedAPI.js rename src/lib/booru/{ => scraped}/parsing/PageParser.js (56%) create mode 100644 src/lib/booru/scraped/parsing/PostParser.js create mode 100644 src/lib/components/MaintenanceStatusIcon.js diff --git a/src/content/listing.js b/src/content/listing.js index 8999eac..56e62c7 100644 --- a/src/content/listing.js +++ b/src/content/listing.js @@ -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(), ) ]); }); diff --git a/src/lib/booru/TagsUtils.js b/src/lib/booru/TagsUtils.js new file mode 100644 index 0000000..c430c4c --- /dev/null +++ b/src/lib/booru/TagsUtils.js @@ -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} Map where key is a tag or alias and value is an actual tag name. + */ +export function buildTagsAndAliasesMap(realAndAliasedTags, realTags) { + /** @type {Map} */ + 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; +} diff --git a/src/lib/booru/parsing/ImagePageParser.js b/src/lib/booru/parsing/ImagePageParser.js deleted file mode 100644 index 8ce34e2..0000000 --- a/src/lib/booru/parsing/ImagePageParser.js +++ /dev/null @@ -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} - */ - 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() - ); - } -} \ No newline at end of file diff --git a/src/lib/booru/scraped/ScrapedAPI.js b/src/lib/booru/scraped/ScrapedAPI.js new file mode 100644 index 0000000..b77ca4a --- /dev/null +++ b/src/lib/booru/scraped/ScrapedAPI.js @@ -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): Set} callback Callback to call to change the content. + * @return {Promise|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) + ); + } +} diff --git a/src/lib/booru/parsing/PageParser.js b/src/lib/booru/scraped/parsing/PageParser.js similarity index 56% rename from src/lib/booru/parsing/PageParser.js rename to src/lib/booru/scraped/parsing/PageParser.js index 66f4f78..8078f75 100644 --- a/src/lib/booru/parsing/PageParser.js +++ b/src/lib/booru/scraped/parsing/PageParser.js @@ -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} 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; - } } diff --git a/src/lib/booru/scraped/parsing/PostParser.js b/src/lib/booru/scraped/parsing/PostParser.js new file mode 100644 index 0000000..33bfdbc --- /dev/null +++ b/src/lib/booru/scraped/parsing/PostParser.js @@ -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} + */ + 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|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]'; +} diff --git a/src/lib/components/MaintenancePopup.js b/src/lib/components/MaintenancePopup.js index 6d0ac75..54d7d76 100644 --- a/src/lib/components/MaintenancePopup.js +++ b/src/lib/components/MaintenancePopup.js @@ -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} */ + #tagsToRemove = new Set(); + + /** @type {Set} */ + #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() { diff --git a/src/lib/components/MaintenanceStatusIcon.js b/src/lib/components/MaintenanceStatusIcon.js new file mode 100644 index 0000000..e43ca01 --- /dev/null +++ b/src/lib/components/MaintenanceStatusIcon.js @@ -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} 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; +} diff --git a/src/lib/components/MediaBoxWrapper.js b/src/lib/components/MediaBoxWrapper.js index 71bd046..b7b1433 100644 --- a/src/lib/components/MediaBoxWrapper.js +++ b/src/lib/components/MediaBoxWrapper.js @@ -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>} 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} */ - 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 + ); + } } /** diff --git a/src/styles/content/listing.scss b/src/styles/content/listing.scss index 2131c98..3ad818a 100644 --- a/src/styles/content/listing.scss +++ b/src/styles/content/listing.scss @@ -71,6 +71,12 @@ } } + .maintenance-status-icon { + position: absolute; + bottom: 6px; + right: 6px; + } + &:hover { .media-box-tools.has-active-profile { &:before, &:after {