mirror of
https://github.com/koloml/philomena-tagging-assistant.git
synced 2026-09-23 04:19:39 +00:00
Compare commits
6 Commits
release/0.
...
feature/du
| Author | SHA1 | Date | |
|---|---|---|---|
| f4e92e313a | |||
| e1b56586be | |||
| b87a8a43e0 | |||
| d19764699f | |||
| f873cae544 | |||
| c23c871337 |
@@ -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": {
|
||||
|
||||
@@ -128,3 +128,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',
|
||||
];
|
||||
|
||||
40
src/content/components/philomena/ImageContainer.ts
Normal file
40
src/content/components/philomena/ImageContainer.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
|
||||
export default class ImageContainer extends BaseComponent {
|
||||
#imageLink: HTMLAnchorElement | null = null;
|
||||
|
||||
protected init() {
|
||||
this.#imageLink = this.container.querySelector('a');
|
||||
}
|
||||
|
||||
extractActualTags(): string[] {
|
||||
return this.#imageLink?.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
}
|
||||
|
||||
extractTagsAndAliases(): string[] {
|
||||
return this.container.dataset.imageTagAliases?.split(', ') || [];
|
||||
}
|
||||
|
||||
extractImageLinks(): App.ImageURIs {
|
||||
const jsonUris = this.container?.dataset.uris;
|
||||
|
||||
if (!jsonUris) {
|
||||
throw new Error('Missing URIs!');
|
||||
}
|
||||
|
||||
return JSON.parse(jsonUris);
|
||||
}
|
||||
|
||||
extractSources(): string[] {
|
||||
const jsonSourceUrls = this.container.dataset.sourceUrls;
|
||||
let sourceUrls: string[] | null = null;
|
||||
|
||||
try {
|
||||
sourceUrls = JSON.parse(jsonSourceUrls || '[]');
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse source URLs for the image!', this, e);
|
||||
}
|
||||
|
||||
return sourceUrls || [];
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,18 @@ 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";
|
||||
|
||||
export class MediaBox extends BaseComponent {
|
||||
#thumbnailContainer: HTMLElement | null = null;
|
||||
#imageLinkElement: HTMLAnchorElement | null = null;
|
||||
#imageContainer: ImageContainer | null = null;
|
||||
#tagsAndAliases: Map<string, string> | 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));
|
||||
}
|
||||
@@ -27,8 +30,8 @@ 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);
|
||||
}
|
||||
@@ -52,13 +55,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
232
src/content/components/philomena/dupe/DupeDiff.ts
Normal file
232
src/content/components/philomena/dupe/DupeDiff.ts
Normal file
@@ -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'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
19
src/content/components/philomena/dupe/DupeImage.ts
Normal file
19
src/content/components/philomena/dupe/DupeImage.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
47
src/content/components/philomena/dupe/DupeReportRow.ts
Normal file
47
src/content/components/philomena/dupe/DupeReportRow.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
49
src/content/components/philomena/dupe/GridDupeReportsList.ts
Normal file
49
src/content/components/philomena/dupe/GridDupeReportsList.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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 childrenElements = this.container.children;
|
||||
|
||||
for (let cellIndex = 0; cellIndex < this.container.childElementCount; cellIndex += 4) {
|
||||
const startingCell = childrenElements.item(cellIndex);
|
||||
|
||||
// First 4 cells are actually table headers, skipping them.
|
||||
if (!(startingCell instanceof HTMLElement) || startingCell.tagName === 'P') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rightImageCell = startingCell.nextElementSibling;
|
||||
const diffCell = rightImageCell?.nextElementSibling || null;
|
||||
const reportOptionsCell = diffCell?.nextElementSibling || null;
|
||||
|
||||
if (!(rightImageCell instanceof HTMLElement) || !(diffCell instanceof HTMLElement) || !(reportOptionsCell instanceof HTMLElement)) {
|
||||
console.error(`Unable to capture duplicate report row from starting cell at index ${cellIndex}!`);
|
||||
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>('.grid--dupe-report-list')) {
|
||||
new GridDupeReportsList(container).initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/content/duplicate-reports.ts
Normal file
3
src/content/duplicate-reports.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import GridDupeReportsList from "$content/components/philomena/dupe/GridDupeReportsList";
|
||||
|
||||
GridDupeReportsList.findAndInitialize();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
18
src/styles/content/duplicate-reports.scss
Normal file
18
src/styles/content/duplicate-reports.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
%with-margin {
|
||||
margin-left: .5em;
|
||||
}
|
||||
|
||||
.dr__diff {
|
||||
.source-icons {
|
||||
@extend %with-margin;
|
||||
|
||||
&__list {
|
||||
display: inline-flex;
|
||||
gap: .25em;
|
||||
}
|
||||
}
|
||||
|
||||
.ratings-difference {
|
||||
@extend %with-margin;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user