diff --git a/manifest.json b/manifest.json index c6f48fd..115f0e6 100644 --- a/manifest.json +++ b/manifest.json @@ -69,6 +69,19 @@ "js": [ "src/content/tags.ts" ] + }, + { + "matches": [ + "*://*.furbooru.org/posts", + "*://*.furbooru.org/posts?*", + "*://*.furbooru.org/forums/*/topics/*" + ], + "js": [ + "src/content/posts.ts" + ], + "css": [ + "src/styles/content/posts.scss" + ] } ], "action": { diff --git a/src/config/tags.ts b/src/config/tags.ts index f60f69a..dfc2897 100644 --- a/src/config/tags.ts +++ b/src/config/tags.ts @@ -14,6 +14,28 @@ export const categories: string[] = [ 'body-type', ]; +/** + * Mapping of namespaces to their respective categories. These namespaces are automatically assigned to them, so we can + * automatically assume categories of tags which start with them. Mapping is extracted from Philomena directly. + * + * This mapping may differ between boorus. + * + * @see https://github.com/philomena-dev/philomena/blob/6086757b654da8792ae52adb2a2f501ea6c30d12/lib/philomena/tags/tag.ex#L33-L45 + */ +export const namespaceCategories: Map = 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'], +]); + /** * List of tags which marked by the site as blacklisted. These tags are blocked from being added by the tag editor and * should usually just be removed automatically. diff --git a/src/content/components/BlockCommunication.ts b/src/content/components/BlockCommunication.ts new file mode 100644 index 0000000..852a80b --- /dev/null +++ b/src/content/components/BlockCommunication.ts @@ -0,0 +1,65 @@ +import { BaseComponent } from "$content/components/base/BaseComponent"; +import TagSettings from "$lib/extension/settings/TagSettings"; +import { getComponent } from "$content/components/base/component-utils"; +import { decodeTagNameFromLink, resolveTagCategoryFromTagName } from "$lib/booru/tag-utils"; + +export class BlockCommunication extends BaseComponent { + #contentSection: HTMLElement | null = null; + #tagLinks: HTMLAnchorElement[] = []; + + #tagLinksReplaced: boolean | null = null; + + protected build() { + this.#contentSection = this.container.querySelector('.communication__content'); + this.#tagLinks = this.#findAllTagLinks(); + } + + protected init() { + BlockCommunication.#tagSettings.resolveReplaceLinks().then(this.#onReplaceLinkSettingResolved.bind(this)); + BlockCommunication.#tagSettings.subscribe(settings => { + this.#onReplaceLinkSettingResolved(settings.replaceLinks ?? false); + }); + } + + #onReplaceLinkSettingResolved(haveToReplaceLinks: boolean) { + if (!this.#tagLinks.length || this.#tagLinksReplaced === haveToReplaceLinks) { + return; + } + + for (const linkElement of this.#tagLinks) { + linkElement.classList.toggle('tag', haveToReplaceLinks); + + // Sometimes tags are being decorated with the code block inside. It should be fine to replace it right away. + if (linkElement.childElementCount === 1 && linkElement.children[0].tagName === 'CODE') { + linkElement.textContent = linkElement.children[0].textContent; + } + + if (haveToReplaceLinks) { + const maybeDecodedTagName = decodeTagNameFromLink(linkElement.pathname) ?? ''; + linkElement.dataset.tagCategory = resolveTagCategoryFromTagName(maybeDecodedTagName) ?? ''; + } else { + linkElement.dataset.tagCategory = ''; + } + } + + this.#tagLinksReplaced = haveToReplaceLinks; + } + + #findAllTagLinks(): HTMLAnchorElement[] { + return Array + .from(this.#contentSection?.querySelectorAll('a') || []) + .filter(link => link.pathname.startsWith('/tags/')) + } + + static #tagSettings = new TagSettings(); + + static findAndInitializeAll() { + for (const container of document.querySelectorAll('.block.communication')) { + if (getComponent(container)) { + continue; + } + + new BlockCommunication(container).initialize(); + } + } +} diff --git a/src/content/posts.ts b/src/content/posts.ts new file mode 100644 index 0000000..a331a58 --- /dev/null +++ b/src/content/posts.ts @@ -0,0 +1,3 @@ +import { BlockCommunication } from "$content/components/BlockCommunication"; + +BlockCommunication.findAndInitializeAll(); diff --git a/src/lib/booru/tag-utils.ts b/src/lib/booru/tag-utils.ts index 3270685..c7cb0ae 100644 --- a/src/lib/booru/tag-utils.ts +++ b/src/lib/booru/tag-utils.ts @@ -1,3 +1,5 @@ +import { namespaceCategories } from "$config/tags"; + /** * Build the map containing both real tags and their aliases. * @@ -31,3 +33,52 @@ export function buildTagsAndAliasesMap(realAndAliasedTags: string[], realTags: s return tagsAndAliasesMap; } + +const tagLinkRegExp = /\/tags\/(?[^/?#]+)/; + +/** + * List of encoded characters from Philomena. + * + * @see https://github.com/philomena-dev/philomena/blob/6086757b654da8792ae52adb2a2f501ea6c30d12/lib/philomena/slug.ex#L52-L57 + */ +const slugEncodedCharacters: Map = new Map([ + ['-dash-', '-'], + ['-fwslash-', '/'], + ['-bwslash-', '\\'], + ['-colon-', ':'], + ['-dot-', '.'], + ['-plus-', '+'], +]); + +/** + * Decode the tag name from its link path. + * + * @param tagLink Full or partial link to the tag. + * + * @return Tag name or NULL if function is failed to recognize the link as tag-related link. + */ +export function decodeTagNameFromLink(tagLink: string): string | null { + tagLinkRegExp.lastIndex = 0; + + const result = tagLinkRegExp.exec(tagLink); + const encodedTagName = result?.groups?.encodedTagName; + + if (!encodedTagName) { + return null; + } + + return decodeURIComponent(encodedTagName) + .replaceAll(/-[a-z]+-/gi, match => slugEncodedCharacters.get(match) ?? match) + .replaceAll('-', ' '); +} + +/** + * Try to resolve the category from the tag name. + * + * @param tagName Name of the tag. + */ +export function resolveTagCategoryFromTagName(tagName: string): string | null { + const namespace = tagName.split(':')[0]; + + return namespaceCategories.get(namespace) ?? null; +} diff --git a/src/lib/extension/settings/TagSettings.ts b/src/lib/extension/settings/TagSettings.ts index e95f7e6..7c69628 100644 --- a/src/lib/extension/settings/TagSettings.ts +++ b/src/lib/extension/settings/TagSettings.ts @@ -2,6 +2,7 @@ import CacheableSettings from "$lib/extension/base/CacheableSettings"; interface TagSettingsFields { groupSeparation: boolean; + replaceLinks: boolean; } export default class TagSettings extends CacheableSettings { @@ -13,7 +14,15 @@ export default class TagSettings extends CacheableSettings { return this._resolveSetting("groupSeparation", true); } + async resolveReplaceLinks() { + return this._resolveSetting("replaceLinks", false); + } + async setGroupSeparation(value: boolean) { return this._writeSetting("groupSeparation", Boolean(value)); } + + async setReplaceLinks(value: boolean) { + return this._writeSetting("replaceLinks", Boolean(value)); + } } diff --git a/src/routes/preferences/tags/+page.svelte b/src/routes/preferences/tags/+page.svelte index 5e60bc9..958c2bf 100644 --- a/src/routes/preferences/tags/+page.svelte +++ b/src/routes/preferences/tags/+page.svelte @@ -5,7 +5,7 @@ import Menu from "$components/ui/menu/Menu.svelte"; import MenuItem from "$components/ui/menu/MenuItem.svelte"; import { stripBlacklistedTagsEnabled } from "$stores/preferences/maintenance"; - import { shouldSeparateTagGroups } from "$stores/preferences/tag"; + import { shouldReplaceLinksOnForumPosts, shouldSeparateTagGroups } from "$stores/preferences/tag"; import { popupTitle } from "$stores/popup"; $popupTitle = 'Tagging Preferences'; @@ -26,4 +26,9 @@ Enable separation of custom tag groups on the image pages + + + Find and replace links to the tags in the forum posts + + diff --git a/src/stores/preferences/tag.ts b/src/stores/preferences/tag.ts index 70722c9..d7a06cf 100644 --- a/src/stores/preferences/tag.ts +++ b/src/stores/preferences/tag.ts @@ -4,15 +4,24 @@ import TagSettings from "$lib/extension/settings/TagSettings"; const tagSettings = new TagSettings(); export const shouldSeparateTagGroups = writable(false); +export const shouldReplaceLinksOnForumPosts = writable(false); -tagSettings.resolveGroupSeparation() - .then(value => shouldSeparateTagGroups.set(value)) +Promise + .allSettled([ + tagSettings.resolveGroupSeparation().then(value => shouldSeparateTagGroups.set(value)), + tagSettings.resolveReplaceLinks().then(value => shouldReplaceLinksOnForumPosts.set(value)), + ]) .then(() => { shouldSeparateTagGroups.subscribe(value => { void tagSettings.setGroupSeparation(value); }); + shouldReplaceLinksOnForumPosts.subscribe(value => { + void tagSettings.setReplaceLinks(value); + }); + tagSettings.subscribe(settings => { shouldSeparateTagGroups.set(Boolean(settings.groupSeparation)); + shouldReplaceLinksOnForumPosts.set(Boolean(settings.replaceLinks)); }); - }) + }); diff --git a/src/styles/content/posts.scss b/src/styles/content/posts.scss new file mode 100644 index 0000000..47df002 --- /dev/null +++ b/src/styles/content/posts.scss @@ -0,0 +1,9 @@ +@use '$styles/booru-vars'; + +.block.communication { + .tag { + &:hover { + color: booru-vars.$resolved-tag-color; + } + } +}