mirror of
https://github.com/koloml/philomena-tagging-assistant.git
synced 2026-11-07 16:18:19 +00:00
Compare commits
41
Commits
515485b3b8
..
0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
223b72d9fb | ||
|
|
5432e63d6e | ||
|
|
a06fed078e | ||
|
|
221051ff41 | ||
|
|
87df78730b | ||
|
|
c6c48b2c36 | ||
|
|
5578e64306 | ||
|
|
7fd5a20b2f | ||
|
|
4dcddc3969 | ||
|
|
c7e1a95172 | ||
|
|
ecd95d3b4f | ||
|
|
f62c150724 | ||
|
|
665057d9f3 | ||
|
|
d75a9b434d | ||
|
|
bdac2692ce | ||
|
|
28c2f2f2e7 | ||
|
|
d150d87d89 | ||
|
|
690faa854f | ||
|
|
a2452a5fea | ||
|
|
b1c85efe03 | ||
|
|
34c0a11b4e | ||
|
|
f4e92e313a | ||
|
|
e1b56586be | ||
|
|
b87a8a43e0 | ||
|
|
d19764699f | ||
|
|
f873cae544 | ||
|
|
c23c871337 | ||
|
|
38d84d0830 | ||
|
|
4eecde6b02 | ||
|
|
394df69936 | ||
|
|
c268eb564d | ||
|
|
cfb4c71f58 | ||
|
|
67cd17d2b6 | ||
|
|
1b4cb284da | ||
|
|
7af6124278 | ||
|
|
51ea806ddc | ||
|
|
234db4f147 | ||
|
|
978918735d | ||
|
|
a6eae657c7 | ||
|
|
9a245ed0f5 | ||
|
|
3e5266ca7b |
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 179 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1015 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -14,12 +14,12 @@ jobs:
|
||||
name: 'Run Unit Tests'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '25'
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -14,12 +14,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '25'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
zip -r "../${{ matrix.site }}-tagging-assistant-extension.zip" .
|
||||
|
||||
- name: Upload extension artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.site }}-tagging-assistant-extension
|
||||
path: ${{ matrix.site }}-tagging-assistant-extension.zip
|
||||
@@ -51,12 +51,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create combined artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: all-extensions
|
||||
path: artifacts/
|
||||
|
||||
@@ -49,6 +49,11 @@ feature is opt-in and should be enabled in the settings first.
|
||||
|
||||

|
||||
|
||||
Fullscreen view now also has interface for quick addition and removal of tags. It will show both currently active
|
||||
tagging profile and tags currently assigned to the image.
|
||||
|
||||

