diff --git a/src/components/NIPBadge.tsx b/src/components/NIPBadge.tsx index b4a253b3..14ed9a96 100644 --- a/src/components/NIPBadge.tsx +++ b/src/components/NIPBadge.tsx @@ -1,6 +1,7 @@ import { getNIPInfo } from "../lib/nip-icons"; import { useAddWindow } from "@/core/state"; import { isNipDeprecated } from "@/constants/nips"; +import { getCommunityNipForNipId } from "@/constants/kinds"; export interface NIPBadgeProps { nipNumber: string; @@ -26,7 +27,19 @@ export function NIPBadge({ nipInfo?.description || `Nostr Implementation Possibility ${nipNumber}`; const isDeprecated = isNipDeprecated(nipNumber); + const communityNip = getCommunityNipForNipId(nipNumber); + const openNIP = () => { + if (communityNip) { + const pointer = { + kind: 30817, + pubkey: communityNip.pubkey, + identifier: communityNip.identifier, + relays: communityNip.relayHints, + }; + addWindow("open", { pointer }, undefined, communityNip.title); + return; + } const paddedNum = nipNumber.toString().padStart(2, "0"); addWindow( "nip", @@ -41,7 +54,13 @@ export function NIPBadge({ className={`flex items-center gap-2 border bg-card px-2.5 py-1.5 text-sm hover:underline hover:decoration-dotted cursor-crosshair ${ isDeprecated ? "opacity-50" : "" } ${className}`} - title={isDeprecated ? `${description} (DEPRECATED)` : description} + title={ + isDeprecated + ? `${description} (DEPRECATED)` + : communityNip + ? `${description} (Community NIP)` + : description + } > {`${showNIPPrefix ? "NIP-" : ""}${nipNumber}`} diff --git a/src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx b/src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx index 9073007b..00ef1e2e 100644 --- a/src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx +++ b/src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx @@ -3,9 +3,11 @@ import { Copy, CopyCheck } from "lucide-react"; import { getTagValue } from "applesauce-core/helpers"; import { UserName } from "../UserName"; import { MarkdownContent } from "../MarkdownContent"; +import { KindBadge } from "@/components/KindBadge"; import { Button } from "@/components/ui/button"; import { useCopy } from "@/hooks/useCopy"; import { formatTimestamp } from "@/hooks/useLocale"; +import { getTagValues } from "@/lib/nostr-utils"; import { toast } from "sonner"; import type { NostrEvent } from "@/types/nostr"; @@ -24,6 +26,12 @@ export function CommunityNIPDetailRenderer({ event }: { event: NostrEvent }) { return getTagValue(event, "r"); }, [event]); + const kinds = useMemo(() => { + return getTagValues(event, "k") + .map(Number) + .filter((n) => !isNaN(n)); + }, [event]); + // Format created date using locale utility const createdDate = formatTimestamp(event.created_at, "long"); @@ -65,6 +73,19 @@ export function CommunityNIPDetailRenderer({ event }: { event: NostrEvent }) { {/* NIP Content - Markdown */} + + {kinds.length > 0 && ( +
+

+ Event Kinds Defined in {title} +

+
+ {kinds.map((kind) => ( + + ))} +
+
+ )} ); } diff --git a/src/components/nostr/kinds/EducationalResourceDetailRenderer.tsx b/src/components/nostr/kinds/EducationalResourceDetailRenderer.tsx new file mode 100644 index 00000000..057062ed --- /dev/null +++ b/src/components/nostr/kinds/EducationalResourceDetailRenderer.tsx @@ -0,0 +1,322 @@ +import { useMemo } from "react"; +import { NostrEvent } from "@/types/nostr"; +import { ExternalLink } from "lucide-react"; +import { useAddWindow } from "@/core/state"; +import { + getAmbName, + getAmbDescription, + getAmbImage, + getAmbLanguage, + getAmbTypes, + getAmbKeywords, + getAmbCreators, + getAmbLearningResourceType, + getAmbEducationalLevel, + getAmbAudience, + getAmbSubjects, + getAmbLicenseId, + getAmbIsAccessibleForFree, + getAmbExternalUrls, + getAmbRelatedResources, + getAmbDateCreated, + getAmbDatePublished, +} from "@/lib/amb-helpers"; +import { Label } from "@/components/ui/label"; +import { UserName } from "@/components/nostr/UserName"; +import { MediaEmbed } from "../MediaEmbed"; + +interface Kind30142DetailRendererProps { + event: NostrEvent; +} + +/** + * Detail renderer for Kind 30142 - Educational Resource (AMB) + * Full metadata view with all AMB properties + */ +export function Kind30142DetailRenderer({ + event, +}: Kind30142DetailRendererProps) { + const addWindow = useAddWindow(); + + const name = getAmbName(event); + const description = getAmbDescription(event); + const image = getAmbImage(event); + const language = getAmbLanguage(event); + const types = getAmbTypes(event); + const keywords = getAmbKeywords(event); + const creators = getAmbCreators(event); + const learningResourceType = getAmbLearningResourceType(event); + const educationalLevel = getAmbEducationalLevel(event); + const audience = getAmbAudience(event); + const subjects = getAmbSubjects(event); + const licenseId = getAmbLicenseId(event); + const isAccessibleForFree = getAmbIsAccessibleForFree(event); + const externalUrls = getAmbExternalUrls(event); + const relatedResources = getAmbRelatedResources(event); + const dateCreated = getAmbDateCreated(event); + const datePublished = getAmbDatePublished(event); + + const licenseLabel = useMemo(() => { + if (!licenseId) return undefined; + // Extract short CC label from URI + const ccMatch = licenseId.match( + /creativecommons\.org\/licenses?\/([\w-]+)/, + ); + if (ccMatch) return `CC ${ccMatch[1].toUpperCase()}`; + return licenseId; + }, [licenseId]); + + const handleRelatedClick = (address: string) => { + try { + const [kindStr, pubkey, ...identifierParts] = address.split(":"); + const pointer = { + kind: parseInt(kindStr), + pubkey, + identifier: identifierParts.join(":"), + }; + addWindow("open", { pointer }); + } catch { + // ignore malformed address + } + }; + + return ( +
+ {/* Header */} +
+

{name || "Untitled Resource"}

+ {types.length > 0 && ( +
+ {types.map((type) => ( + + ))} +
+ )} + {externalUrls[0] && ( + + + + {formatReferenceLabel(externalUrls[0])} + + + )} +
+ + {/* Image */} + {image && } + + {/* Description */} + {description &&

{description}

} + + {/* Metadata Grid */} +
+ {language && ( + + {language} + + )} + + {educationalLevel && ( + + {educationalLevel.label || educationalLevel.id} + + )} + + {learningResourceType && ( + + {learningResourceType.label || learningResourceType.id} + + )} + + {audience && ( + + {audience.label || audience.id} + + )} + + {licenseId && ( + + {licenseId.startsWith("http") ? ( + + {licenseLabel} + + + ) : ( + {licenseLabel} + )} + + )} + + {isAccessibleForFree !== undefined && ( + + {isAccessibleForFree ? "Yes" : "No"} + + )} + + {dateCreated && ( + {dateCreated} + )} + + {datePublished && ( + {datePublished} + )} +
+ + {/* Creators */} + {creators.length > 0 && ( +
+
+ {creators.map((creator, i) => ( +
+
+ {creator.pubkey ? ( + + ) : ( + + {creator.name || "Unknown"} + + )} + {creator.type && ( + + ({creator.type}) + + )} +
+ {creator.affiliationName && ( + + {creator.affiliationName} + + )} +
+ ))} +
+
+ )} + + {/* Subjects */} + {subjects.length > 0 && ( +
+
+ {subjects.map((subject, i) => ( + + ))} +
+
+ )} + + {/* Keywords */} + {keywords.length > 0 && ( +
+
+ {keywords.map((kw) => ( + + ))} +
+
+ )} + + {/* External References */} + {externalUrls.length > 0 && ( +
+
+ {externalUrls.map((url) => ( + + {formatReferenceLabel(url)} + + + ))} +
+
+ )} + + {/* Related Resources */} + {relatedResources.length > 0 && ( +
+
+ {relatedResources.map((res, i) => ( +
+ + {res.relationship && } +
+ ))} +
+
+ )} +
+ ); +} + +function MetadataField({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+

{label}

+ {children} +
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +/** Format a reference URL for display (detect DOI, ISBN) */ +function formatReferenceLabel(url: string): string { + if (url.includes("doi.org/")) { + const doi = url.split("doi.org/")[1]; + return `DOI: ${doi}`; + } + if (url.startsWith("urn:isbn:")) { + return `ISBN: ${url.replace("urn:isbn:", "")}`; + } + return url; +} diff --git a/src/components/nostr/kinds/EducationalResourceRenderer.tsx b/src/components/nostr/kinds/EducationalResourceRenderer.tsx new file mode 100644 index 00000000..2d52f0f0 --- /dev/null +++ b/src/components/nostr/kinds/EducationalResourceRenderer.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import { + BaseEventContainer, + BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { + getAmbName, + getAmbDescription, + getAmbImage, + getAmbLanguage, + getAmbTypes, + getAmbKeywords, + getAmbEducationalLevel, + getAmbLearningResourceType, + getAmbCreators, + getAmbExternalUrls, +} from "@/lib/amb-helpers"; +import { Label } from "@/components/ui/label"; +import { UserName } from "@/components/nostr/UserName"; +import { BookOpen, ExternalLink } from "lucide-react"; + +/** + * Feed renderer for Kind 30142 - Educational Resource (AMB) + * Compact card showing title, types, language, description, keywords, and creators + */ +export function Kind30142Renderer({ event }: BaseEventProps) { + const [imgError, setImgError] = useState(false); + const name = getAmbName(event); + const description = getAmbDescription(event); + const image = getAmbImage(event); + const language = getAmbLanguage(event); + const types = getAmbTypes(event); + const keywords = getAmbKeywords(event); + const educationalLevel = getAmbEducationalLevel(event); + const learningResourceType = getAmbLearningResourceType(event); + const creators = getAmbCreators(event); + const externalUrls = getAmbExternalUrls(event); + const primaryUrl = externalUrls[0]; + const displayUrl = primaryUrl?.replace(/^https?:\/\//, "").replace(/\/$/, ""); + + return ( + +
+ {/* Thumbnail */} + {image && !imgError && ( + {name setImgError(true)} + /> + )} + {image && imgError && ( +
+ +
+ )} + +
+ {/* Title */} + + {name || "Untitled Resource"} + + + {/* Primary URL */} + {primaryUrl && ( + + + {displayUrl} + + )} + + {/* Badges row: types, language, educational level, resource type */} +
+ {types.map((type) => ( + + ))} + {language && } + {educationalLevel?.label && } + {learningResourceType?.label && ( + + )} +
+ + {/* Description */} + {description && ( +

+ {description} +

+ )} + + {/* Keywords */} + {keywords.length > 0 && ( +
+ {keywords.map((kw) => ( + + ))} +
+ )} + + {/* Creators */} + {creators.length > 0 && ( +
+ by + {creators.map((creator, i) => ( + + {i > 0 && ", "} + {creator.pubkey ? ( + + ) : ( + {creator.name || "Unknown"} + )} + + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 63547ca9..031cee20 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -170,6 +170,8 @@ import { MusicTrackDetailRenderer, } from "./MusicTrackRenderer"; import { PlaylistRenderer, PlaylistDetailRenderer } from "./PlaylistRenderer"; +import { Kind30142Renderer } from "./EducationalResourceRenderer"; +import { Kind30142DetailRenderer } from "./EducationalResourceDetailRenderer"; /** * Registry of kind-specific renderers @@ -245,6 +247,7 @@ const kindRenderers: Record> = { 30023: Kind30023Renderer, // Long-form Article 30030: EmojiSetRenderer, // Emoji Sets (NIP-30) 30063: ZapstoreReleaseRenderer, // Zapstore App Release + 30142: Kind30142Renderer, // Educational Resource (AMB) 30166: RelayDiscoveryRenderer, // Relay Discovery (NIP-66) 30267: ZapstoreAppSetRenderer, // Zapstore App Collection 30311: LiveActivityRenderer, // Live Streaming Event (NIP-53) @@ -358,6 +361,7 @@ const detailRenderers: Record< 30023: Kind30023DetailRenderer, // Long-form Article Detail 30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30) 30063: ZapstoreReleaseDetailRenderer, // Zapstore App Release Detail + 30142: Kind30142DetailRenderer, // Educational Resource Detail (AMB) 30166: RelayDiscoveryDetailRenderer, // Relay Discovery Detail (NIP-66) 30267: ZapstoreAppSetDetailRenderer, // Zapstore App Collection Detail 30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53) diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts index 1af86f9c..9b225c79 100644 --- a/src/constants/kinds.ts +++ b/src/constants/kinds.ts @@ -27,6 +27,7 @@ import { GitBranch, GitMerge, GitPullRequest, + GraduationCap, BookHeart, HardDrive, Hash, @@ -74,12 +75,20 @@ import { type LucideIcon, } from "lucide-react"; +export interface CommunityNip { + title: string; + identifier: string; + pubkey: string; + relayHints?: string[]; +} + export interface EventKind { kind: number | string; name: string; description: string; nip: string; icon: LucideIcon; + communityNip?: CommunityNip; } export const SPELL_KIND = 777; @@ -1211,6 +1220,24 @@ export const EVENT_KINDS: Record = { nip: "78", icon: Settings, }, + 30142: { + kind: 30142, + name: "Educational Resource", + description: "AMB Educational Resource Metadata", + nip: "AMB", + icon: GraduationCap, + communityNip: { + title: "NIP-AMB", + identifier: "edufeed-amb", + pubkey: + "bdc21f93b1e2cb75608cecd7a0a00a779779d9367dc9798bd9f213f06c95bc48", + relayHints: [ + "wss://relay.nostr.band", + "wss://nos.lol", + "wss://relay.damus.io", + ], + }, + }, 30166: { kind: 30166, name: "Relay Discovery", @@ -1525,3 +1552,14 @@ export function getKindName(kind: number): string { export function getKindIcon(kind: number): LucideIcon { return EVENT_KINDS[kind]?.icon || MessageSquare; } + +export function getCommunityNipForNipId( + nipId: string, +): CommunityNip | undefined { + for (const entry of Object.values(EVENT_KINDS)) { + if (entry.nip === nipId && entry.communityNip) { + return entry.communityNip; + } + } + return undefined; +} diff --git a/src/lib/amb-helpers.test.ts b/src/lib/amb-helpers.test.ts new file mode 100644 index 00000000..3b8052ca --- /dev/null +++ b/src/lib/amb-helpers.test.ts @@ -0,0 +1,371 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import type { NostrEvent } from "@/types/nostr"; +import { + getAmbCreators, + getAmbRelatedResources, + getAmbIsAccessibleForFree, + getAmbDescription, +} from "./amb-helpers"; + +// Helper to build a minimal event with specific tags +function makeEvent(tags: string[][], content = ""): NostrEvent { + return { + id: "test", + pubkey: "test", + created_at: 0, + kind: 30142, + tags, + content, + sig: "test", + }; +} + +describe("amb-helpers", () => { + let originalLanguage: PropertyDescriptor | undefined; + + function mockBrowserLanguage(lang: string) { + Object.defineProperty(navigator, "language", { + value: lang, + configurable: true, + }); + } + + beforeEach(() => { + originalLanguage = Object.getOwnPropertyDescriptor(navigator, "language"); + }); + + afterEach(() => { + if (originalLanguage) { + Object.defineProperty(navigator, "language", originalLanguage); + } + }); + + describe("findPrefLabel (via getAmbLearningResourceType)", () => { + it("should return browser language label when available", async () => { + mockBrowserLanguage("de"); + // Re-import to pick up the mocked navigator + const { getAmbLearningResourceType } = await import("./amb-helpers.ts"); + + const event = makeEvent([ + ["learningResourceType:id", "https://example.org/type/1"], + ["learningResourceType:prefLabel:en", "Course"], + ["learningResourceType:prefLabel:de", "Kurs"], + ]); + + const result = getAmbLearningResourceType(event); + expect(result?.label).toBe("Kurs"); + }); + + it("should fall back to English when browser language not available", async () => { + mockBrowserLanguage("fr"); + const { getAmbEducationalLevel } = await import("./amb-helpers.ts"); + + const event = makeEvent([ + ["educationalLevel:id", "https://example.org/level/1"], + ["educationalLevel:prefLabel:de", "Grundschule"], + ["educationalLevel:prefLabel:en", "Primary School"], + ]); + + const result = getAmbEducationalLevel(event); + expect(result?.label).toBe("Primary School"); + }); + + it("should fall back to first available when neither browser lang nor en exists", async () => { + mockBrowserLanguage("ja"); + const { getAmbAudience } = await import("./amb-helpers.ts"); + + const event = makeEvent([ + ["audience:id", "https://example.org/audience/1"], + ["audience:prefLabel:de", "Lernende"], + ["audience:prefLabel:es", "Estudiantes"], + ]); + + const result = getAmbAudience(event); + expect(result?.label).toBe("Lernende"); + }); + + it("should return undefined when no labels exist", async () => { + mockBrowserLanguage("en"); + const { getAmbLearningResourceType } = await import("./amb-helpers.ts"); + + const event = makeEvent([ + ["learningResourceType:id", "https://example.org/type/1"], + ]); + + const result = getAmbLearningResourceType(event); + expect(result?.label).toBeUndefined(); + }); + }); + + describe("getAmbSubjects", () => { + it("should pair ids with labels from preferred language only", async () => { + mockBrowserLanguage("de"); + const { getAmbSubjects } = await import("./amb-helpers.ts"); + + const event = makeEvent([ + ["about:id", "https://example.org/subject/math"], + ["about:id", "https://example.org/subject/physics"], + ["about:prefLabel:de", "Mathematik"], + ["about:prefLabel:de", "Physik"], + ["about:prefLabel:en", "Mathematics"], + ["about:prefLabel:en", "Physics"], + ]); + + const subjects = getAmbSubjects(event); + expect(subjects).toHaveLength(2); + expect(subjects[0]).toEqual({ + id: "https://example.org/subject/math", + label: "Mathematik", + }); + expect(subjects[1]).toEqual({ + id: "https://example.org/subject/physics", + label: "Physik", + }); + }); + + it("should fall back to English labels", async () => { + mockBrowserLanguage("ja"); + const { getAmbSubjects } = await import("./amb-helpers.ts"); + + const event = makeEvent([ + ["about:id", "https://example.org/subject/math"], + ["about:prefLabel:de", "Mathematik"], + ["about:prefLabel:en", "Mathematics"], + ]); + + const subjects = getAmbSubjects(event); + expect(subjects).toHaveLength(1); + expect(subjects[0].label).toBe("Mathematics"); + }); + + it("should handle single language correctly", async () => { + mockBrowserLanguage("en"); + const { getAmbSubjects } = await import("./amb-helpers.ts"); + + const event = makeEvent([ + ["about:id", "https://example.org/subject/art"], + ["about:prefLabel:de", "Kunst"], + ]); + + const subjects = getAmbSubjects(event); + expect(subjects).toHaveLength(1); + expect(subjects[0].label).toBe("Kunst"); + }); + + it("should handle no labels gracefully", async () => { + mockBrowserLanguage("en"); + const { getAmbSubjects } = await import("./amb-helpers.ts"); + + const event = makeEvent([ + ["about:id", "https://example.org/subject/math"], + ["about:id", "https://example.org/subject/physics"], + ]); + + const subjects = getAmbSubjects(event); + expect(subjects).toHaveLength(2); + expect(subjects[0]).toEqual({ + id: "https://example.org/subject/math", + label: undefined, + }); + }); + + it("should handle more labels than ids", async () => { + mockBrowserLanguage("en"); + const { getAmbSubjects } = await import("./amb-helpers.ts"); + + const event = makeEvent([ + ["about:id", "https://example.org/subject/math"], + ["about:prefLabel:en", "Mathematics"], + ["about:prefLabel:en", "Physics"], + ]); + + const subjects = getAmbSubjects(event); + expect(subjects).toHaveLength(2); + expect(subjects[1]).toEqual({ + id: undefined, + label: "Physics", + }); + }); + }); + + describe("getAmbCreators", () => { + it("should extract a p-tag only creator", () => { + const event = makeEvent([ + ["p", "abc123", "wss://relay.example.com", "creator"], + ]); + + const creators = getAmbCreators(event); + expect(creators).toEqual([ + { pubkey: "abc123", relayHint: "wss://relay.example.com" }, + ]); + }); + + it("should extract a flattened-tag only creator", () => { + const event = makeEvent([ + ["creator:name", "Alice"], + ["creator:type", "Person"], + ["creator:affiliation:name", "MIT"], + ["creator:id", "https://orcid.org/0000-0001"], + ]); + + const creators = getAmbCreators(event); + expect(creators).toEqual([ + { + name: "Alice", + type: "Person", + affiliationName: "MIT", + id: "https://orcid.org/0000-0001", + }, + ]); + }); + + it("should merge flattened tags into the first p-tag creator", () => { + const event = makeEvent([ + ["p", "abc123", "wss://relay.example.com", "creator"], + ["creator:name", "Alice"], + ["creator:type", "Person"], + ]); + + const creators = getAmbCreators(event); + expect(creators).toHaveLength(1); + expect(creators[0]).toEqual({ + pubkey: "abc123", + relayHint: "wss://relay.example.com", + name: "Alice", + type: "Person", + affiliationName: undefined, + id: undefined, + }); + }); + + it("should only merge into the first p-tag creator", () => { + const event = makeEvent([ + ["p", "abc123", "", "creator"], + ["p", "def456", "wss://relay2.example.com", "creator"], + ["creator:name", "Alice"], + ]); + + const creators = getAmbCreators(event); + expect(creators).toHaveLength(2); + // First creator gets the merged name + expect(creators[0].pubkey).toBe("abc123"); + expect(creators[0].name).toBe("Alice"); + // Second creator has no name + expect(creators[1].pubkey).toBe("def456"); + expect(creators[1].name).toBeUndefined(); + }); + + it("should ignore p tags without creator role", () => { + const event = makeEvent([ + ["p", "abc123", "", "mention"], + ["p", "def456"], + ]); + + const creators = getAmbCreators(event); + expect(creators).toEqual([]); + }); + + it("should handle p-tag with empty relay hint", () => { + const event = makeEvent([["p", "abc123", "", "creator"]]); + + const creators = getAmbCreators(event); + expect(creators).toEqual([{ pubkey: "abc123", relayHint: undefined }]); + }); + + it("should return empty array for event with no creator tags", () => { + const event = makeEvent([ + ["t", "education"], + ["type", "LearningResource"], + ]); + + const creators = getAmbCreators(event); + expect(creators).toEqual([]); + }); + }); + + describe("getAmbRelatedResources", () => { + it("should extract a related resource with all fields", () => { + const event = makeEvent([ + ["a", "30142:pubkey123:d-tag", "wss://relay.example.com", "isPartOf"], + ]); + + const resources = getAmbRelatedResources(event); + expect(resources).toEqual([ + { + address: "30142:pubkey123:d-tag", + relayHint: "wss://relay.example.com", + relationship: "isPartOf", + }, + ]); + }); + + it("should exclude non-30142 a tags", () => { + const event = makeEvent([ + ["a", "30142:pubkey123:d-tag", "", "isPartOf"], + ["a", "30023:pubkey456:d-tag", "wss://relay.example.com"], + ["a", "10002:pubkey789:"], + ]); + + const resources = getAmbRelatedResources(event); + expect(resources).toHaveLength(1); + expect(resources[0].address).toBe("30142:pubkey123:d-tag"); + }); + + it("should handle missing relay hint and relationship", () => { + const event = makeEvent([["a", "30142:pubkey123:d-tag"]]); + + const resources = getAmbRelatedResources(event); + expect(resources).toEqual([ + { + address: "30142:pubkey123:d-tag", + relayHint: undefined, + relationship: undefined, + }, + ]); + }); + + it("should return empty array when no a tags exist", () => { + const event = makeEvent([["t", "education"]]); + + const resources = getAmbRelatedResources(event); + expect(resources).toEqual([]); + }); + }); + + describe("getAmbIsAccessibleForFree", () => { + it("should return true for 'true'", () => { + const event = makeEvent([["isAccessibleForFree", "true"]]); + expect(getAmbIsAccessibleForFree(event)).toBe(true); + }); + + it("should return false for 'false'", () => { + const event = makeEvent([["isAccessibleForFree", "false"]]); + expect(getAmbIsAccessibleForFree(event)).toBe(false); + }); + + it("should return undefined when tag is missing", () => { + const event = makeEvent([["t", "education"]]); + expect(getAmbIsAccessibleForFree(event)).toBeUndefined(); + }); + }); + + describe("getAmbDescription", () => { + it("should use event.content when present", () => { + const event = makeEvent( + [["description", "tag description"]], + "content description", + ); + expect(getAmbDescription(event)).toBe("content description"); + }); + + it("should fall back to description tag when content is empty", () => { + const event = makeEvent([["description", "tag description"]], ""); + expect(getAmbDescription(event)).toBe("tag description"); + }); + + it("should return undefined when both content and tag are missing", () => { + const event = makeEvent([["t", "education"]], ""); + expect(getAmbDescription(event)).toBeUndefined(); + }); + }); +}); diff --git a/src/lib/amb-helpers.ts b/src/lib/amb-helpers.ts new file mode 100644 index 00000000..92a4b11f --- /dev/null +++ b/src/lib/amb-helpers.ts @@ -0,0 +1,285 @@ +import type { NostrEvent } from "@/types/nostr"; +import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers"; + +/** + * AMB (Allgemeines Metadatenprofil für Bildungsressourcen) Helpers + * Extract metadata from kind 30142 educational resource events + * + * Uses flattened tag convention: nested JSON-LD properties are + * represented as colon-delimited tag names (e.g., "creator:name"). + * + * All cached helpers use getOrComputeCachedValue to avoid + * recomputation — no useMemo needed in components. + */ + +// Cache symbols +const TypesSymbol = Symbol("ambTypes"); +const KeywordsSymbol = Symbol("ambKeywords"); +const CreatorsSymbol = Symbol("ambCreators"); +const LearningResourceTypeSymbol = Symbol("ambLearningResourceType"); +const EducationalLevelSymbol = Symbol("ambEducationalLevel"); +const SubjectsSymbol = Symbol("ambSubjects"); +const ExternalUrlsSymbol = Symbol("ambExternalUrls"); +const RelatedResourcesSymbol = Symbol("ambRelatedResources"); +const AudienceSymbol = Symbol("ambAudience"); + +// ============================================================================ +// Simple helpers (direct tag reads) +// ============================================================================ + +export function getAmbName(event: NostrEvent): string | undefined { + return getTagValue(event, "name"); +} + +export function getAmbImage(event: NostrEvent): string | undefined { + return getTagValue(event, "image"); +} + +export function getAmbDescription(event: NostrEvent): string | undefined { + return event.content || getTagValue(event, "description"); +} + +export function getAmbLanguage(event: NostrEvent): string | undefined { + return getTagValue(event, "inLanguage"); +} + +export function getAmbLicenseId(event: NostrEvent): string | undefined { + return getTagValue(event, "license:id"); +} + +export function getAmbIsAccessibleForFree( + event: NostrEvent, +): boolean | undefined { + const val = getTagValue(event, "isAccessibleForFree"); + if (val === "true") return true; + if (val === "false") return false; + return undefined; +} + +export function getAmbDateCreated(event: NostrEvent): string | undefined { + return getTagValue(event, "dateCreated"); +} + +export function getAmbDatePublished(event: NostrEvent): string | undefined { + return getTagValue(event, "datePublished"); +} + +// ============================================================================ +// Cached helpers (iterate tags) +// ============================================================================ + +/** All `type` tag values (e.g., ["LearningResource", "Course"]) */ +export function getAmbTypes(event: NostrEvent): string[] { + return getOrComputeCachedValue(event, TypesSymbol, () => + event.tags.filter((t) => t[0] === "type" && t[1]).map((t) => t[1]), + ); +} + +/** All `t` tag values (keywords) */ +export function getAmbKeywords(event: NostrEvent): string[] { + return getOrComputeCachedValue(event, KeywordsSymbol, () => + event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]), + ); +} + +export interface AmbCreator { + pubkey?: string; + relayHint?: string; + name?: string; + type?: string; + affiliationName?: string; + id?: string; +} + +/** + * Extract creators from both `p` tags with "creator" role + * and `creator:*` flattened tags. + */ +export function getAmbCreators(event: NostrEvent): AmbCreator[] { + return getOrComputeCachedValue(event, CreatorsSymbol, () => { + const creators: AmbCreator[] = []; + + // Nostr-native creators from p tags with "creator" role + for (const tag of event.tags) { + if (tag[0] === "p" && tag[3] === "creator" && tag[1]) { + creators.push({ + pubkey: tag[1], + relayHint: tag[2] || undefined, + }); + } + } + + // External creators from flattened tags + const creatorName = getTagValue(event, "creator:name"); + const creatorType = getTagValue(event, "creator:type"); + const creatorAffiliation = getTagValue(event, "creator:affiliation:name"); + const creatorId = getTagValue(event, "creator:id"); + + if (creatorName || creatorType || creatorAffiliation || creatorId) { + // Merge with the first p-tag creator if it exists, otherwise create new + const existingNostr = creators.find((c) => c.pubkey); + if (existingNostr) { + existingNostr.name = creatorName; + existingNostr.type = creatorType; + existingNostr.affiliationName = creatorAffiliation; + existingNostr.id = creatorId; + } else { + creators.push({ + name: creatorName, + type: creatorType, + affiliationName: creatorAffiliation, + id: creatorId, + }); + } + } + + return creators; + }); +} + +export interface AmbConceptRef { + id?: string; + label?: string; +} + +/** learningResourceType from flattened tags */ +export function getAmbLearningResourceType( + event: NostrEvent, +): AmbConceptRef | undefined { + return getOrComputeCachedValue(event, LearningResourceTypeSymbol, () => { + const id = getTagValue(event, "learningResourceType:id"); + const label = findPrefLabel(event, "learningResourceType"); + if (!id && !label) return undefined; + return { id, label }; + }); +} + +/** educationalLevel from flattened tags */ +export function getAmbEducationalLevel( + event: NostrEvent, +): AmbConceptRef | undefined { + return getOrComputeCachedValue(event, EducationalLevelSymbol, () => { + const id = getTagValue(event, "educationalLevel:id"); + const label = findPrefLabel(event, "educationalLevel"); + if (!id && !label) return undefined; + return { id, label }; + }); +} + +/** audience from flattened tags */ +export function getAmbAudience(event: NostrEvent): AmbConceptRef | undefined { + return getOrComputeCachedValue(event, AudienceSymbol, () => { + const id = getTagValue(event, "audience:id"); + const label = findPrefLabel(event, "audience"); + if (!id && !label) return undefined; + return { id, label }; + }); +} + +/** All about:* subjects as concept references */ +export function getAmbSubjects(event: NostrEvent): AmbConceptRef[] { + return getOrComputeCachedValue(event, SubjectsSymbol, () => { + const ids: string[] = []; + // Group labels by language: Map + const labelsByLang = new Map(); + + for (const tag of event.tags) { + if (tag[0] === "about:id" && tag[1]) { + ids.push(tag[1]); + } + if (tag[0]?.startsWith("about:prefLabel:") && tag[1]) { + const lang = tag[0].slice("about:prefLabel:".length); + let arr = labelsByLang.get(lang); + if (!arr) { + arr = []; + labelsByLang.set(lang, arr); + } + arr.push(tag[1]); + } + } + + // Pick best language set: browser lang > "en" > first available + const browserLang = getBrowserLanguage(); + const labels = + labelsByLang.get(browserLang) ?? + labelsByLang.get("en") ?? + labelsByLang.values().next().value ?? + []; + + // Pair ids with labels positionally + const count = Math.max(ids.length, labels.length); + const subjects: AmbConceptRef[] = []; + for (let i = 0; i < count; i++) { + subjects.push({ + id: ids[i], + label: labels[i], + }); + } + + return subjects; + }); +} + +/** All `r` tag values (external URLs) */ +export function getAmbExternalUrls(event: NostrEvent): string[] { + return getOrComputeCachedValue(event, ExternalUrlsSymbol, () => + event.tags.filter((t) => t[0] === "r" && t[1]).map((t) => t[1]), + ); +} + +export interface AmbRelatedResource { + address: string; + relayHint?: string; + relationship?: string; +} + +/** `a` tags pointing to other 30142 events with relationship type */ +export function getAmbRelatedResources( + event: NostrEvent, +): AmbRelatedResource[] { + return getOrComputeCachedValue(event, RelatedResourcesSymbol, () => + event.tags + .filter((t) => t[0] === "a" && t[1]?.startsWith("30142:")) + .map((t) => ({ + address: t[1], + relayHint: t[2] || undefined, + relationship: t[3] || undefined, + })), + ); +} + +// ============================================================================ +// Internal utilities +// ============================================================================ + +/** Get base language code from browser (e.g., "de", "en") */ +function getBrowserLanguage(): string { + const lang = navigator?.language || "en"; + return lang.split("-")[0].toLowerCase(); +} + +/** + * Find the best prefLabel for a given prefix using locale fallback: + * browser language > "en" > first available + */ +function findPrefLabel(event: NostrEvent, prefix: string): string | undefined { + const labelPrefix = `${prefix}:prefLabel:`; + const browserLang = getBrowserLanguage(); + + let browserMatch: string | undefined; + let enMatch: string | undefined; + let firstMatch: string | undefined; + + for (const tag of event.tags) { + if (tag[0]?.startsWith(labelPrefix) && tag[1]) { + const lang = tag[0].slice(labelPrefix.length); + if (!firstMatch) firstMatch = tag[1]; + if (lang === browserLang && !browserMatch) browserMatch = tag[1]; + if (lang === "en" && !enMatch) enMatch = tag[1]; + // Early exit if we found the best match + if (browserMatch) break; + } + } + + return browserMatch ?? enMatch ?? firstMatch; +}