diff --git a/manifest.json b/manifest.json index ee12cf4..a55ca6d 100644 --- a/manifest.json +++ b/manifest.json @@ -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": { diff --git a/src/config/tags.ts b/src/config/tags.ts index 2065e88..98583ad 100644 --- a/src/config/tags.ts +++ b/src/config/tags.ts @@ -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', +]; diff --git a/src/content/components/philomena/ImageContainer.ts b/src/content/components/philomena/ImageContainer.ts new file mode 100644 index 0000000..10430a5 --- /dev/null +++ b/src/content/components/philomena/ImageContainer.ts @@ -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 || []; + } +} diff --git a/src/content/components/philomena/MediaBox.ts b/src/content/components/philomena/MediaBox.ts index c7b1cc6..20bd571 100644 --- a/src/content/components/philomena/MediaBox.ts +++ b/src/content/components/philomena/MediaBox.ts @@ -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 | 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; } /** diff --git a/src/content/components/philomena/dupe/DupeDiff.ts b/src/content/components/philomena/dupe/DupeDiff.ts new file mode 100644 index 0000000..c4f73d9 --- /dev/null +++ b/src/content/components/philomena/dupe/DupeDiff.ts @@ -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('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([ + [ + ['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'], + ], + ]); +} diff --git a/src/content/components/philomena/dupe/DupeImage.ts b/src/content/components/philomena/dupe/DupeImage.ts new file mode 100644 index 0000000..b6f93d7 --- /dev/null +++ b/src/content/components/philomena/dupe/DupeImage.ts @@ -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('.image-container'); + + if (!imageContainerElement) { + throw new Error('Missing image container inside the dupe row!'); + } + + this.imageContainer = new ImageContainer(imageContainerElement); + this.imageContainer.initialize(); + } +} diff --git a/src/content/components/philomena/dupe/DupeReportRow.ts b/src/content/components/philomena/dupe/DupeReportRow.ts new file mode 100644 index 0000000..5b9ab9f --- /dev/null +++ b/src/content/components/philomena/dupe/DupeReportRow.ts @@ -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; + } +} diff --git a/src/content/components/philomena/dupe/GridDupeReportsList.ts b/src/content/components/philomena/dupe/GridDupeReportsList.ts new file mode 100644 index 0000000..902f1d2 --- /dev/null +++ b/src/content/components/philomena/dupe/GridDupeReportsList.ts @@ -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('.dupe-report-list')) { + new GridDupeReportsList(container).initialize(); + } + } +} diff --git a/src/content/duplicate-reports.ts b/src/content/duplicate-reports.ts new file mode 100644 index 0000000..866a71b --- /dev/null +++ b/src/content/duplicate-reports.ts @@ -0,0 +1,3 @@ +import GridDupeReportsList from "$content/components/philomena/dupe/GridDupeReportsList"; + +GridDupeReportsList.findAndInitialize(); diff --git a/src/lib/dom-utils.ts b/src/lib/dom-utils.ts index bfef56b..9b2164f 100644 --- a/src/lib/dom-utils.ts +++ b/src/lib/dom-utils.ts @@ -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; } diff --git a/src/styles/content/duplicate-reports.scss b/src/styles/content/duplicate-reports.scss new file mode 100644 index 0000000..e5f2a28 --- /dev/null +++ b/src/styles/content/duplicate-reports.scss @@ -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; + } + } + } + } +}