|
||||
|
||||
## Building
|
||||
|
||||
Recommendations on environment:
|
||||
|
||||
+17
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Furbooru Tagging Assistant",
|
||||
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "furbooru-tagging-assistant@thecore.city",
|
||||
@@ -10,7 +10,8 @@
|
||||
"none"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gecko_android": {}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
@@ -28,7 +29,8 @@
|
||||
"*://*.furbooru.org/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/deps/amd.ts"
|
||||
"src/content/deps/amd.ts",
|
||||
"src/content/user-details.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -103,6 +105,18 @@
|
||||
"css": [
|
||||
"src/styles/content/tag-presets.scss"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/duplicate_reports",
|
||||
"*://*.furbooru.org/duplicate_reports?*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/duplicate-reports.ts"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/duplicate-reports.scss"
|
||||
]
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
|
||||
Generated
+357
-410
File diff suppressed because it is too large.
Load diff
+14
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -16,24 +16,27 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.65.0",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@sveltejs/kit": "^2.70.3",
|
||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||
"amd-lite": "^1.0.1",
|
||||
"lz-string": "^1.5.0",
|
||||
"sass": "^1.101.0",
|
||||
"svelte": "^5.56.3"
|
||||
"sass": "^1.105.1",
|
||||
"svelte": "^5.57.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@types/chrome": "^0.1.43",
|
||||
"@types/node": "^25.9.3",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@types/chrome": "^0.3.5",
|
||||
"@types/node": "^25.9.9",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"cheerio": "^1.2.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"svelte-check": "^4.7.6",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.3.5",
|
||||
"vitest": "^4.1.8"
|
||||
"vite": "^7.3.7",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=25"
|
||||
}
|
||||
}
|
||||
+45
-27
@@ -1,18 +1,24 @@
|
||||
/**
|
||||
* Enum-like object with categories used by the Philomena, available for calling in the code. Values are technical
|
||||
* names of these categories.
|
||||
*/
|
||||
export const CategoriesEnum = {
|
||||
Rating: 'rating',
|
||||
Spoiler: 'spoiler',
|
||||
Origin: 'origin',
|
||||
OC: 'oc',
|
||||
Error: 'error',
|
||||
Character: 'character',
|
||||
ContentOfficial: 'content-official',
|
||||
ContentFanmade: 'content-fanmade',
|
||||
Species: 'species',
|
||||
BodyType: 'body-type',
|
||||
}
|
||||
|
||||
/**
|
||||
* List of categories defined by the sites.
|
||||
*/
|
||||
export const categories: string[] = [
|
||||
'rating',
|
||||
'spoiler',
|
||||
'origin',
|
||||
'oc',
|
||||
'error',
|
||||
'character',
|
||||
'content-official',
|
||||
'content-fanmade',
|
||||
'species',
|
||||
'body-type',
|
||||
];
|
||||
export const categories: string[] = Object.values(CategoriesEnum);
|
||||
|
||||
/**
|
||||
* Mapping of namespaces to their respective categories. These namespaces are automatically assigned to them, so we can
|
||||
@@ -23,21 +29,21 @@ export const categories: string[] = [
|
||||
* @see https://github.com/philomena-dev/philomena/blob/6086757b654da8792ae52adb2a2f501ea6c30d12/lib/philomena/tags/tag.ex#L33-L45
|
||||
*/
|
||||
export const namespaceCategories: Map<string, string> = new Map([
|
||||
['artist', 'origin'],
|
||||
['art pack', 'content-fanmade'],
|
||||
['colorist', 'origin'],
|
||||
['comic', 'content-fanmade'],
|
||||
['editor', 'origin'],
|
||||
['fanfic', 'content-fanmade'],
|
||||
['oc', 'oc'],
|
||||
['photographer', 'origin'],
|
||||
['series', 'content-fanmade'],
|
||||
['spoiler', 'spoiler'],
|
||||
['video', 'content-fanmade'],
|
||||
...(__CURRENT_SITE__ === 'tantabus' ? <const> [
|
||||
["prompter", "origin"],
|
||||
["creator", "origin"],
|
||||
["generator", "origin"]
|
||||
['artist', CategoriesEnum.Origin],
|
||||
['art pack', CategoriesEnum.ContentFanmade],
|
||||
['colorist', CategoriesEnum.Origin],
|
||||
['comic', CategoriesEnum.ContentFanmade],
|
||||
['editor', CategoriesEnum.Origin],
|
||||
['fanfic', CategoriesEnum.ContentFanmade],
|
||||
['oc', CategoriesEnum.OC],
|
||||
['photographer', CategoriesEnum.Origin],
|
||||
['series', CategoriesEnum.ContentFanmade],
|
||||
['spoiler', CategoriesEnum.Spoiler],
|
||||
['video', CategoriesEnum.ContentFanmade],
|
||||
...(__CURRENT_SITE__ === 'tantabus' ? <const>[
|
||||
["prompter", CategoriesEnum.Origin],
|
||||
["creator", CategoriesEnum.Origin],
|
||||
["generator", CategoriesEnum.Origin]
|
||||
] : [])
|
||||
]);
|
||||
|
||||
@@ -128,3 +134,15 @@ export const tagsBlacklist: string[] = (__CURRENT_SITE__ === 'furbooru' ? [
|
||||
"solo oc",
|
||||
"tag your shit"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Core rating tags used in the Philomena. These rarely change, so they're pretty safe to hardcode into the source code.
|
||||
*/
|
||||
export const ratingTags: string[] = [
|
||||
'safe',
|
||||
'suggestive',
|
||||
'questionable',
|
||||
'explicit',
|
||||
'grimdark',
|
||||
'grotesque',
|
||||
];
|
||||
@@ -5,6 +5,7 @@ import type { BooruEventsMap } from "$content/components/events/booru-events";
|
||||
import type { TagsFormEventsMap } from "$content/components/events/tags-form-events";
|
||||
import type { TagDropdownEvents } from "$content/components/events/tag-dropdown-events";
|
||||
import type { PresetBlockEventsMap } from "$content/components/events/preset-block-events";
|
||||
import type { TagsListTogglerEventsMap } from "$content/components/events/tags-list-toggler-events";
|
||||
|
||||
type EventsMapping =
|
||||
TaggingProfilePopupEventsMap
|
||||
@@ -12,7 +13,8 @@ type EventsMapping =
|
||||
& BooruEventsMap
|
||||
& TagsFormEventsMap
|
||||
& TagDropdownEvents
|
||||
& PresetBlockEventsMap;
|
||||
& PresetBlockEventsMap
|
||||
& TagsListTogglerEventsMap;
|
||||
|
||||
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
|
||||
export type UnsubscribeFunction = () => void;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const EVENT_TAG_TOGGLED = 'tag-toggled';
|
||||
|
||||
export interface TagsListTogglerEventsMap {
|
||||
[EVENT_TAG_TOGGLED]: [string, boolean];
|
||||
}
|
||||
@@ -1,28 +1,62 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import MiscPreferences, { type FullscreenViewerSize } from "$lib/extension/preferences/MiscPreferences";
|
||||
import { emit, on } from "$content/components/events/comms";
|
||||
import { emit, on, type UnsubscribeFunction } from "$content/components/events/comms";
|
||||
import { EVENT_SIZE_LOADED } from "$content/components/events/fullscreen-viewer-events";
|
||||
import type { MediaBox } from "$content/components/philomena/MediaBox";
|
||||
import TagsListToggler from "$content/components/extension/profiles/TagsListToggler";
|
||||
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/tagging-profile-popup-events";
|
||||
import type TaggingProfile from "$entities/TaggingProfile";
|
||||
import { EVENT_TAG_TOGGLED } from "$content/components/events/tags-list-toggler-events";
|
||||
import type { TaggingProfilePopup } from "$content/components/extension/profiles/TaggingProfilePopup";
|
||||
|
||||
export class FullscreenViewer extends BaseComponent {
|
||||
#currentMediaBox: MediaBox | null = 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');
|
||||
#tagsPopupElement: HTMLElement = document.createElement('div');
|
||||
#imageTagsToggler: TagsListToggler = TagsListToggler.create();
|
||||
#profileTagsToggler: TagsListToggler = TagsListToggler.create();
|
||||
#tagsPopupHider: HTMLElement = document.createElement('div');
|
||||
#activeProfileListenerUnsubscriber: UnsubscribeFunction | null = null;
|
||||
#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() {
|
||||
this.container.classList.add('fullscreen-viewer');
|
||||
|
||||
this.#imageTagsToggler.initialize();
|
||||
this.#profileTagsToggler.initialize();
|
||||
|
||||
const overflowContainer = document.createElement('div');
|
||||
overflowContainer.classList.add('tags-panel__scroller');
|
||||
|
||||
overflowContainer.append(
|
||||
this.#profileTagsToggler.container,
|
||||
document.createElement('hr'),
|
||||
this.#imageTagsToggler.container,
|
||||
);
|
||||
|
||||
this.#tagsPopupElement.append(
|
||||
this.#tagsPopupHider,
|
||||
overflowContainer,
|
||||
);
|
||||
|
||||
this.#tagsPopupHider.classList.add('tags-panel__toggle');
|
||||
this.#tagsPopupHider.textContent = 'Toggle Tags';
|
||||
|
||||
this.#tagsPopupElement.classList.add('tags-panel');
|
||||
|
||||
this.container.append(
|
||||
this.#spinnerElement,
|
||||
this.#sizeSelectorElement,
|
||||
this.#closeButtonElement,
|
||||
this.#tagsPopupElement,
|
||||
);
|
||||
|
||||
this.#spinnerElement.classList.add('spinner', 'fa', 'fa-circle-notch', 'fa-spin');
|
||||
@@ -52,11 +86,20 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.#videoElement.addEventListener('loadeddata', this.#onLoaded.bind(this));
|
||||
this.#imageElement.addEventListener('load', this.#onLoaded.bind(this));
|
||||
this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation());
|
||||
this.#tagsPopupElement.addEventListener('click', event => event.stopPropagation());
|
||||
this.#tagsPopupHider.addEventListener('click', () => this.#togglePopupVisibility());
|
||||
|
||||
FullscreenViewer.#preferences
|
||||
.fullscreenViewerSize.get()
|
||||
.then(this.#onSizeResolved.bind(this))
|
||||
.then(this.#watchForSizeSelectionChanges.bind(this));
|
||||
|
||||
FullscreenViewer.#preferences
|
||||
.fullscreenViewerPopupShown.get()
|
||||
.then(this.#togglePopupVisibility.bind(this));
|
||||
|
||||
on(this.#profileTagsToggler, EVENT_TAG_TOGGLED, this.#onTagToggled.bind(this));
|
||||
on(this.#imageTagsToggler, EVENT_TAG_TOGGLED, this.#onTagToggled.bind(this));
|
||||
}
|
||||
|
||||
#onLoaded() {
|
||||
@@ -193,8 +236,8 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.#sizeSelectorElement.addEventListener('input', () => {
|
||||
const targetSize = this.#sizeSelectorElement.value;
|
||||
|
||||
if (this.#currentURIs) {
|
||||
void this.show(this.#currentURIs);
|
||||
if (this.#currentMediaBox) {
|
||||
void this.show(this.#currentMediaBox);
|
||||
}
|
||||
|
||||
if (!targetSize || targetSize === lastActiveSize || !(targetSize in FullscreenViewer.#previewSizes)) {
|
||||
@@ -206,8 +249,55 @@ export class FullscreenViewer extends BaseComponent {
|
||||
});
|
||||
}
|
||||
|
||||
#onTagToggled(event: CustomEvent<[string, boolean]>) {
|
||||
const sourceTogglerContainer = event.currentTarget;
|
||||
|
||||
if (!(sourceTogglerContainer instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceTogglerContainer !== this.#imageTagsToggler.container) {
|
||||
this.#imageTagsToggler.refreshStateFromChanges();
|
||||
}
|
||||
|
||||
if (sourceTogglerContainer !== this.#profileTagsToggler.container) {
|
||||
this.#profileTagsToggler.refreshStateFromChanges();
|
||||
}
|
||||
|
||||
this.#currentMediaBox?.tools?.popup?.tagsToggler.refreshStateFromChanges();
|
||||
}
|
||||
|
||||
#onActiveProfileChanged(eventOrTaggingProfile: CustomEvent<TaggingProfile | null> | TaggingProfile | null) {
|
||||
let resolvedActiveTaggingProfile: TaggingProfile | null = null;
|
||||
|
||||
if (eventOrTaggingProfile instanceof CustomEvent) {
|
||||
resolvedActiveTaggingProfile = eventOrTaggingProfile.detail;
|
||||
} else {
|
||||
resolvedActiveTaggingProfile = eventOrTaggingProfile;
|
||||
}
|
||||
|
||||
this.#profileTagsToggler.container.classList.toggle('is-hidden', !resolvedActiveTaggingProfile);
|
||||
|
||||
if (!resolvedActiveTaggingProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueTagsSet = this.#currentMediaBox?.uniqueTagsSet;
|
||||
|
||||
this.#profileTagsToggler.replaceTags(resolvedActiveTaggingProfile.settings.tags, uniqueTagsSet ?? new Set());
|
||||
}
|
||||
|
||||
#close() {
|
||||
this.#currentURIs = null;
|
||||
const taggingPopup = this.#currentMediaBox?.tools?.popup;
|
||||
|
||||
if (taggingPopup) {
|
||||
this.#activeProfileListenerUnsubscriber?.();
|
||||
|
||||
taggingPopup.tagsToggler.disconnectChangeSet();
|
||||
taggingPopup.toggleSubmissionLock(false);
|
||||
}
|
||||
|
||||
this.#currentMediaBox = null;
|
||||
|
||||
this.container.classList.remove(FullscreenViewer.#shownState);
|
||||
document.body.style.removeProperty('overflow');
|
||||
@@ -247,16 +337,23 @@ export class FullscreenViewer extends BaseComponent {
|
||||
return imageUris[targetSize as FullscreenViewerSize];
|
||||
}
|
||||
|
||||
async show(imageUris: App.ImageURIs): Promise<void> {
|
||||
this.#currentURIs = imageUris;
|
||||
async show(mediaBox: MediaBox): Promise<void> {
|
||||
const isSameMediaBox = this.#currentMediaBox === mediaBox;
|
||||
this.#currentMediaBox = mediaBox;
|
||||
|
||||
const url = await this.#resolveCurrentSelectedSizeUrl(imageUris);
|
||||
const url = await this.#resolveCurrentSelectedSizeUrl(mediaBox.imageLinks);
|
||||
|
||||
if (!url) {
|
||||
console.warn('Failed to resolve media for the viewer!');
|
||||
return;
|
||||
}
|
||||
|
||||
const taggingPopup = mediaBox?.tools?.popup;
|
||||
|
||||
if (taggingPopup && !isSameMediaBox) {
|
||||
this.#connectAndRefreshTagTogglers(taggingPopup, mediaBox);
|
||||
}
|
||||
|
||||
this.container.classList.add('loading');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
@@ -285,6 +382,45 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.container.append(this.#imageElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* When connecting to the new popup, we need to connect the tag editor inside the fullscreen viewer to the original
|
||||
* popup, since submission is still being sent from the popup inside media box.
|
||||
* @param taggingPopup
|
||||
* @param mediaBox
|
||||
* @private
|
||||
*/
|
||||
#connectAndRefreshTagTogglers(taggingPopup: TaggingProfilePopup, mediaBox: MediaBox) {
|
||||
taggingPopup.toggleSubmissionLock(true);
|
||||
|
||||
const sourceToggler = taggingPopup.tagsToggler;
|
||||
sourceToggler.connectChangeSet(this.#profileTagsToggler);
|
||||
sourceToggler.connectChangeSet(this.#imageTagsToggler);
|
||||
|
||||
const uniqueTagsSet = mediaBox.uniqueTagsSet ?? new Set();
|
||||
const uniqueTags = Array.from(uniqueTagsSet);
|
||||
|
||||
this.#imageTagsToggler.replaceTags(uniqueTags, uniqueTagsSet);
|
||||
|
||||
this.#activeProfileListenerUnsubscriber?.();
|
||||
this.#activeProfileListenerUnsubscriber = on(
|
||||
taggingPopup,
|
||||
EVENT_ACTIVE_PROFILE_CHANGED,
|
||||
this.#onActiveProfileChanged.bind(this),
|
||||
);
|
||||
|
||||
this.#onActiveProfileChanged(taggingPopup.activeProfile);
|
||||
}
|
||||
|
||||
#togglePopupVisibility(maybeState: boolean | undefined = undefined) {
|
||||
if (typeof maybeState === 'undefined') {
|
||||
const isHidden = this.#tagsPopupElement.classList.toggle(FullscreenViewer.#collapsedPopupState);
|
||||
void FullscreenViewer.#preferences.fullscreenViewerPopupShown.set(!isHidden);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#tagsPopupElement.classList.toggle(FullscreenViewer.#collapsedPopupState, !maybeState);
|
||||
}
|
||||
|
||||
static #isVideoUrl(url: string): boolean {
|
||||
return url.endsWith('.mp4') || url.endsWith('.webm');
|
||||
}
|
||||
@@ -296,6 +432,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
static #shownState = 'shown';
|
||||
static #swipeState = 'swiped';
|
||||
static #minRequiredDistance = 50;
|
||||
static #collapsedPopupState = 'is-collapsed';
|
||||
|
||||
static #previewSizes: Record<FullscreenViewerSize, string> = {
|
||||
full: 'Full',
|
||||
|
||||
@@ -45,15 +45,13 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
}
|
||||
|
||||
#onButtonClicked() {
|
||||
const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks;
|
||||
const mediaBox = this.#mediaBoxTools?.mediaBox;
|
||||
|
||||
if (!imageLinks) {
|
||||
if (!mediaBox) {
|
||||
throw new Error('Failed to resolve image links from media box tools!');
|
||||
}
|
||||
|
||||
ImageShowFullscreenButton
|
||||
.#resolveViewer()
|
||||
?.show(imageLinks);
|
||||
ImageShowFullscreenButton.#resolveViewer()?.show(mediaBox);
|
||||
}
|
||||
|
||||
static create(): HTMLElement {
|
||||
|
||||
@@ -50,6 +50,10 @@ export class MediaBoxTools extends BaseComponent {
|
||||
return this.#mediaBox;
|
||||
}
|
||||
|
||||
get popup(): TaggingProfilePopup | null {
|
||||
return this.#popup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a maintenance popup element.
|
||||
* @param childrenElements List of children elements to append to the component.
|
||||
|
||||
@@ -4,31 +4,22 @@ import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import ScrapedAPI from "$lib/philomena/scraping/ScrapedAPI";
|
||||
import { tagsBlacklist } from "$config/tags";
|
||||
import { emitterAt } from "$content/components/events/comms";
|
||||
import { emitterAt, on } from "$content/components/events/comms";
|
||||
import {
|
||||
EVENT_ACTIVE_PROFILE_CHANGED,
|
||||
EVENT_PROFILE_POPUP_STATE_CHANGED,
|
||||
EVENT_TAGS_UPDATED
|
||||
} from "$content/components/events/tagging-profile-popup-events";
|
||||
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
|
||||
import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
|
||||
import TagsListToggler, { BlackListedTagsEncounteredError } from "$content/components/extension/profiles/TagsListToggler";
|
||||
import { EVENT_TAG_TOGGLED } from "$content/components/events/tags-list-toggler-events";
|
||||
|
||||
class BlackListedTagsEncounteredError extends Error {
|
||||
constructor(tagName: string) {
|
||||
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
|
||||
cause: tagName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class TaggingProfilePopup extends BaseComponent {
|
||||
#tagsListElement: HTMLElement = document.createElement('div');
|
||||
#tagsList: HTMLElement[] = [];
|
||||
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
|
||||
#tagsListToggler = TagsListToggler.create();
|
||||
#activeProfile: TaggingProfile | null = null;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
#tagsToRemove: Set<string> = new Set();
|
||||
#tagsToAdd: Set<string> = new Set();
|
||||
#isSubmissionLocked: boolean = false;
|
||||
#isPlanningToSubmit: boolean = false;
|
||||
#isSubmitting: boolean = false;
|
||||
#tagsSubmissionTimer: Timeout | null = null;
|
||||
@@ -41,11 +32,8 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
this.container.innerHTML = '';
|
||||
this.container.classList.add('maintenance-popup');
|
||||
|
||||
this.#tagsListElement.classList.add('tags-list');
|
||||
|
||||
this.container.append(
|
||||
this.#tagsListElement,
|
||||
);
|
||||
this.#tagsListToggler.initialize();
|
||||
this.container.append(this.#tagsListToggler.container);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +55,7 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
this.#mediaBoxTools = mediaBoxTools;
|
||||
|
||||
TaggingProfilePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
|
||||
this.#tagsListElement.addEventListener('click', this.#handleTagClick.bind(this));
|
||||
on(this.#tagsListToggler, EVENT_TAG_TOGGLED, this.#onTagToggled.bind(this));
|
||||
|
||||
const mediaBox = this.#mediaBoxTools.mediaBox;
|
||||
|
||||
@@ -75,108 +63,58 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
throw new Error('Media box component not found!');
|
||||
}
|
||||
|
||||
mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this));
|
||||
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
|
||||
mediaBox.on('mouseout', this.#maybeEnqueueSubmission.bind(this));
|
||||
mediaBox.on('mouseover', this.#cancelQueuedSubmission.bind(this));
|
||||
}
|
||||
|
||||
get tagsToggler() {
|
||||
return this.#tagsListToggler;
|
||||
}
|
||||
|
||||
get activeProfile() {
|
||||
return this.#activeProfile;
|
||||
}
|
||||
|
||||
toggleSubmissionLock(shouldLockSubmission: boolean) {
|
||||
this.#isSubmissionLocked = shouldLockSubmission;
|
||||
|
||||
if (shouldLockSubmission) {
|
||||
this.#cancelQueuedSubmission();
|
||||
return;
|
||||
}
|
||||
|
||||
// First notify that tags might have changed after submission lock was released and then enqueue it.
|
||||
this.#onTagToggled();
|
||||
this.#maybeEnqueueSubmission();
|
||||
}
|
||||
|
||||
#onActiveProfileChanged(activeProfile: TaggingProfile | null) {
|
||||
this.#activeProfile = activeProfile;
|
||||
this.container.classList.toggle('is-active', activeProfile !== null);
|
||||
this.#refreshTagsList();
|
||||
|
||||
this.#refreshTagsToggler();
|
||||
|
||||
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
|
||||
}
|
||||
|
||||
#refreshTagsList() {
|
||||
#refreshTagsToggler() {
|
||||
if (!this.#mediaBoxTools?.mediaBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || [];
|
||||
const currentPostTags = new Set(this.#mediaBoxTools.mediaBox.tagsAndAliases?.keys() || []);
|
||||
|
||||
for (const tagElement of this.#tagsList) {
|
||||
tagElement.remove();
|
||||
}
|
||||
|
||||
for (const tagElement of this.#suggestedInvalidTags.values()) {
|
||||
tagElement.remove();
|
||||
}
|
||||
|
||||
this.#tagsList = new Array(activeProfileTagsList.length);
|
||||
this.#suggestedInvalidTags.clear();
|
||||
|
||||
const currentPostTags = this.#mediaBoxTools.mediaBox.tagsAndAliases;
|
||||
|
||||
activeProfileTagsList
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.forEach((tagName, index) => {
|
||||
const tagElement = TaggingProfilePopup.#buildTagElement(tagName);
|
||||
this.#tagsList[index] = tagElement;
|
||||
this.#tagsListElement.appendChild(tagElement);
|
||||
|
||||
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);
|
||||
|
||||
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
|
||||
if (tagsBlacklist.includes(tagName)) {
|
||||
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
|
||||
this.#suggestedInvalidTags.set(tagName, tagElement);
|
||||
} else {
|
||||
TaggingProfilePopup.#markTagElementWithCategory(
|
||||
tagElement,
|
||||
resolveTagCategoryFromTagName(tagName) ?? '',
|
||||
);
|
||||
}
|
||||
});
|
||||
this.#tagsListToggler.replaceTags(activeProfileTagsList, currentPostTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and process clicks made directly to the tags.
|
||||
*/
|
||||
#handleTagClick(event: MouseEvent) {
|
||||
const targetObject = event.target;
|
||||
#onTagToggled() {
|
||||
const hasPendingChanges = this.#tagsListToggler.hasChanges;
|
||||
|
||||
|
||||
if (!targetObject || !(targetObject instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tagElement: HTMLElement | null = targetObject;
|
||||
|
||||
if (!tagElement.classList.contains('tag')) {
|
||||
tagElement = tagElement.closest<HTMLElement>('.tag');
|
||||
}
|
||||
|
||||
if (!tagElement?.dataset.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = tagElement.dataset.name;
|
||||
|
||||
if (tagElement.classList.contains('is-present')) {
|
||||
const isToBeRemoved = tagElement.classList.toggle('is-removed');
|
||||
|
||||
if (isToBeRemoved) {
|
||||
this.#tagsToRemove.add(tagName);
|
||||
} else {
|
||||
this.#tagsToRemove.delete(tagName);
|
||||
}
|
||||
}
|
||||
|
||||
if (tagElement.classList.contains('is-missing')) {
|
||||
const isToBeAdded = tagElement.classList.toggle('is-added');
|
||||
|
||||
if (isToBeAdded) {
|
||||
this.#tagsToAdd.add(tagName);
|
||||
} else {
|
||||
this.#tagsToAdd.delete(tagName);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#tagsToAdd.size || this.#tagsToRemove.size) {
|
||||
if (hasPendingChanges) {
|
||||
// Notify only once, when first planning to submit
|
||||
if (!this.#isPlanningToSubmit) {
|
||||
TaggingProfilePopup.#notifyAboutPendingSubmission(true);
|
||||
@@ -188,7 +126,7 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
|
||||
// Whenever user undoes the change they wanted to do in the popup, it's better to not send the submission and just
|
||||
// do nothing.
|
||||
if (!this.#tagsToAdd.size && !this.#tagsToRemove.size && this.#isPlanningToSubmit) {
|
||||
if (!hasPendingChanges && this.#isPlanningToSubmit) {
|
||||
this.#isPlanningToSubmit = false;
|
||||
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'ready');
|
||||
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
|
||||
@@ -200,14 +138,14 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
#onMouseEnteredArea() {
|
||||
#cancelQueuedSubmission() {
|
||||
if (this.#tagsSubmissionTimer) {
|
||||
clearTimeout(this.#tagsSubmissionTimer);
|
||||
}
|
||||
}
|
||||
|
||||
#onMouseLeftArea() {
|
||||
if (this.#isPlanningToSubmit && !this.#isSubmitting) {
|
||||
#maybeEnqueueSubmission() {
|
||||
if (this.#isPlanningToSubmit && !this.#isSubmitting && !this.#isSubmissionLocked) {
|
||||
this.#tagsSubmissionTimer = setTimeout(
|
||||
this.#onSubmissionTimerPassed.bind(this),
|
||||
TaggingProfilePopup.#delayBeforeSubmissionMs
|
||||
@@ -216,7 +154,7 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
async #onSubmissionTimerPassed() {
|
||||
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
|
||||
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox || this.#isSubmissionLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -232,29 +170,7 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
try {
|
||||
maybeTagsAndAliasesAfterUpdate = await TaggingProfilePopup.#scrapedAPI.updateImageTags(
|
||||
this.#mediaBoxTools.mediaBox.imageId,
|
||||
tagsList => {
|
||||
for (let tagName of this.#tagsToRemove) {
|
||||
tagsList.delete(tagName);
|
||||
}
|
||||
|
||||
for (let tagName of this.#tagsToAdd) {
|
||||
tagsList.add(tagName);
|
||||
}
|
||||
|
||||
if (shouldAutoRemove) {
|
||||
for (let tagName of tagsBlacklist) {
|
||||
tagsList.delete(tagName);
|
||||
}
|
||||
} else {
|
||||
for (let tagName of tagsList) {
|
||||
if (tagsBlacklist.includes(tagName)) {
|
||||
throw new BlackListedTagsEncounteredError(tagName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tagsList;
|
||||
}
|
||||
this.#tagsListToggler.createTagsUpdater(shouldAutoRemove),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof BlackListedTagsEncounteredError) {
|
||||
@@ -277,10 +193,8 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
|
||||
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'complete');
|
||||
|
||||
this.#tagsToAdd.clear();
|
||||
this.#tagsToRemove.clear();
|
||||
|
||||
this.#refreshTagsList();
|
||||
this.#tagsListToggler.clearChangeSet();
|
||||
this.#refreshTagsToggler();
|
||||
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#isSubmitting = false;
|
||||
@@ -291,37 +205,13 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
|
||||
const activeTags = new Set(this.#mediaBoxTools.mediaBox.tagsAndAliases?.keys() || []);
|
||||
|
||||
if (!tagsAndAliases) {
|
||||
if (!activeTags.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstTagInList = this.#tagsList[0];
|
||||
|
||||
for (let tagName of tagsBlacklist) {
|
||||
if (tagsAndAliases.has(tagName)) {
|
||||
if (this.#suggestedInvalidTags.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagElement = TaggingProfilePopup.#buildTagElement(tagName);
|
||||
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
|
||||
tagElement.classList.add('is-present');
|
||||
|
||||
this.#suggestedInvalidTags.set(tagName, tagElement);
|
||||
|
||||
if (firstTagInList && firstTagInList.isConnected) {
|
||||
this.#tagsListElement.insertBefore(tagElement, firstTagInList);
|
||||
} else {
|
||||
this.#tagsListElement.appendChild(tagElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return this.container.classList.contains('is-active');
|
||||
this.#tagsListToggler.prependTags(tagsBlacklist, activeTags);
|
||||
}
|
||||
|
||||
static create(): HTMLElement {
|
||||
@@ -332,24 +222,6 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
return container;
|
||||
}
|
||||
|
||||
static #buildTagElement(tagName: string): HTMLElement {
|
||||
const tagElement = document.createElement('span');
|
||||
tagElement.classList.add('tag');
|
||||
tagElement.innerText = tagName;
|
||||
tagElement.dataset.name = tagName;
|
||||
|
||||
return tagElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the tag element with specified category.
|
||||
* @param tagElement Element to mark.
|
||||
* @param category Code name of category to mark.
|
||||
*/
|
||||
static #markTagElementWithCategory(tagElement: HTMLElement, category: string) {
|
||||
tagElement.dataset.tagCategory = category;
|
||||
tagElement.setAttribute('data-tag-category', category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller with maintenance settings.
|
||||
@@ -433,5 +305,5 @@ export class TaggingProfilePopup extends BaseComponent {
|
||||
/**
|
||||
* Amount of pending submissions or NULL if logic was not yet initialized.
|
||||
*/
|
||||
static #pendingSubmissionCount: number|null = null;
|
||||
static #pendingSubmissionCount: number | null = null;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { CategoriesEnum, tagsBlacklist } from "$config/tags";
|
||||
import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
|
||||
import { EVENT_TAG_TOGGLED } from "$content/components/events/tags-list-toggler-events";
|
||||
import { emit } from "$content/components/events/comms";
|
||||
import type { TagsUpdaterFunction } from "$lib/philomena/scraping/ScrapedAPI";
|
||||
|
||||
export default class TagsListToggler extends BaseComponent {
|
||||
#tagsList: HTMLElement[] = [];
|
||||
#invalidTagsList: Map<string, HTMLElement> = new Map();
|
||||
#tagsToAdd: Set<string> = new Set();
|
||||
#tagsToRemove: Set<string> = new Set();
|
||||
|
||||
protected build() {
|
||||
this.container.classList.add('tags-list');
|
||||
}
|
||||
|
||||
protected init() {
|
||||
this.container.addEventListener('click', this.#handleTagClick.bind(this));
|
||||
}
|
||||
|
||||
get hasChanges(): boolean {
|
||||
return (this.#tagsToAdd.size + this.#tagsToRemove.size) > 0;
|
||||
}
|
||||
|
||||
replaceTags(tags: string[], activeTags: Set<string>) {
|
||||
for (const tagElement of this.#tagsList) {
|
||||
tagElement.remove();
|
||||
}
|
||||
|
||||
for (const tagElement of this.#invalidTagsList.values()) {
|
||||
tagElement.remove();
|
||||
}
|
||||
|
||||
this.#tagsList = [];
|
||||
this.#invalidTagsList = new Map();
|
||||
|
||||
this.#renderAndInsertTags(tags, activeTags);
|
||||
}
|
||||
|
||||
prependTags(tags: string[], activeTags: Set<string>) {
|
||||
this.#renderAndInsertTags(tags, activeTags, true);
|
||||
}
|
||||
|
||||
refreshStateFromChanges() {
|
||||
for (const tagElement of this.#tagsList) {
|
||||
const tagName = tagElement.dataset.name;
|
||||
|
||||
if (!tagName) {
|
||||
throw new Error('Missing tag name!');
|
||||
}
|
||||
|
||||
tagElement.classList.toggle(TagsListToggler.#addedClass, this.#tagsToAdd.has(tagName));
|
||||
tagElement.classList.toggle(TagsListToggler.#removedClass, this.#tagsToRemove.has(tagName));
|
||||
}
|
||||
}
|
||||
|
||||
#renderAndInsertTags(toggleableTags: string[], activeTags: Set<string>, shouldPrepend: boolean = false) {
|
||||
toggleableTags.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
let lastInserted: HTMLElement|null = null;
|
||||
|
||||
const hasStoredChanges = this.hasChanges;
|
||||
|
||||
for (let tagIndex = 0; tagIndex < toggleableTags.length; tagIndex++) {
|
||||
const tagName = toggleableTags[tagIndex];
|
||||
const tagElement = TagsListToggler.#buildTagElement(tagName);
|
||||
|
||||
this.#tagsList.push(tagElement);
|
||||
|
||||
const isPresent = activeTags.has(tagName);
|
||||
|
||||
tagElement.classList.toggle(TagsListToggler.#missingClass, !isPresent);
|
||||
tagElement.classList.toggle(TagsListToggler.#containsClass, isPresent);
|
||||
|
||||
if (hasStoredChanges) {
|
||||
tagElement.classList.toggle(TagsListToggler.#addedClass, !isPresent && this.#tagsToAdd.has(tagName));
|
||||
tagElement.classList.toggle(TagsListToggler.#removedClass, isPresent && this.#tagsToRemove.has(tagName));
|
||||
}
|
||||
|
||||
const tagCategory = tagsBlacklist.includes(tagName) && CategoriesEnum.Error
|
||||
|| resolveTagCategoryFromTagName(tagName)
|
||||
|| '';
|
||||
|
||||
TagsListToggler.#markTagElementWithCategory(tagElement, tagCategory);
|
||||
|
||||
if (tagCategory === CategoriesEnum.Error) {
|
||||
this.#invalidTagsList.set(tagName, tagElement);
|
||||
}
|
||||
|
||||
if (shouldPrepend) {
|
||||
if (lastInserted) {
|
||||
lastInserted.after(tagElement);
|
||||
} else {
|
||||
this.container.prepend(tagElement);
|
||||
}
|
||||
|
||||
lastInserted = tagElement;
|
||||
} else {
|
||||
this.container.append(tagElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createTagsUpdater(shouldAutoRemoveInvalid: boolean): TagsUpdaterFunction {
|
||||
return tagsList => {
|
||||
for (let tagName of this.#tagsToRemove) {
|
||||
tagsList.delete(tagName);
|
||||
}
|
||||
|
||||
for (let tagName of this.#tagsToAdd) {
|
||||
tagsList.add(tagName);
|
||||
}
|
||||
|
||||
if (shouldAutoRemoveInvalid) {
|
||||
for (let tagName of tagsBlacklist) {
|
||||
tagsList.delete(tagName);
|
||||
}
|
||||
} else {
|
||||
for (let tagName of tagsList) {
|
||||
if (tagsBlacklist.includes(tagName)) {
|
||||
throw new BlackListedTagsEncounteredError(tagName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tagsList;
|
||||
}
|
||||
}
|
||||
|
||||
connectChangeSet(secondaryToggler: TagsListToggler) {
|
||||
secondaryToggler.#tagsToAdd = this.#tagsToAdd;
|
||||
secondaryToggler.#tagsToRemove = this.#tagsToRemove;
|
||||
}
|
||||
|
||||
disconnectChangeSet() {
|
||||
this.#tagsToAdd = new Set(this.#tagsToAdd.values());
|
||||
this.#tagsToRemove = new Set(this.#tagsToRemove.values());
|
||||
}
|
||||
|
||||
clearChangeSet() {
|
||||
this.#tagsToAdd.clear();
|
||||
this.#tagsToRemove.clear();
|
||||
}
|
||||
|
||||
#handleTagClick(event: MouseEvent) {
|
||||
const targetObject = event.target;
|
||||
|
||||
|
||||
if (!targetObject || !(targetObject instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tagElement: HTMLElement | null = targetObject;
|
||||
|
||||
if (!tagElement.classList.contains('tag')) {
|
||||
tagElement = tagElement.closest<HTMLElement>('.tag');
|
||||
}
|
||||
|
||||
if (!tagElement?.dataset.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = tagElement.dataset.name;
|
||||
|
||||
let willBeActive = false;
|
||||
|
||||
if (tagElement.classList.contains(TagsListToggler.#containsClass)) {
|
||||
const isToBeRemoved = tagElement.classList.toggle(TagsListToggler.#removedClass);
|
||||
willBeActive = !isToBeRemoved;
|
||||
|
||||
if (isToBeRemoved) {
|
||||
this.#tagsToRemove.add(tagName);
|
||||
} else {
|
||||
this.#tagsToRemove.delete(tagName);
|
||||
}
|
||||
}
|
||||
|
||||
if (tagElement.classList.contains(TagsListToggler.#missingClass)) {
|
||||
const isToBeAdded = tagElement.classList.toggle(TagsListToggler.#addedClass);
|
||||
willBeActive = isToBeAdded;
|
||||
|
||||
if (isToBeAdded) {
|
||||
this.#tagsToAdd.add(tagName);
|
||||
} else {
|
||||
this.#tagsToAdd.delete(tagName);
|
||||
}
|
||||
}
|
||||
|
||||
emit(this.container, EVENT_TAG_TOGGLED, [tagName, willBeActive]);
|
||||
}
|
||||
|
||||
static #buildTagElement(tagName: string): HTMLElement {
|
||||
const tagElement = document.createElement('span');
|
||||
tagElement.classList.add('tag');
|
||||
tagElement.innerText = tagName;
|
||||
tagElement.dataset.name = tagName;
|
||||
|
||||
return tagElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the tag element with specified category.
|
||||
* @param tagElement Element to mark.
|
||||
* @param category Code name of category to mark.
|
||||
*/
|
||||
static #markTagElementWithCategory(tagElement: HTMLElement, category: string) {
|
||||
tagElement.dataset.tagCategory = category;
|
||||
tagElement.setAttribute('data-tag-category', category);
|
||||
}
|
||||
|
||||
static create(): TagsListToggler {
|
||||
const tagsListContainer = document.createElement('div');
|
||||
return new TagsListToggler(tagsListContainer);
|
||||
}
|
||||
|
||||
static #containsClass = 'is-present';
|
||||
static #missingClass = 'is-missing';
|
||||
|
||||
static #addedClass = 'is-added';
|
||||
static #removedClass = 'is-removed';
|
||||
}
|
||||
|
||||
export class BlackListedTagsEncounteredError extends Error {
|
||||
constructor(tagName: string) {
|
||||
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
|
||||
cause: tagName
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
|
||||
export default class ImageContainer extends BaseComponent {
|
||||
#imageLink: HTMLAnchorElement | null = null;
|
||||
|
||||
protected init() {
|
||||
this.#imageLink = this.container.querySelector('a');
|
||||
}
|
||||
|
||||
extractActualTags(): string[] {
|
||||
return this.#imageLink?.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
}
|
||||
|
||||
extractTagsAndAliases(): string[] {
|
||||
return this.container.dataset.imageTagAliases?.split(', ') || [];
|
||||
}
|
||||
|
||||
extractImageLinks(): App.ImageURIs {
|
||||
const jsonUris = this.container?.dataset.uris;
|
||||
|
||||
if (!jsonUris) {
|
||||
throw new Error('Missing URIs!');
|
||||
}
|
||||
|
||||
return JSON.parse(jsonUris);
|
||||
}
|
||||
|
||||
extractSources(): string[] {
|
||||
const jsonSourceUrls = this.container.dataset.sourceUrls;
|
||||
let sourceUrls: string[] | null = null;
|
||||
|
||||
try {
|
||||
sourceUrls = JSON.parse(jsonSourceUrls || '[]');
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse source URLs for the image!', this, e);
|
||||
}
|
||||
|
||||
return sourceUrls || [];
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,22 @@ import { getComponent } from "$content/components/base/component-utils";
|
||||
import { buildTagsAndAliasesMap } from "$lib/philomena/tag-utils";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_TAGS_UPDATED } from "$content/components/events/tagging-profile-popup-events";
|
||||
import ImageContainer from "$content/components/philomena/ImageContainer";
|
||||
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
|
||||
|
||||
export class MediaBox extends BaseComponent {
|
||||
#thumbnailContainer: HTMLElement | null = null;
|
||||
#imageLinkElement: HTMLAnchorElement | null = null;
|
||||
#imageContainer: ImageContainer | null = null;
|
||||
#tagsAndAliases: Map<string, string> | null = null;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
|
||||
init() {
|
||||
this.#thumbnailContainer = this.container.querySelector('.image-container');
|
||||
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
|
||||
const imageContainerElement = this.container.querySelector('.image-container');
|
||||
|
||||
this.#imageContainer = imageContainerElement instanceof HTMLElement
|
||||
? new ImageContainer(imageContainerElement)
|
||||
: null;
|
||||
|
||||
this.#imageContainer?.initialize();
|
||||
|
||||
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
}
|
||||
@@ -27,12 +34,22 @@ export class MediaBox extends BaseComponent {
|
||||
}
|
||||
|
||||
#calculateMediaBoxTags() {
|
||||
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
|
||||
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
const tagAliases = this.#imageContainer?.extractTagsAndAliases() || [];
|
||||
const actualTags = this.#imageContainer?.extractActualTags() || [];
|
||||
|
||||
return buildTagsAndAliasesMap(tagAliases, actualTags);
|
||||
}
|
||||
|
||||
get uniqueTagsSet(): Set<string> | null {
|
||||
const tagsAndAliases = this.tagsAndAliases;
|
||||
|
||||
if (tagsAndAliases === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Set(tagsAndAliases.values());
|
||||
}
|
||||
|
||||
get tagsAndAliases(): Map<string, string> | null {
|
||||
if (!this.#tagsAndAliases) {
|
||||
this.#tagsAndAliases = this.#calculateMediaBoxTags();
|
||||
@@ -52,26 +69,31 @@ export class MediaBox extends BaseComponent {
|
||||
}
|
||||
|
||||
get imageLinks(): App.ImageURIs {
|
||||
const jsonUris = this.#thumbnailContainer?.dataset.uris;
|
||||
const sourceUrls = this.#imageContainer?.extractImageLinks();
|
||||
|
||||
if (!jsonUris) {
|
||||
throw new Error('Missing URIs!');
|
||||
if (!sourceUrls) {
|
||||
throw new Error('Missing image container!');
|
||||
}
|
||||
|
||||
return JSON.parse(jsonUris);
|
||||
return sourceUrls;
|
||||
}
|
||||
|
||||
get tools(): MediaBoxTools | null {
|
||||
return this.#mediaBoxTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the media box element into the special wrapper.
|
||||
*/
|
||||
static initialize(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
|
||||
new MediaBox(mediaBoxContainer)
|
||||
.initialize();
|
||||
static initialize(mediaBoxContainer: HTMLElement, mediaBoxToolsElement: HTMLElement) {
|
||||
const mediaBox = new MediaBox(mediaBoxContainer);
|
||||
mediaBox.initialize();
|
||||
|
||||
for (let childComponentElement of childComponentElements) {
|
||||
mediaBoxContainer.appendChild(childComponentElement);
|
||||
getComponent(childComponentElement)?.initialize();
|
||||
}
|
||||
mediaBoxContainer.appendChild(mediaBoxToolsElement);
|
||||
const mediaBoxToolsComponent = getComponent<MediaBoxTools>(mediaBoxToolsElement);
|
||||
mediaBoxToolsComponent?.initialize();
|
||||
|
||||
mediaBox.#mediaBoxTools = mediaBoxToolsComponent;
|
||||
}
|
||||
|
||||
static findElements(): NodeListOf<HTMLElement> {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { createFontAwesomeIcon } from "$lib/dom-utils";
|
||||
|
||||
const brandsSubtype = 'brands';
|
||||
|
||||
export default class DupeDiff extends BaseComponent {
|
||||
#sourcesLine: HTMLElement | null = null;
|
||||
#ratingsLine: HTMLElement | null = null;
|
||||
|
||||
protected init() {
|
||||
const {
|
||||
"6": sourcesLine,
|
||||
"7": ratingsLine,
|
||||
} = this.container.querySelectorAll<HTMLElement>('table tr > td')
|
||||
|
||||
this.#sourcesLine = sourcesLine;
|
||||
this.#ratingsLine = ratingsLine;
|
||||
}
|
||||
|
||||
renderSources(leftSources: string[], rightSources: string[]): void {
|
||||
if (!this.#sourcesLine) {
|
||||
console.error("Can't render sources since no sources line in diff fonud!");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const childElement of this.#sourcesLine.children) {
|
||||
childElement.remove();
|
||||
}
|
||||
|
||||
const sourceIconsContainer = document.createElement('span');
|
||||
sourceIconsContainer.classList.add('source-icons');
|
||||
|
||||
sourceIconsContainer.append(
|
||||
this.#renderSourceIcons(leftSources),
|
||||
" vs ",
|
||||
this.#renderSourceIcons(rightSources),
|
||||
);
|
||||
|
||||
this.#sourcesLine.append(sourceIconsContainer);
|
||||
}
|
||||
|
||||
renderRatings(leftRating: string | null, rightRating: string | null) {
|
||||
if (!this.#ratingsLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const childElement of this.#ratingsLine.children) {
|
||||
childElement.remove();
|
||||
}
|
||||
|
||||
if (leftRating === rightRating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ratingDifference = document.createElement('span');
|
||||
ratingDifference.classList.add('ratings-difference');
|
||||
ratingDifference.textContent = `(${leftRating || '(none)'} vs ${rightRating || '(none)'})`;
|
||||
|
||||
this.#ratingsLine.append(ratingDifference);
|
||||
}
|
||||
|
||||
#renderSourceIcons(sourcesList: string[]): HTMLElement {
|
||||
const iconsContainer = document.createElement('span');
|
||||
iconsContainer.classList.add('source-icons__list');
|
||||
|
||||
for (const sourceUrl of sourcesList) {
|
||||
iconsContainer.append(this.#renderIcon(sourceUrl));
|
||||
}
|
||||
|
||||
if (!sourcesList.length) {
|
||||
iconsContainer.append('(none)');
|
||||
}
|
||||
|
||||
return iconsContainer;
|
||||
}
|
||||
|
||||
#renderIcon(url: string): HTMLElement {
|
||||
let iconSlug = 'globe';
|
||||
let maybeSubtype: string | undefined;
|
||||
|
||||
for (const [iconOrIconWithSubtype, singleOrMultiPattern] of DupeDiff.#iconsToPatternsMap) {
|
||||
if (
|
||||
typeof singleOrMultiPattern === 'string' && url.includes(singleOrMultiPattern)
|
||||
|| Array.isArray(singleOrMultiPattern) && singleOrMultiPattern.some(singlePattern => url.includes(singlePattern))
|
||||
) {
|
||||
if (Array.isArray(iconOrIconWithSubtype)) {
|
||||
[iconSlug, maybeSubtype] = iconOrIconWithSubtype;
|
||||
} else {
|
||||
iconSlug = iconOrIconWithSubtype;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceIcon = createFontAwesomeIcon(iconSlug, maybeSubtype);
|
||||
sourceIcon.title = url;
|
||||
|
||||
return sourceIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirroring of mapping from source URL domains to appropriate FontAwesome icons which is used in Philomena. Doesn't
|
||||
* match 1-to-1, but pretty close.
|
||||
*
|
||||
* Keys are the icons and values are the patterns extension should check for each URL.
|
||||
*/
|
||||
static #iconsToPatternsMap = new Map<string[] | string, string[] | string>([
|
||||
[
|
||||
['artstation', brandsSubtype],
|
||||
'artstation.com'
|
||||
],
|
||||
[
|
||||
'bed',
|
||||
'pillowfort.social',
|
||||
],
|
||||
[
|
||||
'bolt-lightning',
|
||||
'boosty.to',
|
||||
],
|
||||
[
|
||||
['bluesky', brandsSubtype],
|
||||
'bsky.app',
|
||||
],
|
||||
[
|
||||
'brush',
|
||||
['artfight.net', 'newgrounds.com'],
|
||||
],
|
||||
[
|
||||
'coffee',
|
||||
['ko-fi.com', 'buymeacoffee.com'],
|
||||
],
|
||||
[
|
||||
['deviantart', brandsSubtype],
|
||||
['deviantart.com', 'sta.sh', 'fav.me'],
|
||||
],
|
||||
[
|
||||
['discord', brandsSubtype],
|
||||
['discordapp.com', 'discord.com', 'discord.gg'],
|
||||
],
|
||||
[
|
||||
'dove',
|
||||
'itaku.ee',
|
||||
],
|
||||
[
|
||||
['etsy', brandsSubtype],
|
||||
'etsy.com',
|
||||
],
|
||||
[
|
||||
['facebook', brandsSubtype],
|
||||
['facebook.com', 'fb.me'],
|
||||
],
|
||||
[
|
||||
['flickr', brandsSubtype],
|
||||
'flickr.com',
|
||||
],
|
||||
[
|
||||
['instagram', brandsSubtype],
|
||||
'instagram.com',
|
||||
],
|
||||
[
|
||||
['mastodon', brandsSubtype],
|
||||
[
|
||||
'awoo.space',
|
||||
'bark.light',
|
||||
'equestria.social',
|
||||
'mastodon.social',
|
||||
'meow.social',
|
||||
'pawoo.net',
|
||||
'pettingzoo.co',
|
||||
'pony.social',
|
||||
'vulpine.club',
|
||||
'yiff.life',
|
||||
'socel.net',
|
||||
'octodon.social',
|
||||
'filly.social',
|
||||
'pone.social',
|
||||
'hooves.social',
|
||||
'baraag.net',
|
||||
'furries.club',
|
||||
],
|
||||
],
|
||||
[
|
||||
'palette',
|
||||
['ych.art', 'commishes.com']
|
||||
],
|
||||
[
|
||||
['patreon', brandsSubtype],
|
||||
'patreon.com'
|
||||
],
|
||||
[
|
||||
'paw',
|
||||
['furaffinity.net', 'e621.net', 'furbooru.org', 'inkbunny.net', 'e926.net', 'sofurry.com', 'weasyl.co'],
|
||||
],
|
||||
[
|
||||
['pixiv', brandsSubtype],
|
||||
['pixiv.net', 'pixiv.me'],
|
||||
],
|
||||
[
|
||||
['reddit', brandsSubtype],
|
||||
['reddit.com', 'redd.it'],
|
||||
],
|
||||
// Patreon piracy website, applying custom icon to easily catch such posts.
|
||||
[
|
||||
'skull-crossbones',
|
||||
'kemono.cr',
|
||||
],
|
||||
[
|
||||
['telegram', brandsSubtype],
|
||||
't.me',
|
||||
],
|
||||
[
|
||||
['tiktok', brandsSubtype],
|
||||
'tiktok.com'
|
||||
],
|
||||
[
|
||||
['tumblr', brandsSubtype],
|
||||
['tumblr.com', 'tmblr.co', 'tumbex.com'],
|
||||
],
|
||||
[
|
||||
['vk', brandsSubtype],
|
||||
['vk.com', 'vk.ru'],
|
||||
],
|
||||
[
|
||||
['x-twitter', brandsSubtype],
|
||||
['x.com', 'twitter.com', 'twimg.com']
|
||||
],
|
||||
[
|
||||
['youtube', brandsSubtype],
|
||||
['youtube.com', 'youtu.be'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import ImageContainer from "$content/components/philomena/ImageContainer";
|
||||
|
||||
export default class DupeImage extends BaseComponent {
|
||||
readonly imageContainer: ImageContainer;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
super(container);
|
||||
|
||||
const imageContainerElement = container.querySelector<HTMLElement>('.image-container');
|
||||
|
||||
if (!imageContainerElement) {
|
||||
throw new Error('Missing image container inside the dupe row!');
|
||||
}
|
||||
|
||||
this.imageContainer = new ImageContainer(imageContainerElement);
|
||||
this.imageContainer.initialize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import DupeDiff from "$content/components/philomena/dupe/DupeDiff";
|
||||
import DupeImage from "$content/components/philomena/dupe/DupeImage";
|
||||
import { ratingTags } from "$config/tags";
|
||||
|
||||
export default class DupeReportRow extends BaseComponent {
|
||||
#leftImage: DupeImage;
|
||||
#rightImage: DupeImage;
|
||||
#difference: DupeDiff;
|
||||
|
||||
constructor(leftCell: HTMLElement, rightCell: HTMLElement, diffCell: HTMLElement, reportOptions: HTMLElement) {
|
||||
super(reportOptions);
|
||||
|
||||
this.#leftImage = new DupeImage(leftCell);
|
||||
this.#rightImage = new DupeImage(rightCell);
|
||||
this.#difference = new DupeDiff(diffCell);
|
||||
}
|
||||
|
||||
protected build() {
|
||||
this.#leftImage.initialize();
|
||||
this.#rightImage.initialize();
|
||||
this.#difference.initialize();
|
||||
}
|
||||
|
||||
protected init() {
|
||||
this.#extractAndRenderSourcesIcons();
|
||||
this.#extractAndDisplayRatings();
|
||||
}
|
||||
|
||||
#extractAndRenderSourcesIcons() {
|
||||
this.#difference.renderSources(
|
||||
this.#leftImage.imageContainer.extractSources(),
|
||||
this.#rightImage.imageContainer.extractSources(),
|
||||
);
|
||||
}
|
||||
|
||||
#extractAndDisplayRatings() {
|
||||
this.#difference.renderRatings(
|
||||
DupeReportRow.#extractRating(this.#leftImage),
|
||||
DupeReportRow.#extractRating(this.#rightImage),
|
||||
);
|
||||
}
|
||||
|
||||
static #extractRating(image: DupeImage): string | null {
|
||||
return image.imageContainer.extractActualTags().find(tagName => ratingTags.includes(tagName)) || null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import DupeReportRow from "$content/components/philomena/dupe/DupeReportRow";
|
||||
|
||||
export default class GridDupeReportsList extends BaseComponent {
|
||||
readonly #reports: DupeReportRow[] = [];
|
||||
|
||||
protected build() {
|
||||
const repotRowsList = this.container.querySelectorAll(':scope > tbody > tr');
|
||||
|
||||
for (const reportRow of repotRowsList) {
|
||||
const startingCell = reportRow.firstElementChild;
|
||||
const rightImageCell = startingCell?.nextElementSibling || null;
|
||||
const diffCell = rightImageCell?.nextElementSibling || null;
|
||||
const reportOptionsCell = diffCell?.nextElementSibling || null;
|
||||
|
||||
if (!(startingCell instanceof HTMLElement) || !(rightImageCell instanceof HTMLElement) || !(diffCell instanceof HTMLElement) || !(reportOptionsCell instanceof HTMLElement)) {
|
||||
console.error(`Unable to capture duplicate report!`, reportRow);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.#reports.push(
|
||||
new DupeReportRow(
|
||||
startingCell,
|
||||
rightImageCell,
|
||||
diffCell,
|
||||
reportOptionsCell,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected init() {
|
||||
for (const report of this.#reports) {
|
||||
report.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
static findAndInitialize() {
|
||||
for (const container of document.querySelectorAll<HTMLElement>('.dupe-report-list')) {
|
||||
new GridDupeReportsList(container).initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import GridDupeReportsList from "$content/components/philomena/dupe/GridDupeReportsList";
|
||||
|
||||
GridDupeReportsList.findAndInitialize();
|
||||
@@ -8,13 +8,11 @@ import { ImageListContainer } from "$content/components/philomena/listing/ImageL
|
||||
const mediaBoxes = MediaBox.findElements();
|
||||
|
||||
mediaBoxes.forEach(mediaBoxElement => {
|
||||
MediaBox.initialize(mediaBoxElement, [
|
||||
MediaBoxTools.create(
|
||||
TaggingProfilePopup.create(),
|
||||
TaggingProfileStatusIcon.create(),
|
||||
ImageShowFullscreenButton.create(),
|
||||
)
|
||||
]);
|
||||
MediaBox.initialize(mediaBoxElement, MediaBoxTools.create(
|
||||
TaggingProfilePopup.create(),
|
||||
TaggingProfileStatusIcon.create(),
|
||||
ImageShowFullscreenButton.create(),
|
||||
));
|
||||
|
||||
// Attempt to fix misplacement of media boxes
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { UserDetails } from "$lib/extension/preferences/UserDetails";
|
||||
|
||||
(async () => {
|
||||
const userDetails = new UserDetails();
|
||||
const userDataStore = document.querySelector<HTMLElement>('.js-datastore');
|
||||
|
||||
if (!userDataStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userDetails.isAuthorized.set(userDataStore.dataset.userIsSignedIn === 'true');
|
||||
})();
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* Reusable function to create icons from FontAwesome. Usable only for website, since extension doesn't host its own
|
||||
* copy of FA styles. Extension should use imports of SVGs inside CSS instead.
|
||||
* @param iconSlug Slug of the icon to be added.
|
||||
* @param [subtype="solid"] Subtype of the icon. Some icons only exist in specific subtypes.
|
||||
* @return Element with classes for FontAwesome icon added.
|
||||
*/
|
||||
export function createFontAwesomeIcon(iconSlug: string): HTMLElement {
|
||||
export function createFontAwesomeIcon(iconSlug: string, subtype: string = 'solid'): HTMLElement {
|
||||
const iconElement = document.createElement('i');
|
||||
iconElement.classList.add('fa-solid', `fa-${iconSlug}`);
|
||||
iconElement.classList.add(`fa-${subtype}`, `fa-${iconSlug}`);
|
||||
return iconElement;
|
||||
}
|
||||
@@ -2,7 +2,15 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export default class EntitiesController {
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
/**
|
||||
* Instance of storage helper used to store/read/subscribe to storage changes.
|
||||
*
|
||||
* Mainly exposed for the testing purposes. When class is loaded outside of extension context, will hold `null`
|
||||
* instead. Any operations of entities will throw an error in this case.
|
||||
*/
|
||||
static storage: StorageHelper | null = typeof chrome !== 'undefined'
|
||||
? new StorageHelper(chrome.storage.local)
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
|
||||
@@ -14,7 +22,11 @@ export default class EntitiesController {
|
||||
* @return List of entities of the given type.
|
||||
*/
|
||||
static async readAllEntities<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type): Promise<Type[]> {
|
||||
const rawEntities = await this.#storageHelper.read(entityName, {});
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
const rawEntities = await this.storage.read(entityName, {});
|
||||
|
||||
if (!rawEntities || Object.keys(rawEntities).length === 0) {
|
||||
return [];
|
||||
@@ -32,10 +44,14 @@ export default class EntitiesController {
|
||||
* @param entity Entity to update.
|
||||
*/
|
||||
static async updateEntity(entityName: string, entity: StorageEntity<Object>): Promise<void> {
|
||||
this.#storageHelper.write(
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
this.storage.write(
|
||||
entityName,
|
||||
Object.assign(
|
||||
await this.#storageHelper.read(
|
||||
await this.storage.read(
|
||||
entityName, {}
|
||||
),
|
||||
{
|
||||
@@ -52,9 +68,13 @@ export default class EntitiesController {
|
||||
* @param entityId ID of the entity to delete.
|
||||
*/
|
||||
static async deleteEntity(entityName: string, entityId: string): Promise<void> {
|
||||
const entities = await this.#storageHelper.read(entityName, {});
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
const entities = await this.storage.read(entityName, {});
|
||||
delete entities[entityId];
|
||||
this.#storageHelper.write(entityName, entities);
|
||||
this.storage.write(entityName, entities);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +88,12 @@ export default class EntitiesController {
|
||||
* @return Unsubscribe function.
|
||||
*/
|
||||
static subscribeToEntity<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type, callback: (entities: Type[]) => void): () => void {
|
||||
if (!this.storage) {
|
||||
throw new Error('Missing storage!');
|
||||
}
|
||||
|
||||
const storage = this.storage;
|
||||
|
||||
/**
|
||||
* Watch the changes made to the storage and call the callback when the entity changes.
|
||||
*/
|
||||
@@ -80,8 +106,8 @@ export default class EntitiesController {
|
||||
.then(callback);
|
||||
}
|
||||
|
||||
this.#storageHelper.subscribe(subscriber);
|
||||
storage.subscribe(subscriber);
|
||||
|
||||
return () => this.#storageHelper.unsubscribe(subscriber);
|
||||
return () => storage.unsubscribe(subscriber);
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,10 @@ export class PreferenceField<
|
||||
set(value: Fields[Key]) {
|
||||
return this.#preferences.writeRaw(this.#fieldKey, value);
|
||||
}
|
||||
|
||||
get key(): Key {
|
||||
return this.#fieldKey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ export type FullscreenViewerSize = keyof App.ImageURIs;
|
||||
interface MiscPreferencesFields {
|
||||
fullscreenViewer: boolean;
|
||||
fullscreenViewerSize: FullscreenViewerSize;
|
||||
fullscreenViewerPopupShown: boolean;
|
||||
}
|
||||
|
||||
export default class MiscPreferences extends CacheablePreferences<MiscPreferencesFields> implements WithFields<MiscPreferencesFields> {
|
||||
@@ -24,4 +25,9 @@ export default class MiscPreferences extends CacheablePreferences<MiscPreference
|
||||
field: "fullscreenViewerSize",
|
||||
defaultValue: "large",
|
||||
});
|
||||
|
||||
readonly fullscreenViewerPopupShown = new PreferenceField(this, {
|
||||
field: "fullscreenViewerPopupShown",
|
||||
defaultValue: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
|
||||
export interface UserDetailsFields {
|
||||
isAuthorized: boolean;
|
||||
}
|
||||
|
||||
export class UserDetails extends CacheablePreferences<UserDetailsFields> implements WithFields<UserDetailsFields> {
|
||||
constructor() {
|
||||
super('userDetails');
|
||||
}
|
||||
|
||||
isAuthorized = new PreferenceField(this, {
|
||||
field: 'isAuthorized',
|
||||
defaultValue: false,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import PostParser from "$lib/philomena/scraping/parsing/PostParser";
|
||||
|
||||
type UpdaterFunction = (tags: Set<string>) => Set<string>;
|
||||
export type TagsUpdaterFunction = (tags: Set<string>) => Set<string>;
|
||||
|
||||
export default class ScrapedAPI {
|
||||
/**
|
||||
@@ -9,7 +9,7 @@ export default class ScrapedAPI {
|
||||
* @param callback Callback to call to change the content.
|
||||
* @return Updated tags and aliases list for updating internal cached state.
|
||||
*/
|
||||
async updateImageTags(imageId: number, callback: UpdaterFunction): Promise<Map<string, string> | null> {
|
||||
async updateImageTags(imageId: number, callback: TagsUpdaterFunction): Promise<Map<string, string> | null> {
|
||||
const postParser = new PostParser(imageId);
|
||||
const formData = await postParser.resolveTagEditorFormData();
|
||||
const tagsFieldValue = formData.get(PostParser.tagsInputName);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Writable } from "svelte/store";
|
||||
import CacheablePreferences, { type PreferenceField } from "$lib/extension/base/CacheablePreferences";
|
||||
|
||||
interface PrerefenceConnectionOptions<ValueType> {
|
||||
/**
|
||||
* Optional callback to apply modifications to the value when sending them from the preference field to the single
|
||||
* storage. If not provided, value itself will be sent instead.
|
||||
*/
|
||||
updateToStore?: (value: any) => ValueType;
|
||||
}
|
||||
|
||||
export class PreferenceSync<PreferencesType extends Record<string, any> = Record<string, any>> {
|
||||
readonly #preferences: CacheablePreferences<PreferencesType>;
|
||||
readonly #syncedPairs: [PreferenceField<PreferencesType, string>, Writable<any>, PrerefenceConnectionOptions<any>][] = [];
|
||||
|
||||
protected constructor(preferences: CacheablePreferences<PreferencesType>) {
|
||||
this.#preferences = preferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect the specific preference field to the provided storage.
|
||||
* @param field Field object.
|
||||
* @param store Storage object. Should contain the appropriate type for the value.
|
||||
* @param [options] Additional settings.
|
||||
*/
|
||||
connect<FieldKey extends keyof PreferencesType>(
|
||||
field: PreferenceField<PreferencesType, FieldKey>,
|
||||
store: Writable<PreferencesType[FieldKey]>,
|
||||
options: PrerefenceConnectionOptions<PreferencesType[FieldKey]> = {}
|
||||
): this {
|
||||
this.#syncedPairs.push([field as any, store, options]);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async startSync(): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
this.#syncedPairs.map(
|
||||
([field, store, options]) => field.get()
|
||||
.then(initialValue => store.set(options.updateToStore?.(initialValue) ?? initialValue))
|
||||
)
|
||||
);
|
||||
|
||||
for (const [field, store] of this.#syncedPairs) {
|
||||
store.subscribe(value => field.set(value));
|
||||
}
|
||||
|
||||
this.#preferences.subscribe(updatedSettings => {
|
||||
for (const [field, store, options] of this.#syncedPairs) {
|
||||
if (!(field.key in updatedSettings)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedValue = updatedSettings[field.key];
|
||||
store.set(options.updateToStore?.(updatedValue) ?? updatedValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static for<
|
||||
PreferencesType extends Record<string, any> = Record<string, any>
|
||||
>(preferences: CacheablePreferences<PreferencesType>): PreferenceSync<PreferencesType> {
|
||||
return new PreferenceSync<PreferencesType>(preferences);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
import { activeTaggingProfile, taggingProfiles } from "$stores/entities/tagging-profiles";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
import { user } from "$stores/preferences/user";
|
||||
import Notice from "$components/ui/Notice.svelte";
|
||||
|
||||
$popupTitle = 'Tagging Profiles';
|
||||
|
||||
@@ -28,6 +30,18 @@
|
||||
<Menu>
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/profiles/new/edit" icon="plus">Create New</MenuItem>
|
||||
</Menu>
|
||||
{#if $user?.isAuthorized === false}
|
||||
<Menu>
|
||||
<hr>
|
||||
</Menu>
|
||||
<Notice level="warning">
|
||||
<strong>Tagging profiles will only work when you're signed in!</strong>
|
||||
<br><br>
|
||||
Unauthorized users have to solve the captcha before sending any submissions to the site.
|
||||
</Notice>
|
||||
{/if}
|
||||
<Menu>
|
||||
{#if profiles.length}
|
||||
<hr>
|
||||
{/if}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { writable } from "svelte/store";
|
||||
import MiscPreferences from "$lib/extension/preferences/MiscPreferences";
|
||||
import { PreferenceSync } from "$lib/store-sync";
|
||||
|
||||
export const fullScreenViewerEnabled = writable(true);
|
||||
|
||||
const preferences = new MiscPreferences();
|
||||
|
||||
Promise.allSettled([
|
||||
preferences.fullscreenViewer.get().then(v => fullScreenViewerEnabled.set(v))
|
||||
]).then(() => {
|
||||
fullScreenViewerEnabled.subscribe(value => {
|
||||
void preferences.fullscreenViewer.set(value);
|
||||
});
|
||||
|
||||
preferences.subscribe(settings => {
|
||||
fullScreenViewerEnabled.set(Boolean(settings.fullscreenViewer));
|
||||
});
|
||||
});
|
||||
void PreferenceSync.for(preferences)
|
||||
.connect(preferences.fullscreenViewer, fullScreenViewerEnabled, {updateToStore: Boolean})
|
||||
.startSync();
|
||||
@@ -1,18 +1,13 @@
|
||||
import { writable } from "svelte/store";
|
||||
import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
|
||||
import { PreferenceSync } from "$lib/store-sync";
|
||||
|
||||
export const stripBlacklistedTagsEnabled = writable(true);
|
||||
|
||||
const preferences = new TaggingProfilesPreferences();
|
||||
|
||||
Promise
|
||||
.all([
|
||||
preferences.stripBlacklistedTags.get().then(v => stripBlacklistedTagsEnabled.set(v ?? true))
|
||||
])
|
||||
.then(() => {
|
||||
preferences.subscribe(settings => {
|
||||
stripBlacklistedTagsEnabled.set(typeof settings.stripBlacklistedTags === 'boolean' ? settings.stripBlacklistedTags : true);
|
||||
});
|
||||
|
||||
stripBlacklistedTagsEnabled.subscribe(v => preferences.stripBlacklistedTags.set(v));
|
||||
});
|
||||
void PreferenceSync.for(preferences)
|
||||
.connect(preferences.stripBlacklistedTags, stripBlacklistedTagsEnabled, {
|
||||
updateToStore: maybeBoolean => typeof maybeBoolean === 'boolean' ? maybeBoolean : true,
|
||||
})
|
||||
.startSync();
|
||||
@@ -1,5 +1,6 @@
|
||||
import { writable } from "svelte/store";
|
||||
import TagsPreferences from "$lib/extension/preferences/TagsPreferences";
|
||||
import { PreferenceSync } from "$lib/store-sync";
|
||||
|
||||
const preferences = new TagsPreferences();
|
||||
|
||||
@@ -7,28 +8,8 @@ export const shouldSeparateTagGroups = writable(false);
|
||||
export const shouldReplaceLinksOnForumPosts = writable(false);
|
||||
export const shouldReplaceTextOfTagLinks = writable(true);
|
||||
|
||||
Promise
|
||||
.allSettled([
|
||||
preferences.groupSeparation.get().then(value => shouldSeparateTagGroups.set(value)),
|
||||
preferences.replaceLinks.get().then(value => shouldReplaceLinksOnForumPosts.set(value)),
|
||||
preferences.replaceLinkText.get().then(value => shouldReplaceTextOfTagLinks.set(value)),
|
||||
])
|
||||
.then(() => {
|
||||
shouldSeparateTagGroups.subscribe(value => {
|
||||
void preferences.groupSeparation.set(value);
|
||||
});
|
||||
|
||||
shouldReplaceLinksOnForumPosts.subscribe(value => {
|
||||
void preferences.replaceLinks.set(value);
|
||||
});
|
||||
|
||||
shouldReplaceTextOfTagLinks.subscribe(value => {
|
||||
void preferences.replaceLinkText.set(value);
|
||||
});
|
||||
|
||||
preferences.subscribe(settings => {
|
||||
shouldSeparateTagGroups.set(Boolean(settings.groupSeparation));
|
||||
shouldReplaceLinksOnForumPosts.set(Boolean(settings.replaceLinks));
|
||||
shouldReplaceTextOfTagLinks.set(Boolean(settings.replaceLinkText));
|
||||
});
|
||||
});
|
||||
void PreferenceSync.for(preferences)
|
||||
.connect(preferences.groupSeparation, shouldSeparateTagGroups, {updateToStore: Boolean})
|
||||
.connect(preferences.replaceLinks, shouldReplaceLinksOnForumPosts, {updateToStore: Boolean})
|
||||
.connect(preferences.replaceLinkText, shouldReplaceTextOfTagLinks, {updateToStore: Boolean})
|
||||
.startSync();
|
||||
@@ -0,0 +1,18 @@
|
||||
import { UserDetails, type UserDetailsFields } from "$lib/extension/preferences/UserDetails";
|
||||
import { readable } from "svelte/store";
|
||||
|
||||
const userDetails = new UserDetails();
|
||||
|
||||
export const user = readable<UserDetailsFields | null>(null, set => {
|
||||
userDetails.subscribe(settings => {
|
||||
set({
|
||||
isAuthorized: Boolean(settings.isAuthorized)
|
||||
});
|
||||
});
|
||||
|
||||
userDetails.isAuthorized.get().then(isAuthorized => {
|
||||
set({
|
||||
isAuthorized
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,15 @@ $padding-small: var(--padding-small);
|
||||
$padding-normal: var(--padding-normal);
|
||||
$padding-large: var(--padding-large);
|
||||
$block-spacing: var(--block-spacing);
|
||||
$block-header-light-color: var(--block-header-light-color);
|
||||
$block-header-link-text-color: var(--block-header-link-text-color);
|
||||
$border: var(--border);
|
||||
$header-spacing: var(--header-spacing);
|
||||
$block-header-sub-height: var(--block-header-sub-height);
|
||||
|
||||
// These variables are defined dynamically based on the category of the tag
|
||||
$resolved-tag-background: var(--tag-background);
|
||||
$resolved-tag-border: var(--tag-border);
|
||||
$resolved-tag-color: var(--tag-color);
|
||||
|
||||
$limited-layout-width: var(--limited-layout-width);
|
||||
@@ -0,0 +1,46 @@
|
||||
.dr__diff {
|
||||
.source-icons {
|
||||
&__list {
|
||||
display: inline-flex;
|
||||
gap: .25em;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
// Trying to make it more usable on mobile by regrouping table cells into grid.
|
||||
@media screen and (max-width: 800px) {
|
||||
.dupe-report-list {
|
||||
& > thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > tbody > tr {
|
||||
display: grid;
|
||||
grid-template-areas: "source target" "diff actions";
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
.dupe-report-list--image {
|
||||
grid-area: source;
|
||||
width: auto;
|
||||
|
||||
+ .dupe-report-list--image {
|
||||
grid-area: target;
|
||||
}
|
||||
}
|
||||
|
||||
.dr__diff {
|
||||
grid-area: diff;
|
||||
|
||||
& + td {
|
||||
grid-area: actions;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,38 @@
|
||||
@use '$styles/booru-vars';
|
||||
@use '$styles/environment';
|
||||
|
||||
%box {
|
||||
background: booru-vars.$background-color;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
%tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
.tag {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
%toggleable-tag {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0 5px;
|
||||
gap: 0;
|
||||
|
||||
&:hover {
|
||||
background: booru-vars.$resolved-tag-color;
|
||||
color: booru-vars.$resolved-tag-background;
|
||||
}
|
||||
|
||||
&.is-missing:not(.is-added),
|
||||
&.is-present.is-removed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// This will fix wierd misplacing of the modified media boxes in the listing.
|
||||
.js-resizable-media-container {
|
||||
display: flex;
|
||||
@@ -40,14 +72,14 @@
|
||||
}
|
||||
|
||||
.maintenance-popup {
|
||||
@extend %box;
|
||||
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
padding: 5px;
|
||||
left: -50%;
|
||||
right: -50%;
|
||||
z-index: 8;
|
||||
background: booru-vars.$background-color;
|
||||
border: {
|
||||
left: booru-vars.$media-border;
|
||||
right: booru-vars.$media-border;
|
||||
@@ -55,31 +87,13 @@
|
||||
};
|
||||
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
@extend %tags-list;
|
||||
|
||||
.tag {
|
||||
margin: 0;
|
||||
}
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tag {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0 5px;
|
||||
gap: 0;
|
||||
|
||||
&:hover {
|
||||
background: booru-vars.$resolved-tag-color;
|
||||
color: booru-vars.$resolved-tag-background;
|
||||
}
|
||||
|
||||
&.is-missing:not(.is-added),
|
||||
&.is-present.is-removed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@extend %toggleable-tag;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,4 +258,64 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tags-panel {
|
||||
@extend %box;
|
||||
|
||||
display: flex;
|
||||
z-index: 1;
|
||||
width: 96vw;
|
||||
max-width: booru-vars.$limited-layout-width;
|
||||
max-height: 50vh;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
transition: transform .25s ease-in-out;
|
||||
border: {
|
||||
top: booru-vars.$media-border;
|
||||
left: booru-vars.$media-border;
|
||||
right: booru-vars.$media-border;
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
bottom: calc(100% + 1px);
|
||||
border: booru-vars.$border;
|
||||
background: booru-vars.$block-header-light-color;
|
||||
color: booru-vars.$block-header-link-text-color;
|
||||
font-weight: 700;
|
||||
padding: 0 calc(#{booru-vars.$header-spacing} * .5);
|
||||
line-height: booru-vars.$block-header-sub-height;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__scroller {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
&.is-collapsed {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
@extend %tags-list;
|
||||
|
||||
&.is-hidden {
|
||||
display: none;
|
||||
|
||||
& + hr {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag {
|
||||
@extend %toggleable-tag;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import EntitiesController from "$lib/extension/EntitiesController";
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import { TestedEntity, type TestedSettings } from "$tests/stubs/Entity";
|
||||
import { randomString } from "$tests/utils";
|
||||
import { randomInt } from "crypto";
|
||||
|
||||
describe('EntitiesController', () => {
|
||||
let mockedStorage: ChromeStorageArea;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedStorage = new ChromeStorageArea();
|
||||
EntitiesController.storage = new StorageHelper(mockedStorage);
|
||||
});
|
||||
|
||||
it('should throw when storage is not present', async () => {
|
||||
EntitiesController.storage = null;
|
||||
|
||||
const readPromise = EntitiesController.readAllEntities(
|
||||
TestedEntity._entityName,
|
||||
TestedEntity,
|
||||
);
|
||||
|
||||
const deletePromise = EntitiesController.deleteEntity(
|
||||
TestedEntity._entityName,
|
||||
randomString(),
|
||||
);
|
||||
|
||||
const updatePromise = EntitiesController.updateEntity(
|
||||
TestedEntity._entityName,
|
||||
new TestedEntity(randomString(), {
|
||||
numberField: randomInt(1000),
|
||||
stringField: randomString(),
|
||||
}),
|
||||
);
|
||||
|
||||
const subscribe = () => {
|
||||
EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, vi.fn());
|
||||
}
|
||||
|
||||
await expect(readPromise).rejects.toThrow(Error);
|
||||
await expect(deletePromise).rejects.toThrow(Error);
|
||||
await expect(updatePromise).rejects.toThrow(Error);
|
||||
|
||||
expect(subscribe).toThrow(Error);
|
||||
});
|
||||
|
||||
describe('readAllEntities', () => {
|
||||
it('should return empty array when nothing in the storage yet', async () => {
|
||||
const entities = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
expect(entities).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should properly capture different entities from storage', async () => {
|
||||
const storageWithEntities: Record<string, Record<string, Partial<TestedSettings>>> = {
|
||||
[TestedEntity._entityName]: {
|
||||
[randomString()]: {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100_000, 100_000),
|
||||
},
|
||||
[randomString()]: {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100_000, 100_000),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
mockedStorage.insertMockedData(storageWithEntities);
|
||||
|
||||
const loadedEntities = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
|
||||
expect(loadedEntities).toHaveLength(2);
|
||||
|
||||
for (const entity of loadedEntities) {
|
||||
const rawStorageEntry = storageWithEntities[TestedEntity._entityName][entity.id];
|
||||
|
||||
expect(entity.settings.stringField).toBe(rawStorageEntry.stringField);
|
||||
expect(entity.settings.numberField).toBe(rawStorageEntry.numberField);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEntity', () => {
|
||||
it('should create a storage structure if it is not created yet', async () => {
|
||||
expect(mockedStorage.mockedData).toEqual({});
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(100_000),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[entity.id]: {
|
||||
stringField: entity.settings.stringField,
|
||||
numberField: entity.settings.numberField,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should update entity inside the existing', async () => {
|
||||
const id = randomString();
|
||||
const initialStringValue = randomString();
|
||||
const updatedStringValue = randomString();
|
||||
|
||||
mockedStorage.insertMockedData({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: {
|
||||
stringField: initialStringValue,
|
||||
numberField: randomInt(100_000),
|
||||
} as TestedSettings,
|
||||
}
|
||||
});
|
||||
|
||||
const [entity] = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
|
||||
expect(entity.settings.stringField).toBe(initialStringValue);
|
||||
|
||||
entity.settings.stringField = updatedStringValue;
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
const entityInsideStorage = mockedStorage.mockedData[TestedEntity._entityName][id];
|
||||
expect(entityInsideStorage.stringField).toBe(updatedStringValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEntity', () => {
|
||||
it('should initialize the storage structure if delete called', async () => {
|
||||
expect(mockedStorage.mockedData).toEqual({});
|
||||
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, randomString());
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete entity and keep the storage object empty', async () => {
|
||||
const id = randomString();
|
||||
const settings: TestedSettings = {
|
||||
numberField: randomInt(100_000),
|
||||
stringField: randomString(),
|
||||
};
|
||||
|
||||
mockedStorage.insertMockedData({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: settings,
|
||||
}
|
||||
});
|
||||
|
||||
// Doesn't touch existing instance if ID is not found in the storage
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, randomString());
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: settings,
|
||||
}
|
||||
});
|
||||
|
||||
await EntitiesController.deleteEntity(TestedEntity._entityName, id);
|
||||
|
||||
expect(mockedStorage.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeToEntity', () => {
|
||||
it('should notify about changes and return new entities', async () => {
|
||||
let receivedEntities: TestedEntity[] | null = null;
|
||||
|
||||
const subscriber = vi.fn((entities: TestedEntity[]) => {
|
||||
receivedEntities = entities;
|
||||
});
|
||||
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, subscriber);
|
||||
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
|
||||
const createdEntity = new TestedEntity(randomString(), {
|
||||
numberField: randomInt(100),
|
||||
stringField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, createdEntity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalled();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
const [firstReceivedEntity] = receivedEntities || [];
|
||||
|
||||
expect(firstReceivedEntity).toBeInstanceOf(TestedEntity);
|
||||
expect(firstReceivedEntity).not.toBe(createdEntity);
|
||||
expect(firstReceivedEntity).toEqual(createdEntity);
|
||||
});
|
||||
|
||||
it('should stop receiving updates once unsubscribed', async () => {
|
||||
const firstSubscriber = vi.fn();
|
||||
const unsubscribeFirst = EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, firstSubscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
numberField: randomInt(100_000),
|
||||
stringField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(firstSubscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
firstSubscriber.mockReset();
|
||||
unsubscribeFirst();
|
||||
|
||||
const secondSubscriber = vi.fn();
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, secondSubscriber);
|
||||
|
||||
entity.settings.stringField = randomString();
|
||||
|
||||
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(secondSubscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
expect(firstSubscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not notify when something else was changed in the storage', async () => {
|
||||
const rawStorageSubscriber = vi.fn();
|
||||
const entitiesSubscriber = vi.fn();
|
||||
|
||||
void EntitiesController.storage?.subscribe(rawStorageSubscriber);
|
||||
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, entitiesSubscriber);
|
||||
|
||||
EntitiesController.storage?.write('otherStorage', {
|
||||
someField: randomString(),
|
||||
});
|
||||
|
||||
await EntitiesController.updateEntity(
|
||||
TestedEntity._entityName,
|
||||
new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(100_000),
|
||||
}),
|
||||
);
|
||||
|
||||
EntitiesController.storage?.write('otherStorage', {
|
||||
someField: randomString(),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(entitiesSubscriber).toHaveBeenCalledOnce();
|
||||
expect(rawStorageSubscriber).toHaveBeenCalledTimes(3);
|
||||
}, {timeout: 10, interval: 1});
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,6 @@
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import { randomString } from "$tests/utils";
|
||||
import { randomInt } from "crypto";
|
||||
|
||||
interface TestedFields {
|
||||
numberField: number;
|
||||
stringField: string;
|
||||
}
|
||||
|
||||
class TestedPreferences extends CacheablePreferences<TestedFields> implements WithFields<TestedFields> {
|
||||
readonly defaults: TestedFields;
|
||||
readonly mockedSettingsNamespace: string;
|
||||
readonly mockedStorageArea: ChromeStorageArea;
|
||||
readonly mockedStorageHelper: StorageHelper;
|
||||
|
||||
numberField;
|
||||
stringField;
|
||||
|
||||
constructor(settingsNamespace: string, mockedDefaults: TestedFields) {
|
||||
const mockedStorageArea = new ChromeStorageArea();
|
||||
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
|
||||
const mockedConfigurationController = new ConfigurationController(
|
||||
settingsNamespace,
|
||||
mockedStorageHelper,
|
||||
);
|
||||
|
||||
super(settingsNamespace, mockedConfigurationController);
|
||||
|
||||
this.mockedSettingsNamespace = settingsNamespace;
|
||||
this.mockedStorageArea = mockedStorageArea;
|
||||
this.mockedStorageHelper = mockedStorageHelper;
|
||||
this.defaults = mockedDefaults;
|
||||
|
||||
this.numberField = new PreferenceField(this, {
|
||||
field: 'numberField',
|
||||
defaultValue: this.defaults.numberField,
|
||||
});
|
||||
|
||||
this.stringField = new PreferenceField(this, {
|
||||
field: 'stringField',
|
||||
defaultValue: this.defaults.stringField,
|
||||
});
|
||||
}
|
||||
}
|
||||
import { TestedPreferences } from "$tests/stubs/Preferences";
|
||||
|
||||
describe('CachablePreferences', () => {
|
||||
let preferences: TestedPreferences;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import { randomString } from "$tests/utils";
|
||||
import { randomInt } from "crypto";
|
||||
import EntitiesController from "$lib/extension/EntitiesController";
|
||||
import { TestedEntity } from "$tests/stubs/Entity";
|
||||
|
||||
describe("StorageEntity", () => {
|
||||
let mockedStorageArea: ChromeStorageArea;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedStorageArea = new ChromeStorageArea();
|
||||
EntitiesController.storage = new StorageHelper(mockedStorageArea);
|
||||
});
|
||||
|
||||
describe("readAll", () => {
|
||||
it("should return empty array if no entities stored", async () => {
|
||||
const entities = await TestedEntity.readAll();
|
||||
|
||||
expect(entities).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should read all entities from storage", async () => {
|
||||
const entity1 = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
const entity2 = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity1.save();
|
||||
await entity2.save();
|
||||
|
||||
const entities = await TestedEntity.readAll();
|
||||
|
||||
expect(entities).toHaveLength(2);
|
||||
expect(entities[0].id).toBe(entity1.id);
|
||||
expect(entities[0].settings).toEqual(entity1.settings);
|
||||
expect(entities[1].id).toBe(entity2.id);
|
||||
expect(entities[1].settings).toEqual(entity2.settings);
|
||||
});
|
||||
|
||||
it("should build entities with correct class", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
const [savedEntity] = await TestedEntity.readAll();
|
||||
|
||||
expect(savedEntity).toBeInstanceOf(TestedEntity);
|
||||
});
|
||||
});
|
||||
|
||||
describe("save", () => {
|
||||
it("should save entity to storage", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
expect(mockedStorageArea.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[entity.id]: entity.settings,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should overwrite existing entity with same ID", async () => {
|
||||
const id = randomString();
|
||||
const originalSettings = {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
};
|
||||
|
||||
const entity1 = new TestedEntity(id, originalSettings);
|
||||
await entity1.save();
|
||||
|
||||
const updatedSettings = {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
};
|
||||
|
||||
const entity2 = new TestedEntity(id, updatedSettings);
|
||||
await entity2.save();
|
||||
|
||||
expect(mockedStorageArea.mockedData).toEqual({
|
||||
[TestedEntity._entityName]: {
|
||||
[id]: updatedSettings,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should delete entity from storage", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
expect(mockedStorageArea.mockedData[TestedEntity._entityName]).not.toEqual({});
|
||||
|
||||
await entity.delete();
|
||||
expect(mockedStorageArea.mockedData[TestedEntity._entityName]).toEqual({});
|
||||
});
|
||||
|
||||
it("should not fail if entity does not exist", async () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await expect(entity.delete()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribe", () => {
|
||||
it("should notify about new entities", async () => {
|
||||
const subscriber = vi.fn();
|
||||
void TestedEntity.subscribe(subscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
// Saving is not notified about immediately.
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
expect(subscriber).toHaveBeenCalledWith([entity]);
|
||||
}, {timeout: 10, interval: 1});
|
||||
});
|
||||
|
||||
it("should notify about entity updates", async () => {
|
||||
const subscriber = vi.fn();
|
||||
void TestedEntity.subscribe(subscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
subscriber.mockReset();
|
||||
|
||||
entity.settings.stringField = randomString();
|
||||
await entity.save();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
expect(subscriber).toHaveBeenCalledWith([entity]);
|
||||
}, {interval: 1, timeout: 10});
|
||||
});
|
||||
|
||||
it("should notify about entity deletion", async () => {
|
||||
const subscriber = vi.fn();
|
||||
void TestedEntity.subscribe(subscriber);
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
await entity.delete();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriber).toHaveBeenCalledTimes(2);
|
||||
}, {interval: 1, timeout: 10});
|
||||
|
||||
expect(subscriber).toHaveBeenCalledWith([entity]);
|
||||
expect(subscriber).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("should stop notifications after unsubscribe", async () => {
|
||||
const subscriber = vi.fn();
|
||||
const unsubscribe = TestedEntity.subscribe(subscriber);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
await entity.save();
|
||||
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("properties", () => {
|
||||
it("should expose id", () => {
|
||||
const id = randomString();
|
||||
const entity = new TestedEntity(id, {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
expect(entity.id).toBe(id);
|
||||
});
|
||||
|
||||
it("should expose settings", () => {
|
||||
const settings = {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
};
|
||||
|
||||
const entity = new TestedEntity(randomString(), settings);
|
||||
|
||||
expect(entity.settings).toEqual(settings);
|
||||
});
|
||||
|
||||
it("should expose type from _entityName", () => {
|
||||
const entity = new TestedEntity(randomString(), {
|
||||
stringField: randomString(),
|
||||
numberField: randomInt(-100000, 100000),
|
||||
});
|
||||
|
||||
expect(entity.type).toBe(TestedEntity._entityName);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +1,141 @@
|
||||
import { URL } from 'url';
|
||||
import { resolveTagNameFromLink, slugEncodedCharacters } from '$lib/philomena/tag-utils';
|
||||
import {
|
||||
buildTagsAndAliasesMap,
|
||||
resolveTagCategoryFromTagName,
|
||||
resolveTagNameFromLink,
|
||||
slugEncodedCharacters
|
||||
} from '$lib/philomena/tag-utils';
|
||||
import { randomString } from "$tests/utils";
|
||||
import { namespaceCategories } from "$config/tags";
|
||||
|
||||
describe('tag-utils', () => {
|
||||
const origin = 'https://furbooru.org';
|
||||
const origin = 'https://furbooru.org';
|
||||
|
||||
describe('resolveTagNameFromLink', () => {
|
||||
function resolveFromSearchQuery(encodedQuery: string): string | null {
|
||||
return resolveTagNameFromLink(new URL(`/search?q=${encodedQuery}`, origin));
|
||||
}
|
||||
describe('buildTagsAndAliasesMap', () => {
|
||||
it('should return regular tags if both real and real+alias tags are the same', () => {
|
||||
const tagsAndAliases = ['avali', 'experiment (casualties unknown)', 'fictional species'];
|
||||
|
||||
describe('Parsing from /search/?q=tag links', () => {
|
||||
it('should resolve a single tag from /search URLs', () => {
|
||||
expect(resolveFromSearchQuery('safe')).toBe('safe');
|
||||
});
|
||||
|
||||
it('should return null for queries with multiple comma-separated tags', () => {
|
||||
// Comma acts as a separator in the query, resulting in multiple tokens
|
||||
expect(resolveFromSearchQuery('safe, suggestive')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null if query is empty or not a term', () => {
|
||||
expect(resolveFromSearchQuery('')).toBe(null);
|
||||
expect(resolveFromSearchQuery('!')).toBe(null);
|
||||
});
|
||||
|
||||
it('should properly treat parentheses in the query with single tag', () => {
|
||||
// Parentheses are operators in the query language, but when inside the tag name, they should still be properly
|
||||
// working.
|
||||
expect(resolveFromSearchQuery('experiment (casualties unknown)')).toBe('experiment (casualties unknown)');
|
||||
});
|
||||
|
||||
it('should properly resolve queries with encoded characters', () => {
|
||||
expect(resolveFromSearchQuery('pok%C3%A9mon')).toBe('pokémon');
|
||||
});
|
||||
|
||||
it('should unquote quoted term', () => {
|
||||
expect(resolveFromSearchQuery('"experiment (casualties unknown)"')).toBe('experiment (casualties unknown)')
|
||||
expect(resolveFromSearchQuery('"single tag, really"')).toBe('single tag, really');
|
||||
});
|
||||
})
|
||||
|
||||
describe('Parsing from /tags/name links', () => {
|
||||
function resolveFromTagLink(encodedTagName: string): string | null {
|
||||
return resolveTagNameFromLink(new URL(`/tags/${encodedTagName}`, origin));
|
||||
expect(buildTagsAndAliasesMap(tagsAndAliases, tagsAndAliases)).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"avali" => "avali",
|
||||
"experiment (casualties unknown)" => "experiment (casualties unknown)",
|
||||
"fictional species" => "fictional species",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should resolve a single tag', () => {
|
||||
expect(resolveFromTagLink('safe')).toBe('safe');
|
||||
});
|
||||
it('should identify any aliases going after the real tag', () => {
|
||||
const realTags = ['avali', 'experiment (casualties unknown)', 'fictional species'];
|
||||
const realAndAliasesTags = ['avali', 'experiment (casualties unknown)', 'experiment (gunsaw)', 'fictional species'];
|
||||
|
||||
it('should only read the tag page even if query is provided', () => {
|
||||
expect(resolveFromTagLink('grotesque?q=explicit')).toBe('grotesque');
|
||||
});
|
||||
expect(buildTagsAndAliasesMap(realAndAliasesTags, realTags)).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"avali" => "avali",
|
||||
"experiment (casualties unknown)" => "experiment (casualties unknown)",
|
||||
"fictional species" => "fictional species",
|
||||
"experiment (gunsaw)" => "experiment (casualties unknown)",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should properly resolve links with encoded characters', () => {
|
||||
expect(resolveFromTagLink('pok%C3%A9mon')).toBe('pokémon');
|
||||
});
|
||||
it('should ignore any non-real tags coming before first tag is found', () => {
|
||||
const outOfOrderTag = randomString();
|
||||
|
||||
it('should decoded slug-encoded characters', () => {
|
||||
// More common example where tag is.
|
||||
expect(resolveFromTagLink(`namespace-colon-tag+name`)).toBe('namespace:tag name');
|
||||
const realTags = ['avali', 'experiment (casualties unknown)', 'fictional species'];
|
||||
const realAndAliasesTags = [outOfOrderTag, 'avali', 'experiment (casualties unknown)', 'experiment (gunsaw)', 'fictional species'];
|
||||
|
||||
// Testing the whole list of encoded characters.
|
||||
for (const [encodedCharacter, decodedCharacter] of slugEncodedCharacters.entries()) {
|
||||
expect(resolveFromTagLink(`test+symbol${encodedCharacter}without+spaces`)).toBe(`test symbol${decodedCharacter}without spaces`);
|
||||
expect(resolveFromTagLink(`test+symbol+${encodedCharacter}+with+spaces`)).toBe(`test symbol ${decodedCharacter} with spaces`);
|
||||
}
|
||||
});
|
||||
});
|
||||
const warn = vi.spyOn(console, 'warn');
|
||||
|
||||
it('should return null for unsupported URLs', () => {
|
||||
expect(resolveTagNameFromLink(new URL('/pages/example', origin))).toBe(null);
|
||||
expect(buildTagsAndAliasesMap(realAndAliasesTags, realTags)).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"avali" => "avali",
|
||||
"experiment (casualties unknown)" => "experiment (casualties unknown)",
|
||||
"fictional species" => "fictional species",
|
||||
"experiment (gunsaw)" => "experiment (casualties unknown)",
|
||||
}
|
||||
`);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(`No real tag found for the alias:`, outOfOrderTag);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTagNameFromLink', () => {
|
||||
function resolveFromSearchQuery(encodedQuery: string): string | null {
|
||||
return resolveTagNameFromLink(new URL(`/search?q=${encodedQuery}`, origin));
|
||||
}
|
||||
|
||||
describe('Parsing from /search/?q=tag links', () => {
|
||||
it('should resolve a single tag from /search URLs', () => {
|
||||
expect(resolveFromSearchQuery('safe')).toBe('safe');
|
||||
});
|
||||
|
||||
it('should return null for queries with multiple comma-separated tags', () => {
|
||||
// Comma acts as a separator in the query, resulting in multiple tokens
|
||||
expect(resolveFromSearchQuery('safe, suggestive')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null if query is empty or not a term', () => {
|
||||
expect(resolveFromSearchQuery('')).toBe(null);
|
||||
expect(resolveFromSearchQuery('!')).toBe(null);
|
||||
});
|
||||
|
||||
it('should properly treat parentheses in the query with single tag', () => {
|
||||
// Parentheses are operators in the query language, but when inside the tag name, they should still be properly
|
||||
// working.
|
||||
expect(resolveFromSearchQuery('experiment (casualties unknown)')).toBe('experiment (casualties unknown)');
|
||||
});
|
||||
|
||||
it('should properly resolve queries with encoded characters', () => {
|
||||
expect(resolveFromSearchQuery('pok%C3%A9mon')).toBe('pokémon');
|
||||
});
|
||||
|
||||
it('should unquote quoted term', () => {
|
||||
expect(resolveFromSearchQuery('"experiment (casualties unknown)"')).toBe('experiment (casualties unknown)')
|
||||
expect(resolveFromSearchQuery('"single tag, really"')).toBe('single tag, really');
|
||||
});
|
||||
})
|
||||
|
||||
describe('Parsing from /tags/name links', () => {
|
||||
function resolveFromTagLink(encodedTagName: string): string | null {
|
||||
return resolveTagNameFromLink(new URL(`/tags/${encodedTagName}`, origin));
|
||||
}
|
||||
|
||||
it('should resolve a single tag', () => {
|
||||
expect(resolveFromTagLink('safe')).toBe('safe');
|
||||
});
|
||||
|
||||
it('should only read the tag page even if query is provided', () => {
|
||||
expect(resolveFromTagLink('grotesque?q=explicit')).toBe('grotesque');
|
||||
});
|
||||
|
||||
it('should properly resolve links with encoded characters', () => {
|
||||
expect(resolveFromTagLink('pok%C3%A9mon')).toBe('pokémon');
|
||||
});
|
||||
|
||||
it('should decoded slug-encoded characters', () => {
|
||||
// More common example where tag is.
|
||||
expect(resolveFromTagLink(`namespace-colon-tag+name`)).toBe('namespace:tag name');
|
||||
|
||||
// Testing the whole list of encoded characters.
|
||||
for (const [encodedCharacter, decodedCharacter] of slugEncodedCharacters.entries()) {
|
||||
expect(resolveFromTagLink(`test+symbol${encodedCharacter}without+spaces`)).toBe(`test symbol${decodedCharacter}without spaces`);
|
||||
expect(resolveFromTagLink(`test+symbol+${encodedCharacter}+with+spaces`)).toBe(`test symbol ${decodedCharacter} with spaces`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for unsupported URLs', () => {
|
||||
expect(resolveTagNameFromLink(new URL('/pages/example', origin))).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTagCategoryFromTagName', () => {
|
||||
it('should resolve any known namespace into its known category', () => {
|
||||
for (const [namespace, category] of namespaceCategories) {
|
||||
expect(resolveTagCategoryFromTagName(`${namespace}:${randomString()}`)).toBe(category);
|
||||
}
|
||||
});
|
||||
|
||||
it('should ignore any namespace not listed in config', () => {
|
||||
expect(resolveTagCategoryFromTagName(`${randomString()}:${randomString()}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export interface TestedSettings {
|
||||
stringField: string;
|
||||
numberField: number;
|
||||
nested?: {
|
||||
field: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class TestedEntity extends StorageEntity<TestedSettings> {
|
||||
static readonly _entityName = "entity";
|
||||
|
||||
constructor(id: string, settings: TestedSettings) {
|
||||
super(id, settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
|
||||
export interface TestedFields {
|
||||
numberField: number;
|
||||
stringField: string;
|
||||
}
|
||||
|
||||
export class TestedPreferences extends CacheablePreferences<TestedFields> implements WithFields<TestedFields> {
|
||||
readonly defaults: TestedFields;
|
||||
readonly mockedSettingsNamespace: string;
|
||||
readonly mockedStorageArea: ChromeStorageArea;
|
||||
readonly mockedStorageHelper: StorageHelper;
|
||||
|
||||
numberField;
|
||||
stringField;
|
||||
|
||||
constructor(settingsNamespace: string, mockedDefaults: TestedFields) {
|
||||
const mockedStorageArea = new ChromeStorageArea();
|
||||
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
|
||||
const mockedConfigurationController = new ConfigurationController(
|
||||
settingsNamespace,
|
||||
mockedStorageHelper,
|
||||
);
|
||||
|
||||
super(settingsNamespace, mockedConfigurationController);
|
||||
|
||||
this.mockedSettingsNamespace = settingsNamespace;
|
||||
this.mockedStorageArea = mockedStorageArea;
|
||||
this.mockedStorageHelper = mockedStorageHelper;
|
||||
this.defaults = mockedDefaults;
|
||||
|
||||
this.numberField = new PreferenceField(this, {
|
||||
field: 'numberField',
|
||||
defaultValue: this.defaults.numberField,
|
||||
});
|
||||
|
||||
this.stringField = new PreferenceField(this, {
|
||||
field: 'stringField',
|
||||
defaultValue: this.defaults.stringField,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user