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

Merge remote-tracking branch 'origin/master' into feature/bulk-import-and-export

# Conflicts:
#	src/lib/extension/transporting/exporters.ts
This commit is contained in:
2025-04-04 18:25:27 +04:00
63 changed files with 4106 additions and 756 deletions

31
.github/workflows/build-and-tests.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Testing
on:
push:
branches:
- master
pull_request:
branches:
- master
- 'release/**'
jobs:
run-tests:
name: 'Run Unit Tests'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install npm dependencies
run: npm ci
- name: Building the extension
run: npm run build
- name: Running unit tests
run: npm run test

3
.gitignore vendored
View File

@@ -2,10 +2,11 @@
.DS_Store
node_modules
/build
/coverage
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
vite.config.ts.timestamp-*

View File

@@ -1,7 +1,7 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
"version": "0.4.1",
"version": "0.4.4",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city"
@@ -27,7 +27,7 @@
"*://*.furbooru.org/galleries/*"
],
"js": [
"src/content/listing.js"
"src/content/listing.ts"
],
"css": [
"src/styles/content/listing.scss"
@@ -38,12 +38,20 @@
"*://*.furbooru.org/*"
],
"js": [
"src/content/header.js"
"src/content/header.ts"
],
"css": [
"src/styles/content/header.scss"
]
},
{
"matches": [
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/tags-editor.ts"
]
},
{
"matches": [
"*://*.furbooru.org/images?*",
@@ -59,15 +67,7 @@
"*://*.furbooru.org/filters/*"
],
"js": [
"src/content/tags.js"
]
},
{
"matches": [
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/tags-editor.js"
"src/content/tags.ts"
]
}
],

