mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2026-02-06 23:32:58 +00:00
Compare commits
26 Commits
0.4.3
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
| fb626a3928 | |||
| 4060e6c44b | |||
| 6098a11115 | |||
| a87d8b94b8 | |||
| c283b96285 | |||
| 02478f0bf0 | |||
| 59c15f27eb | |||
| 134e96bc4c | |||
| 1c05159ddf | |||
| bb14492578 | |||
| 30320e7283 | |||
| 8839373292 | |||
| 0e35d1d0ba | |||
| bca21da6d1 | |||
| 60491f57d4 | |||
| c26c4bcf62 | |||
| 1b4b646024 | |||
| 928fe5ddb0 | |||
| 6586141134 | |||
| d587bd2453 | |||
| e2eb8a0ca7 | |||
| 0876e5f001 | |||
| d5ad66d902 | |||
| cb6b5f4f9d | |||
| 193941b0ac | |||
| 562274b3d8 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Furbooru Tagging Assistant",
|
||||
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.5",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "furbooru-tagging-assistant@thecore.city"
|
||||
@@ -44,6 +44,14 @@
|
||||
"src/styles/content/header.scss"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/images/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/tags-editor.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/images?*",
|
||||
@@ -61,14 +69,6 @@
|
||||
"js": [
|
||||
"src/content/tags.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/images/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/tags-editor.ts"
|
||||
]
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
|
||||
889
package-lock.json
generated
889
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "npm run build:popup && npm run build:extension",
|
||||
@@ -12,21 +12,20 @@
|
||||
"test:watch": "vitest watch --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.19.0",
|
||||
"@sveltejs/kit": "^2.21.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/chrome": "^0.0.309",
|
||||
"@types/node": "^22.13.10",
|
||||
"@vitest/coverage-v8": "^3.0.8",
|
||||
"@types/chrome": "^0.0.326",
|
||||
"@types/node": "^22.15.29",
|
||||
"@vitest/coverage-v8": "^3.2.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"sass": "^1.85.1",
|
||||
"svelte": "^5.23.0",
|
||||
"svelte-check": "^4.1.5",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.1.1",
|
||||
"vitest": "^3.0.8"
|
||||
"jsdom": "^26.1.0",
|
||||
"sass": "^1.89.1",
|
||||
"svelte": "^5.33.14",
|
||||
"svelte-check": "^4.2.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.2.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
|
||||
a {
|
||||
color: colors.$text;
|
||||
|
||||
@@ -3,8 +3,10 @@ import { createMediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
import { calculateMediaBoxesPositions, initializeMediaBox } from "$lib/components/MediaBoxWrapper";
|
||||
import { createMaintenanceStatusIcon } from "$lib/components/MaintenanceStatusIcon";
|
||||
import { createImageShowFullscreenButton } from "$lib/components/ImageShowFullscreenButton";
|
||||
import { initializeImageListContainer } from "$lib/components/listing/ImageListContainer";
|
||||
|
||||
const mediaBoxes = document.querySelectorAll<HTMLElement>('.media-box');
|
||||
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
|
||||
|
||||
mediaBoxes.forEach(mediaBoxElement => {
|
||||
initializeMediaBox(mediaBoxElement, [
|
||||
@@ -22,3 +24,7 @@ mediaBoxes.forEach(mediaBoxElement => {
|
||||
});
|
||||
|
||||
calculateMediaBoxesPositions(mediaBoxes);
|
||||
|
||||
if (imageListContainer) {
|
||||
initializeImageListContainer(imageListContainer);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
import { emit, on } from "$lib/components/events/comms";
|
||||
import { eventSizeLoaded } from "$lib/components/events/fullscreen-viewer-events";
|
||||
import { EVENT_SIZE_LOADED } from "$lib/components/events/fullscreen-viewer-events";
|
||||
|
||||
export class FullscreenViewer extends BaseComponent {
|
||||
#videoElement: HTMLVideoElement = document.createElement('video');
|
||||
@@ -173,7 +173,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.#sizeSelectorElement.value = size;
|
||||
this.#isSizeFetched = true;
|
||||
|
||||
emit(this.container, eventSizeLoaded, size);
|
||||
emit(this.container, EVENT_SIZE_LOADED, size);
|
||||
}
|
||||
|
||||
#watchForSizeSelectionChanges() {
|
||||
@@ -224,7 +224,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
await new Promise(
|
||||
resolve => on(
|
||||
this.container,
|
||||
eventSizeLoaded,
|
||||
EVENT_SIZE_LOADED,
|
||||
resolve
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,9 +6,9 @@ import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI";
|
||||
import { tagsBlacklist } from "$config/tags";
|
||||
import { emitterAt } from "$lib/components/events/comms";
|
||||
import {
|
||||
eventActiveProfileChanged,
|
||||
eventMaintenanceStateChanged,
|
||||
eventTagsUpdated
|
||||
EVENT_ACTIVE_PROFILE_CHANGED,
|
||||
EVENT_MAINTENANCE_STATE_CHANGED,
|
||||
EVENT_TAGS_UPDATED
|
||||
} from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
|
||||
@@ -83,7 +83,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.container.classList.toggle('is-active', activeProfile !== null);
|
||||
this.#refreshTagsList();
|
||||
|
||||
this.#emitter.emit(eventActiveProfileChanged, activeProfile);
|
||||
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
|
||||
}
|
||||
|
||||
#refreshTagsList() {
|
||||
@@ -177,7 +177,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = true;
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'waiting');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.#isPlanningToSubmit = false;
|
||||
this.#isSubmitting = true;
|
||||
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'processing');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
|
||||
|
||||
let maybeTagsAndAliasesAfterUpdate;
|
||||
|
||||
@@ -246,17 +246,17 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'failed');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
|
||||
this.#isSubmitting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (maybeTagsAndAliasesAfterUpdate) {
|
||||
this.#emitter.emit(eventTagsUpdated, maybeTagsAndAliasesAfterUpdate);
|
||||
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
|
||||
}
|
||||
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'complete');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
|
||||
|
||||
this.#tagsToAdd.clear();
|
||||
this.#tagsToRemove.clear();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventMaintenanceStateChanged } from "$lib/components/events/maintenance-popup-events";
|
||||
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
|
||||
export class MaintenanceStatusIcon extends BaseComponent {
|
||||
@@ -22,7 +22,7 @@ export class MaintenanceStatusIcon extends BaseComponent {
|
||||
throw new Error('Status icon element initialized outside of the media box!');
|
||||
}
|
||||
|
||||
on(this.#mediaBoxTools, eventMaintenanceStateChanged, this.#onMaintenanceStateChanged.bind(this));
|
||||
on(this.#mediaBoxTools, EVENT_MAINTENANCE_STATE_CHANGED, this.#onMaintenanceStateChanged.bind(this));
|
||||
}
|
||||
|
||||
#onMaintenanceStateChanged(stateChangeEvent: CustomEvent<string>) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { MaintenancePopup } from "$lib/components/MaintenancePopup";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventActiveProfileChanged } from "$lib/components/events/maintenance-popup-events";
|
||||
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxWrapper } from "$lib/components/MediaBoxWrapper";
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
@@ -39,7 +39,7 @@ export class MediaBoxTools extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
on(this, eventActiveProfileChanged, this.#onActiveProfileChanged.bind(this));
|
||||
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
|
||||
}
|
||||
|
||||
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventTagsUpdated } from "$lib/components/events/maintenance-popup-events";
|
||||
import { EVENT_TAGS_UPDATED } from "$lib/components/events/maintenance-popup-events";
|
||||
|
||||
export class MediaBoxWrapper extends BaseComponent {
|
||||
#thumbnailContainer: HTMLElement | null = null;
|
||||
@@ -13,7 +13,7 @@ export class MediaBoxWrapper extends BaseComponent {
|
||||
this.#thumbnailContainer = this.container.querySelector('.image-container');
|
||||
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
|
||||
|
||||
on(this, eventTagsUpdated, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
}
|
||||
|
||||
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
|
||||
@@ -90,5 +90,10 @@ export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElem
|
||||
lastMediaBox = mediaBoxElement;
|
||||
lastMediaBoxPosition = yPosition;
|
||||
}
|
||||
|
||||
// Last-ever media box is checked separately
|
||||
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
|
||||
lastMediaBox.classList.add('media-box--last');
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events";
|
||||
import { eventTagCustomGroupResolved } from "$lib/components/events/tag-dropdown-events";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
const categoriesResolver = new CustomCategoriesResolver();
|
||||
@@ -54,7 +54,7 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
}
|
||||
});
|
||||
|
||||
on(this, eventTagCustomGroupResolved, this.#onTagGroupResolved.bind(this));
|
||||
on(this, EVENT_TAG_GROUP_RESOLVED, this.#onTagGroupResolved.bind(this));
|
||||
}
|
||||
|
||||
#onTagGroupResolved(resolvedGroupEvent: CustomEvent<TagGroup | null>) {
|
||||
@@ -302,7 +302,7 @@ export function watchTagDropdownsInTagsEditor() {
|
||||
});
|
||||
|
||||
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
|
||||
on(document.body, eventFormEditorUpdated, event => {
|
||||
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
|
||||
for (const tagDropdownElement of event.detail.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { emit, on, type UnsubscribeFunction } from "$lib/components/events/comms";
|
||||
import { eventFetchComplete } from "$lib/components/events/booru-events";
|
||||
import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events";
|
||||
import { EVENT_FETCH_COMPLETE } from "$lib/components/events/booru-events";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
|
||||
|
||||
export class TagsForm extends BaseComponent {
|
||||
protected init() {
|
||||
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
|
||||
const unsubscribe = on(
|
||||
this.container,
|
||||
eventFetchComplete,
|
||||
EVENT_FETCH_COMPLETE,
|
||||
() => this.#waitAndDetectUpdatedForm(unsubscribe),
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export class TagsForm extends BaseComponent {
|
||||
const fullTagEditor = tagFormComponent.parentTagEditorElement;
|
||||
|
||||
if (fullTagEditor) {
|
||||
emit(document.body, eventFormEditorUpdated, fullTagEditor);
|
||||
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
|
||||
} else {
|
||||
console.info('Tag form is not in the tag editor. Event is not sent.');
|
||||
}
|
||||
|
||||
@@ -2,13 +2,18 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { eventTagCustomGroupResolved } from "$lib/components/events/tag-dropdown-events";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
|
||||
import TagSettings from "$lib/extension/settings/TagSettings";
|
||||
|
||||
export class TagsListBlock extends BaseComponent {
|
||||
#tagsListButtonsContainer: HTMLElement | null = null;
|
||||
#tagsListContainer: HTMLElement | null = null;
|
||||
|
||||
#toggleGroupingButton = document.createElement('a');
|
||||
#toggleGroupingButtonIcon = document.createElement('i');
|
||||
|
||||
#tagSettings = new TagSettings();
|
||||
|
||||
#shouldDisplaySeparation = false;
|
||||
@@ -21,7 +26,21 @@ export class TagsListBlock extends BaseComponent {
|
||||
#isReorderingPlanned = false;
|
||||
|
||||
protected build() {
|
||||
this.#tagsListButtonsContainer = this.container.querySelector('.block.tagsauce .block__header__buttons');
|
||||
this.#tagsListContainer = this.container.querySelector('.tag-list');
|
||||
|
||||
this.#toggleGroupingButton.innerText = ' Grouping';
|
||||
this.#toggleGroupingButton.href = 'javascript:void(0)';
|
||||
this.#toggleGroupingButton.classList.add('button', 'button--link', 'button--inline');
|
||||
this.#toggleGroupingButton.title = 'Toggle the global groups separation option. This will only toggle global ' +
|
||||
'setting without changing the separation of specific groups.';
|
||||
|
||||
this.#toggleGroupingButtonIcon.classList.add('fas', TagsListBlock.#iconGroupingDisabled);
|
||||
this.#toggleGroupingButton.prepend(this.#toggleGroupingButtonIcon);
|
||||
|
||||
if (this.#tagsListButtonsContainer) {
|
||||
this.#tagsListButtonsContainer.append(this.#toggleGroupingButton);
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -32,9 +51,11 @@ export class TagsListBlock extends BaseComponent {
|
||||
|
||||
on(
|
||||
this,
|
||||
eventTagCustomGroupResolved,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
this.#onTagDropdownCustomGroupResolved.bind(this)
|
||||
);
|
||||
|
||||
this.#toggleGroupingButton.addEventListener('click', this.#onToggleGroupingClicked.bind(this));
|
||||
}
|
||||
|
||||
#onTagSeparationChange(isSeparationEnabled: boolean) {
|
||||
@@ -44,6 +65,12 @@ export class TagsListBlock extends BaseComponent {
|
||||
|
||||
this.#shouldDisplaySeparation = isSeparationEnabled;
|
||||
this.#reorderSeparatedGroups();
|
||||
this.#updateToggleSeparationButton();
|
||||
}
|
||||
|
||||
#updateToggleSeparationButton() {
|
||||
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingEnabled, this.#shouldDisplaySeparation);
|
||||
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingDisabled, !this.#shouldDisplaySeparation);
|
||||
}
|
||||
|
||||
#onTagDropdownCustomGroupResolved(resolvedCustomGroupEvent: CustomEvent<TagGroup | null>) {
|
||||
@@ -74,6 +101,11 @@ export class TagsListBlock extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
#onToggleGroupingClicked(event: Event) {
|
||||
event.preventDefault();
|
||||
void this.#tagSettings.setGroupSeparation(!this.#shouldDisplaySeparation);
|
||||
}
|
||||
|
||||
#handleTagGroupChanges(tagGroup: TagGroup) {
|
||||
const groupId = tagGroup.id;
|
||||
const processedGroup = this.#separatedGroups.get(groupId);
|
||||
@@ -181,6 +213,9 @@ export class TagsListBlock extends BaseComponent {
|
||||
static #orderCssVariableForGroup(groupId: string): string {
|
||||
return `--ta-order-${groupId}`;
|
||||
}
|
||||
|
||||
static #iconGroupingDisabled = 'fa-folder';
|
||||
static #iconGroupingEnabled = 'fa-folder-tree';
|
||||
}
|
||||
|
||||
export function initializeAllTagsLists() {
|
||||
@@ -195,7 +230,14 @@ export function initializeAllTagsLists() {
|
||||
}
|
||||
|
||||
export function watchForUpdatedTagLists() {
|
||||
on(document, eventFormEditorUpdated, event => {
|
||||
event.detail.closest('#image_tags_and_source')
|
||||
on(document, EVENT_FORM_EDITOR_UPDATED, event => {
|
||||
const tagsListElement = event.detail.closest<HTMLElement>('#image_tags_and_source');
|
||||
|
||||
if (!tagsListElement || getComponent(tagsListElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
new TagsListBlock(tagsListElement)
|
||||
.initialize();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const eventFetchComplete = 'fetchcomplete';
|
||||
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
|
||||
|
||||
export interface BooruEventsMap {
|
||||
[eventFetchComplete]: null; // Site sends the response, but extension will not get it due to isolation.
|
||||
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
|
||||
export const eventSizeLoaded = 'size-loaded';
|
||||
export const EVENT_SIZE_LOADED = 'size-loaded';
|
||||
|
||||
export interface FullscreenViewerEventsMap {
|
||||
[eventSizeLoaded]: FullscreenViewerSize;
|
||||
[EVENT_SIZE_LOADED]: FullscreenViewerSize;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
export const eventActiveProfileChanged = 'active-profile-changed';
|
||||
export const eventMaintenanceStateChanged = 'maintenance-state-change';
|
||||
export const eventTagsUpdated = 'tags-updated';
|
||||
export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed';
|
||||
export const EVENT_MAINTENANCE_STATE_CHANGED = 'maintenance-state-change';
|
||||
export const EVENT_TAGS_UPDATED = 'tags-updated';
|
||||
|
||||
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
|
||||
|
||||
export interface MaintenancePopupEventsMap {
|
||||
[eventActiveProfileChanged]: MaintenanceProfile | null;
|
||||
[eventMaintenanceStateChanged]: MaintenanceState;
|
||||
[eventTagsUpdated]: Map<string, string> | null;
|
||||
[EVENT_ACTIVE_PROFILE_CHANGED]: MaintenanceProfile | null;
|
||||
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
|
||||
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
export const eventTagCustomGroupResolved = 'tag-group-resolved';
|
||||
export const EVENT_TAG_GROUP_RESOLVED = 'tag-group-resolved';
|
||||
|
||||
export interface TagDropdownEvents {
|
||||
[eventTagCustomGroupResolved]: TagGroup | null;
|
||||
[EVENT_TAG_GROUP_RESOLVED]: TagGroup | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const eventFormEditorUpdated = 'tags-form-updated';
|
||||
export const EVENT_FORM_EDITOR_UPDATED = 'tags-form-updated';
|
||||
|
||||
export interface TagsFormEventsMap {
|
||||
[eventFormEditorUpdated]: HTMLElement;
|
||||
[EVENT_FORM_EDITOR_UPDATED]: HTMLElement;
|
||||
}
|
||||
|
||||
19
src/lib/components/listing/ImageListContainer.ts
Normal file
19
src/lib/components/listing/ImageListContainer.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { ImageListInfo } from "$lib/components/listing/ImageListInfo";
|
||||
|
||||
export class ImageListContainer extends BaseComponent {
|
||||
#info: ImageListInfo | null = null;
|
||||
|
||||
protected build() {
|
||||
const imageListInfoContainer = this.container.querySelector<HTMLElement>('.js-imagelist-info');
|
||||
|
||||
if (imageListInfoContainer) {
|
||||
this.#info = new ImageListInfo(imageListInfoContainer);
|
||||
this.#info.initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeImageListContainer(element: HTMLElement) {
|
||||
new ImageListContainer(element).initialize();
|
||||
}
|
||||
75
src/lib/components/listing/ImageListInfo.ts
Normal file
75
src/lib/components/listing/ImageListInfo.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
|
||||
export class ImageListInfo extends BaseComponent {
|
||||
#tagElement: HTMLElement | null = null;
|
||||
#impliedTags: string[] = [];
|
||||
#showUntaggedImplicationsButton: HTMLAnchorElement = document.createElement('a');
|
||||
|
||||
protected build() {
|
||||
const sectionAfterImage = this.container.querySelector('.tag-info__image + .flex__grow');
|
||||
|
||||
this.#tagElement = sectionAfterImage?.querySelector<HTMLElement>('.tag.dropdown') ?? null;
|
||||
|
||||
const labels = this.container
|
||||
.querySelectorAll<HTMLElement>('.tag-info__image + .flex__grow strong');
|
||||
|
||||
let targetElementToInsertBefore: HTMLElement | null = null;
|
||||
|
||||
for (const potentialListStarter of labels) {
|
||||
if (potentialListStarter.innerText === ImageListInfo.#implicationsStarterText) {
|
||||
targetElementToInsertBefore = potentialListStarter;
|
||||
this.#collectImplicationsFromListStarter(potentialListStarter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#impliedTags.length && targetElementToInsertBefore) {
|
||||
this.#showUntaggedImplicationsButton.href = '#';
|
||||
this.#showUntaggedImplicationsButton.innerText = '(Q)';
|
||||
this.#showUntaggedImplicationsButton.title =
|
||||
'Query untagged implications\n\n' +
|
||||
'This will open the search results with all untagged implications for the current tag.';
|
||||
this.#showUntaggedImplicationsButton.classList.add('detail-link');
|
||||
|
||||
targetElementToInsertBefore.insertAdjacentElement('beforebegin', this.#showUntaggedImplicationsButton);
|
||||
}
|
||||
}
|
||||
|
||||
protected init() {
|
||||
this.#showUntaggedImplicationsButton.addEventListener('click', this.#onShowUntaggedImplicationsClicked.bind(this));
|
||||
}
|
||||
|
||||
#collectImplicationsFromListStarter(listStarter: HTMLElement) {
|
||||
let targetElement: Element | null = listStarter.nextElementSibling;
|
||||
|
||||
while (targetElement) {
|
||||
if (targetElement instanceof HTMLAnchorElement) {
|
||||
this.#impliedTags.push(targetElement.innerText.trim());
|
||||
}
|
||||
|
||||
// First line break is considered the end of the list.
|
||||
if (targetElement instanceof HTMLBRElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
targetElement = targetElement.nextElementSibling;
|
||||
}
|
||||
}
|
||||
|
||||
#onShowUntaggedImplicationsClicked(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
url.pathname = '/search';
|
||||
url.search = '';
|
||||
|
||||
const currentTagName = this.#tagElement?.dataset.tagName;
|
||||
|
||||
url.searchParams.set('q', `${currentTagName}, !(${this.#impliedTags.join(", ")})`);
|
||||
|
||||
location.assign(url.href);
|
||||
}
|
||||
|
||||
static #implicationsStarterText = 'Implies:';
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { escapeRegExp } from "$lib/utils";
|
||||
import { emit } from "$lib/components/events/comms";
|
||||
import { eventTagCustomGroupResolved } from "$lib/components/events/tag-dropdown-events";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
|
||||
|
||||
export default class CustomCategoriesResolver {
|
||||
#exactGroupMatches = new Map<string, TagGroup>();
|
||||
@@ -58,7 +58,7 @@ export default class CustomCategoriesResolver {
|
||||
|
||||
emit(
|
||||
tagDropdown,
|
||||
eventTagCustomGroupResolved,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
this.#exactGroupMatches.get(tagName)!
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ export default class CustomCategoriesResolver {
|
||||
|
||||
emit(
|
||||
tagDropdown,
|
||||
eventTagCustomGroupResolved,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
this.#regExpGroupMatches.get(targetRegularExpression)!
|
||||
);
|
||||
|
||||
@@ -119,7 +119,7 @@ export default class CustomCategoriesResolver {
|
||||
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
|
||||
emit(
|
||||
tagDropdown,
|
||||
eventTagCustomGroupResolved,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
33
tests/lib/booru/tag-utils.spec.ts
Normal file
33
tests/lib/booru/tag-utils.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
|
||||
|
||||
describe('buildTagsAndAliasesMap', () => {
|
||||
const exampleTag = 'safe';
|
||||
const exampleTagAlias = 'rating:safe';
|
||||
const tagsAndAliases = [exampleTag, exampleTagAlias, 'anthro', 'cat', 'feline', 'mammal', 'male', 'boy'];
|
||||
const tagsOnly = [exampleTag, 'anthro', 'cat', 'feline', 'mammal', 'male'];
|
||||
const mapping = buildTagsAndAliasesMap(tagsAndAliases, tagsOnly);
|
||||
|
||||
it('should return a map of tags', () => {
|
||||
expect(mapping).toBeInstanceOf(Map);
|
||||
});
|
||||
|
||||
it('should point aliases to their original tags', () => {
|
||||
expect(mapping.get(exampleTagAlias)).toBe(exampleTag);
|
||||
});
|
||||
|
||||
it('should point tags to themselves', () => {
|
||||
expect(mapping.get(exampleTag)).toBe(exampleTag);
|
||||
});
|
||||
|
||||
it('should ignore broken tag aliases and show a warning', () => {
|
||||
vi.spyOn(console, 'warn');
|
||||
|
||||
const brokenMapping = buildTagsAndAliasesMap(
|
||||
['broken alias', 'tag1', 'tag2'],
|
||||
['tag1', 'tag2'],
|
||||
);
|
||||
|
||||
expect(console.warn).toBeCalledTimes(1);
|
||||
expect(brokenMapping.has('broken alias')).toBe(false);
|
||||
});
|
||||
});
|
||||
43
tests/lib/utils.spec.ts
Normal file
43
tests/lib/utils.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { randomString } from "$tests/utils";
|
||||
import { escapeRegExp, findDeepObject } from "$lib/utils";
|
||||
import { randomInt } from "node:crypto";
|
||||
|
||||
describe('findDeepObject', () => {
|
||||
const targetObject = {
|
||||
somewhere: {
|
||||
deep: {
|
||||
stringValue: randomString(),
|
||||
numericValue: randomInt(0, 1000),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
it('should just return null when nothing is found', () => {
|
||||
const nonExistentValue = findDeepObject(targetObject, ['completely', 'wrong']);
|
||||
expect(nonExistentValue).toBe(null);
|
||||
});
|
||||
|
||||
it('should retrieve something stored deep inside object', () => {
|
||||
const returnedDeepObject = findDeepObject(targetObject, ['somewhere', 'deep']);
|
||||
expect(returnedDeepObject).toBe(targetObject.somewhere.deep);
|
||||
});
|
||||
|
||||
it('should return null if value located on given path is not an object', () => {
|
||||
const returnedForStringValue = findDeepObject(targetObject, ['somewhere', 'deep', 'stringValue']);
|
||||
expect(returnedForStringValue).not.toBe(targetObject.somewhere.deep.stringValue);
|
||||
expect(returnedForStringValue).toBe(null);
|
||||
|
||||
const returnedForNumericValue = findDeepObject(targetObject, ['somewhere', 'deep', 'numericValue']);
|
||||
expect(returnedForNumericValue).not.toBe(targetObject.somewhere.deep.numericValue);
|
||||
expect(returnedForNumericValue).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeRegExp', () => {
|
||||
const specialCharactersToMatch = "$[(?:)]{}()*./\\+?|^";
|
||||
|
||||
it('should sufficiently enough escape special characters', () => {
|
||||
const generatedRegExp = new RegExp(`^${escapeRegExp(specialCharactersToMatch)}$`, 'm');
|
||||
expect(generatedRegExp.test(specialCharactersToMatch)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user