Browse Source

Publication writer done.

master
Silberengel 8 months ago
parent
commit
3fca7c0699
  1. 185
      src/lib/components/CommentBox.svelte
  2. 114
      src/lib/components/EventInput.svelte
  3. 77
      src/lib/components/EventSearch.svelte
  4. 198
      src/lib/utils/event_input_utils.ts
  5. 76
      src/lib/utils/nostrUtils.ts
  6. 18
      src/routes/events/+page.svelte

185
src/lib/components/CommentBox.svelte

@ -6,6 +6,7 @@
getEventHash, getEventHash,
signEvent, signEvent,
getUserMetadata, getUserMetadata,
prefixNostrAddresses,
type NostrProfile, type NostrProfile,
} from "$lib/utils/nostrUtils"; } from "$lib/utils/nostrUtils";
import { standardRelays, fallbackRelays } from "$lib/consts"; import { standardRelays, fallbackRelays } from "$lib/consts";
@ -29,17 +30,22 @@
let showOtherRelays = $state(false); let showOtherRelays = $state(false);
let showFallbackRelays = $state(false); let showFallbackRelays = $state(false);
let userProfile = $state<NostrProfile | null>(null); let userProfile = $state<NostrProfile | null>(null);
let pubkey = $state<string | null>(null); let pubkey = $derived(() => get(activePubkey));
$effect(() => { $effect(() => {
pubkey = get(activePubkey); if (!pubkey()) {
userProfile = null;
error = null;
}
}); });
// Fetch user profile on mount // Remove the onMount block that sets pubkey and userProfile only once. Instead, fetch userProfile reactively when pubkey changes.
onMount(() => { $effect(() => {
const trimmedPubkey = pubkey?.trim(); const trimmedPubkey = pubkey()?.trim();
if (trimmedPubkey && /^[a-fA-F0-9]{64}$/.test(trimmedPubkey)) { if (trimmedPubkey && /^[a-fA-F0-9]{64}$/.test(trimmedPubkey)) {
const npub = nip19.npubEncode(trimmedPubkey);
// Call an async function, but don't make the effect itself async
(async () => { (async () => {
const npub = nip19.npubEncode(trimmedPubkey);
userProfile = await getUserMetadata(npub); userProfile = await getUserMetadata(npub);
error = null; error = null;
})(); })();
@ -52,6 +58,13 @@
} }
}); });
$effect(() => {
if (success) {
content = '';
preview = '';
}
});
// Markup buttons // Markup buttons
const markupButtons = [ const markupButtons = [
{ label: "Bold", action: () => insertMarkup("**", "**") }, { label: "Bold", action: () => insertMarkup("**", "**") },
@ -141,16 +154,17 @@
success = null; success = null;
try { try {
if (!pubkey || !/^[a-fA-F0-9]{64}$/.test(pubkey)) { const pk = pubkey() || '';
if (!pk || !/^[a-fA-F0-9]{64}$/.test(pk)) {
throw new Error('Invalid public key: must be a 64-character hex string.'); throw new Error('Invalid public key: must be a 64-character hex string.');
} }
if (props.event.kind === undefined || props.event.kind === null) { if (props.event.kind === undefined || props.event.kind === null) {
throw new Error('Invalid event: missing kind'); throw new Error('Invalid event: missing kind');
} }
// Always use kind 1111 for comments
const kind = 1111;
const parent = props.event; const parent = props.event;
// Use the same kind as parent for replies, or 1111 for generic replies
const kind = parent.kind === 1 ? 1 : 1111;
// Try to extract root info from parent tags (NIP-22 threading) // Try to extract root info from parent tags (NIP-22 threading)
let rootKind = parent.kind; let rootKind = parent.kind;
let rootPubkey = getPubkeyString(parent.pubkey); let rootPubkey = getPubkeyString(parent.pubkey);
@ -161,9 +175,18 @@
let parentAddress = ''; let parentAddress = '';
let parentKind = parent.kind; let parentKind = parent.kind;
let parentPubkey = getPubkeyString(parent.pubkey); let parentPubkey = getPubkeyString(parent.pubkey);
// Try to find root event info from tags (E/A/I)
// Check if parent is a replaceable event (3xxxxx kinds)
const isParentReplaceable = parentKind >= 30000 && parentKind < 40000;
// Check if parent is a comment (kind 1111) - if so, we need to find the original root
const isParentComment = parentKind === 1111;
// Try to find root event info from parent tags (E/A/I)
let isRootA = false; let isRootA = false;
let isRootI = false; let isRootI = false;
let rootIValue = '';
let rootIRelay = '';
if (parent.tags) { if (parent.tags) {
const rootE = parent.tags.find((t: string[]) => t[0] === 'E'); const rootE = parent.tags.find((t: string[]) => t[0] === 'E');
const rootA = parent.tags.find((t: string[]) => t[0] === 'A'); const rootA = parent.tags.find((t: string[]) => t[0] === 'A');
@ -181,36 +204,134 @@
rootPubkey = getPubkeyString(parent.tags.find((t: string[]) => t[0] === 'P')?.[1] || rootPubkey); rootPubkey = getPubkeyString(parent.tags.find((t: string[]) => t[0] === 'P')?.[1] || rootPubkey);
rootKind = parent.tags.find((t: string[]) => t[0] === 'K')?.[1] || rootKind; rootKind = parent.tags.find((t: string[]) => t[0] === 'K')?.[1] || rootKind;
} else if (rootI) { } else if (rootI) {
rootAddress = rootI[1]; rootIValue = rootI[1];
rootIRelay = getRelayString(rootI[2]);
rootKind = parent.tags.find((t: string[]) => t[0] === 'K')?.[1] || rootKind; rootKind = parent.tags.find((t: string[]) => t[0] === 'K')?.[1] || rootKind;
} }
} }
// Compose tags according to NIP-22
// Compose tags according to event kind
const tags: string[][] = []; const tags: string[][] = [];
// Root scope (uppercase)
if (rootAddress) { if (kind === 1) {
tags.push([isRootA ? 'A' : isRootI ? 'I' : 'E', rootAddress || rootId, rootRelay, rootPubkey]); // Kind 1 replies use simple e/p tags, not NIP-22 threading
} else { tags.push(['e', parent.id, parentRelay, 'root']);
tags.push(['E', rootId, rootRelay, rootPubkey]); tags.push(['p', parentPubkey]);
}
tags.push(['K', String(rootKind), '', '']); // If parent is replaceable, also add the address
tags.push(['P', rootPubkey, rootRelay, '']); if (isParentReplaceable) {
// Parent (lowercase) const dTag = parent.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '';
if (parentAddress) { if (dTag) {
tags.push([isRootA ? 'a' : isRootI ? 'i' : 'e', parentAddress || parent.id, parentRelay, parentPubkey]); const parentAddress = `${parentKind}:${parentPubkey}:${dTag}`;
tags.push(['a', parentAddress, '', 'root']);
}
}
} else { } else {
tags.push(['e', parent.id, parentRelay, parentPubkey]); // Kind 1111 uses NIP-22 threading format
// For replaceable events, use A/a tags; for regular events, use E/e tags
if (isParentReplaceable) {
// For replaceable events, construct the address: kind:pubkey:d-tag
const dTag = parent.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '';
if (dTag) {
const parentAddress = `${parentKind}:${parentPubkey}:${dTag}`;
// If we're replying to a comment, use the root from the comment's tags
if (isParentComment && rootId !== parent.id) {
// Root scope (uppercase) - use the original article
tags.push(['A', parentAddress, parentRelay]);
tags.push(['K', String(rootKind)]);
tags.push(['P', rootPubkey, rootRelay]);
// Parent scope (lowercase) - the comment we're replying to
tags.push(['e', parent.id, parentRelay]);
tags.push(['k', String(parentKind)]);
tags.push(['p', parentPubkey, parentRelay]);
} else {
// Top-level comment - root and parent are the same
tags.push(['A', parentAddress, parentRelay]);
tags.push(['K', String(rootKind)]);
tags.push(['P', rootPubkey, rootRelay]);
tags.push(['a', parentAddress, parentRelay]);
tags.push(['e', parent.id, parentRelay]);
tags.push(['k', String(parentKind)]);
tags.push(['p', parentPubkey, parentRelay]);
}
} else {
// Fallback to E/e tags if no d-tag found
if (isParentComment && rootId !== parent.id) {
tags.push(['E', rootId, rootRelay]);
tags.push(['K', String(rootKind)]);
tags.push(['P', rootPubkey, rootRelay]);
tags.push(['e', parent.id, parentRelay]);
tags.push(['k', String(parentKind)]);
tags.push(['p', parentPubkey, parentRelay]);
} else {
tags.push(['E', parent.id, parentRelay]);
tags.push(['K', String(rootKind)]);
tags.push(['P', rootPubkey, rootRelay]);
tags.push(['e', parent.id, parentRelay]);
tags.push(['k', String(parentKind)]);
tags.push(['p', parentPubkey, parentRelay]);
}
}
} else {
// For regular events, use E/e tags
if (isParentComment && rootId !== parent.id) {
// Reply to a comment - distinguish root from parent
if (rootAddress) {
tags.push([isRootA ? 'A' : isRootI ? 'I' : 'E', rootAddress || rootId, rootRelay]);
} else if (rootIValue) {
tags.push(['I', rootIValue, rootIRelay]);
} else {
tags.push(['E', rootId, rootRelay]);
}
tags.push(['K', String(rootKind)]);
if (rootPubkey && !rootIValue) {
tags.push(['P', rootPubkey, rootRelay]);
}
tags.push(['e', parent.id, parentRelay]);
tags.push(['k', String(parentKind)]);
tags.push(['p', parentPubkey, parentRelay]);
} else {
// Top-level comment or regular event
if (rootAddress) {
tags.push([isRootA ? 'A' : isRootI ? 'I' : 'E', rootAddress || rootId, rootRelay]);
tags.push(['K', String(rootKind)]);
if (rootPubkey) {
tags.push(['P', rootPubkey, rootRelay]);
}
tags.push([isRootA ? 'a' : isRootI ? 'i' : 'e', parentAddress || parent.id, parentRelay]);
tags.push(['e', parent.id, parentRelay]);
tags.push(['k', String(parentKind)]);
tags.push(['p', parentPubkey, parentRelay]);
} else if (rootIValue) {
tags.push(['I', rootIValue, rootIRelay]);
tags.push(['K', String(rootKind)]);
tags.push(['i', rootIValue, rootIRelay]);
tags.push(['k', String(parentKind)]);
} else {
tags.push(['E', rootId, rootRelay]);
tags.push(['K', String(rootKind)]);
if (rootPubkey) {
tags.push(['P', rootPubkey, rootRelay]);
}
tags.push(['e', parent.id, parentRelay]);
tags.push(['k', String(parentKind)]);
tags.push(['p', parentPubkey, parentRelay]);
}
}
}
} }
tags.push(['k', String(parentKind), '', '']);
tags.push(['p', parentPubkey, parentRelay, '']); // Prefix Nostr addresses before publishing
const prefixedContent = prefixNostrAddresses(content);
// Create a completely plain object to avoid proxy cloning issues // Create a completely plain object to avoid proxy cloning issues
const eventToSign = { const eventToSign = {
kind: Number(kind), kind: Number(kind),
created_at: Number(Math.floor(Date.now() / 1000)), created_at: Number(Math.floor(Date.now() / 1000)),
tags: tags.map(tag => [String(tag[0]), String(tag[1]), String(tag[2] || ''), String(tag[3] || '')]), tags: tags.map(tag => [String(tag[0]), String(tag[1]), String(tag[2] || ''), String(tag[3] || '')]),
content: String(content), content: String(prefixedContent),
pubkey: String(pubkey), pubkey: pk,
}; };
let sig, id; let sig, id;
@ -384,16 +505,16 @@
<span class="text-gray-900 dark:text-gray-100"> <span class="text-gray-900 dark:text-gray-100">
{userProfile.displayName || {userProfile.displayName ||
userProfile.name || userProfile.name ||
nip19.npubEncode(pubkey || '').slice(0, 8) + "..."} nip19.npubEncode(pubkey() || '').slice(0, 8) + "..."}
</span> </span>
</div> </div>
{/if} {/if}
<Button <Button
on:click={() => handleSubmit()} on:click={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !pubkey} disabled={isSubmitting || !content.trim() || !pubkey()}
class="w-full md:w-auto" class="w-full md:w-auto"
> >
{#if !pubkey} {#if !pubkey()}
Not Signed In Not Signed In
{:else if isSubmitting} {:else if isSubmitting}
Publishing... Publishing...
@ -403,7 +524,7 @@
</Button> </Button>
</div> </div>
{#if !pubkey} {#if !pubkey()}
<Alert color="yellow" class="mt-4"> <Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your Please sign in to post comments. Your comments will be signed with your
current account. current account.

114
src/lib/components/EventInput.svelte

@ -1,9 +1,10 @@
<script lang='ts'> <script lang='ts'>
import { getTitleTagForEvent, getDTagForEvent, requiresDTag, hasDTag, validateNotAsciidoc, validateAsciiDoc, build30040EventSet, titleToDTag } from '$lib/utils/event_input_utils'; import { getTitleTagForEvent, getDTagForEvent, requiresDTag, hasDTag, validateNotAsciidoc, validateAsciiDoc, build30040EventSet, titleToDTag, validate30040EventSet, get30040EventDescription, analyze30040Event, get30040FixGuidance } from '$lib/utils/event_input_utils';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { ndkInstance, activePubkey } from '$lib/ndk'; import { ndkInstance, activePubkey } from '$lib/ndk';
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk'; import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from '$lib/utils/nostrUtils';
import { prefixNostrAddresses } from '$lib/utils/nostrUtils';
import { standardRelays } from '$lib/consts'; import { standardRelays } from '$lib/consts';
let kind = $state<number>(30023); let kind = $state<number>(30023);
@ -39,6 +40,7 @@
content = (e.target as HTMLTextAreaElement).value; content = (e.target as HTMLTextAreaElement).value;
if (!titleManuallyEdited) { if (!titleManuallyEdited) {
const extracted = extractTitleFromContent(content); const extracted = extractTitleFromContent(content);
console.log('Content input - extracted title:', extracted);
title = extracted; title = extracted;
} }
} }
@ -54,8 +56,11 @@
} }
$effect(() => { $effect(() => {
console.log('Effect running - title:', title, 'dTagManuallyEdited:', dTagManuallyEdited);
if (!dTagManuallyEdited) { if (!dTagManuallyEdited) {
dTag = titleToDTag(title); const newDTag = titleToDTag(title);
console.log('Setting dTag to:', newDTag);
dTag = newDTag;
} }
}); });
@ -81,7 +86,11 @@
const v = validateNotAsciidoc(content); const v = validateNotAsciidoc(content);
if (!v.valid) return v; if (!v.valid) return v;
} }
if (kind === 30040 || kind === 30041 || kind === 30818) { if (kind === 30040) {
const v = validate30040EventSet(content);
if (!v.valid) return v;
}
if (kind === 30041 || kind === 30818) {
const v = validateAsciiDoc(content); const v = validateAsciiDoc(content);
if (!v.valid) return v; if (!v.valid) return v;
} }
@ -91,7 +100,7 @@
function handleSubmit(e: Event) { function handleSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
dTagError = ''; dTagError = '';
if (!dTag || dTag.trim() === '') { if (requiresDTag(kind) && (!dTag || dTag.trim() === '')) {
dTagError = 'A d-tag is required.'; dTagError = 'A d-tag is required.';
return; return;
} }
@ -130,38 +139,73 @@
const baseEvent = { pubkey, created_at: createdAt }; const baseEvent = { pubkey, created_at: createdAt };
let events: NDKEvent[] = []; let events: NDKEvent[] = [];
if (kind === 30040) { console.log('Publishing event with kind:', kind);
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); console.log('Content length:', content.length);
events = [indexEvent, ...sectionEvents]; console.log('Content preview:', content.substring(0, 100));
console.log('Tags:', tags);
console.log('Title:', title);
console.log('DTag:', dTag);
if (Number(kind) === 30040) {
console.log('=== 30040 EVENT CREATION START ===');
console.log('Creating 30040 event set with content:', content);
try {
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
console.log('Index event:', indexEvent);
console.log('Section events:', sectionEvents);
// Publish all 30041 section events first, then the 30040 index event
events = [...sectionEvents, indexEvent];
console.log('Total events to publish:', events.length);
// Debug the index event to ensure it's correct
const indexEventData = {
content: indexEvent.content,
tags: indexEvent.tags.map(tag => [tag[0], tag[1]] as [string, string]),
kind: indexEvent.kind || 30040
};
const analysis = debug30040Event(indexEventData);
if (!analysis.valid) {
console.warn('30040 index event has issues:', analysis.issues);
}
console.log('=== 30040 EVENT CREATION END ===');
} catch (error) {
console.error('Error in build30040EventSet:', error);
error = `Failed to build 30040 event set: ${error instanceof Error ? error.message : 'Unknown error'}`;
loading = false;
return;
}
} else { } else {
let eventTags = [...tags]; let eventTags = [...tags];
// Ensure d-tag exists and has a value for addressable events // Ensure d-tag exists and has a value for addressable events
if (requiresDTag(kind)) { if (requiresDTag(kind)) {
const dTagIndex = eventTags.findIndex(([k]) => k === 'd'); const dTagIndex = eventTags.findIndex(([k]) => k === 'd');
const existingDTag = dTagIndex >= 0 ? eventTags[dTagIndex][1] : ''; const dTagValue = dTag.trim() || getDTagForEvent(kind, content, '');
const generatedDTag = getDTagForEvent(kind, content, existingDTag);
if (generatedDTag) { if (dTagValue) {
if (dTagIndex >= 0) { if (dTagIndex >= 0) {
// Update existing d-tag // Update existing d-tag
eventTags[dTagIndex] = ['d', generatedDTag]; eventTags[dTagIndex] = ['d', dTagValue];
} else { } else {
// Add new d-tag // Add new d-tag
eventTags = [...eventTags, ['d', generatedDTag]]; eventTags = [...eventTags, ['d', dTagValue]];
} }
} }
} }
const title = getTitleTagForEvent(kind, content); // Add title tag if we have a title
if (title) { const titleValue = title.trim() || getTitleTagForEvent(kind, content);
eventTags = [...eventTags, ['title', title]]; if (titleValue) {
eventTags = [...eventTags, ['title', titleValue]];
} }
// Prefix Nostr addresses before publishing
const prefixedContent = prefixNostrAddresses(content);
// Create event with proper serialization // Create event with proper serialization
const eventData = { const eventData = {
kind, kind,
content, content: prefixedContent,
tags: eventTags, tags: eventTags,
pubkey, pubkey,
created_at: createdAt, created_at: createdAt,
@ -173,8 +217,16 @@
let atLeastOne = false; let atLeastOne = false;
let relaysPublished: string[] = []; let relaysPublished: string[] = [];
for (const event of events) { for (let i = 0; i < events.length; i++) {
const event = events[i];
try { try {
console.log('Publishing event:', {
kind: event.kind,
content: event.content,
tags: event.tags,
hasContent: event.content && event.content.length > 0
});
// Always sign with a plain object if window.nostr is available // Always sign with a plain object if window.nostr is available
// Create a completely plain object to avoid proxy cloning issues // Create a completely plain object to avoid proxy cloning issues
const plainEvent = { const plainEvent = {
@ -248,7 +300,14 @@
if (published) { if (published) {
atLeastOne = true; atLeastOne = true;
lastPublishedEventId = event.id; // For 30040, set lastPublishedEventId to the index event (last in array)
if (Number(kind) === 30040) {
if (i === events.length - 1) {
lastPublishedEventId = event.id;
}
} else {
lastPublishedEventId = event.id;
}
} }
} catch (signError) { } catch (signError) {
console.error('Error signing/publishing event:', signError); console.error('Error signing/publishing event:', signError);
@ -271,6 +330,18 @@
loading = false; loading = false;
} }
} }
/**
* Debug function to analyze a 30040 event and provide guidance.
*/
function debug30040Event(eventData: { content: string; tags: [string, string][]; kind: number }) {
const analysis = analyze30040Event(eventData);
console.log('30040 Event Analysis:', analysis);
if (!analysis.valid) {
console.log('Guidance:', get30040FixGuidance());
}
return analysis;
}
</script> </script>
{#if pubkey} {#if pubkey}
@ -285,6 +356,11 @@
Kind must be an integer between 0 and 65535 (NIP-01). Kind must be an integer between 0 and 65535 (NIP-01).
</div> </div>
{/if} {/if}
{#if kind === 30040}
<div class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-900 p-2 rounded">
<strong>30040 - Publication Index:</strong> {get30040EventDescription()}
</div>
{/if}
</div> </div>
<div> <div>
<label class='block font-medium mb-1' for='tags-container'>Tags</label> <label class='block font-medium mb-1' for='tags-container'>Tags</label>
@ -330,7 +406,7 @@
oninput={handleDTagInput} oninput={handleDTagInput}
placeholder='d-tag (auto-generated from title)' placeholder='d-tag (auto-generated from title)'
class='input input-bordered w-full' class='input input-bordered w-full'
required required={requiresDTag(kind)}
/> />
{#if dTagError} {#if dTagError}
<div class='text-red-600 text-sm mt-1'>{dTagError}</div> <div class='text-red-600 text-sm mt-1'>{dTagError}</div>

77
src/lib/components/EventSearch.svelte

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Input, Button } from "flowbite-svelte"; import { Input, Button } from "flowbite-svelte";
import { Spinner } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils"; import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
import { nip19 } from "$lib/utils/nostrUtils"; import { nip19 } from "$lib/utils/nostrUtils";
@ -16,6 +17,8 @@
onEventFound, onEventFound,
onSearchResults, onSearchResults,
event, event,
onClear,
onLoadingChange,
} = $props<{ } = $props<{
loading: boolean; loading: boolean;
error: string | null; error: string | null;
@ -24,6 +27,8 @@
onEventFound: (event: NDKEvent) => void; onEventFound: (event: NDKEvent) => void;
onSearchResults: (results: NDKEvent[]) => void; onSearchResults: (results: NDKEvent[]) => void;
event: NDKEvent | null; event: NDKEvent | null;
onClear?: () => void;
onLoadingChange?: (loading: boolean) => void;
}>(); }>();
let searchQuery = $state(""); let searchQuery = $state("");
@ -53,6 +58,7 @@
async function searchByDTag(dTag: string) { async function searchByDTag(dTag: string) {
localError = null; localError = null;
searching = true; searching = true;
if (onLoadingChange) { onLoadingChange(true); }
// Convert d-tag to lowercase for consistent searching // Convert d-tag to lowercase for consistent searching
const normalizedDTag = dTag.toLowerCase(); const normalizedDTag = dTag.toLowerCase();
@ -62,6 +68,8 @@
const ndk = $ndkInstance; const ndk = $ndkInstance;
if (!ndk) { if (!ndk) {
localError = "NDK not initialized"; localError = "NDK not initialized";
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return; return;
} }
@ -79,22 +87,32 @@
if (eventArray.length === 0) { if (eventArray.length === 0) {
localError = `No events found with d-tag: ${normalizedDTag}`; localError = `No events found with d-tag: ${normalizedDTag}`;
onSearchResults([]); onSearchResults([]);
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return;
} else if (eventArray.length === 1) { } else if (eventArray.length === 1) {
// If only one event found, treat it as a single event result // If only one event found, treat it as a single event result
handleFoundEvent(eventArray[0]); handleFoundEvent(eventArray[0]);
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return;
} else { } else {
// Multiple events found, show as search results // Multiple events found, show as search results
console.log( console.log(
`[Events] Found ${eventArray.length} events with d-tag: ${normalizedDTag}`, `[Events] Found ${eventArray.length} events with d-tag: ${normalizedDTag}`,
); );
onSearchResults(eventArray); onSearchResults(eventArray);
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return;
} }
} catch (err) { } catch (err) {
console.error("[Events] Error searching by d-tag:", err); console.error("[Events] Error searching by d-tag:", err);
localError = "Error searching for events with this d-tag."; localError = "Error searching for events with this d-tag.";
onSearchResults([]); onSearchResults([]);
} finally {
searching = false; searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return;
} }
} }
@ -103,10 +121,16 @@
queryOverride?: string, queryOverride?: string,
) { ) {
localError = null; localError = null;
searching = true;
if (onLoadingChange) { onLoadingChange(true); }
const query = ( const query = (
queryOverride !== undefined ? queryOverride : searchQuery queryOverride !== undefined ? queryOverride : searchQuery
).trim(); ).trim();
if (!query) return; if (!query) {
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return;
}
// Check if this is a d-tag search // Check if this is a d-tag search
if (query.toLowerCase().startsWith("d:")) { if (query.toLowerCase().startsWith("d:")) {
@ -118,6 +142,8 @@
keepFocus: true, keepFocus: true,
noScroll: true, noScroll: true,
}); });
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return; return;
} }
} }
@ -159,17 +185,25 @@
); );
if (profileEvent) { if (profileEvent) {
handleFoundEvent(profileEvent); handleFoundEvent(profileEvent);
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return; return;
} else { } else {
localError = "No profile found for this NIP-05 address."; localError = "No profile found for this NIP-05 address.";
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return; return;
} }
} else { } else {
localError = "NIP-05 address not found."; localError = "NIP-05 address not found.";
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return; return;
} }
} catch (e) { } catch (e) {
localError = "Error resolving NIP-05 address."; localError = "Error resolving NIP-05 address.";
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return; return;
} }
} }
@ -196,10 +230,15 @@
profileEvent.pubkey.toLowerCase() === cleanedQuery.toLowerCase() profileEvent.pubkey.toLowerCase() === cleanedQuery.toLowerCase()
) { ) {
handleFoundEvent(profileEvent); handleFoundEvent(profileEvent);
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return;
} else if (eventResult) { } else if (eventResult) {
handleFoundEvent(eventResult); handleFoundEvent(eventResult);
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return;
} }
return;
} else if ( } else if (
/^(nevent|note|naddr|npub|nprofile)[a-z0-9]+$/i.test(cleanedQuery) /^(nevent|note|naddr|npub|nprofile)[a-z0-9]+$/i.test(cleanedQuery)
) { ) {
@ -240,6 +279,8 @@
} catch (e) { } catch (e) {
console.error("[Events] Invalid Nostr identifier:", cleanedQuery, e); console.error("[Events] Invalid Nostr identifier:", cleanedQuery, e);
localError = "Invalid Nostr identifier."; localError = "Invalid Nostr identifier.";
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return; return;
} }
} }
@ -255,13 +296,19 @@
if (!event) { if (!event) {
console.warn("[Events] Event not found for filterOrId:", filterOrId); console.warn("[Events] Event not found for filterOrId:", filterOrId);
localError = "Event not found"; localError = "Event not found";
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
} else { } else {
console.log("[Events] Event found:", event); console.log("[Events] Event found:", event);
handleFoundEvent(event); handleFoundEvent(event);
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
} }
} catch (err) { } catch (err) {
console.error("[Events] Error fetching event:", err, "Query:", query); console.error("[Events] Error fetching event:", err, "Query:", query);
localError = "Error fetching event. Please check the ID and try again."; localError = "Error fetching event. Please check the ID and try again.";
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
} }
} }
@ -269,18 +316,34 @@
foundEvent = event; foundEvent = event;
onEventFound(event); onEventFound(event);
} }
function handleClear() {
searchQuery = '';
localError = null;
foundEvent = null;
relayStatuses = {};
if (onClear) {
onClear();
}
}
</script> </script>
<div class="flex flex-col space-y-6"> <div class="flex flex-col space-y-6">
<div class="flex gap-2"> <div class="flex gap-2 items-center">
<Input <Input
bind:value={searchQuery} bind:value={searchQuery}
placeholder="Enter event ID, nevent, naddr, or d:tag-name..." placeholder="Enter event ID, nevent, naddr, or d:tag-name..."
class="flex-grow" class="flex-grow"
on:keydown={(e: KeyboardEvent) => e.key === "Enter" && searchEvent(true)} onkeydown={(e: KeyboardEvent) => e.key === "Enter" && searchEvent(true)}
/> />
<Button on:click={() => searchEvent(true)} disabled={loading}> <Button onclick={() => searchEvent(true)} disabled={loading}>
{loading ? "Searching..." : "Search"} {#if searching}
<Spinner class="mr-2 text-gray-600 dark:text-gray-300" size="5" />
{/if}
{searching ? "Searching..." : "Search"}
</Button>
<Button onclick={handleClear} color="alternative" type="button" disabled={loading && !searchQuery && !localError}>
Clear
</Button> </Button>
</div> </div>

198
src/lib/utils/event_input_utils.ts

@ -56,6 +56,38 @@ export function validateAsciiDoc(content: string): { valid: boolean; reason?: st
return { valid: true }; return { valid: true };
} }
/**
* Validates that a 30040 event set will be created correctly.
* Returns { valid, reason }.
*/
export function validate30040EventSet(content: string): { valid: boolean; reason?: string } {
// First validate as AsciiDoc
const asciiDocValidation = validateAsciiDoc(content);
if (!asciiDocValidation.valid) {
return asciiDocValidation;
}
// Check that we have at least one section
const sectionsResult = splitAsciiDocSections(content);
if (sectionsResult.sections.length === 0) {
return { valid: false, reason: '30040 events must contain at least one section.' };
}
// Check that we have a document title
const documentTitle = extractAsciiDocDocumentHeader(content);
if (!documentTitle) {
return { valid: false, reason: '30040 events must have a document title (line starting with "=").' };
}
// Check that the content will result in an empty 30040 event
// The 30040 event should have empty content, with all content split into 30041 events
if (!content.trim().startsWith('=')) {
return { valid: false, reason: '30040 events must start with a document title ("=").' };
}
return { valid: true };
}
// ========================= // =========================
// Extraction & Normalization // Extraction & Normalization
// ========================= // =========================
@ -105,22 +137,63 @@ function extractMarkdownTopHeader(content: string): string | null {
/** /**
* Splits AsciiDoc content into sections at each '==' header. Returns array of section strings. * Splits AsciiDoc content into sections at each '==' header. Returns array of section strings.
* Document title (= header) is excluded from sections and only used for the index event title.
* Section headers (==) are discarded from content.
* Text between document header and first section becomes a "Preamble" section.
*/ */
function splitAsciiDocSections(content: string): string[] { function splitAsciiDocSections(content: string): { sections: string[]; sectionHeaders: string[]; hasPreamble: boolean } {
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
const sections: string[] = []; const sections: string[] = [];
const sectionHeaders: string[] = [];
let current: string[] = []; let current: string[] = [];
let foundFirstSection = false;
let hasPreamble = false;
let preambleContent: string[] = [];
for (const line of lines) { for (const line of lines) {
if (/^==\s+/.test(line) && current.length > 0) { // Skip document title lines (= header)
sections.push(current.join('\n').trim()); if (/^=\s+/.test(line)) {
current = []; continue;
}
// If we encounter a section header (==) and we have content, start a new section
if (/^==\s+/.test(line)) {
if (current.length > 0) {
sections.push(current.join('\n').trim());
current = [];
}
// Extract section header for title tag
const headerMatch = line.match(/^==\s+(.+)$/);
if (headerMatch) {
sectionHeaders.push(headerMatch[1].trim());
}
foundFirstSection = true;
} else if (foundFirstSection) {
// Only add lines to current section if we've found the first section
current.push(line);
} else {
// Text before first section becomes preamble
if (line.trim() !== '') {
preambleContent.push(line);
}
} }
current.push(line);
} }
// Add the last section
if (current.length > 0) { if (current.length > 0) {
sections.push(current.join('\n').trim()); sections.push(current.join('\n').trim());
} }
return sections;
// Add preamble as first section if it exists
if (preambleContent.length > 0) {
sections.unshift(preambleContent.join('\n').trim());
sectionHeaders.unshift('Preamble');
hasPreamble = true;
}
return { sections, sectionHeaders, hasPreamble };
} }
// ========================= // =========================
@ -144,15 +217,29 @@ export function build30040EventSet(
tags: [string, string][], tags: [string, string][],
baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number } baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number }
): { indexEvent: NDKEvent; sectionEvents: NDKEvent[] } { ): { indexEvent: NDKEvent; sectionEvents: NDKEvent[] } {
console.log('=== build30040EventSet called ===');
console.log('Input content:', content);
console.log('Input tags:', tags);
console.log('Input baseEvent:', baseEvent);
const ndk = getNdk(); const ndk = getNdk();
const sections = splitAsciiDocSections(content); console.log('NDK instance:', ndk);
const sectionHeaders = extractAsciiDocSectionHeaders(content);
const sectionsResult = splitAsciiDocSections(content);
const sections = sectionsResult.sections;
const sectionHeaders = sectionsResult.sectionHeaders;
console.log('Sections:', sections);
console.log('Section headers:', sectionHeaders);
const dTags = sectionHeaders.length === sections.length const dTags = sectionHeaders.length === sections.length
? sectionHeaders.map(normalizeDTagValue) ? sectionHeaders.map(normalizeDTagValue)
: sections.map((_, i) => `section${i}`); : sections.map((_, i) => `section${i}`);
console.log('D tags:', dTags);
const sectionEvents: NDKEvent[] = sections.map((section, i) => { const sectionEvents: NDKEvent[] = sections.map((section, i) => {
const header = sectionHeaders[i] || `Section ${i + 1}`; const header = sectionHeaders[i] || `Section ${i + 1}`;
const dTag = dTags[i]; const dTag = dTags[i];
console.log(`Creating section ${i}:`, { header, dTag, content: section });
return new NDKEventClass(ndk, { return new NDKEventClass(ndk, {
kind: 30041, kind: 30041,
content: section, content: section,
@ -165,10 +252,23 @@ export function build30040EventSet(
created_at: baseEvent.created_at, created_at: baseEvent.created_at,
}); });
}); });
// Create proper a tags with format: kind:pubkey:d-tag
const aTags = dTags.map(dTag => ['a', `30041:${baseEvent.pubkey}:${dTag}`] as [string, string]);
console.log('A tags:', aTags);
// Extract document title for the index event
const documentTitle = extractAsciiDocDocumentHeader(content);
const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : 'index';
console.log('Index event:', { documentTitle, indexDTag });
const indexTags = [ const indexTags = [
...tags, ...tags,
...dTags.map(d => ['a', d] as [string, string]), ['d', indexDTag],
['title', documentTitle || 'Untitled'],
...aTags,
]; ];
const indexEvent: NDKEvent = new NDKEventClass(ndk, { const indexEvent: NDKEvent = new NDKEventClass(ndk, {
kind: 30040, kind: 30040,
content: '', content: '',
@ -176,6 +276,8 @@ export function build30040EventSet(
pubkey: baseEvent.pubkey, pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at, created_at: baseEvent.created_at,
}); });
console.log('Final index event:', indexEvent);
console.log('=== build30040EventSet completed ===');
return { indexEvent, sectionEvents }; return { indexEvent, sectionEvents };
} }
@ -217,3 +319,81 @@ export function getDTagForEvent(kind: number, content: string, existingDTag?: st
return null; return null;
} }
/**
* Returns a description of what a 30040 event structure should be.
*/
export function get30040EventDescription(): string {
return `30040 events are publication indexes that contain:
- Empty content (metadata only)
- A d-tag for the publication identifier
- A title tag for the publication title
- A tags referencing 30041 content events (one per section)
The content is split into sections, each published as a separate 30041 event.`;
}
/**
* Analyzes a 30040 event to determine if it was created correctly.
* Returns { valid, issues } where issues is an array of problems found.
*/
export function analyze30040Event(event: { content: string; tags: [string, string][]; kind: number }): { valid: boolean; issues: string[] } {
const issues: string[] = [];
// Check if it's actually a 30040 event
if (event.kind !== 30040) {
issues.push('Event is not kind 30040');
return { valid: false, issues };
}
// Check if content is empty (30040 should be metadata only)
if (event.content && event.content.trim() !== '') {
issues.push('30040 events should have empty content (metadata only)');
issues.push('Content should be split into separate 30041 events');
}
// Check for required tags
const hasTitle = event.tags.some(([k, v]) => k === 'title' && v);
const hasDTag = event.tags.some(([k, v]) => k === 'd' && v);
const hasATags = event.tags.some(([k, v]) => k === 'a' && v);
if (!hasTitle) {
issues.push('Missing title tag');
}
if (!hasDTag) {
issues.push('Missing d tag');
}
if (!hasATags) {
issues.push('Missing a tags (should reference 30041 content events)');
}
// Check if a tags have the correct format (kind:pubkey:d-tag)
const aTags = event.tags.filter(([k, v]) => k === 'a' && v);
for (const [, value] of aTags) {
if (!value.includes(':')) {
issues.push(`Invalid a tag format: ${value} (should be "kind:pubkey:d-tag")`);
}
}
return { valid: issues.length === 0, issues };
}
/**
* Returns guidance on how to fix incorrect 30040 events.
*/
export function get30040FixGuidance(): string {
return `To fix a 30040 event:
1. **Content Issue**: 30040 events should have empty content. All content should be split into separate 30041 events.
2. **Structure**: A proper 30040 event should contain:
- Empty content
- d tag: publication identifier
- title tag: publication title
- a tags: references to 30041 content events (format: "30041:pubkey:d-tag")
3. **Process**: When creating a 30040 event:
- Write your content with document title (= Title) and sections (== Section)
- The system will automatically split it into one 30040 index event and multiple 30041 content events
- The 30040 will have empty content and reference the 30041s via a tags`;
}

