mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2025-12-23 23:02:58 +00:00
@@ -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.2",
|
||||
"version": "0.4.3",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "furbooru-tagging-assistant@thecore.city"
|
||||
|
||||
699
package-lock.json
generated
699
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "npm run build:popup && npm run build:extension",
|
||||
@@ -14,18 +14,19 @@
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.17.2",
|
||||
"@sveltejs/kit": "^2.19.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/chrome": "^0.0.304",
|
||||
"@vitest/coverage-v8": "^3.0.6",
|
||||
"@types/chrome": "^0.0.309",
|
||||
"@types/node": "^22.13.10",
|
||||
"@vitest/coverage-v8": "^3.0.8",
|
||||
"cheerio": "^1.0.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"sass": "^1.85.0",
|
||||
"svelte": "^5.20.1",
|
||||
"svelte-check": "^4.1.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.0.6"
|
||||
"sass": "^1.85.1",
|
||||
"svelte": "^5.23.0",
|
||||
"svelte-check": "^4.1.5",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.1.1",
|
||||
"vitest": "^3.0.8"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
3
src/app.d.ts
vendored
3
src/app.d.ts
vendored
@@ -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 {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { TagsForm } from "$lib/components/TagsForm";
|
||||
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$lib/components/TagsListBlock";
|
||||
|
||||
initializeAllTagsLists();
|
||||
watchForUpdatedTagLists();
|
||||
TagsForm.watchForEditors();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 "/";
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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!');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { getComponent } from "$lib/components/base/component-utils";
|
||||
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events";
|
||||
import { eventTagCustomGroupResolved } from "$lib/components/events/tag-dropdown-events";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
const categoriesResolver = new CustomCategoriesResolver();
|
||||
|
||||
@@ -51,6 +53,23 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
this.#updateButtons();
|
||||
}
|
||||
});
|
||||
|
||||
on(this, eventTagCustomGroupResolved, 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() {
|
||||
@@ -188,7 +207,7 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
* @param onActiveProfileChange Callback to call when profile was
|
||||
* changed.
|
||||
*/
|
||||
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile|null) => void) {
|
||||
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) {
|
||||
let lastActiveProfile: string | null = null;
|
||||
|
||||
this.#maintenanceSettings.subscribe((settings) => {
|
||||
|
||||
201
src/lib/components/TagsListBlock.ts
Normal file
201
src/lib/components/TagsListBlock.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
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 { eventFormEditorUpdated } from "$lib/components/events/tags-form-events";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { eventTagCustomGroupResolved } from "$lib/components/events/tag-dropdown-events";
|
||||
import TagSettings from "$lib/extension/settings/TagSettings";
|
||||
|
||||
export class TagsListBlock extends BaseComponent {
|
||||
#tagsListContainer: HTMLElement | null = null;
|
||||
#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.#tagsListContainer = this.container.querySelector('.tag-list');
|
||||
}
|
||||
|
||||
init() {
|
||||
this.#tagSettings.resolveGroupSeparation().then(this.#onTagSeparationChange.bind(this));
|
||||
this.#tagSettings.subscribe(settings => {
|
||||
this.#onTagSeparationChange(Boolean(settings.groupSeparation))
|
||||
});
|
||||
|
||||
on(
|
||||
this,
|
||||
eventTagCustomGroupResolved,
|
||||
this.#onTagDropdownCustomGroupResolved.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
#onTagSeparationChange(isSeparationEnabled: boolean) {
|
||||
if (this.#shouldDisplaySeparation === isSeparationEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#shouldDisplaySeparation = isSeparationEnabled;
|
||||
this.#reorderSeparatedGroups();
|
||||
}
|
||||
|
||||
#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));
|
||||
}
|
||||
}
|
||||
|
||||
#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}`;
|
||||
}
|
||||
}
|
||||
|
||||
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, eventFormEditorUpdated, event => {
|
||||
event.detail.closest('#image_tags_and_source')
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
|
||||
const instanceSymbol = Symbol('instance');
|
||||
const instanceSymbol = Symbol.for('instance');
|
||||
|
||||
interface ElementWithComponent<T> extends HTMLElement {
|
||||
[instanceSymbol]?: T;
|
||||
|
||||
@@ -3,12 +3,14 @@ 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";
|
||||
|
||||
type EventsMapping =
|
||||
MaintenancePopupEventsMap
|
||||
& FullscreenViewerEventsMap
|
||||
& BooruEventsMap
|
||||
& TagsFormEventsMap;
|
||||
& TagsFormEventsMap
|
||||
& TagDropdownEvents;
|
||||
|
||||
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
|
||||
export type UnsubscribeFunction = () => void;
|
||||
|
||||
7
src/lib/components/events/tag-dropdown-events.ts
Normal file
7
src/lib/components/events/tag-dropdown-events.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
export const eventTagCustomGroupResolved = 'tag-group-resolved';
|
||||
|
||||
export interface TagDropdownEvents {
|
||||
[eventTagCustomGroupResolved]: TagGroup | null;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 { eventTagCustomGroupResolved } 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,
|
||||
eventTagCustomGroupResolved,
|
||||
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,
|
||||
eventTagCustomGroupResolved,
|
||||
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,
|
||||
eventTagCustomGroupResolved,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
static #unprocessedTagsTimeout = 0;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
19
src/lib/extension/settings/TagSettings.ts
Normal file
19
src/lib/extension/settings/TagSettings.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,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
48
src/lib/popup-links.ts
Normal 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();
|
||||
}
|
||||
@@ -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/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
18
src/stores/preferences/tag.ts
Normal file
18
src/stores/preferences/tag.ts
Normal 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));
|
||||
});
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
186
tests/lib/extension/ConfigurationController.spec.ts
Normal file
186
tests/lib/extension/ConfigurationController.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
75
tests/lib/popup-links.spec.ts
Normal file
75
tests/lib/popup-links.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
27
tests/mocks/ChromeStorageChangeEvent.ts
Normal file
27
tests/mocks/ChromeStorageChangeEvent.ts
Normal 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
7
tests/utils.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user