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 'refs/remotes/origin/release/0.4.3' into feature/workaronud-for-opening-in-new-tab

This commit is contained in:
2025-02-28 03:28:13 +04:00
17 changed files with 305 additions and 38 deletions

18
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@sveltejs/kit": "^2.17.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.304",
"@types/node": "^22.13.5",
"@vitest/coverage-v8": "^3.0.6",
"cheerio": "^1.0.0",
"jsdom": "^26.0.0",
@@ -814,6 +815,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.13.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
"integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.6.tgz",
@@ -2972,6 +2983,13 @@
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz",

View File

@@ -17,6 +17,7 @@
"@sveltejs/kit": "^2.17.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.304",
"@types/node": "^22.13.5",
"@vitest/coverage-v8": "^3.0.6",
"cheerio": "^1.0.0",
"jsdom": "^26.0.0",

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

@@ -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,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

@@ -47,7 +47,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
}
#onButtonClicked() {
const imageLinks = this.#mediaBoxTools?.mediaBox.imageLinks;
const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks;
if (!imageLinks) {
throw new Error('Failed to resolve image links from media box tools!');

View File

@@ -30,7 +30,7 @@ export class MaintenancePopup extends BaseComponent {
#tagsToAdd: Set<string> = new Set();
#isPlanningToSubmit: boolean = false;
#isSubmitting: boolean = false;
#tagsSubmissionTimer: number | null = null;
#tagsSubmissionTimer: Timeout | null = null;
#emitter = emitterAt(this);
/**
@@ -70,6 +70,10 @@ 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));
}
@@ -83,7 +87,7 @@ export class MaintenancePopup extends BaseComponent {
}
#refreshTagsList() {
if (!this.#mediaBoxTools) {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
@@ -109,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)) {
@@ -193,7 +197,7 @@ export class MaintenancePopup extends BaseComponent {
}
async #onSubmissionTimerPassed() {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools) {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
return;
}
@@ -264,7 +268,7 @@ export class MaintenancePopup extends BaseComponent {
}
#revealInvalidTags() {
if (!this.#mediaBoxTools) {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}

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

@@ -6,7 +6,7 @@ export default class CustomCategoriesResolver {
#tagCategories = new Map<string, string>();
#compiledRegExps = new Map<RegExp, string>();
#tagDropdowns: TagDropdownWrapper[] = [];
#nextQueuedUpdate = -1;
#nextQueuedUpdate: Timeout | null = null;
constructor() {
TagGroup.subscribe(this.#onTagGroupsReceived.bind(this));
@@ -24,7 +24,9 @@ export default class CustomCategoriesResolver {
}
#queueUpdatingTags() {
clearTimeout(this.#nextQueuedUpdate);
if (this.#nextQueuedUpdate) {
clearTimeout(this.#nextQueuedUpdate);
}
this.#nextQueuedUpdate = setTimeout(
this.#updateUnprocessedTags.bind(this),

View File

@@ -1,9 +1,6 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
function randomString() {
return crypto.randomUUID();
}
import { randomString } from "$tests/utils";
describe('BaseComponent', () => {
it('should bind the component to the element', () => {

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

@@ -1,9 +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();
addListener = vi.fn();
getRules = vi.fn();
hasListener = vi.fn();
removeRules = vi.fn();
addRules = vi.fn();
removeListener = vi.fn();
hasListeners = vi.fn();
}

View File

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

View File

@@ -1,4 +1,4 @@
import ChromeEvent from "./ChromeEvent";
import ChromeStorageChangeEvent from "$tests/mocks/ChromeStorageChangeEvent";
type ChangedEventCallback = (changes: chrome.storage.StorageChange) => void
@@ -13,8 +13,20 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea {
})
});
set = vi.fn((...args: any[]): Promise<void> => {
return new Promise((resolve, reject) => {
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();
})
});
@@ -23,7 +35,16 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea {
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();
}
@@ -58,7 +79,7 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea {
});
});
setAccessLevel = vi.fn();
onChanged = new ChromeEvent<ChangedEventCallback>();
onChanged = new ChromeStorageChangeEvent();
getKeys = vi.fn();
insertMockedData(data: Record<string, any>) {

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));
}