76
src/lib/utils/nostrUtils.ts

@ -507,3 +507,79 @@ export async function signEvent(event: {
const sig = await schnorr.sign(id, event.pubkey); const sig = await schnorr.sign(id, event.pubkey);
return bytesToHex(sig); return bytesToHex(sig);
} }
/**
* Prefixes Nostr addresses (npub, nprofile, nevent, naddr, note, etc.) with "nostr:"
* if they are not already prefixed and are not part of a hyperlink
*/
export function prefixNostrAddresses(content: string): string {
// Regex to match Nostr addresses that are not already prefixed with "nostr:"
// and are not part of a markdown link or HTML link
// Must be followed by at least 20 alphanumeric characters to be considered an address
const nostrAddressPattern = /\b(npub|nprofile|nevent|naddr|note)[a-zA-Z0-9]{20,}\b/g;
return content.replace(nostrAddressPattern, (match, offset) => {
// Check if this match is part of a markdown link [text](url)
const beforeMatch = content.substring(0, offset);
const afterMatch = content.substring(offset + match.length);
// Check if it's part of a markdown link
const beforeBrackets = beforeMatch.lastIndexOf('[');
const afterParens = afterMatch.indexOf(')');
if (beforeBrackets !== -1 && afterParens !== -1) {
const textBeforeBrackets = beforeMatch.substring(0, beforeBrackets);
const lastOpenBracket = textBeforeBrackets.lastIndexOf('[');
const lastCloseBracket = textBeforeBrackets.lastIndexOf(']');
// If we have [text] before this, it might be a markdown link
if (lastOpenBracket !== -1 && lastCloseBracket > lastOpenBracket) {
return match; // Don't prefix if it's part of a markdown link
}
}
// Check if it's part of an HTML link
const beforeHref = beforeMatch.lastIndexOf('href=');
if (beforeHref !== -1) {
const afterHref = afterMatch.indexOf('"');
if (afterHref !== -1) {
return match; // Don't prefix if it's part of an HTML link
}
}
// Check if it's already prefixed with "nostr:"
const beforeNostr = beforeMatch.lastIndexOf('nostr:');
if (beforeNostr !== -1) {
const textAfterNostr = beforeMatch.substring(beforeNostr + 6);
if (!textAfterNostr.includes(' ')) {
return match; // Already prefixed
}
}
// Additional check: ensure it's actually a valid Nostr address format
// The part after the prefix should be a valid bech32 string
const addressPart = match.substring(4); // Remove npub, nprofile, etc.
if (addressPart.length < 20) {
return match; // Too short to be a valid address
}
// Check if it looks like a valid bech32 string (alphanumeric, no special chars)
if (!/^[a-zA-Z0-9]+$/.test(addressPart)) {
return match; // Not a valid bech32 format
}
// Additional check: ensure the word before is not a common word that would indicate
// this is just a general reference, not an actual address
const wordBefore = beforeMatch.match(/\b(\w+)\s*$/);
if (wordBefore) {
const beforeWord = wordBefore[1].toLowerCase();
const commonWords = ['the', 'a', 'an', 'this', 'that', 'my', 'your', 'his', 'her', 'their', 'our'];
if (commonWords.includes(beforeWord)) {
return match; // Likely just a general reference, not an actual address
}
}
// Prefix with "nostr:"
return `nostr:${match}`;
});
}

