11 changed files with 498 additions and 151 deletions
@ -0,0 +1,236 @@ |
|||||||
|
/** |
||||||
|
* Auto-tagging utilities for extracting tags from content |
||||||
|
* Automatically extracts t-tags (hashtags), p-tags (mentions), e-tags (event references), and a-tags (addressable events) |
||||||
|
*/ |
||||||
|
|
||||||
|
import { extractMentions, getMentionPubkeys } from './mentions.js'; |
||||||
|
import { findNIP21Links } from './nostr/nip21-parser.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { isParameterizedReplaceableKind } from '../types/kind-lookup.js'; |
||||||
|
import { normalizeTitleToDTag } from './text-utils.js'; |
||||||
|
|
||||||
|
export interface AutoTaggingOptions { |
||||||
|
content: string; |
||||||
|
existingTags?: string[][]; |
||||||
|
kind?: number; |
||||||
|
includeHashtags?: boolean; |
||||||
|
includeMentions?: boolean; |
||||||
|
includeNostrLinks?: boolean; |
||||||
|
maxHashtags?: number; |
||||||
|
} |
||||||
|
|
||||||
|
export interface AutoTaggingResult { |
||||||
|
tags: string[][]; |
||||||
|
dTag?: string; // Normalized d-tag if created from title
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract hashtags from content and return as t-tags |
||||||
|
* @param content - The content to search for hashtags |
||||||
|
* @param maxHashtags - Maximum number of hashtags to extract (default: 3) |
||||||
|
* @param existingTags - Existing tags to check for duplicates |
||||||
|
* @returns Array of t-tags |
||||||
|
*/ |
||||||
|
export function extractHashtags( |
||||||
|
content: string, |
||||||
|
maxHashtags: number = 3, |
||||||
|
existingTags: string[][] = [] |
||||||
|
): string[][] { |
||||||
|
const hashtagPattern = /#([a-zA-Z0-9_]+)/g; |
||||||
|
const hashtags = new Set<string>(); |
||||||
|
let hashtagMatch; |
||||||
|
|
||||||
|
while ((hashtagMatch = hashtagPattern.exec(content)) !== null) { |
||||||
|
const hashtag = hashtagMatch[1].toLowerCase(); |
||||||
|
if (hashtag.length > 0) { |
||||||
|
hashtags.add(hashtag); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Add t-tags for hashtags (max to avoid spam)
|
||||||
|
const hashtagArray = Array.from(hashtags).slice(0, maxHashtags); |
||||||
|
const tTags: string[][] = []; |
||||||
|
|
||||||
|
for (const hashtag of hashtagArray) { |
||||||
|
// Check if t-tag already exists to avoid duplicates
|
||||||
|
if (!existingTags.some(t => t[0] === 't' && t[1] === hashtag)) { |
||||||
|
tTags.push(['t', hashtag]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return tTags; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract nostr: links and convert to appropriate tags |
||||||
|
* @param content - The content to search for nostr: links |
||||||
|
* @param existingTags - Existing tags to check for duplicates |
||||||
|
* @returns Array of tags (p, e, or a tags) |
||||||
|
*/ |
||||||
|
export async function extractNostrLinkTags( |
||||||
|
content: string, |
||||||
|
existingTags: string[][] = [] |
||||||
|
): Promise<string[][]> { |
||||||
|
const tags: string[][] = []; |
||||||
|
const nostrLinks = findNIP21Links(content); |
||||||
|
|
||||||
|
for (const link of nostrLinks) { |
||||||
|
const parsed = link.parsed; |
||||||
|
try { |
||||||
|
if (parsed.type === 'npub' || parsed.type === 'nprofile') { |
||||||
|
// Add p-tag for profile mentions
|
||||||
|
let pubkey: string | undefined; |
||||||
|
if (parsed.type === 'npub') { |
||||||
|
const decoded = nip19.decode(parsed.data); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
pubkey = decoded.data as string; |
||||||
|
} |
||||||
|
} else if (parsed.type === 'nprofile') { |
||||||
|
const decoded = nip19.decode(parsed.data); |
||||||
|
if (decoded.type === 'nprofile') { |
||||||
|
pubkey = decoded.data.pubkey; |
||||||
|
} |
||||||
|
} |
||||||
|
if (pubkey && !existingTags.some(t => t[0] === 'p' && t[1] === pubkey)) { |
||||||
|
tags.push(['p', pubkey]); |
||||||
|
} |
||||||
|
} else if (parsed.type === 'note' || parsed.type === 'nevent') { |
||||||
|
// Add e-tag for event references
|
||||||
|
let eventId: string | undefined; |
||||||
|
if (parsed.type === 'note') { |
||||||
|
const decoded = nip19.decode(parsed.data); |
||||||
|
if (decoded.type === 'note') { |
||||||
|
eventId = decoded.data as string; |
||||||
|
} |
||||||
|
} else if (parsed.type === 'nevent') { |
||||||
|
const decoded = nip19.decode(parsed.data); |
||||||
|
if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { |
||||||
|
eventId = decoded.data.id as string; |
||||||
|
} |
||||||
|
} |
||||||
|
if (eventId && !existingTags.some(t => t[0] === 'e' && t[1] === eventId)) { |
||||||
|
tags.push(['e', eventId]); |
||||||
|
} |
||||||
|
} else if (parsed.type === 'naddr') { |
||||||
|
// Add a-tag for parameterized replaceable events
|
||||||
|
const decoded = nip19.decode(parsed.data); |
||||||
|
if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') { |
||||||
|
const naddrData = decoded.data as { kind: number; pubkey: string; identifier?: string }; |
||||||
|
const aTag = `${naddrData.kind}:${naddrData.pubkey}:${naddrData.identifier || ''}`; |
||||||
|
if (!existingTags.some(t => t[0] === 'a' && t[1] === aTag)) { |
||||||
|
tags.push(['a', aTag]); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// Skip invalid nostr links
|
||||||
|
console.debug('Error parsing nostr link:', e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return tags; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract mentions (@handles) and convert to p-tags |
||||||
|
* @param content - The content to search for mentions |
||||||
|
* @param existingTags - Existing tags to check for duplicates |
||||||
|
* @returns Array of p-tags |
||||||
|
*/ |
||||||
|
export async function extractMentionTags( |
||||||
|
content: string, |
||||||
|
existingTags: string[][] = [] |
||||||
|
): Promise<string[][]> { |
||||||
|
const mentions = await extractMentions(content); |
||||||
|
const mentionPubkeys = getMentionPubkeys(mentions); |
||||||
|
const pTags: string[][] = []; |
||||||
|
|
||||||
|
for (const pubkey of mentionPubkeys) { |
||||||
|
if (!existingTags.some(t => t[0] === 'p' && t[1] === pubkey)) { |
||||||
|
pTags.push(['p', pubkey]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return pTags; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Ensure d-tag exists for parameterized replaceable events |
||||||
|
* @param tags - Existing tags |
||||||
|
* @param kind - Event kind |
||||||
|
* @returns d-tag value if created, undefined if already exists or not needed |
||||||
|
*/ |
||||||
|
export function ensureDTagForParameterizedReplaceable( |
||||||
|
tags: string[][], |
||||||
|
kind: number |
||||||
|
): { dTag: string } | null { |
||||||
|
if (!isParameterizedReplaceableKind(kind)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if d-tag already exists
|
||||||
|
const existingDTag = tags.find(t => t[0] === 'd' && t[1]); |
||||||
|
if (existingDTag) { |
||||||
|
return null; // Already exists
|
||||||
|
} |
||||||
|
|
||||||
|
// Try to get d-tag from title tag
|
||||||
|
const titleTag = tags.find(t => t[0] === 'title' && t[1]); |
||||||
|
if (titleTag && titleTag[1]) { |
||||||
|
const normalizedDTag = normalizeTitleToDTag(titleTag[1]); |
||||||
|
if (normalizedDTag) { |
||||||
|
return { dTag: normalizedDTag }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null; // No title to normalize
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Auto-extract tags from content |
||||||
|
* @param options - Auto-tagging options |
||||||
|
* @returns Tags and optional d-tag |
||||||
|
*/ |
||||||
|
export async function autoExtractTags(options: AutoTaggingOptions): Promise<AutoTaggingResult> { |
||||||
|
const { |
||||||
|
content, |
||||||
|
existingTags = [], |
||||||
|
kind, |
||||||
|
includeHashtags = true, |
||||||
|
includeMentions = true, |
||||||
|
includeNostrLinks = true, |
||||||
|
maxHashtags = 3 |
||||||
|
} = options; |
||||||
|
|
||||||
|
const tags: string[][] = []; |
||||||
|
|
||||||
|
// Extract hashtags (t-tags)
|
||||||
|
if (includeHashtags) { |
||||||
|
const hashtagTags = extractHashtags(content, maxHashtags, existingTags); |
||||||
|
tags.push(...hashtagTags); |
||||||
|
} |
||||||
|
|
||||||
|
// Extract mentions (@handles) as p-tags
|
||||||
|
if (includeMentions) { |
||||||
|
const mentionTags = await extractMentionTags(content, [...existingTags, ...tags]); |
||||||
|
tags.push(...mentionTags); |
||||||
|
} |
||||||
|
|
||||||
|
// Extract nostr: links (p, e, a tags)
|
||||||
|
if (includeNostrLinks) { |
||||||
|
const nostrLinkTags = await extractNostrLinkTags(content, [...existingTags, ...tags]); |
||||||
|
tags.push(...nostrLinkTags); |
||||||
|
} |
||||||
|
|
||||||
|
// Ensure d-tag for parameterized replaceable events
|
||||||
|
let dTag: string | undefined; |
||||||
|
if (kind !== undefined) { |
||||||
|
const dTagResult = ensureDTagForParameterizedReplaceable([...existingTags, ...tags], kind); |
||||||
|
if (dTagResult) { |
||||||
|
tags.push(['d', dTagResult.dTag]); |
||||||
|
dTag = dTagResult.dTag; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { tags, dTag }; |
||||||
|
} |
||||||
Loading…
Reference in new issue