Files
boris/src/services/bookmarkProcessing.ts
Gigi a84d439489 fix: properly deduplicate web bookmarks by d-tag
- Web bookmarks (kind:39701) are replaceable events and should be deduplicated by d-tag
- Update dedupeNip51Events to include kind:39701 in d-tag deduplication logic
- Use coordinate format (kind:pubkey:d-tag) for web bookmark IDs instead of event IDs
- Ensures same URL bookmarked multiple times only appears once
- Keeps newest version when duplicates exist
2025-10-22 13:28:36 +02:00

219 lines
7.5 KiB
TypeScript

import { Helpers } from 'applesauce-core'
import {
ActiveAccount,
IndividualBookmark
} from '../types/bookmarks'
import { BookmarkHiddenSymbol, hasNip04Decrypt, hasNip44Decrypt, processApplesauceBookmarks } from './bookmarkHelpers'
import type { NostrEvent } from './bookmarkHelpers'
type DecryptFn = (pubkey: string, content: string) => Promise<string>
type UnlockHiddenTagsFn = typeof Helpers.unlockHiddenTags
type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
/**
* Decrypt/unlock a single event and return private bookmarks
*/
async function decryptEvent(
evt: NostrEvent,
activeAccount: ActiveAccount,
signerCandidate: unknown,
metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string }
): Promise<IndividualBookmark[]> {
const { dTag, setTitle, setDescription, setImage } = metadata
const privateItems: IndividualBookmark[] = []
try {
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) {
try {
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner)
} catch {
try {
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
} catch (_err) {
// Ignore unlock errors
}
}
} else if (evt.content && evt.content.length > 0) {
let decryptedContent: string | undefined
// Try to detect encryption method from content format
// NIP-44 starts with version byte (currently 0x02), NIP-04 is base64
const looksLikeNip44 = evt.content.length > 0 && !evt.content.includes('?iv=')
// Try the likely method first (no timeout - let it fail naturally like debug page)
if (looksLikeNip44 && hasNip44Decrypt(signerCandidate)) {
try {
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(evt.pubkey, evt.content)
} catch (_err) {
// Ignore NIP-44 decryption errors
}
}
// Fallback to nip04 if nip44 failed or content looks like nip04
if (!decryptedContent && hasNip04Decrypt(signerCandidate)) {
try {
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(evt.pubkey, evt.content)
} catch (_err) {
// Ignore NIP-04 decryption errors
}
}
if (decryptedContent) {
try {
const hiddenTags = JSON.parse(decryptedContent) as string[][]
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
privateItems.push(
...processApplesauceBookmarks(manualPrivate, activeAccount, true, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
setTitle,
setDescription,
setImage
}))
)
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
} catch (err) {
// ignore parse errors
}
}
}
const priv = Helpers.getHiddenBookmarks(evt)
if (priv) {
privateItems.push(
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
setTitle,
setDescription,
setImage
}))
)
}
} catch {
// ignore individual event failures
}
return privateItems
}
export async function collectBookmarksFromEvents(
bookmarkListEvents: NostrEvent[],
activeAccount: ActiveAccount,
signerCandidate?: unknown
): Promise<{
publicItemsAll: IndividualBookmark[]
privateItemsAll: IndividualBookmark[]
newestCreatedAt: number
latestContent: string
allTags: string[][]
}> {
const publicItemsAll: IndividualBookmark[] = []
let newestCreatedAt = 0
let latestContent = ''
let allTags: string[][] = []
// Build list of events needing decrypt and collect public items immediately
const decryptJobs: Array<{ evt: NostrEvent; metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string } }> = []
for (const evt of bookmarkListEvents) {
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined
const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined
const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined
const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined
const metadata = { dTag, setTitle, setDescription, setImage }
// Handle web bookmarks (kind:39701) as individual bookmarks
if (evt.kind === 39701) {
// Use coordinate format for web bookmarks to enable proper deduplication
// Web bookmarks are replaceable events (kind:39701:pubkey:d-tag)
const webBookmarkId = dTag ? `${evt.kind}:${evt.pubkey}:${dTag}` : evt.id
publicItemsAll.push({
id: webBookmarkId,
content: evt.content || '',
created_at: evt.created_at ?? null,
pubkey: evt.pubkey,
kind: evt.kind,
tags: evt.tags || [],
parsedContent: undefined,
type: 'web' as const,
isPrivate: false,
sourceKind: 39701,
setName: dTag,
setTitle,
setDescription,
setImage,
listUpdatedAt: evt.created_at ?? null
})
continue
}
const pub = Helpers.getPublicBookmarks(evt)
const processedPub = processApplesauceBookmarks(pub, activeAccount, false, evt.created_at)
publicItemsAll.push(
...processedPub.map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
setTitle,
setDescription,
setImage
}))
)
// Schedule decrypt if needed
// Check for NIP-44 (Helpers.hasHiddenContent), NIP-04 (?iv= in content), or encrypted tags
const hasNip04Content = evt.content && evt.content.includes('?iv=')
const needsDecrypt = signerCandidate && (
(Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) ||
Helpers.hasHiddenContent(evt) ||
hasNip04Content
)
if (needsDecrypt) {
decryptJobs.push({ evt, metadata })
} else {
// Check for already-unlocked hidden bookmarks
const priv = Helpers.getHiddenBookmarks(evt)
if (priv) {
publicItemsAll.push(
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
setTitle,
setDescription,
setImage
}))
)
}
}
}
// Decrypt events sequentially
const privateItemsAll: IndividualBookmark[] = []
if (decryptJobs.length > 0 && signerCandidate) {
for (const job of decryptJobs) {
const privateItems = await decryptEvent(job.evt, activeAccount, signerCandidate, job.metadata)
if (privateItems && privateItems.length > 0) {
privateItemsAll.push(...privateItems)
}
}
}
return { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }
}