18
src/routes/events/+page.svelte

@ -2,6 +2,7 @@
import { Heading, P } from "flowbite-svelte"; import { Heading, P } from "flowbite-svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import EventSearch from "$lib/components/EventSearch.svelte"; import EventSearch from "$lib/components/EventSearch.svelte";
import EventDetails from "$lib/components/EventDetails.svelte"; import EventDetails from "$lib/components/EventDetails.svelte";
@ -52,6 +53,10 @@
profile = null; profile = null;
} }
function handleClear() {
goto('/events', { replaceState: true });
}
function getSummary(event: NDKEvent): string | undefined { function getSummary(event: NDKEvent): string | undefined {
return getMatchingTags(event, "summary")[0]?.[1]; return getMatchingTags(event, "summary")[0]?.[1];
} }
@ -61,6 +66,10 @@
return getMatchingTags(event, "deferrel")[0]?.[1]; return getMatchingTags(event, "deferrel")[0]?.[1];
} }
function onLoadingChange(val: boolean) {
loading = val;
}
$effect(() => { $effect(() => {
const id = $page.url.searchParams.get("id"); const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get("d"); const dTag = $page.url.searchParams.get("d");
@ -75,6 +84,13 @@
dTagValue = dTag ? dTag.toLowerCase() : null; dTagValue = dTag ? dTag.toLowerCase() : null;
searchValue = null; searchValue = null;
} }
// Reset state if both id and dTag are absent
if (!id && !dTag) {
event = null;
searchResults = [];
profile = null;
}
}); });
onMount(() => { onMount(() => {
@ -108,6 +124,8 @@
{event} {event}
onEventFound={handleEventFound} onEventFound={handleEventFound}
onSearchResults={handleSearchResults} onSearchResults={handleSearchResults}
onClear={handleClear}
onLoadingChange={onLoadingChange}
/> />
{#if $isLoggedIn && !event && searchResults.length === 0} {#if $isLoggedIn && !event && searchResults.length === 0}

Loading…
Cancel
Save