11 changed files with 498 additions and 151 deletions
@ -0,0 +1,236 @@
@@ -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