Merge pull request #181 from koloml/release/0.8.0

Release: 0.8.0
This commit is contained in:
koloml authored and GitHub committed 2026-10-14 03:37:10 +02:00
commit 223b72d9fb
48 files changed
+2348 -866

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

+3 -3
View File
@@ -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
+6 -6
View File
@@ -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/
+5
View File
@@ -49,6 +49,11 @@ feature is opt-in and should be enabled in the settings first.
![Fullscreen Viewer Showcase](.github/assets/fullscreen-viewer-showcase.png)
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.
![Fullscreen Viewer with Tags](.github/assets/fullscreen-viewer-tags.png)
## Building
Recommendations on environment:
+17 -3
View File
@@ -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": {
+357 -410
View File
File diff suppressed because it is too large. Load diff
+14 -11
View File
@@ -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
View File
@@ -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',
];
+3 -1
View File
@@ -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 || [];
}
}
+39 -17
View File
@@ -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();
}
}
}
+3
View File
@@ -0,0 +1,3 @@
import GridDupeReportsList from "$content/components/philomena/dupe/GridDupeReportsList";
GridDupeReportsList.findAndInitialize();
+5 -7
View File
@@ -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(() => {
+14
View File
@@ -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');
})();
+3 -2
View File
@@ -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;
}
+34 -8
View File
@@ -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,
});
}
+2 -2
View File
@@ -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);
+65
View File
@@ -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);
}
}
+14
View File
@@ -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}
+4 -11
View File
@@ -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();
+6 -11
View File
@@ -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();
+6 -25
View File
@@ -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();
+18
View File
@@ -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
});
});
});
+7
View File
@@ -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);
+46
View File
@@ -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;
}
}
}
}
}
+97 -23
View File
@@ -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);
});
});
});
+127 -62
View File
@@ -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();
});
});
+17
View File
@@ -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);
}
}
+45
View File
@@ -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,
});
}
}