Merge remote-tracking branch 'origin/release/0.8.0' into feature/fullscreen-tagging-profile

# Conflicts:
#	src/content/components/philomena/MediaBox.ts
This commit is contained in:
koloml committed 2026-10-14 01:31:14 +02:00
commit ecd95d3b4f
16 files changed
+556 -60

No files matched your search

+12
View File
@@ -104,6 +104,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": {
+12
View File
@@ -134,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',
];
@@ -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 || [];
}
}
+14 -11
View File
@@ -3,17 +3,20 @@ 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;
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
@@ -29,13 +32,13 @@ 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 {
get uniqueTagsSet(): Set<string> | null {
const tagsAndAliases = this.tagsAndAliases;
if (tagsAndAliases === null) {
@@ -64,13 +67,13 @@ 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 {
@@ -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();
+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;
}
@@ -70,6 +70,10 @@ export class PreferenceField<
set(value: Fields[Key]) {
return this.#preferences.writeRaw(this.#fieldKey, value);
}
get key(): Key {
return this.#fieldKey;
}
}
/**
+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);
}
}
+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();
+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;
}
}
}
}
}