diff --git a/js/utils/videoTags.js b/js/utils/videoTags.js new file mode 100644 index 00000000..0a7188d3 --- /dev/null +++ b/js/utils/videoTags.js @@ -0,0 +1,136 @@ +// js/utils/videoTags.js + +/** + * @module utils/videoTags + * @description Helpers for collecting and formatting normalized video tags from + * various metadata sources. + */ + +const HASH_PREFIX_PATTERN = /^#+/; + +function isObject(value) { + return Boolean(value) && typeof value === "object"; +} + +function asIterableStrings(value) { + if (Array.isArray(value)) { + return value; + } + if (typeof value === "string") { + return [value]; + } + return []; +} + +function normalizeTagValue(rawValue) { + if (typeof rawValue !== "string") { + return ""; + } + + const trimmed = rawValue.trim(); + if (!trimmed) { + return ""; + } + + const withoutHashes = trimmed.replace(HASH_PREFIX_PATTERN, "").trim(); + + return withoutHashes; +} + +/** + * Prefix a tag with `#` for display purposes, ensuring a single leading hash. + * + * @param {string} tag - The normalized tag to format. + * @returns {string} The tag prefixed with `#` when a tag exists, otherwise an + * empty string. + */ +export function formatTagDisplay(tag) { + if (typeof tag !== "string") { + return ""; + } + + const trimmed = tag.trim(); + if (!trimmed) { + return ""; + } + + return `#${trimmed.replace(HASH_PREFIX_PATTERN, "").trim()}`; +} + +/** + * Collect normalized hashtags from a video object, deduping across NIP-71 + * metadata and traditional Nostr tag arrays. Results are sorted alphabetically + * in a case-insensitive manner while preserving the first-seen casing. + * + * @param {object} video - Video metadata that may contain NIP-71 structured + * fields or Nostr tag arrays. + * @param {object} [options] + * @param {boolean} [options.includeHashes=false] - When true, prefix `#` on the + * returned tag strings. + * @returns {string[]} Sorted list of unique tag strings. + */ +export function collectVideoTags(video, { includeHashes = false } = {}) { + if (!isObject(video)) { + return []; + } + + const deduped = new Set(); + const collected = []; + + const pushTag = (rawValue) => { + const normalized = normalizeTagValue(rawValue); + if (!normalized) { + return; + } + + const lower = normalized.toLowerCase(); + if (deduped.has(lower)) { + return; + } + + deduped.add(lower); + collected.push(normalized); + }; + + if (isObject(video.nip71)) { + const { hashtags, t } = video.nip71; + for (const candidate of asIterableStrings(hashtags)) { + pushTag(candidate); + } + for (const candidate of asIterableStrings(t)) { + pushTag(candidate); + } + } + + if (Array.isArray(video.tags)) { + for (const tagTuple of video.tags) { + if (!Array.isArray(tagTuple) || tagTuple.length < 2) { + continue; + } + + const [marker, value] = tagTuple; + if (typeof marker !== "string" || marker.toLowerCase() !== "t") { + continue; + } + + pushTag(value); + } + } + + collected.sort((a, b) => { + const lowerA = a.toLowerCase(); + const lowerB = b.toLowerCase(); + if (lowerA === lowerB) { + return a.localeCompare(b); + } + return lowerA.localeCompare(lowerB); + }); + + if (!includeHashes) { + return collected; + } + + return collected.map((tag) => formatTagDisplay(tag)); +} + +export default collectVideoTags; diff --git a/tests/utils/video-tags.test.mjs b/tests/utils/video-tags.test.mjs new file mode 100644 index 00000000..e1053506 --- /dev/null +++ b/tests/utils/video-tags.test.mjs @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { collectVideoTags, formatTagDisplay } from "../../js/utils/videoTags.js"; + +test("collectVideoTags dedupes across metadata sources and respects casing", () => { + const video = { + nip71: { + hashtags: ["#Tag", "Another"], + t: ["Third"], + }, + tags: [ + ["t", "tag"], + ["t", "ANOTHER"], + ["t", " #FOURTH "], + ["x", "ignored"], + ["t"], + ], + }; + + const tags = collectVideoTags(video); + + assert.deepEqual(tags, ["Another", "FOURTH", "Tag", "Third"]); +}); + +test("collectVideoTags sorts case-insensitively and adds hashes when requested", () => { + const video = { + nip71: { + hashtags: ["beta", "Alpha"], + }, + tags: [["t", "gamma"], ["t", "alpha"]], + }; + + const tags = collectVideoTags(video, { includeHashes: true }); + + assert.deepEqual(tags, ["#Alpha", "#beta", "#gamma"]); +}); + +test("collectVideoTags handles malformed inputs safely", () => { + assert.deepEqual(collectVideoTags(null), []); + assert.deepEqual(collectVideoTags(undefined), []); + assert.deepEqual(collectVideoTags(42), []); + assert.deepEqual( + collectVideoTags({ nip71: { hashtags: [null, " "] }, tags: [["t"], ["t", null]] }), + [], + ); +}); + +test("formatTagDisplay normalizes leading hashes", () => { + assert.equal(formatTagDisplay(" example"), "#example"); + assert.equal(formatTagDisplay("##Example"), "#Example"); + assert.equal(formatTagDisplay(""), ""); + assert.equal(formatTagDisplay(null), ""); +});