2430
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,32 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.4.1",
"version": "0.4.4",
"private": true,
"scripts": {
"build": "npm run build:popup && npm run build:extension",
"build:popup": "vite build",
"build:extension": "node build-extension.js",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run --coverage",
"test:watch": "vitest watch --coverage"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.2",
"@sveltejs/kit": "^2.20.3",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.304",
"@types/chrome": "^0.0.313",
"@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.1.1",
"cheerio": "^1.0.0",
"sass": "^1.85.0",
"svelte": "^5.20.1",
"svelte-check": "^4.1.4",
"typescript": "^5.7.3",
"vite": "^6.1.0"
"jsdom": "^26.0.0",
"sass": "^1.86.3",
"svelte": "^5.25.6",
"svelte-check": "^4.1.5",
"typescript": "^5.8.2",
"vite": "^6.2.5",
"vitest": "^3.1.1"
},
"type": "module",
"dependencies": {

3
src/app.d.ts vendored
View File

@@ -4,6 +4,9 @@ import MaintenanceProfile from "$entities/MaintenanceProfile";
import type TagGroup from "$entities/TagGroup";
declare global {
// Helper type to not deal with differences between the setTimeout of @types/node and usual web browser's type.
type Timeout = ReturnType<typeof setTimeout>;
namespace App {
// interface Error {}
// interface Locals {}

View File

@@ -9,7 +9,8 @@
let { group }: GroupViewProps = $props();
let sortedTagsList = $derived<string[]>(group.settings.tags.sort((a, b) => a.localeCompare(b))),
sortedPrefixes = $derived<string[]>(group.settings.prefixes.sort((a, b) => a.localeCompare(b)));
sortedPrefixes = $derived<string[]>(group.settings.prefixes.sort((a, b) => a.localeCompare(b))),
sortedSuffixes = $derived<string[]>(group.settings.suffixes.sort((a, b) => a.localeCompare(b)));
</script>
@@ -41,6 +42,18 @@
</TagsColorContainer>
</div>
{/if}
{#if sortedSuffixes.length}
<div class="block">
<strong>Suffixes:</strong>
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedSuffixes as suffixName}
<span class="tag">*{suffixName}</span>
{/each}
</div>
</TagsColorContainer>
</div>
{/if}
<style lang="scss">
.tags-list {

View File

@@ -4,10 +4,12 @@
interface TagEditorProps {
// List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
tags?: string[];
mapTagNames?: (tagName: string) => string;
}
let {
tags = $bindable([])
tags = $bindable([]),
mapTagNames,
}: TagEditorProps = $props();
let uniqueTags = $state<Set<string>>(new Set());
@@ -87,7 +89,7 @@
<div class="tags-editor">
{#each uniqueTags.values() as tagName}
<div class="tag">
{tagName}
{mapTagNames?.(tagName) ?? tagName}
<span class="remove" onclick={createTagRemoveHandler(tagName)}
onkeydown={createTagRemoveHandler(tagName)}
role="button" tabindex="0">x</span>

View File

@@ -1,6 +1,6 @@
import { initializeSiteHeader } from "$lib/components/SiteHeaderWrapper";
const siteHeader = document.querySelector('.header');
const siteHeader = document.querySelector<HTMLElement>('.header');
if (siteHeader) {
initializeSiteHeader(siteHeader);

View File

@@ -4,8 +4,7 @@ import { calculateMediaBoxesPositions, initializeMediaBox } from "$lib/component
import { createMaintenanceStatusIcon } from "$lib/components/MaintenanceStatusIcon";
import { createImageShowFullscreenButton } from "$lib/components/ImageShowFullscreenButton";
/** @type {NodeListOf<HTMLElement>} */
const mediaBoxes = document.querySelectorAll('.media-box');
const mediaBoxes = document.querySelectorAll<HTMLElement>('.media-box');
mediaBoxes.forEach(mediaBoxElement => {
initializeMediaBox(mediaBoxElement, [

View File

@@ -1,3 +0,0 @@
import { TagsForm } from "$lib/components/TagsForm";
TagsForm.watchForEditors();

View File

@@ -0,0 +1,6 @@
import { TagsForm } from "$lib/components/TagsForm";
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$lib/components/TagsListBlock";
initializeAllTagsLists();
watchForUpdatedTagLists();
TagsForm.watchForEditors();

View File

@@ -1,6 +1,6 @@
import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$lib/components/TagDropdownWrapper";
for (let tagDropdownElement of document.querySelectorAll('.tag.dropdown')) {
for (let tagDropdownElement of document.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}

View File

@@ -1,8 +1,13 @@
/** @type {import('@sveltejs/kit').Reroute} */
export function reroute({url}) {
import type { Reroute } from "@sveltejs/kit";
export const reroute: Reroute = ({url}) => {
// Reroute index.html as just / for the root.
// Browser extension starts from with the index.html file in the pathname which is not correct for the router.
if (url.pathname === '/index.html') {
if (url.searchParams.has('path')) {
return url.searchParams.get('path')!;
}
return "/";
}
}
};

View File

@@ -1,19 +1,25 @@
import PostParser from "$lib/booru/scraped/parsing/PostParser";
type UpdaterFunction = (tags: Set<string>) => Set<string>;
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.
* @param imageId ID of the image.
* @param callback Callback to call to change the content.
* @return Updated tags and aliases list for updating internal cached state.
*/
async updateImageTags(imageId, callback) {
async updateImageTags(imageId: number, callback: UpdaterFunction): Promise<Map<string, string> | null> {
const postParser = new PostParser(imageId);
const formData = await postParser.resolveTagEditorFormData();
const tagsFieldValue = formData.get(PostParser.tagsInputName);
if (typeof tagsFieldValue !== 'string') {
throw new Error('Missing tags field!');
}
const tagsList = new Set(
formData
.get(PostParser.tagsInputName)
tagsFieldValue
.split(',')
.map(tagName => tagName.trim())
);

View File

@@ -1,17 +1,12 @@
export default class PageParser {
/** @type {string} */
#url;
/** @type {DocumentFragment|null} */
#fragment = null;
readonly #url: string;
#fragment: DocumentFragment | null = null;
constructor(url) {
constructor(url: string) {
this.#url = url;
}
/**
* @return {Promise<DocumentFragment>}
*/
async resolveFragment() {
async resolveFragment(): Promise<DocumentFragment> {
if (this.#fragment) {
return this.#fragment;
}
@@ -34,12 +29,12 @@ export default class PageParser {
/**
* 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.
* @param 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.
* @return Resulting document fragment ready for processing.
*/
static async resolveFragmentFromResponse(response) {
static async resolveFragmentFromResponse(response: Response): Promise<DocumentFragment> {
const documentFragment = document.createDocumentFragment();
const template = document.createElement('template');
template.innerHTML = await response.text();

View File

@@ -2,23 +2,19 @@ import PageParser from "$lib/booru/scraped/parsing/PageParser";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
export default class PostParser extends PageParser {
/** @type {HTMLFormElement} */
#tagEditorForm;
#tagEditorForm: HTMLFormElement | null = null;
constructor(imageId) {
constructor(imageId: number) {
super(`/images/${imageId}`);
}
/**
* @return {Promise<HTMLFormElement>}
*/
async resolveTagEditorForm() {
async resolveTagEditorForm(): Promise<HTMLFormElement> {
if (this.#tagEditorForm) {
return this.#tagEditorForm;
}
const documentFragment = await this.resolveFragment();
const tagsFormElement = documentFragment.querySelector("#tags-form");
const tagsFormElement = documentFragment.querySelector<HTMLFormElement>("#tags-form");
if (!tagsFormElement) {
throw new Error("Failed to find the tag editor form");
@@ -37,10 +33,8 @@ export default class PostParser extends PageParser {
/**
* Resolve the tags and aliases mapping from the post page.
*
* @return {Promise<Map<string, string>|null>}
*/
async resolveTagsAndAliases() {
async resolveTagsAndAliases(): Promise<Map<string, string> | null> {
return PostParser.resolveTagsAndAliasesFromPost(
await this.resolveFragment()
);
@@ -49,25 +43,32 @@ export default class PostParser extends PageParser {
/**
* Resolve the list of tags and aliases from the post content.
*
* @param {DocumentFragment} documentFragment Real content to parse the data from.
* @param documentFragment Real content to parse the data from.
*
* @return {Map<string, string>|null} Tags and aliases or null if failed to parse.
* @return 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');
static resolveTagsAndAliasesFromPost(documentFragment: DocumentFragment): Map<string, string> | null {
const imageShowContainer = documentFragment.querySelector<HTMLElement>('.image-show-container');
const tagsForm = documentFragment.querySelector<HTMLFormElement>('#tags-form');
if (!imageShowContainer || !tagsForm) {
return null;
}
const tagsFormData = new FormData(tagsForm);
const tagsAndAliasesValue = imageShowContainer.dataset.imageTagAliases;
const tagsValue = tagsFormData.get(this.tagsInputName);
const tagsAndAliasesList = imageShowContainer.dataset.imageTagAliases
if (!tagsAndAliasesValue || !tagsValue || typeof tagsValue !== 'string') {
console.warn('Failed to locate tags & aliases!');
return null;
}
const tagsAndAliasesList = tagsAndAliasesValue
.split(',')
.map(tagName => tagName.trim());
const actualTagsList = tagsFormData.get(this.tagsInputName)
const actualTagsList = tagsValue
.split(',')
.map(tagName => tagName.trim());

View File

@@ -22,7 +22,7 @@ export default class StorageHelper {
* @return The JSON object or the default value if the entry does not exist.
*/
async read<Type = any, DefaultType = any>(key: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
return (await this.#storageArea.get(key))?.[key] || defaultValue;
return (await this.#storageArea.get(key))?.[key] ?? defaultValue;
}
/**

View File

@@ -1,30 +1,22 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import MiscSettings from "$lib/extension/settings/MiscSettings";
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
import { emit, on } from "$lib/components/events/comms";
import { EVENT_SIZE_LOADED } from "$lib/components/events/fullscreen-viewer-events";
export class FullscreenViewer extends BaseComponent {
/** @type {HTMLVideoElement} */
#videoElement = document.createElement('video');
/** @type {HTMLImageElement} */
#imageElement = document.createElement('img');
#spinnerElement = document.createElement('i');
#sizeSelectorElement = document.createElement('select');
#closeButtonElement = document.createElement('i');
/** @type {number|null} */
#touchId = null;
/** @type {number|null} */
#startX = null;
/** @type {number|null} */
#startY = null;
/** @type {boolean|null} */
#isClosingSwipeStarted = null;
#isSizeFetched = false;
/** @type {App.ImageURIs|null} */
#currentURIs = null;
#videoElement: HTMLVideoElement = document.createElement('video');
#imageElement: HTMLImageElement = document.createElement('img');
#spinnerElement: HTMLElement = document.createElement('i');
#sizeSelectorElement: HTMLSelectElement = document.createElement('select');
#closeButtonElement: HTMLElement = document.createElement('i');
#touchId: number | null = null;
#startX: number | null = null;
#startY: number | null = null;
#isClosingSwipeStarted: boolean | null = null;
#isSizeFetched: boolean = false;
#currentURIs: App.ImageURIs | null = null;
/**
* @protected
*/
build() {
protected build() {
this.container.classList.add('fullscreen-viewer');
this.container.append(
@@ -71,10 +63,7 @@ export class FullscreenViewer extends BaseComponent {
this.container.classList.remove('loading');
}
/**
* @param {TouchEvent} event
*/
#onTouchStart(event) {
#onTouchStart(event: TouchEvent) {
if (this.#touchId !== null) {
return;
}
@@ -88,14 +77,12 @@ export class FullscreenViewer extends BaseComponent {
this.#touchId = firstTouch.identifier;
this.#startX = firstTouch.clientX;
this.#startY = firstTouch.clientY;
this.container.classList.add(FullscreenViewer.#swipeState);
}
/**
* @param {TouchEvent} event
*/
#onTouchEnd(event) {
if (this.#touchId === null) {
#onTouchEnd(event: TouchEvent) {
if (this.#touchId === null || this.#startY === null) {
return;
}
@@ -126,11 +113,8 @@ export class FullscreenViewer extends BaseComponent {
});
}
/**
* @param {TouchEvent} event
*/
#onTouchMove(event) {
if (this.#touchId === null) {
#onTouchMove(event: TouchEvent) {
if (this.#touchId === null || this.#startY === null || this.#startX === null) {
return;
}
@@ -179,23 +163,17 @@ export class FullscreenViewer extends BaseComponent {
}
}
/**
* @param {KeyboardEvent} event
*/
#onDocumentKeyPressed(event) {
#onDocumentKeyPressed(event: KeyboardEvent) {
if (event.code === 'Escape' || event.code === 'Esc') {
this.#close();
}
}
/**
* @param {import("$lib/extension/settings/MiscSettings").FullscreenViewerSize} size
*/
#onSizeResolved(size) {
#onSizeResolved(size: FullscreenViewerSize) {
this.#sizeSelectorElement.value = size;
this.#isSizeFetched = true;
this.emit('size-loaded');
emit(this.container, EVENT_SIZE_LOADED, size);
}
#watchForSizeSelectionChanges() {
@@ -232,7 +210,7 @@ export class FullscreenViewer extends BaseComponent {
this.#currentURIs = null;
this.container.classList.remove(FullscreenViewer.#shownState);
document.body.style.overflow = null;
document.body.style.removeProperty('overflow');
requestAnimationFrame(() => {
this.#videoElement.volume = 0;
@@ -241,16 +219,18 @@ export class FullscreenViewer extends BaseComponent {
});
}
/**
* @param {App.ImageURIs} imageUris
* @return {Promise<string|null>}
*/
async #resolveCurrentSelectedSizeUrl(imageUris) {
async #resolveCurrentSelectedSizeUrl(imageUris: App.ImageURIs): Promise<string | null> {
if (!this.#isSizeFetched) {
await new Promise(resolve => this.on('size-loaded', resolve))
await new Promise(
resolve => on(
this.container,
EVENT_SIZE_LOADED,
resolve
),
);
}
let targetSize = this.#sizeSelectorElement.value;
let targetSize: FullscreenViewerSize | string = this.#sizeSelectorElement.value;
if (!imageUris.hasOwnProperty(targetSize)) {
targetSize = FullscreenViewer.#fallbackSize;
@@ -264,13 +244,10 @@ export class FullscreenViewer extends BaseComponent {
return null;
}
return imageUris[targetSize];
return imageUris[targetSize as FullscreenViewerSize];
}
/**
* @param {App.ImageURIs} imageUris
*/
async show(imageUris) {
async show(imageUris: App.ImageURIs): Promise<void> {
this.#currentURIs = imageUris;
const url = await this.#resolveCurrentSelectedSizeUrl(imageUris);
@@ -308,11 +285,7 @@ export class FullscreenViewer extends BaseComponent {
this.container.append(this.#imageElement);
}
/**
* @param {string} url
* @return {boolean}
*/
static #isVideoUrl(url) {
static #isVideoUrl(url: string): boolean {
return url.endsWith('.mp4') || url.endsWith('.webm');
}
@@ -324,10 +297,7 @@ export class FullscreenViewer extends BaseComponent {
static #swipeState = 'swiped';
static #minRequiredDistance = 50;
/**
* @type {Record<import("$lib/extension/settings/MiscSettings").FullscreenViewerSize, string>}
*/
static #previewSizes = {
static #previewSizes: Record<FullscreenViewerSize, string> = {
full: 'Full',
large: 'Large',
medium: 'Medium',

View File

@@ -2,21 +2,23 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import MiscSettings from "$lib/extension/settings/MiscSettings";
import { FullscreenViewer } from "$lib/components/FullscreenViewer";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
export class ImageShowFullscreenButton extends BaseComponent {
/**
* @type {import('./MediaBoxTools').MediaBoxTools|null}
*/
#mediaBoxTools= null;
#isFullscreenButtonEnabled = false;
#mediaBoxTools: MediaBoxTools | null = null;
#isFullscreenButtonEnabled: boolean = false;
build() {
protected build() {
this.container.innerText = '🔍';
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
}
init() {
protected init() {
if (!this.container.parentElement) {
throw new Error('Missing parent element!');
}
this.#mediaBoxTools = getComponent(this.container.parentElement);
if (!this.#mediaBoxTools) {
@@ -32,7 +34,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
this.#updateFullscreenButtonVisibility();
})
.then(() => {
ImageShowFullscreenButton.#miscSettings.subscribe(settings => {
ImageShowFullscreenButton.#miscSettings?.subscribe(settings => {
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
this.#updateFullscreenButtonVisibility();
})
@@ -45,28 +47,25 @@ export class ImageShowFullscreenButton extends BaseComponent {
}
#onButtonClicked() {
const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks;
if (!imageLinks) {
throw new Error('Failed to resolve image links from media box tools!');
}
ImageShowFullscreenButton
.#resolveViewer()
.show(this.#mediaBoxTools.mediaBox.imageLinks);
?.show(imageLinks);
}
/**
* @type {FullscreenViewer|null}
*/
static #viewer = null;
static #viewer: FullscreenViewer | null = null;
/**
* @return {FullscreenViewer}
*/
static #resolveViewer() {
static #resolveViewer(): FullscreenViewer {
this.#viewer ??= this.#buildViewer();
return this.#viewer;
}
/**
* @return {FullscreenViewer}
*/
static #buildViewer() {
static #buildViewer(): FullscreenViewer {
const element = document.createElement('div');
const viewer = new FullscreenViewer(element);
@@ -77,10 +76,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
return viewer;
}
/**
* @type {MiscSettings|null}
*/
static #miscSettings = null;
static #miscSettings: MiscSettings | null = null;
}
export function createImageShowFullscreenButton() {

View File

@@ -6,51 +6,31 @@ import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI";
import { tagsBlacklist } from "$config/tags";
import { emitterAt } from "$lib/components/events/comms";
import {
eventActiveProfileChanged,
eventMaintenanceStateChanged,
eventTagsUpdated
EVENT_ACTIVE_PROFILE_CHANGED,
EVENT_MAINTENANCE_STATE_CHANGED,
EVENT_TAGS_UPDATED
} from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
class BlackListedTagsEncounteredError extends Error {
/**
* @param {string} tagName
*/
constructor(tagName) {
super(`This tag is blacklisted and prevents submission: ${tagName}`);
constructor(tagName: string) {
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
cause: tagName
});
}
}
export class MaintenancePopup extends BaseComponent {
/** @type {HTMLElement} */
#tagsListElement = null;
/** @type {HTMLElement[]} */
#tagsList = [];
/** @type {Map<string, HTMLElement>} */
#suggestedInvalidTags = new Map();
/** @type {MaintenanceProfile|null} */
#activeProfile = null;
/** @type {import('$lib/components/MediaBoxTools').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;
#tagsListElement: HTMLElement = document.createElement('div');
#tagsList: HTMLElement[] = [];
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
#activeProfile: MaintenanceProfile | null = null;
#mediaBoxTools: MediaBoxTools | null = null;
#tagsToRemove: Set<string> = new Set();
#tagsToAdd: Set<string> = new Set();
#isPlanningToSubmit: boolean = false;
#isSubmitting: boolean = false;
#tagsSubmissionTimer: Timeout | null = null;
#emitter = emitterAt(this);
/**
@@ -60,7 +40,6 @@ export class MaintenancePopup extends BaseComponent {
this.container.innerHTML = '';
this.container.classList.add('maintenance-popup');
this.#tagsListElement = document.createElement('div');
this.#tagsListElement.classList.add('tags-list');
this.container.append(
@@ -72,14 +51,13 @@ export class MaintenancePopup extends BaseComponent {
* @protected
*/
init() {
const mediaBoxToolsElement = this.container.closest('.media-box-tools');
const mediaBoxToolsElement = this.container.closest<HTMLElement>('.media-box-tools');
if (!mediaBoxToolsElement) {
throw new Error('Maintenance popup initialized outside of the media box tools!');
}
/** @type {MediaBoxTools|null} */
const mediaBoxTools = getComponent(mediaBoxToolsElement);
const mediaBoxTools = getComponent<MediaBoxTools>(mediaBoxToolsElement);
if (!mediaBoxTools) {
throw new Error('Media box tools component not found!');
@@ -92,24 +70,28 @@ export class MaintenancePopup extends BaseComponent {
const mediaBox = this.#mediaBoxTools.mediaBox;
if (!mediaBox) {
throw new Error('Media box component not found!');
}
mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this));
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
}
/**
* @param {MaintenanceProfile|null} activeProfile
*/
#onActiveProfileChanged(activeProfile) {
#onActiveProfileChanged(activeProfile: MaintenanceProfile | null) {
this.#activeProfile = activeProfile;
this.container.classList.toggle('is-active', activeProfile !== null);
this.#refreshTagsList();
this.#emitter.emit(eventActiveProfileChanged, activeProfile);
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
}
#refreshTagsList() {
/** @type {string[]} */
const activeProfileTagsList = this.#activeProfile?.settings.tags || [];
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || [];
for (const tagElement of this.#tagsList) {
tagElement.remove();
@@ -131,11 +113,11 @@ export class MaintenancePopup extends BaseComponent {
this.#tagsList[index] = tagElement;
this.#tagsListElement.appendChild(tagElement);
const isPresent = currentPostTags.has(tagName);
const isPresent = currentPostTags?.has(tagName);
tagElement.classList.toggle('is-present', isPresent);
tagElement.classList.toggle('is-missing', !isPresent);
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags.get(tagName) !== tagName);
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags?.get(tagName) !== tagName);
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
if (tagsBlacklist.includes(tagName)) {
@@ -147,17 +129,22 @@ export class MaintenancePopup extends BaseComponent {
/**
* Detect and process clicks made directly to the tags.
* @param {MouseEvent} event
*/
#handleTagClick(event) {
/** @type {HTMLElement} */
let tagElement = event.target;
#handleTagClick(event: MouseEvent) {
const targetObject = event.target;
if (!tagElement.classList.contains('tag')) {
tagElement = tagElement.closest('.tag');
if (!targetObject || !(targetObject instanceof HTMLElement)) {
return;
}
if (!tagElement) {
let tagElement: HTMLElement | null = targetObject;
if (!tagElement.classList.contains('tag')) {
tagElement = tagElement.closest<HTMLElement>('.tag');
}
if (!tagElement?.dataset.name) {
return;
}
@@ -190,7 +177,7 @@ export class MaintenancePopup extends BaseComponent {
}
this.#isPlanningToSubmit = true;
this.#emitter.emit(eventMaintenanceStateChanged, 'waiting');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
}
}
@@ -210,14 +197,14 @@ export class MaintenancePopup extends BaseComponent {
}
async #onSubmissionTimerPassed() {
if (!this.#isPlanningToSubmit || this.#isSubmitting) {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
return;
}
this.#isPlanningToSubmit = false;
this.#isSubmitting = true;
this.#emitter.emit(eventMaintenanceStateChanged, 'processing');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
let maybeTagsAndAliasesAfterUpdate;
@@ -259,17 +246,17 @@ export class MaintenancePopup extends BaseComponent {
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.#emitter.emit(eventMaintenanceStateChanged, 'failed');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
this.#isSubmitting = false;
return;
}
if (maybeTagsAndAliasesAfterUpdate) {
this.#emitter.emit(eventTagsUpdated, maybeTagsAndAliasesAfterUpdate);
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
}
this.#emitter.emit(eventMaintenanceStateChanged, 'complete');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
this.#tagsToAdd.clear();
this.#tagsToRemove.clear();
@@ -281,6 +268,10 @@ export class MaintenancePopup extends BaseComponent {
}
#revealInvalidTags() {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
if (!tagsAndAliases) {
@@ -310,18 +301,11 @@ export class MaintenancePopup extends BaseComponent {
}
}
/**
* @return {boolean}
*/
get isActive() {
return this.container.classList.contains('is-active');
}
/**
* @param {string} tagName
* @return {HTMLElement}
*/
static #buildTagElement(tagName) {
static #buildTagElement(tagName: string): HTMLElement {
const tagElement = document.createElement('span');
tagElement.classList.add('tag');
tagElement.innerText = tagName;
@@ -332,28 +316,26 @@ export class MaintenancePopup extends BaseComponent {
/**
* Marks the tag with red color.
* @param {HTMLElement} tagElement Element to mark.
* @param tagElement Element to mark.
*/
static #markTagAsInvalid(tagElement) {
static #markTagAsInvalid(tagElement: HTMLElement) {
tagElement.dataset.tagCategory = 'error';
tagElement.setAttribute('data-tag-category', 'error');
}
/**
* Controller with maintenance settings.
* @type {MaintenanceSettings}
*/
static #maintenanceSettings = new MaintenanceSettings();
/**
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
* at the very start to retrieve the currently active profile.
* @param {function(MaintenanceProfile|null):void} callback Callback to execute whenever selection of active profile
* or profile itself has been changed.
* @return {function(): void} Unsubscribe function. Call it to stop watching for changes.
* @param callback Callback to execute whenever selection of active profile or profile itself has been changed.
* @return Unsubscribe function. Call it to stop watching for changes.
*/
static #watchActiveProfile(callback) {
let lastActiveProfileId;
static #watchActiveProfile(callback: (profile: MaintenanceProfile | null) => void): () => void {
let lastActiveProfileId: string | null | undefined = null;
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
if (lastActiveProfileId) {
@@ -393,9 +375,9 @@ export class MaintenancePopup extends BaseComponent {
/**
* Notify the frontend about new pending submission started.
* @param {boolean} isStarted True if started, false if ended.
* @param isStarted True if started, false if ended.
*/
static #notifyAboutPendingSubmission(isStarted) {
static #notifyAboutPendingSubmission(isStarted: boolean) {
if (this.#pendingSubmissionCount === null) {
this.#pendingSubmissionCount = 0;
this.#initializeExitPromptHandler();
@@ -424,9 +406,8 @@ export class MaintenancePopup extends BaseComponent {
/**
* Amount of pending submissions or NULL if logic was not yet initialized.
* @type {number|null}
*/
static #pendingSubmissionCount = null;
static #pendingSubmissionCount: number|null = null;
}
export function createMaintenancePopup() {

View File

@@ -1,30 +1,31 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { on } from "$lib/components/events/comms";
import { eventMaintenanceStateChanged } from "$lib/components/events/maintenance-popup-events";
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
export class MaintenanceStatusIcon extends BaseComponent {
/** @type {import('./MediaBoxTools').MediaBoxTools} */
#mediaBoxTools;
#mediaBoxTools: MediaBoxTools | null = null;
build() {
this.container.innerText = '🔧';
}
init() {
if (!this.container.parentElement) {
throw new Error('Missing parent element for the maintenance status icon!');
}
this.#mediaBoxTools = getComponent(this.container.parentElement);
if (!this.#mediaBoxTools) {
throw new Error('Status icon element initialized outside of the media box!');
}
on(this.#mediaBoxTools, eventMaintenanceStateChanged, this.#onMaintenanceStateChanged.bind(this));
on(this.#mediaBoxTools, EVENT_MAINTENANCE_STATE_CHANGED, this.#onMaintenanceStateChanged.bind(this));
}
/**
* @param {CustomEvent<string>} stateChangeEvent
*/
#onMaintenanceStateChanged(stateChangeEvent) {
#onMaintenanceStateChanged(stateChangeEvent: CustomEvent<string>) {
// TODO Replace those with FontAwesome icons later. Those icons can probably be sourced from the website itself.
switch (stateChangeEvent.detail) {
case "ready":

View File

@@ -2,17 +2,16 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { MaintenancePopup } from "$lib/components/MaintenancePopup";
import { on } from "$lib/components/events/comms";
import { eventActiveProfileChanged } from "$lib/components/events/maintenance-popup-events";
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxWrapper } from "$lib/components/MediaBoxWrapper";
import type MaintenanceProfile from "$entities/MaintenanceProfile";
export class MediaBoxTools extends BaseComponent {
/** @type {import('./MediaBoxWrapper').MediaBoxWrapper|null} */
#mediaBox;
/** @type {MaintenancePopup|null} */
#maintenancePopup = null;
#mediaBox: MediaBoxWrapper | null = null;
#maintenancePopup: MaintenancePopup | null = null;
init() {
const mediaBoxElement = this.container.closest('.media-box');
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
if (!mediaBoxElement) {
throw new Error('Toolbox element initialized outside of the media box!');
@@ -21,6 +20,10 @@ export class MediaBoxTools extends BaseComponent {
this.#mediaBox = getComponent(mediaBoxElement);
for (let childElement of this.container.children) {
if (!(childElement instanceof HTMLElement)) {
continue;
}
const component = getComponent(childElement);
if (!component) {
@@ -36,37 +39,28 @@ export class MediaBoxTools extends BaseComponent {
}
}
on(this, eventActiveProfileChanged, this.#onActiveProfileChanged.bind(this));
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
}
/**
* @param {CustomEvent<import('$entities/MaintenanceProfile').default|null>} profileChangedEvent
*/
#onActiveProfileChanged(profileChangedEvent) {
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
}
/**
* @return {MaintenancePopup|null}
*/
get maintenancePopup() {
get maintenancePopup(): MaintenancePopup | null {
return this.#maintenancePopup;
}
/**
* @return {import('./MediaBoxWrapper').MediaBoxWrapper|null}
*/
get mediaBox() {
get mediaBox(): MediaBoxWrapper | null {
return this.#mediaBox;
}
}
/**
* Create a maintenance popup element.
* @param {HTMLElement[]} childrenElements List of children elements to append to the component.
* @return {HTMLElement} The maintenance popup element.
* @param childrenElements List of children elements to append to the component.
* @return The maintenance popup element.
*/
export function createMediaBoxTools(...childrenElements) {
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
const mediaBoxToolsContainer = document.createElement('div');
mediaBoxToolsContainer.classList.add('media-box-tools');

View File

@@ -1,104 +0,0 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
import { on } from "$lib/components/events/comms";
import { eventTagsUpdated } from "$lib/components/events/maintenance-popup-events";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer = null;
#imageLinkElement = null;
/** @type {Map<string,string>|null} */
#tagsAndAliases = null;
init() {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer.querySelector('a');
on(this, eventTagsUpdated, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
/**
* @param {CustomEvent<Map<string,string>|null>} 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() {
/** @type {string[]|string[]} */
const
tagAliases = this.#thumbnailContainer.dataset.imageTagAliases?.split(', ') || [],
actualTags = this.#imageLinkElement.title.split(' | Tagged: ')[1]?.split(', ') || [];
return buildTagsAndAliasesMap(tagAliases, actualTags);
}
/**
* @return {Map<string, string>|null}
*/
get tagsAndAliases() {
if (!this.#tagsAndAliases) {
this.#tagsAndAliases = this.#calculateMediaBoxTags();
}
return this.#tagsAndAliases;
}
get imageId() {
return parseInt(
this.container.dataset.imageId
);
}
/**
* @return {App.ImageURIs}
*/
get imageLinks() {
return JSON.parse(this.#thumbnailContainer.dataset.uris);
}
}
/**
* Wrap the media box element into the special wrapper.
* @param {HTMLElement} mediaBoxContainer
* @param {HTMLElement[]} childComponentElements
*/
export function initializeMediaBox(mediaBoxContainer, childComponentElements) {
new MediaBoxWrapper(mediaBoxContainer)
.initialize();
for (let childComponentElement of childComponentElements) {
mediaBoxContainer.appendChild(childComponentElement);
getComponent(childComponentElement)?.initialize();
}
}
/**
* @param {NodeListOf<HTMLElement>} mediaBoxesList
*/
export function calculateMediaBoxesPositions(mediaBoxesList) {
window.addEventListener('resize', () => {
/** @type {HTMLElement|null} */
let lastMediaBox = null,
/** @type {number|null} */
lastMediaBoxPosition = null;
for (let mediaBoxElement of mediaBoxesList) {
const yPosition = mediaBoxElement.getBoundingClientRect().y;
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
lastMediaBox = mediaBoxElement;
lastMediaBoxPosition = yPosition;
}
})
}

View File

@@ -0,0 +1,99 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
import { on } from "$lib/components/events/comms";
import { EVENT_TAGS_UPDATED } from "$lib/components/events/maintenance-popup-events";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer: HTMLElement | null = null;
#imageLinkElement: HTMLAnchorElement | null = null;
#tagsAndAliases: Map<string, string> | null = null;
init() {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
const updatedMap = tagsUpdatedEvent.detail;
if (!(updatedMap instanceof Map)) {
throw new TypeError("Tags and aliases should be stored as Map!");
}
this.#tagsAndAliases = updatedMap;
}
#calculateMediaBoxTags() {
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
return buildTagsAndAliasesMap(tagAliases, actualTags);
}
get tagsAndAliases(): Map<string, string> | null {
if (!this.#tagsAndAliases) {
this.#tagsAndAliases = this.#calculateMediaBoxTags();
}
return this.#tagsAndAliases;
}
get imageId(): number {
const imageId = this.container.dataset.imageId;
if (!imageId) {
throw new Error('Missing image ID');
}
return parseInt(imageId);
}
get imageLinks(): App.ImageURIs {
const jsonUris = this.#thumbnailContainer?.dataset.uris;
if (!jsonUris) {
throw new Error('Missing URIs!');
}
return JSON.parse(jsonUris);
}
}
/**
* Wrap the media box element into the special wrapper.
*/
export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
new MediaBoxWrapper(mediaBoxContainer)
.initialize();
for (let childComponentElement of childComponentElements) {
mediaBoxContainer.appendChild(childComponentElement);
getComponent(childComponentElement)?.initialize();
}
}
export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElement>) {
window.addEventListener('resize', () => {
let lastMediaBox: HTMLElement | null = null;
let lastMediaBoxPosition: number | null = null;
for (const mediaBoxElement of mediaBoxesList) {
const yPosition = mediaBoxElement.getBoundingClientRect().y;
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
lastMediaBox = mediaBoxElement;
lastMediaBoxPosition = yPosition;
}
// Last-ever media box is checked separately
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
lastMediaBox.classList.add('media-box--last');
}
})
}

View File

@@ -1,29 +1,25 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { QueryLexer, QuotedTermToken, TermToken, Token } from "$lib/booru/search/QueryLexer";
import SearchSettings from "$lib/extension/settings/SearchSettings";
import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings";
export class SearchWrapper extends BaseComponent {
/** @type {HTMLInputElement|null} */
#searchField = null;
/** @type {string|null} */
#lastParsedSearchValue = null;
/** @type {Token[]} */
#cachedParsedQuery = [];
#searchSettings = new SearchSettings();
#arePropertiesSuggestionsEnabled = false;
/** @type {"start"|"end"} */
#propertiesSuggestionsPosition = "start";
/** @type {HTMLElement|null} */
#cachedAutocompleteContainer = null;
/** @type {TermToken|QuotedTermToken|null} */
#lastTermToken = null;
#searchField: HTMLInputElement | null = null;
#lastParsedSearchValue: string | null = null;
#cachedParsedQuery: Token[] = [];
#searchSettings: SearchSettings = new SearchSettings();
#arePropertiesSuggestionsEnabled: boolean = false;
#propertiesSuggestionsPosition: SuggestionsPosition = "start";
#cachedAutocompleteContainer: HTMLElement | null = null;
#lastTermToken: TermToken | QuotedTermToken | null = null;
build() {
this.#searchField = this.container.querySelector('input[name=q]');
}
init() {
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this));
if (this.#searchField) {
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this))
}
this.#searchSettings.resolvePropertiesSuggestionsEnabled()
.then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled);
@@ -31,18 +27,18 @@ export class SearchWrapper extends BaseComponent {
.then(position => this.#propertiesSuggestionsPosition = position);
this.#searchSettings.subscribe(settings => {
this.#arePropertiesSuggestionsEnabled = settings.suggestProperties;
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition;
this.#arePropertiesSuggestionsEnabled = Boolean(settings.suggestProperties);
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition || "start";
});
}
/**
* Catch the user input and execute suggestions logic.
* @param {InputEvent} event Source event to find the input element from.
* @param event Source event to find the input element from.
*/
#onInputFindProperties(event) {
#onInputFindProperties(event: Event) {
// Ignore events until option is enabled.
if (!this.#arePropertiesSuggestionsEnabled) {
if (!this.#arePropertiesSuggestionsEnabled || !(event.currentTarget instanceof HTMLInputElement)) {
return;
}
@@ -60,20 +56,26 @@ export class SearchWrapper extends BaseComponent {
/**
* Get the selection position in the search field.
* @return {number}
*/
#getInputUserSelection() {
#getInputUserSelection(): number {
if (!this.#searchField) {
throw new Error('Missing search field!');
}
return Math.min(
this.#searchField.selectionStart,
this.#searchField.selectionEnd
this.#searchField.selectionStart ?? 0,
this.#searchField.selectionEnd ?? 0,
);
}
/**
* Parse the search query and return the list of parsed tokens. Result will be cached for current search query.
* @return {Token[]}
*/
#resolveQueryTokens() {
#resolveQueryTokens(): Token[] {
if (!this.#searchField) {
throw new Error('Missing search field!');
}
const searchValue = this.#searchField.value;
if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) {
@@ -88,9 +90,9 @@ export class SearchWrapper extends BaseComponent {
/**
* Find the currently selected term.
* @return {string|null} Selected term or null if none found.
* @return Selected term or null if none found.
*/
#findCurrentTagFragment() {
#findCurrentTagFragment(): string | null {
if (!this.#searchField) {
return null;
}
@@ -127,9 +129,9 @@ export class SearchWrapper extends BaseComponent {
*
* This means, that properties will only be suggested once actual autocomplete logic was activated.
*
* @return {HTMLElement|null} Resolved element or nothing.
* @return Resolved element or nothing.
*/
#resolveAutocompleteContainer() {
#resolveAutocompleteContainer(): HTMLElement | null {
if (this.#cachedAutocompleteContainer) {
return this.#cachedAutocompleteContainer;
}
@@ -141,11 +143,10 @@ export class SearchWrapper extends BaseComponent {
/**
* Render the list of suggestions into the existing popup or create and populate a new one.
* @param {string[]} suggestions List of suggestion to render the popup from.
* @param {HTMLInputElement} targetInput Target input to attach the popup to.
* @param suggestions List of suggestion to render the popup from.
* @param targetInput Target input to attach the popup to.
*/
#renderSuggestions(suggestions, targetInput) {
/** @type {HTMLElement[]} */
#renderSuggestions(suggestions: string[], targetInput: HTMLInputElement) {
const suggestedListItems = suggestions
.map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm));
@@ -170,6 +171,10 @@ export class SearchWrapper extends BaseComponent {
const listContainer = autocompleteContainer.querySelector('ul');
if (!listContainer) {
return;
}
switch (this.#propertiesSuggestionsPosition) {
case "start":
listContainer.prepend(...suggestedListItems);
@@ -183,10 +188,11 @@ export class SearchWrapper extends BaseComponent {
console.warn("Invalid position for property suggestions!");
}
const parentScrollTop = targetInput.parentElement?.scrollTop ?? 0;
autocompleteContainer.style.position = 'absolute';
autocompleteContainer.style.left = `${targetInput.offsetLeft}px`;
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - targetInput.parentElement.scrollTop}px`;
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - parentScrollTop}px`;
document.body.append(autocompleteContainer);
})
@@ -194,30 +200,28 @@ export class SearchWrapper extends BaseComponent {
/**
* Loosely estimate where current selected search term is located and return it if found.
* @param {Token[]} tokens Search value to find the actively selected term from.
* @param {number} userSelectionIndex The index of the user selection.
* @return {Token|null} Search term object or NULL if nothing found.
* @param tokens Search value to find the actively selected term from.
* @param userSelectionIndex The index of the user selection.
* @return Search term object or NULL if nothing found.
*/
static #findActiveSearchTermPosition(tokens, userSelectionIndex) {
static #findActiveSearchTermPosition(tokens: Token[], userSelectionIndex: number): Token | null {
return tokens.find(
token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex
);
) ?? null;
}
/**
* Regular expression to search the properties' syntax.
* @type {RegExp}
*/
static #propertySearchTermHeadingRegExp = /^(?<name>[a-z\d_]+)(?<op_syntax>\.(?<op>[a-z]*))?(?<value_syntax>:(?<value>.*))?$/;
/**
* Create a list of suggested elements using the input received from the user.
* @param {string} searchTermValue Original decoded term received from the user.
* @param searchTermValue Original decoded term received from the user.
* @return {string[]} List of suggestions. Could be empty.
*/
static #resolveSuggestionsFromTerm(searchTermValue) {
/** @type {string[]} */
const suggestionsList = [];
static #resolveSuggestionsFromTerm(searchTermValue: string): string[] {
const suggestionsList: string[] = [];
this.#propertySearchTermHeadingRegExp.lastIndex = 0;
const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue);
@@ -226,22 +230,28 @@ export class SearchWrapper extends BaseComponent {
return suggestionsList;
}
const propertyName = parsedResult.groups.name;
const propertyName = parsedResult.groups?.name;
if (!propertyName) {
return suggestionsList;
}
const propertyType = this.#properties.get(propertyName);
const hasOperatorSyntax = Boolean(parsedResult.groups.op_syntax);
const hasValueSyntax = Boolean(parsedResult.groups.value_syntax);
const hasOperatorSyntax = Boolean(parsedResult.groups?.op_syntax);
const hasValueSyntax = Boolean(parsedResult.groups?.value_syntax);
// No suggestions for values for now, maybe could add suggestions for namespaces like my:*
if (hasValueSyntax) {
if (hasValueSyntax && propertyType) {
if (this.#typeValues.has(propertyType)) {
const givenValue = parsedResult.groups.value;
const givenValue = parsedResult.groups?.value;
const candidateValues = this.#typeValues.get(propertyType) || [];
for (let candidateValue of this.#typeValues.get(propertyType)) {
for (let candidateValue of candidateValues) {
if (givenValue && !candidateValue.startsWith(givenValue)) {
continue;
}
suggestionsList.push(`${propertyName}${parsedResult.groups.op_syntax ?? ''}:${candidateValue}`);
suggestionsList.push(`${propertyName}${parsedResult.groups?.op_syntax ?? ''}:${candidateValue}`);
}
}
@@ -249,11 +259,12 @@ export class SearchWrapper extends BaseComponent {
}
// If at least one dot placed, start suggesting operators
if (hasOperatorSyntax) {
if (hasOperatorSyntax && propertyType) {
if (this.#typeOperators.has(propertyType)) {
const operatorName = parsedResult.groups.op;
const operatorName = parsedResult.groups?.op;
const candidateOperators = this.#typeOperators.get(propertyType) ?? [];
for (let candidateOperator of this.#typeOperators.get(propertyType)) {
for (let candidateOperator of candidateOperators) {
if (operatorName && !candidateOperator.startsWith(operatorName)) {
continue;
}
@@ -279,11 +290,10 @@ export class SearchWrapper extends BaseComponent {
/**
* Render a single suggestion item and connect required events to interact with the user.
* @param {string} suggestedTerm Term to use for suggestion item.
* @return {HTMLElement} Resulting element.
* @param suggestedTerm Term to use for suggestion item.
* @return Resulting element.
*/
#renderTermSuggestion(suggestedTerm) {
/** @type {HTMLElement} */
#renderTermSuggestion(suggestedTerm: string): HTMLElement {
const suggestionItem = document.createElement('li');
suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property');
suggestionItem.dataset.value = suggestedTerm;
@@ -311,10 +321,10 @@ export class SearchWrapper extends BaseComponent {
/**
* Automatically replace the last active token stored in the variable with the new value.
* @param {string} suggestedTerm Term to replace the value with.
* @param suggestedTerm Term to replace the value with.
*/
#replaceLastActiveTokenWithSuggestion(suggestedTerm) {
if (!this.#lastTermToken) {
#replaceLastActiveTokenWithSuggestion(suggestedTerm: string) {
if (!this.#lastTermToken || !this.#searchField) {
return;
}
@@ -334,10 +344,10 @@ export class SearchWrapper extends BaseComponent {
/**
* Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's
* front-end.
* @param {HTMLElement} suggestedElement Target element to search from. If element is disconnected from the DOM,
* search will be halted.
* @param suggestedElement Target element to search from. If element is disconnected from the DOM, search will be
* halted.
*/
static #findAndResetSelectedSuggestion(suggestedElement) {
static #findAndResetSelectedSuggestion(suggestedElement: HTMLElement) {
if (!suggestedElement.parentElement) {
return;
}

View File

@@ -2,11 +2,10 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
import { SearchWrapper } from "$lib/components/SearchWrapper";
class SiteHeaderWrapper extends BaseComponent {
/** @type {SearchWrapper|null} */
#searchWrapper = null;
#searchWrapper: SearchWrapper | null = null;
build() {
const searchForm = this.container.querySelector('.header__search');
const searchForm = this.container.querySelector<HTMLElement>('.header__search');
this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null;
}
@@ -17,7 +16,7 @@ class SiteHeaderWrapper extends BaseComponent {
}
}
export function initializeSiteHeader(siteHeaderElement) {
export function initializeSiteHeader(siteHeaderElement: HTMLElement) {
new SiteHeaderWrapper(siteHeaderElement)
.initialize();
}

View File

@@ -3,45 +3,40 @@ import MaintenanceProfile from "$entities/MaintenanceProfile";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import { getComponent } from "$lib/components/base/component-utils";
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
import { on } from "$lib/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
import type TagGroup from "$entities/TagGroup";
const isTagEditorProcessedKey = Symbol();
const categoriesResolver = new CustomCategoriesResolver();
export class TagDropdownWrapper extends BaseComponent {
/**
* Container with dropdown elements to insert options into.
* @type {HTMLElement}
*/
#dropdownContainer;
#dropdownContainer: HTMLElement | null = null;
/**
* Button to add or remove the current tag into/from the active profile.
* @type {HTMLAnchorElement|null}
*/
#toggleOnExistingButton = null;
#toggleOnExistingButton: HTMLAnchorElement | null = null;
/**
* Button to create a new profile, make it active and add the current tag into the active profile.
* @type {HTMLAnchorElement|null}
*/
#addToNewButton = null;
#addToNewButton: HTMLAnchorElement | null = null;
/**
* Local clone of the currently active profile used for updating the list of tags.
* @type {MaintenanceProfile|null}
*/
#activeProfile = null;
#activeProfile: MaintenanceProfile | null = null;
/**
* Is cursor currently entered the dropdown.
* @type {boolean}
*/
#isEntered = false;
#isEntered: boolean = false;
/**
* @type {string|undefined|null}
*/
#originalCategory = null;
#originalCategory: string | undefined | null = null;
build() {
this.#dropdownContainer = this.container.querySelector('.dropdown__content');
@@ -58,6 +53,23 @@ export class TagDropdownWrapper extends BaseComponent {
this.#updateButtons();
}
});
on(this, EVENT_TAG_GROUP_RESOLVED, this.#onTagGroupResolved.bind(this));
}
#onTagGroupResolved(resolvedGroupEvent: CustomEvent<TagGroup | null>) {
if (this.originalCategory) {
return;
}
const maybeTagGroup = resolvedGroupEvent.detail;
if (!maybeTagGroup) {
this.tagCategory = this.originalCategory;
return;
}
this.tagCategory = maybeTagGroup.settings.category;
}
get tagName() {
@@ -116,7 +128,7 @@ export class TagDropdownWrapper extends BaseComponent {
);
if (!this.#addToNewButton.isConnected) {
this.#dropdownContainer.append(this.#addToNewButton);
this.#dropdownContainer?.append(this.#addToNewButton);
}
} else {
this.#addToNewButton?.remove();
@@ -130,15 +142,16 @@ export class TagDropdownWrapper extends BaseComponent {
const profileName = this.#activeProfile.settings.name;
let profileSpecificButtonText = `Add to profile "${profileName}"`;
const tagName = this.tagName;
if (this.#activeProfile.settings.tags.includes(this.tagName)) {
if (tagName && this.#activeProfile.settings.tags.includes(tagName)) {
profileSpecificButtonText = `Remove from profile "${profileName}"`;
}
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
if (!this.#toggleOnExistingButton.isConnected) {
this.#dropdownContainer.append(this.#toggleOnExistingButton);
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
}
return;
@@ -148,6 +161,12 @@ export class TagDropdownWrapper extends BaseComponent {
}
async #onAddToNewClicked() {
const tagName = this.tagName;
if (!tagName) {
throw new Error('Missing tag name to create the profile!');
}
const profile = new MaintenanceProfile(crypto.randomUUID(), {
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
tags: [this.tagName],
@@ -166,6 +185,10 @@ export class TagDropdownWrapper extends BaseComponent {
const tagsList = new Set(this.#activeProfile.settings.tags);
const targetTagName = this.tagName;
if (!targetTagName) {
throw new Error('Missing tag name!');
}
if (tagsList.has(targetTagName)) {
tagsList.delete(targetTagName);
} else {
@@ -181,14 +204,14 @@ export class TagDropdownWrapper extends BaseComponent {
/**
* Watch for changes to active profile.
* @param {(profile: MaintenanceProfile|null) => void} onActiveProfileChange Callback to call when profile was
* @param onActiveProfileChange Callback to call when profile was
* changed.
*/
static #watchActiveProfile(onActiveProfileChange) {
let lastActiveProfile;
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) {
let lastActiveProfile: string | null = null;
this.#maintenanceSettings.subscribe((settings) => {
lastActiveProfile = settings.activeProfile;
lastActiveProfile = settings.activeProfile ?? null;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
@@ -199,7 +222,8 @@ export class TagDropdownWrapper extends BaseComponent {
const activeProfile = profiles
.find(profile => profile.id === lastActiveProfile);
onActiveProfileChange(activeProfile);
onActiveProfileChange(activeProfile ?? null
);
});
this.#maintenanceSettings
@@ -212,12 +236,11 @@ export class TagDropdownWrapper extends BaseComponent {
/**
* Create element for dropdown.
* @param {string} text Base text for the option.
* @param {(event: MouseEvent) => void} onClickHandler Click handler. Event will be prevented by default.
* @return {HTMLAnchorElement}
* @param text Base text for the option.
* @param onClickHandler Click handler. Event will be prevented by default.
* @return
*/
static #createDropdownLink(text, onClickHandler) {
/** @type {HTMLAnchorElement} */
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
const dropdownLink = document.createElement('a');
dropdownLink.href = '#';
dropdownLink.innerText = text;
@@ -232,7 +255,7 @@ export class TagDropdownWrapper extends BaseComponent {
}
}
export function wrapTagDropdown(element) {
export function wrapTagDropdown(element: HTMLElement) {
// Skip initialization when tag component is already wrapped
if (getComponent(element)) {
return;
@@ -244,6 +267,8 @@ export function wrapTagDropdown(element) {
categoriesResolver.addElement(tagDropdown);
}
const processedElementsSet = new WeakSet<HTMLElement>();
export function watchTagDropdownsInTagsEditor() {
// We only need to watch for new editor elements if there is a tag editor present on the page
if (!document.querySelector('#image_tags_and_source')) {
@@ -251,26 +276,35 @@ export function watchTagDropdownsInTagsEditor() {
}
document.body.addEventListener('mouseover', event => {
/** @type {HTMLElement} */
const targetElement = event.target;
if (targetElement[isTagEditorProcessedKey]) {
if (!(targetElement instanceof HTMLElement)) {
return;
}
/** @type {HTMLElement|null} */
const closestTagEditor = targetElement.closest('#image_tags_and_source');
if (!closestTagEditor || closestTagEditor[isTagEditorProcessedKey]) {
targetElement[isTagEditorProcessedKey] = true;
if (processedElementsSet.has(targetElement)) {
return;
}
targetElement[isTagEditorProcessedKey] = true;
closestTagEditor[isTagEditorProcessedKey] = true;
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
for (const tagDropdownElement of closestTagEditor.querySelectorAll('.tag.dropdown')) {
if (!closestTagEditor || processedElementsSet.has(closestTagEditor)) {
processedElementsSet.add(targetElement);
return;
}
processedElementsSet.add(targetElement);
processedElementsSet.add(closestTagEditor);
for (const tagDropdownElement of closestTagEditor.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
})
});
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
for (const tagDropdownElement of event.detail.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
});
}

View File

@@ -1,81 +0,0 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
export class TagsForm extends BaseComponent {
/**
* Collect all the tag categories available on the page and color the tags in the editor according to them.
*/
refreshTagColors() {
const tagCategories = this.#gatherTagCategories();
const editableTags = this.container.querySelectorAll('.tag');
for (let tagElement of editableTags) {
// Tag name is stored in the "remove" link and not in the tag itself.
const removeLink = tagElement.querySelector('a');
if (!removeLink) {
continue;
}
const tagName = removeLink.dataset.tagName;
if (!tagCategories.has(tagName)) {
continue;
}
const categoryName = tagCategories.get(tagName);
tagElement.dataset.tagCategory = categoryName;
tagElement.setAttribute('data-tag-category', categoryName);
}
}
/**
* Collect list of categories from the tags on the page.
* @return {Map<string, string>}
*/
#gatherTagCategories() {
/** @type {Map<string, string>} */
const tagCategories = new Map();
for (let tagElement of document.querySelectorAll('.tag[data-tag-name][data-tag-category]')) {
tagCategories.set(tagElement.dataset.tagName, tagElement.dataset.tagCategory);
}
return tagCategories;
}
static watchForEditors() {
document.body.addEventListener('click', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
if (!tagEditorWrapper) {
return;
}
const refreshTrigger = targetElement.closest('.js-taginput-show, #edit-tags')
if (!refreshTrigger) {
return;
}
const tagFormElement = tagEditorWrapper.querySelector('#tags-form');
/** @type {TagsForm|null} */
let tagEditor = getComponent(tagFormElement);
if (!tagEditor || (!tagEditor instanceof TagsForm)) {
tagEditor = new TagsForm(tagFormElement);
tagEditor.initialize();
}
tagEditor.refreshTagColors();
});
}
}

View File

@@ -0,0 +1,150 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { emit, on, type UnsubscribeFunction } from "$lib/components/events/comms";
import { EVENT_FETCH_COMPLETE } from "$lib/components/events/booru-events";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
export class TagsForm extends BaseComponent {
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(
this.container,
EVENT_FETCH_COMPLETE,
() => this.#waitAndDetectUpdatedForm(unsubscribe),
);
}
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
const elementContainingTagEditor = this.container
.closest('#image_tags_and_source')
?.parentElement;
if (!elementContainingTagEditor) {
return;
}
const observer = new MutationObserver(() => {
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
if (!tagsFormElement || getComponent(tagsFormElement)) {
return;
}
const tagFormComponent = new TagsForm(tagsFormElement);
tagFormComponent.initialize();
const fullTagEditor = tagFormComponent.parentTagEditorElement;
if (fullTagEditor) {
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
} else {
console.info('Tag form is not in the tag editor. Event is not sent.');
}
observer.disconnect();
unsubscribe();
});
observer.observe(elementContainingTagEditor, {
subtree: true,
childList: true,
});
// Make sure to forcibly disconnect everything after a while.
setTimeout(() => {
observer.disconnect();
unsubscribe();
}, 5000);
}
get parentTagEditorElement(): HTMLElement | null {
return this.container.closest<HTMLElement>('.js-tagsauce')
}
/**
* Collect all the tag categories available on the page and color the tags in the editor according to them.
*/
refreshTagColors() {
const tagCategories = this.#gatherTagCategories();
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
for (const tagElement of editableTags) {
// Tag name is stored in the "remove" link and not in the tag itself.
const removeLink = tagElement.querySelector('a');
if (!removeLink) {
continue;
}
const tagName = removeLink.dataset.tagName;
if (!tagName || !tagCategories.has(tagName)) {
continue;
}
const categoryName = tagCategories.get(tagName)!;
tagElement.dataset.tagCategory = categoryName;
tagElement.setAttribute('data-tag-category', categoryName);
}
}
/**
* Collect list of categories from the tags on the page.
* @return
*/
#gatherTagCategories(): Map<string, string> {
const tagCategories: Map<string, string> = new Map();
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
const tagName = tagElement.dataset.tagName;
const tagCategory = tagElement.dataset.tagCategory;
if (!tagName || !tagCategory) {
console.warn('Missing tag name or category!');
continue;
}
tagCategories.set(tagName, tagCategory);
}
return tagCategories;
}
static watchForEditors() {
document.body.addEventListener('click', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
if (!tagEditorWrapper) {
return;
}
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
if (!refreshTrigger) {
return;
}
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
if (!tagFormElement) {
return;
}
let tagEditor = getComponent(tagFormElement);
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
tagEditor = new TagsForm(tagFormElement);
tagEditor.initialize();
}
(tagEditor as TagsForm).refreshTagColors();
});
}
}

View File

@@ -0,0 +1,243 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import type TagGroup from "$entities/TagGroup";
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
import { on } from "$lib/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
import { getComponent } from "$lib/components/base/component-utils";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
import TagSettings from "$lib/extension/settings/TagSettings";
export class TagsListBlock extends BaseComponent {
#tagsListButtonsContainer: HTMLElement | null = null;
#tagsListContainer: HTMLElement | null = null;
#toggleGroupingButton = document.createElement('a');
#toggleGroupingButtonIcon = document.createElement('i');
#tagSettings = new TagSettings();
#shouldDisplaySeparation = false;
#separatedGroups = new Map<string, TagGroup>();
#separatedHeaders = new Map<string, HTMLElement>();
#groupsCount = new Map<string, number>();
#lastTagGroup = new WeakMap<TagDropdownWrapper, TagGroup | null>;
#isReorderingPlanned = false;
protected build() {
this.#tagsListButtonsContainer = this.container.querySelector('.block.tagsauce .block__header__buttons');
this.#tagsListContainer = this.container.querySelector('.tag-list');
this.#toggleGroupingButton.innerText = ' Grouping';
this.#toggleGroupingButton.href = 'javascript:void(0)';
this.#toggleGroupingButton.classList.add('button', 'button--link', 'button--inline');
this.#toggleGroupingButton.title = 'Toggle the global groups separation option. This will only toggle global ' +
'setting without changing the separation of specific groups.';
this.#toggleGroupingButtonIcon.classList.add('fas', TagsListBlock.#iconGroupingDisabled);
this.#toggleGroupingButton.prepend(this.#toggleGroupingButtonIcon);
if (this.#tagsListButtonsContainer) {
this.#tagsListButtonsContainer.append(this.#toggleGroupingButton);
}
}
init() {
this.#tagSettings.resolveGroupSeparation().then(this.#onTagSeparationChange.bind(this));
this.#tagSettings.subscribe(settings => {
this.#onTagSeparationChange(Boolean(settings.groupSeparation))
});
on(
this,
EVENT_TAG_GROUP_RESOLVED,
this.#onTagDropdownCustomGroupResolved.bind(this)
);
this.#toggleGroupingButton.addEventListener('click', this.#onToggleGroupingClicked.bind(this));
}
#onTagSeparationChange(isSeparationEnabled: boolean) {
if (this.#shouldDisplaySeparation === isSeparationEnabled) {
return;
}
this.#shouldDisplaySeparation = isSeparationEnabled;
this.#reorderSeparatedGroups();
this.#updateToggleSeparationButton();
}
#updateToggleSeparationButton() {
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingEnabled, this.#shouldDisplaySeparation);
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingDisabled, !this.#shouldDisplaySeparation);
}
#onTagDropdownCustomGroupResolved(resolvedCustomGroupEvent: CustomEvent<TagGroup | null>) {
const maybeDropdownElement = resolvedCustomGroupEvent.target;
if (!(maybeDropdownElement instanceof HTMLElement)) {
return;
}
const tagDropdown = getComponent<TagDropdownWrapper>(maybeDropdownElement);
if (!tagDropdown) {
return;
}
const tagGroup = resolvedCustomGroupEvent.detail;
if (tagGroup) {
this.#handleTagGroupChanges(tagGroup);
}
this.#handleResolvedTagGroup(tagGroup, tagDropdown);
if (!this.#isReorderingPlanned) {
this.#isReorderingPlanned = true;
requestAnimationFrame(this.#reorderSeparatedGroups.bind(this));
}
}
#onToggleGroupingClicked(event: Event) {
event.preventDefault();
void this.#tagSettings.setGroupSeparation(!this.#shouldDisplaySeparation);
}
#handleTagGroupChanges(tagGroup: TagGroup) {
const groupId = tagGroup.id;
const processedGroup = this.#separatedGroups.get(groupId);
if (!tagGroup.settings.separate && processedGroup) {
this.#separatedGroups.delete(groupId);
this.#separatedHeaders.get(groupId)?.remove();
this.#separatedHeaders.delete(groupId);
return;
}
// Every time group is updated, a new object is being initialized
if (tagGroup !== processedGroup) {
this.#createOrUpdateHeaderForGroup(tagGroup);
this.#separatedGroups.set(groupId, tagGroup);
}
}
#createOrUpdateHeaderForGroup(group: TagGroup) {
let heading = this.#separatedHeaders.get(group.id);
if (!heading) {
heading = document.createElement('h2');
// Heading is hidden by default and shown on next frame if there are tags to show in the section.
heading.style.display = 'none';
heading.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(group.id)}, 0)`;
heading.style.flexBasis = '100%';
// We're inserting heading to the top just to make sure that heading is always in front of the tags related to
// this category.
this.#tagsListContainer?.insertAdjacentElement('afterbegin', heading);
this.#separatedHeaders.set(group.id, heading);
}
heading.innerText = group.settings.name;
}
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdownWrapper) {
const previousGroupId = this.#lastTagGroup.get(tagComponent)?.id;
const currentGroupId = resolvedGroup?.id;
const isDifferentId = currentGroupId !== previousGroupId;
const isSeparationEnabled = resolvedGroup?.settings.separate;
if (isDifferentId) {
// Make sure to subtract the element from counters if there was a count before.
if (previousGroupId && this.#groupsCount.has(previousGroupId)) {
this.#groupsCount.set(previousGroupId, this.#groupsCount.get(previousGroupId)! - 1);
}
// We only need to count groups which have separation enabled.
if (currentGroupId && isSeparationEnabled) {
const count = this.#groupsCount.get(resolvedGroup.id) ?? 0;
this.#groupsCount.set(currentGroupId, count + 1);
}
}
// We're adding the CSS order for the tag as the CSS variable. This variable is updated later.
if (currentGroupId && isSeparationEnabled) {
tagComponent.container.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(currentGroupId)}, 0)`;
} else {
tagComponent.container.style.removeProperty('order');
}
// If separation is disabled in the new group, then we should remove the tag from map, so it can be recaptured
// when tag group is getting enabled later.
if (currentGroupId && !isSeparationEnabled) {
this.#lastTagGroup.delete(tagComponent);
return;
}
// Mark this tag component as related to the following group.
this.#lastTagGroup.set(tagComponent, resolvedGroup);
}
#reorderSeparatedGroups() {
this.#isReorderingPlanned = false;
const tagGroups = Array.from(this.#separatedGroups.values())
.toSorted((a, b) => a.settings.name.localeCompare(b.settings.name));
for (let index = 0; index < tagGroups.length; index++) {
const tagGroup = tagGroups[index];
const groupId = tagGroup.id;
const usedCount = this.#groupsCount.get(groupId);
const relatedHeading = this.#separatedHeaders.get(groupId);
if (this.#shouldDisplaySeparation) {
this.container.style.setProperty(TagsListBlock.#orderCssVariableForGroup(groupId), (index + 1).toString());
} else {
this.container.style.removeProperty(TagsListBlock.#orderCssVariableForGroup(groupId));
}
if (relatedHeading) {
if (!this.#shouldDisplaySeparation || !usedCount || usedCount <= 0) {
relatedHeading.style.display = 'none';
} else {
relatedHeading.style.removeProperty('display');
}
}
}
}
static #orderCssVariableForGroup(groupId: string): string {
return `--ta-order-${groupId}`;
}
static #iconGroupingDisabled = 'fa-folder';
static #iconGroupingEnabled = 'fa-folder-tree';
}
export function initializeAllTagsLists() {
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
if (getComponent(element)) {
return;
}
new TagsListBlock(element)
.initialize();
}
}
export function watchForUpdatedTagLists() {
on(document, EVENT_FORM_EDITOR_UPDATED, event => {
const tagsListElement = event.detail.closest<HTMLElement>('#image_tags_and_source');
if (!tagsListElement || getComponent(tagsListElement)) {
return;
}
new TagsListBlock(tagsListElement)
.initialize();
});
}

View File

@@ -1,18 +1,14 @@
import { bindComponent } from "$lib/components/base/component-utils";
/**
* @abstract
*/
export class BaseComponent {
/** @type {HTMLElement} */
#container;
type ComponentEventListener<EventName extends keyof HTMLElementEventMap> =
(this: HTMLElement, event: HTMLElementEventMap[EventName]) => void;
export class BaseComponent<ContainerType extends HTMLElement = HTMLElement> {
readonly #container: ContainerType;
#isInitialized = false;
/**
* @param {HTMLElement} container
*/
constructor(container) {
constructor(container: ContainerType) {
this.#container = container;
bindComponent(container, this);
@@ -29,42 +25,33 @@ export class BaseComponent {
this.init();
}
/**
* @protected
*/
build() {
protected build(): void {
// This method can be implemented by the component classes to modify or create the inner elements.
}
/**
* @protected
*/
init() {
protected init(): void {
// This method can be implemented by the component classes to initialize the component.
}
};
/**
* @return {HTMLElement}
*/
get container() {
get container(): ContainerType {
return this.#container;
}
/**
* Check if the component is initialized already. If not checked, subsequent calls to the `initialize` method will
* throw an error.
* @return {boolean}
* @return
*/
get isInitialized() {
get isInitialized(): boolean {
return this.#isInitialized;
}
/**
* Emit the custom event on the container element.
* @param {keyof HTMLElementEventMap|string} event The event name.
* @param {any} [detail] The event detail. Can be omitted.
* @param event The event name.
* @param [detail] The event detail. Can be omitted.
*/
emit(event, detail = undefined) {
emit(event: keyof HTMLElementEventMap | string, detail: any = undefined): void {
this.#container.dispatchEvent(
new CustomEvent(
event,
@@ -78,12 +65,16 @@ export class BaseComponent {
/**
* Subscribe to the DOM event on the container element.
* @param {keyof HTMLElementEventMap|string} event The event name.
* @param {function(Event): void} listener The event listener.
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
* @return {function(): void} The unsubscribe function.
* @param event The event name.
* @param listener The event listener.
* @param [options] The event listener options. Can be omitted.
* @return The unsubscribe function.
*/
on(event, listener, options = undefined) {
on<EventName extends keyof HTMLElementEventMap>(
event: EventName,
listener: ComponentEventListener<EventName>,
options?: AddEventListenerOptions,
): () => void {
this.#container.addEventListener(event, listener, options);
return () => void this.#container.removeEventListener(event, listener, options);
@@ -91,12 +82,16 @@ export class BaseComponent {
/**
* Subscribe to the DOM event on the container element. The event listener will be called only once.
* @param {keyof HTMLElementEventMap|string} event The event name.
* @param {function(Event): void} listener The event listener.
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
* @return {function(): void} The unsubscribe function.
* @param event The event name.
* @param listener The event listener.
* @param [options] The event listener options. Can be omitted.
* @return The unsubscribe function.
*/
once(event, listener, options = undefined) {
once<EventName extends keyof HTMLElementEventMap>(
event: EventName,
listener: ComponentEventListener<EventName>,
options?: AddEventListenerOptions,
): () => void {
options = options || {};
options.once = true;

View File

@@ -1,9 +1,9 @@
import type { BaseComponent } from "$lib/components/base/BaseComponent";
const instanceSymbol = Symbol('instance');
const instanceSymbol = Symbol.for('instance');
interface ElementWithComponent extends HTMLElement {
[instanceSymbol]?: BaseComponent;
interface ElementWithComponent<T> extends HTMLElement {
[instanceSymbol]?: T;
}
/**
@@ -11,7 +11,7 @@ interface ElementWithComponent extends HTMLElement {
* @param {HTMLElement} element
* @return
*/
export function getComponent(element: ElementWithComponent): BaseComponent | null {
export function getComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>): T | null {
return element[instanceSymbol] || null;
}
@@ -20,7 +20,7 @@ export function getComponent(element: ElementWithComponent): BaseComponent | nul
* @param element The element to bind the component to.
* @param instance The component instance.
*/
export function bindComponent(element: ElementWithComponent, instance: BaseComponent): void {
export function bindComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>, instance: T): void {
if (element[instanceSymbol]) {
throw new Error('The element is already bound to a component.');
}

View File

@@ -0,0 +1,5 @@
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
export interface BooruEventsMap {
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
}

View File

@@ -1,11 +1,19 @@
import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events";
import type { BooruEventsMap } from "$lib/components/events/booru-events";
import type { TagsFormEventsMap } from "$lib/components/events/tags-form-events";
import type { TagDropdownEvents } from "$lib/components/events/tag-dropdown-events";
interface EventsMapping extends MaintenancePopupEventsMap {
}
type EventsMapping =
MaintenancePopupEventsMap
& FullscreenViewerEventsMap
& BooruEventsMap
& TagsFormEventsMap
& TagDropdownEvents;
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
type UnsubscribeFunction = () => void;
export type UnsubscribeFunction = () => void;
type ResolvableTarget = EventTarget | BaseComponent;
function resolveTarget(componentOrElement: ResolvableTarget): EventTarget {

View File

@@ -0,0 +1,7 @@
import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
export const EVENT_SIZE_LOADED = 'size-loaded';
export interface FullscreenViewerEventsMap {
[EVENT_SIZE_LOADED]: FullscreenViewerSize;
}

View File

@@ -1,13 +1,13 @@
import type MaintenanceProfile from "$entities/MaintenanceProfile";
export const eventActiveProfileChanged = 'active-profile-changed';
export const eventMaintenanceStateChanged = 'maintenance-state-change';
export const eventTagsUpdated = 'tags-updated';
export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed';
export const EVENT_MAINTENANCE_STATE_CHANGED = 'maintenance-state-change';
export const EVENT_TAGS_UPDATED = 'tags-updated';
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
export interface MaintenancePopupEventsMap {
[eventActiveProfileChanged]: MaintenanceProfile | null;
[eventMaintenanceStateChanged]: MaintenanceState;
[eventTagsUpdated]: Map<string, string> | null;
[EVENT_ACTIVE_PROFILE_CHANGED]: MaintenanceProfile | null;
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
}

View File

@@ -0,0 +1,7 @@
import type TagGroup from "$entities/TagGroup";
export const EVENT_TAG_GROUP_RESOLVED = 'tag-group-resolved';
export interface TagDropdownEvents {
[EVENT_TAG_GROUP_RESOLVED]: TagGroup | null;
}

View File

@@ -0,0 +1,5 @@
export const EVENT_FORM_EDITOR_UPDATED = 'tags-form-updated';
export interface TagsFormEventsMap {
[EVENT_FORM_EDITOR_UPDATED]: HTMLElement;
}

View File

@@ -2,12 +2,16 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag
export default class ConfigurationController {
readonly #configurationName: string;
readonly #storage: StorageHelper;
/**
* @param {string} configurationName Name of the configuration to work with.
* @param {StorageHelper} [storage] Selected storage where the settings are stored in. If not provided, local storage
* is used.
*/
constructor(configurationName: string) {
constructor(configurationName: string, storage: StorageHelper = new StorageHelper(chrome.storage.local)) {
this.#configurationName = configurationName;
this.#storage = storage;
}
/**
@@ -19,7 +23,7 @@ export default class ConfigurationController {
* @return The setting value or the default value if the setting does not exist.
*/
async readSetting<Type = any, DefaultType = any>(settingName: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
return settings[settingName] ?? defaultValue;
}
@@ -32,11 +36,11 @@ export default class ConfigurationController {
* @return {Promise<void>}
*/
async writeSetting(settingName: string, value: any): Promise<void> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
settings[settingName] = value;
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
this.#storage.write(this.#configurationName, settings);
}
/**
@@ -45,11 +49,11 @@ export default class ConfigurationController {
* @param {string} settingName Setting name to delete.
*/
async deleteSetting(settingName: string): Promise<void> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
delete settings[settingName];
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
this.#storage.write(this.#configurationName, settings);
}
/**
@@ -69,10 +73,8 @@ export default class ConfigurationController {
callback(changes[this.#configurationName].newValue);
}
ConfigurationController.#storageHelper.subscribe(subscriber);
this.#storage.subscribe(subscriber);
return () => ConfigurationController.#storageHelper.unsubscribe(subscriber);
return () => this.#storage.unsubscribe(subscriber);
}
static #storageHelper = new StorageHelper(chrome.storage.local);
}

View File

@@ -1,12 +1,14 @@
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
import TagGroup from "$entities/TagGroup";
import { escapeRegExp } from "$lib/utils";
import { emit } from "$lib/components/events/comms";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
export default class CustomCategoriesResolver {
#tagCategories = new Map<string, string>();
#compiledRegExps = new Map<RegExp, string>();
#exactGroupMatches = new Map<string, TagGroup>();
#regExpGroupMatches = new Map<RegExp, TagGroup>();
#tagDropdowns: TagDropdownWrapper[] = [];
#nextQueuedUpdate = -1;
#nextQueuedUpdate: Timeout | null = null;
constructor() {
TagGroup.subscribe(this.#onTagGroupsReceived.bind(this));
@@ -16,7 +18,7 @@ export default class CustomCategoriesResolver {
public addElement(tagDropdown: TagDropdownWrapper): void {
this.#tagDropdowns.push(tagDropdown);
if (!this.#tagCategories.size && !this.#compiledRegExps.size) {
if (!this.#exactGroupMatches.size && !this.#regExpGroupMatches.size) {
return;
}
@@ -24,7 +26,9 @@ export default class CustomCategoriesResolver {
}
#queueUpdatingTags() {
clearTimeout(this.#nextQueuedUpdate);
if (this.#nextQueuedUpdate) {
clearTimeout(this.#nextQueuedUpdate);
}
this.#nextQueuedUpdate = setTimeout(
this.#updateUnprocessedTags.bind(this),
@@ -34,7 +38,6 @@ export default class CustomCategoriesResolver {
#updateUnprocessedTags() {
this.#tagDropdowns
.filter(CustomCategoriesResolver.#skipTagsWithOriginalCategory)
.filter(this.#applyCustomCategoryForExactMatches.bind(this))
.filter(this.#matchCustomCategoryByRegExp.bind(this))
.forEach(CustomCategoriesResolver.#resetToOriginalCategory);
@@ -49,23 +52,33 @@ export default class CustomCategoriesResolver {
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
const tagName = tagDropdown.tagName!;
if (!this.#tagCategories.has(tagName)) {
if (!this.#exactGroupMatches.has(tagName)) {
return true;
}
tagDropdown.tagCategory = this.#tagCategories.get(tagName)!;
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
this.#exactGroupMatches.get(tagName)!
);
return false;
}
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
const tagName = tagDropdown.tagName!;
for (const targetRegularExpression of this.#compiledRegExps.keys()) {
for (const targetRegularExpression of this.#regExpGroupMatches.keys()) {
if (!targetRegularExpression.test(tagName)) {
continue;
}
tagDropdown.tagCategory = this.#compiledRegExps.get(targetRegularExpression)!;
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
this.#regExpGroupMatches.get(targetRegularExpression)!
);
return false;
}
@@ -73,24 +86,29 @@ export default class CustomCategoriesResolver {
}
#onTagGroupsReceived(tagGroups: TagGroup[]) {
this.#tagCategories.clear();
this.#compiledRegExps.clear();
this.#exactGroupMatches.clear();
this.#regExpGroupMatches.clear();
if (!tagGroups.length) {
return;
}
for (const tagGroup of tagGroups) {
const categoryName = tagGroup.settings.category;
for (const tagName of tagGroup.settings.tags) {
this.#tagCategories.set(tagName, categoryName);
this.#exactGroupMatches.set(tagName, tagGroup);
}
for (const tagPrefix of tagGroup.settings.prefixes) {
this.#compiledRegExps.set(
this.#regExpGroupMatches.set(
new RegExp(`^${escapeRegExp(tagPrefix)}`),
categoryName
tagGroup,
);
}
for (let tagSuffix of tagGroup.settings.suffixes) {
this.#regExpGroupMatches.set(
new RegExp(`${escapeRegExp(tagSuffix)}$`),
tagGroup,
);
}
}
@@ -98,12 +116,12 @@ export default class CustomCategoriesResolver {
this.#queueUpdatingTags();
}
static #skipTagsWithOriginalCategory(tagDropdown: TagDropdownWrapper): boolean {
return !tagDropdown.originalCategory;
}
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
tagDropdown.tagCategory = tagDropdown.originalCategory;
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
null,
);
}
static #unprocessedTagsTimeout = 0;

View File

@@ -4,7 +4,9 @@ export interface TagGroupSettings {
name: string;
tags: string[];
prefixes: string[];
suffixes: string[];
category: string;
separate: boolean;
}
export default class TagGroup extends StorageEntity<TagGroupSettings> {
@@ -13,7 +15,9 @@ export default class TagGroup extends StorageEntity<TagGroupSettings> {
name: settings.name || '',
tags: settings.tags || [],
prefixes: settings.prefixes || [],
category: settings.category || ''
suffixes: settings.suffixes || [],
category: settings.category || '',
separate: Boolean(settings.separate),
});
}

View File

@@ -1,6 +1,6 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
export type FullscreenViewerSize = 'small' | 'medium' | 'large' | 'full';
export type FullscreenViewerSize = keyof App.ImageURIs;
interface MiscSettingsFields {
fullscreenViewer: boolean;

View File

@@ -0,0 +1,19 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
interface TagSettingsFields {
groupSeparation: boolean;
}
export default class TagSettings extends CacheableSettings<TagSettingsFields> {
constructor() {
super("tag");
}
async resolveGroupSeparation() {
return this._resolveSetting("groupSeparation", true);
}
async setGroupSeparation(value: boolean) {
return this._writeSetting("groupSeparation", Boolean(value));
}
}

View File

@@ -27,7 +27,9 @@ const entitiesExporters: ExportersMap = {
name: entity.settings.name,
tags: entity.settings.tags,
prefixes: entity.settings.prefixes,
suffixes: entity.settings.suffixes,
category: entity.settings.category,
separate: entity.settings.separate,
}
}
};

48
src/lib/popup-links.ts Normal file
View File

@@ -0,0 +1,48 @@
function resolveReplaceableLink(target: EventTarget | null = null): HTMLAnchorElement | null {
if (!(target instanceof HTMLElement)) {
return null;
}
const closestLink = target.closest('a');
if (
closestLink instanceof HTMLAnchorElement
&& !closestLink.search
&& closestLink.origin === location.origin
) {
return closestLink;
}
return null;
}
function replaceLink(linkElement: HTMLAnchorElement) {
const params = new URLSearchParams([
['path', linkElement.pathname]
]);
linkElement.search = params.toString();
linkElement.pathname = "/index.html";
}
export function initializeLinksReplacement(): () => void {
const abortController = new AbortController();
const replacementHandler = (event: Event) => {
const closestLink = resolveReplaceableLink(event.target);
if (closestLink) {
replaceLink(closestLink);
}
}
// Dynamically replace the links from the Svelte default links to the links usable for the popup.
document.body.addEventListener('mousedown', replacementHandler, {
signal: abortController.signal,
});
document.body.addEventListener('click', replacementHandler, {
signal: abortController.signal,
})
return () => abortController.abort();
}

View File

@@ -2,6 +2,8 @@
import "../styles/popup.scss";
import Header from "$components/layout/Header.svelte";
import Footer from "$components/layout/Footer.svelte";
import { initializeLinksReplacement } from "$lib/popup-links";
import { onDestroy } from "svelte";
interface Props {
children?: import('svelte').Snippet;
@@ -12,6 +14,12 @@
// Sort of a hack, detect if we rendered in the browser tab or in the popup.
// Popup will always should have fixed 320px size, otherwise we consider it opened in the tab.
document.body.classList.toggle('is-in-tab', window.innerWidth > 320);
const disconnectLinkReplacement = initializeLinksReplacement();
onDestroy(() => {
disconnectLinkReplacement();
})
</script>
<Header/>

View File

@@ -11,6 +11,7 @@
import TagsEditor from "$components/tags/TagsEditor.svelte";
import TagGroup from "$entities/TagGroup";
import { tagGroups } from "$stores/entities/tag-groups";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
let groupId = $derived(page.params.id);
@@ -25,7 +26,9 @@
let groupName = $state<string>('');
let tagsList = $state<string[]>([]);
let prefixesList = $state<string[]>([]);
let tagCategory = $state<string>('');
let suffixesList = $state<string[]>([]);
let tagCategory = $state<string>('')
let separateGroup = $state<boolean>(false);
$effect(() => {
if (groupId === 'new') {
@@ -40,7 +43,9 @@
groupName = targetGroup.settings.name;
tagsList = [...targetGroup.settings.tags].sort((a, b) => a.localeCompare(b));
prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b));
suffixesList = [...targetGroup.settings.suffixes].sort((a, b) => a.localeCompare(b));
tagCategory = targetGroup.settings.category;
separateGroup = targetGroup.settings.separate;
});
async function saveGroup() {
@@ -52,21 +57,36 @@
targetGroup.settings.name = groupName;
targetGroup.settings.tags = [...tagsList];
targetGroup.settings.prefixes = [...prefixesList];
targetGroup.settings.suffixes = [...suffixesList];
targetGroup.settings.category = tagCategory;
targetGroup.settings.separate = separateGroup;
await targetGroup.save();
await goto(`/features/groups/${targetGroup.id}`);
}
function mapPrefixNames(tagName: string): string {
return `${tagName}*`;
}
function mapSuffixNames(tagName: string): string {
return `*${tagName}`;
}
</script>
<Menu>
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/groups/{groupId === 'new' ? '' : groupId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Group Name">
<TextField bind:value={groupName} placeholder="Group Name"></TextField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={separateGroup}>
Display tags found by this group in separate list after all other tags.
</CheckboxField>
</FormControl>
<FormControl label="Group Color">
<TagCategorySelectField bind:value={tagCategory}/>
</FormControl>
@@ -77,7 +97,12 @@
</TagsColorContainer>
<TagsColorContainer targetCategory={tagCategory}>
<FormControl label="Tag Prefixes">
<TagsEditor bind:tags={prefixesList}/>
<TagsEditor bind:tags={prefixesList} mapTagNames={mapPrefixNames}/>
</FormControl>
</TagsColorContainer>
<TagsColorContainer targetCategory={tagCategory}>
<FormControl label="Tag Suffixes">
<TagsEditor bind:tags={suffixesList} mapTagNames={mapSuffixNames}/>
</FormControl>
</TagsColorContainer>
</FormContainer>

View File

@@ -5,6 +5,7 @@
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { stripBlacklistedTagsEnabled } from "$stores/preferences/maintenance";
import { shouldSeparateTagGroups } from "$stores/preferences/tag";
</script>
<Menu>
@@ -17,4 +18,9 @@
Automatically remove black-listed tags from the images
</CheckboxField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={$shouldSeparateTagGroups}>
Enable separation of custom tag groups on the image pages
</CheckboxField>
</FormControl>
</FormContainer>

View File

@@ -0,0 +1,18 @@
import { writable } from "svelte/store";
import TagSettings from "$lib/extension/settings/TagSettings";
const tagSettings = new TagSettings();
export const shouldSeparateTagGroups = writable(false);
tagSettings.resolveGroupSeparation()
.then(value => shouldSeparateTagGroups.set(value))
.then(() => {
shouldSeparateTagGroups.subscribe(value => {
void tagSettings.setGroupSeparation(value);
});
tagSettings.subscribe(settings => {
shouldSeparateTagGroups.set(Boolean(settings.groupSeparation));
});
})

View File

@@ -203,6 +203,7 @@
top: 5px;
left: 5px;
z-index: 1;
background-color: booru-vars.$background-color;
}
.close {

View File

@@ -19,6 +19,7 @@ const config = {
"$styles": "./src/styles",
"$stores": "./src/stores",
"$entities": "./src/lib/extension/entities",
"$tests": "./tests"
},
typescript: {
config: config => {

View File

@@ -0,0 +1,40 @@
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
import StorageHelper from "$lib/browser/StorageHelper";
import { expect } from "vitest";
describe('StorageHelper', () => {
let storageAreaMock: ChromeStorageArea;
let storageHelper: StorageHelper;
beforeEach(() => {
storageAreaMock = new ChromeStorageArea();
storageHelper = new StorageHelper(storageAreaMock);
});
it("should return value when data exists", async () => {
const key = 'existingKey';
const value = 'test value';
storageAreaMock.insertMockedData({[key]: value});
expect(await storageHelper.read(key)).toBe(value);
});
it('should return default when data is not present', async () => {
const fallbackValue = 'fallback';
expect(await storageHelper.read('nonexistent', fallbackValue)).toBe(fallbackValue);
});
it('should treat falsy values as existing values', async () => {
const falsyValues = [false, '', 0];
const key = 'testedKey';
const fallbackValue = 'fallback';
for (let testedValue of falsyValues) {
storageAreaMock.insertMockedData({[key]: testedValue});
expect(await storageHelper.read(key, fallbackValue)).toBe(testedValue);
}
});
});

View File

@@ -0,0 +1,106 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { randomString } from "$tests/utils";
describe('BaseComponent', () => {
it('should bind the component to the element', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
expect(getComponent(element)).toBe(component);
});
it('should throw an error when attempting to initialize component on same element multiple times', () => {
const element = document.createElement('div');
expect(() => new BaseComponent(element)).not.toThrowError();
expect(() => new BaseComponent(element)).toThrowError();
});
it('should return the element as component container', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
expect(component.container).toBe(element);
});
it('should mark itself as initialized after initialization', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
expect(component.isInitialized).toBe(false);
component.initialize();
expect(component.isInitialized).toBe(true);
});
it('should throw error when attempting to initialize component multiple times', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
expect(() => component.initialize()).not.toThrowError();
expect(() => component.initialize()).toThrowError();
});
it('should emit custom events on element', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
let receivedEvent: CustomEvent<string> | null = null;
const eventName = randomString();
const eventData = randomString();
const eventHandler = vi.fn(event => {
receivedEvent = event;
});
element.addEventListener(eventName, eventHandler);
component.emit(eventName, eventData);
expect(eventHandler).toBeCalled();
expect(receivedEvent).toBeInstanceOf(CustomEvent);
expect(receivedEvent!.detail).toBe(eventData);
});
it('should listen events on element', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
const eventName = 'click';
const eventHandler = vi.fn();
component.on(eventName, eventHandler);
element.dispatchEvent(new Event(eventName));
expect(eventHandler).toBeCalled();
});
it('should disconnect listener with unsubscribe function', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
const eventName = 'click';
const eventHandler = vi.fn();
const unsubscribe = component.on(eventName, eventHandler);
element.dispatchEvent(new Event(eventName));
unsubscribe();
element.dispatchEvent(new Event(eventName));
expect(eventHandler).toBeCalledTimes(1);
});
it('should listen for event once', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
const eventName = 'click';
const eventHandler = vi.fn();
component.once(eventName, eventHandler);
element.dispatchEvent(new Event(eventName));
element.dispatchEvent(new Event(eventName));
expect(eventHandler).toBeCalledTimes(1);
});
});

View File

@@ -0,0 +1,186 @@
import ConfigurationController from "$lib/extension/ConfigurationController";
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
import StorageHelper from "$lib/browser/StorageHelper";
import { randomString } from "$tests/utils";
describe('ConfigurationController', () => {
const mockedStorageArea = new ChromeStorageArea();
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
beforeEach(() => {
mockedStorageArea.clear();
});
it('should read setting from the field inside the configuration object', async () => {
const name = randomString();
const field = randomString();
const value = randomString();
mockedStorageArea.insertMockedData({
[name]: {
[field]: value
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
const returnedValue = await controller.readSetting(field);
expect(returnedValue).toBe(value);
});
it('should return fallback value if configuration field does not exist', async () => {
const controller = new ConfigurationController(randomString(), mockedStorageHelper);
const fallbackValue = randomString();
const returnedValue = await controller.readSetting(randomString(), fallbackValue);
expect(returnedValue).toBe(fallbackValue);
});
it('should treat existing falsy values as existing values', async () => {
const name = randomString();
const falsyValuesStorage = [0, false, ''].reduce((record, value) => {
record[randomString()] = value;
return record;
}, {} as Record<string, any>);
mockedStorageArea.insertMockedData({
[name]: falsyValuesStorage
});
const controller = new ConfigurationController(name, mockedStorageHelper);
for (const fieldName of Object.keys(falsyValuesStorage)) {
const returnedValue = await controller.readSetting(fieldName, randomString());
expect(returnedValue).toBe(falsyValuesStorage[fieldName]);
}
});
it('should write data to storage', async () => {
const name = randomString();
const field = randomString();
const value = randomString();
const controller = new ConfigurationController(name, mockedStorageHelper);
await controller.writeSetting(field, value);
const expectedStructure = {
[name]: {
[field]: value,
}
};
expect(mockedStorageArea.mockedData).toEqual(expectedStructure);
});
it('should update existing object without touching other entries', async () => {
const name = randomString();
const existingField = randomString();
const existingValue = randomString();
const addedField = randomString();
const addedValue = randomString();
mockedStorageArea.insertMockedData({
[name]: {
[existingField]: existingValue,
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
await controller.writeSetting(addedField, addedValue);
const expectedStructure = {
[name]: {
[existingField]: existingValue,
[addedField]: addedValue,
}
}
expect(mockedStorageArea.mockedData).toEqual(expectedStructure);
});
it('should delete setting from storage', async () => {
const name = randomString();
const field = randomString();
const value = randomString();
mockedStorageArea.insertMockedData({
[name]: {
[field]: value
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
await controller.deleteSetting(field);
expect(mockedStorageArea.mockedData).toEqual({
[name]: {},
});
});
it('should return updated settings contents on changes', async () => {
const name = randomString();
const initialField = randomString();
const initialValue = randomString();
const addedField = randomString();
const addedValue = randomString();
const updatedInitialValue = randomString();
const receivedData: Record<string, string>[] = [];
mockedStorageArea.insertMockedData({
[name]: {
[initialField]: initialValue,
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
const subscriber = vi.fn((storageState: Record<string, string>) => {
receivedData.push(JSON.parse(JSON.stringify(storageState)));
});
controller.subscribeToChanges(subscriber);
await controller.writeSetting(addedField, addedValue);
await controller.writeSetting(initialField, updatedInitialValue);
await controller.deleteSetting(initialField);
expect(subscriber).toBeCalledTimes(3);
const expectedData: Record<string, string>[] = [
// First, initial data and added field are present
{
[initialField]: initialValue,
[addedField]: addedValue,
},
// Then we get new value on initial field
{
[initialField]: updatedInitialValue,
[addedField]: addedValue,
},
// And then the initial value is dropped
{
[addedField]: addedValue,
}
];
expect(receivedData).toEqual(expectedData);
});
it('should stop listening once unsubscribe called', async () => {
const controller = new ConfigurationController(randomString(), mockedStorageHelper);
const subscriber = vi.fn();
const unsubscribe = controller.subscribeToChanges(subscriber);
await controller.writeSetting(randomString(), randomString());
expect(subscriber).toBeCalledTimes(1);
unsubscribe();
subscriber.mockReset();
await controller.writeSetting(randomString(), randomString())
expect(subscriber).not.toBeCalled();
});
});

View File

@@ -0,0 +1,75 @@
import { randomString } from "$tests/utils";
import { initializeLinksReplacement } from "$lib/popup-links";
describe('popup-links', () => {
let expectedPath = '';
let testLink: HTMLAnchorElement = document.createElement('a');
let disconnectCallback: (() => void) | null = null;
function fireEventAt(target: EventTarget, eventName: string) {
target.dispatchEvent(new Event(eventName, {bubbles: true}));
}
beforeEach(() => {
expectedPath = `/test/${randomString()}`;
testLink.href = expectedPath;
document.body.append(testLink);
});
afterEach(() => {
if (disconnectCallback) {
disconnectCallback();
disconnectCallback = null;
}
});
it('should replace link on any mouse button down', () => {
disconnectCallback = initializeLinksReplacement();
fireEventAt(testLink, "mousedown");
const resultUrl = new URL(testLink.href);
expect(resultUrl.searchParams.get('path')).toBe(expectedPath);
});
it('should replace link when link is pressed by keyboard or clicked', () => {
disconnectCallback = initializeLinksReplacement();
fireEventAt(testLink, "click");
const resultUrl = new URL(testLink.href);
expect(resultUrl.searchParams.get('path')).toBe(expectedPath);
});
it('should not replace already replaced links', () => {
disconnectCallback = initializeLinksReplacement();
fireEventAt(testLink, "click");
const hrefAfterFirstClick = testLink.href;
fireEventAt(testLink, "click");
const hrefAfterSecondClick = testLink.href;
expect(hrefAfterFirstClick).toBe(hrefAfterSecondClick);
});
it('should stop replacing links once disconnect is called', () => {
const hrefBefore = testLink.href;
disconnectCallback = initializeLinksReplacement();
disconnectCallback();
fireEventAt(testLink, "mousedown");
fireEventAt(testLink, "click");
expect(hrefBefore).toBe(testLink.href);
});
it('should not touch links with different origin', () => {
testLink.href = "https://external.example.com/" + randomString() + "/";
const hrefBefore = testLink.href;
disconnectCallback = initializeLinksReplacement();
fireEventAt(testLink, "click");
expect(testLink.href).toBe(hrefBefore);
});
});

View File

@@ -0,0 +1,9 @@
export default class ChromeEvent<T extends Function> implements chrome.events.Event<T> {
addListener = vi.fn();
getRules = vi.fn();
hasListener = vi.fn();
removeRules = vi.fn();
addRules = vi.fn();
removeListener = vi.fn();
hasListeners = vi.fn();
}

View File

@@ -0,0 +1,5 @@
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
export class ChromeLocalStorageArea extends ChromeStorageArea implements chrome.storage.LocalStorageArea {
QUOTA_BYTES = 100000;
}

View File

@@ -0,0 +1,92 @@
import ChromeStorageChangeEvent from "$tests/mocks/ChromeStorageChangeEvent";
type ChangedEventCallback = (changes: chrome.storage.StorageChange) => void
export default class ChromeStorageArea implements chrome.storage.StorageArea {
#mockedData: Record<string, any> = {};
getBytesInUse = vi.fn();
clear = vi.fn((): Promise<void> => {
return new Promise(resolve => {
this.#mockedData = {};
resolve();
})
});
set = vi.fn((...args: any[]): Promise<void> => {
return new Promise((resolve) => {
const change: Record<string, chrome.storage.StorageChange> = {};
const setter = args[0];
for (let targetKey of Object.keys(setter)) {
change[targetKey] = {
oldValue: this.#mockedData[targetKey] ?? undefined,
newValue: setter[targetKey],
};
}
this.#mockedData = Object.assign(this.#mockedData, args[0]);
this.onChanged.mockEmitStorageChange(change);
resolve();
})
});
remove = vi.fn((...args: any[]): Promise<void> => {
return new Promise((resolve, reject) => {
const key = args[0];
if (typeof key === 'string') {
const change: chrome.storage.StorageChange = {
oldValue: this.#mockedData[key],
};
delete this.#mockedData[key];
this.onChanged.mockEmitStorageChange({
[key]: change
});
resolve();
}
reject(new Error('This behavior is not mocked!'));
});
});
get = vi.fn((...args: any[]) => {
return new Promise((resolve, reject) => {
const key = args[0];
if (!key) {
resolve(this.#mockedData);
return;
}
if (typeof key === 'string') {
resolve({[key]: this.#mockedData[key]});
return;
}
if (Array.isArray(key)) {
resolve(
(key as string[]).reduce((entries, key) => {
entries[key] = this.#mockedData[key];
return entries;
}, {} as Record<string, any>)
);
return;
}
reject(new Error('This behavior is not implemented by the mock.'));
});
});
setAccessLevel = vi.fn();
onChanged = new ChromeStorageChangeEvent();
getKeys = vi.fn();
insertMockedData(data: Record<string, any>) {
this.#mockedData = data;
}
get mockedData(): Record<string, any> {
return this.#mockedData;
}
}

View File

@@ -0,0 +1,27 @@
import ChromeEvent from "$tests/mocks/ChromeEvent";
import { EventEmitter } from "node:events";
type MockedStorageChanges = Record<string, chrome.storage.StorageChange>;
type IncomingStorageChangeListener = (changes: MockedStorageChanges) => void;
const storageChangeEvent = Symbol();
interface StorageChangeEventMap {
[storageChangeEvent]: [MockedStorageChanges];
}
export default class ChromeStorageChangeEvent extends ChromeEvent<IncomingStorageChangeListener> {
#emitter = new EventEmitter<StorageChangeEventMap>();
addListener = vi.fn((actualListener: IncomingStorageChangeListener) => {
this.#emitter.addListener(storageChangeEvent, actualListener);
});
removeListener = vi.fn((actualListener: IncomingStorageChangeListener) => {
this.#emitter.removeListener(storageChangeEvent, actualListener);
});
mockEmitStorageChange(changes: MockedStorageChanges) {
this.#emitter.emit(storageChangeEvent, changes);
}
}

7
tests/utils.ts Normal file
View File

@@ -0,0 +1,7 @@
export function randomString(): string {
return crypto.randomUUID();
}
export function copyValue<T>(object: T): T {
return JSON.parse(JSON.stringify(object));
}

View File

@@ -11,5 +11,9 @@
"strict": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"types": [
"vitest/globals",
"@types/chrome",
]
}
}

View File

@@ -1,5 +1,5 @@
import {sveltekit} from '@sveltejs/kit/vite';
import {defineConfig} from 'vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
build: {
@@ -9,4 +9,13 @@ export default defineConfig({
plugins: [
sveltekit(),
],
test: {
globals: true,
environment: 'jsdom',
exclude: ['**/node_modules/**', '.*\\.d\\.ts$', '.*\\.spec\\.ts$'],
coverage: {
reporter: ['text', 'html'],
include: ['src/lib/**/*.{js,ts}'],
}
}
});