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 @@ @@ -6,6 +6,7 @@
getEventHash,
signEvent,
getUserMetadata,
prefixNostrAddresses,
type NostrProfile,
} from "$lib/utils/nostrUtils";
import { standardRelays, fallbackRelays } from "$lib/consts";
@ -29,17 +30,22 @@ @@ -29,17 +30,22 @@
let showOtherRelays = $state(false);
let showFallbackRelays = $state(false);
let userProfile = $state<NostrProfile | null>(null);
let pubkey = $state<string | null>(null);
let pubkey = $derived(() => get(activePubkey));
$effect(() => {
pubkey = get(activePubkey);
if (!pubkey()) {
userProfile = null;
error = null;
}
});
// Fetch user profile on mount
onMount(() => {
const trimmedPubkey = pubkey?.trim();
// Remove the onMount block that sets pubkey and userProfile only once. Instead, fetch userProfile reactively when pubkey changes.
$effect(() => {
const trimmedPubkey = pubkey()?.trim();
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 () => {
const npub = nip19.npubEncode(trimmedPubkey);
userProfile = await getUserMetadata(npub);
error = null;
})();
@ -52,6 +58,13 @@ @@ -52,6 +58,13 @@
}
});
$effect(() => {
if (success) {
content = '';
preview = '';
}
});
// Markup buttons
const markupButtons = [
{ label: "Bold", action: () => insertMarkup("**", "**") },
@ -141,16 +154,17 @@ @@ -141,16 +154,17 @@
success = null;
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.');
}
if (props.event.kind === undefined || props.event.kind === null) {
throw new Error('Invalid event: missing kind');
}
// Always use kind 1111 for comments
const kind = 1111;
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)
let rootKind = parent.kind;
let rootPubkey = getPubkeyString(parent.pubkey);
@ -161,9 +175,18 @@ @@ -161,9 +175,18 @@
let parentAddress = '';
let parentKind = parent.kind;
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 isRootI = false;
let rootIValue = '';
let rootIRelay = '';
if (parent.tags) {
const rootE = parent.tags.find((t: string[]) => t[0] === 'E');
const rootA = parent.tags.find((t: string[]) => t[0] === 'A');
@ -181,36 +204,134 @@ @@ -181,36 +204,134 @@
rootPubkey = getPubkeyString(parent.tags.find((t: string[]) => t[0] === 'P')?.[1] || rootPubkey);
rootKind = parent.tags.find((t: string[]) => t[0] === 'K')?.[1] || rootKind;
} else if (rootI) {
rootAddress = rootI[1];
rootIValue = rootI[1];
rootIRelay = getRelayString(rootI[2]);
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[][] = [];
// Root scope (uppercase)
if (rootAddress) {
tags.push([isRootA ? 'A' : isRootI ? 'I' : 'E', rootAddress || rootId, rootRelay, rootPubkey]);
} else {
tags.push(['E', rootId, rootRelay, rootPubkey]);
}
tags.push(['K', String(rootKind), '', '']);
tags.push(['P', rootPubkey, rootRelay, '']);
// Parent (lowercase)
if (parentAddress) {
tags.push([isRootA ? 'a' : isRootI ? 'i' : 'e', parentAddress || parent.id, parentRelay, parentPubkey]);
if (kind === 1) {
// Kind 1 replies use simple e/p tags, not NIP-22 threading
tags.push(['e', parent.id, parentRelay, 'root']);
tags.push(['p', parentPubkey]);
// If parent is replaceable, also add the address
if (isParentReplaceable) {
const dTag = parent.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '';
if (dTag) {
const parentAddress = `${parentKind}:${parentPubkey}:${dTag}`;
tags.push(['a', parentAddress, '', 'root']);
}
}
} 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
const eventToSign = {
kind: Number(kind),
created_at: Number(Math.floor(Date.now() / 1000)),
tags: tags.map(tag => [String(tag[0]), String(tag[1]), String(tag[2] || ''), String(tag[3] || '')]),
content: String(content),
pubkey: String(pubkey),
content: String(prefixedContent),
pubkey: pk,
};
let sig, id;
@ -384,16 +505,16 @@ @@ -384,16 +505,16 @@
<span class="text-gray-900 dark:text-gray-100">
{userProfile.displayName ||
userProfile.name ||
nip19.npubEncode(pubkey || '').slice(0, 8) + "..."}
nip19.npubEncode(pubkey() || '').slice(0, 8) + "..."}
</span>
</div>
{/if}
<Button
on:click={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !pubkey}
disabled={isSubmitting || !content.trim() || !pubkey()}
class="w-full md:w-auto"
>
{#if !pubkey}
{#if !pubkey()}
Not Signed In
{:else if isSubmitting}
Publishing...
@ -403,7 +524,7 @@ @@ -403,7 +524,7 @@
</Button>
</div>
{#if !pubkey}
{#if !pubkey()}
<Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your
current account.

114
src/lib/components/EventInput.svelte

@ -1,9 +1,10 @@ @@ -1,9 +1,10 @@
<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 { ndkInstance, activePubkey } from '$lib/ndk';
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { prefixNostrAddresses } from '$lib/utils/nostrUtils';
import { standardRelays } from '$lib/consts';
let kind = $state<number>(30023);
@ -39,6 +40,7 @@ @@ -39,6 +40,7 @@
content = (e.target as HTMLTextAreaElement).value;
if (!titleManuallyEdited) {
const extracted = extractTitleFromContent(content);
console.log('Content input - extracted title:', extracted);
title = extracted;
}
}
@ -54,8 +56,11 @@ @@ -54,8 +56,11 @@
}
$effect(() => {
console.log('Effect running - title:', title, 'dTagManuallyEdited:', dTagManuallyEdited);
if (!dTagManuallyEdited) {
dTag = titleToDTag(title);
const newDTag = titleToDTag(title);
console.log('Setting dTag to:', newDTag);
dTag = newDTag;
}
});
@ -81,7 +86,11 @@ @@ -81,7 +86,11 @@
const v = validateNotAsciidoc(content);
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);
if (!v.valid) return v;
}
@ -91,7 +100,7 @@ @@ -91,7 +100,7 @@
function handleSubmit(e: Event) {
e.preventDefault();
dTagError = '';
if (!dTag || dTag.trim() === '') {
if (requiresDTag(kind) && (!dTag || dTag.trim() === '')) {
dTagError = 'A d-tag is required.';
return;
}
@ -130,38 +139,73 @@ @@ -130,38 +139,73 @@
const baseEvent = { pubkey, created_at: createdAt };
let events: NDKEvent[] = [];
if (kind === 30040) {
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
events = [indexEvent, ...sectionEvents];
console.log('Publishing event with kind:', kind);
console.log('Content length:', content.length);
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 {
let eventTags = [...tags];
// Ensure d-tag exists and has a value for addressable events
if (requiresDTag(kind)) {
const dTagIndex = eventTags.findIndex(([k]) => k === 'd');
const existingDTag = dTagIndex >= 0 ? eventTags[dTagIndex][1] : '';
const generatedDTag = getDTagForEvent(kind, content, existingDTag);
const dTagValue = dTag.trim() || getDTagForEvent(kind, content, '');
if (generatedDTag) {
if (dTagValue) {
if (dTagIndex >= 0) {
// Update existing d-tag
eventTags[dTagIndex] = ['d', generatedDTag];
eventTags[dTagIndex] = ['d', dTagValue];
} else {
// Add new d-tag
eventTags = [...eventTags, ['d', generatedDTag]];
eventTags = [...eventTags, ['d', dTagValue]];
}
}
}
const title = getTitleTagForEvent(kind, content);
if (title) {
eventTags = [...eventTags, ['title', title]];
// Add title tag if we have a title
const titleValue = title.trim() || getTitleTagForEvent(kind, content);
if (titleValue) {
eventTags = [...eventTags, ['title', titleValue]];
}
// Prefix Nostr addresses before publishing
const prefixedContent = prefixNostrAddresses(content);
// Create event with proper serialization
const eventData = {
kind,
content,
content: prefixedContent,
tags: eventTags,
pubkey,
created_at: createdAt,
@ -173,8 +217,16 @@ @@ -173,8 +217,16 @@
let atLeastOne = false;
let relaysPublished: string[] = [];
for (const event of events) {
for (let i = 0; i < events.length; i++) {
const event = events[i];
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
// Create a completely plain object to avoid proxy cloning issues
const plainEvent = {
@ -248,7 +300,14 @@ @@ -248,7 +300,14 @@
if (published) {
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) {
console.error('Error signing/publishing event:', signError);
@ -271,6 +330,18 @@ @@ -271,6 +330,18 @@
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>
{#if pubkey}
@ -285,6 +356,11 @@ @@ -285,6 +356,11 @@
Kind must be an integer between 0 and 65535 (NIP-01).
</div>
{/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>
<label class='block font-medium mb-1' for='tags-container'>Tags</label>
@ -330,7 +406,7 @@ @@ -330,7 +406,7 @@
oninput={handleDTagInput}
placeholder='d-tag (auto-generated from title)'
class='input input-bordered w-full'
required
required={requiresDTag(kind)}
/>
{#if dTagError}
<div class='text-red-600 text-sm mt-1'>{dTagError}</div>

77
src/lib/components/EventSearch.svelte

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

198
src/lib/utils/event_input_utils.ts

@ -56,6 +56,38 @@ export function validateAsciiDoc(content: string): { valid: boolean; reason?: st @@ -56,6 +56,38 @@ export function validateAsciiDoc(content: string): { valid: boolean; reason?: st
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
// =========================
@ -105,22 +137,63 @@ function extractMarkdownTopHeader(content: string): string | null { @@ -105,22 +137,63 @@ function extractMarkdownTopHeader(content: string): string | null {
/**
* 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 sections: string[] = [];
const sectionHeaders: string[] = [];
let current: string[] = [];
let foundFirstSection = false;
let hasPreamble = false;
let preambleContent: string[] = [];
for (const line of lines) {
if (/^==\s+/.test(line) && current.length > 0) {
sections.push(current.join('\n').trim());
current = [];
// Skip document title lines (= header)
if (/^=\s+/.test(line)) {
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) {
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( @@ -144,15 +217,29 @@ export function build30040EventSet(
tags: [string, string][],
baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number }
): { 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 sections = splitAsciiDocSections(content);
const sectionHeaders = extractAsciiDocSectionHeaders(content);
console.log('NDK instance:', ndk);
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
? sectionHeaders.map(normalizeDTagValue)
: sections.map((_, i) => `section${i}`);
console.log('D tags:', dTags);
const sectionEvents: NDKEvent[] = sections.map((section, i) => {
const header = sectionHeaders[i] || `Section ${i + 1}`;
const dTag = dTags[i];
console.log(`Creating section ${i}:`, { header, dTag, content: section });
return new NDKEventClass(ndk, {
kind: 30041,
content: section,
@ -165,10 +252,23 @@ export function build30040EventSet( @@ -165,10 +252,23 @@ export function build30040EventSet(
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 = [
...tags,
...dTags.map(d => ['a', d] as [string, string]),
['d', indexDTag],
['title', documentTitle || 'Untitled'],
...aTags,
];
const indexEvent: NDKEvent = new NDKEventClass(ndk, {
kind: 30040,
content: '',
@ -176,6 +276,8 @@ export function build30040EventSet( @@ -176,6 +276,8 @@ export function build30040EventSet(
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
console.log('Final index event:', indexEvent);
console.log('=== build30040EventSet completed ===');
return { indexEvent, sectionEvents };
}
@ -216,4 +318,82 @@ export function getDTagForEvent(kind: number, content: string, existingDTag?: st @@ -216,4 +318,82 @@ export function getDTagForEvent(kind: number, content: string, existingDTag?: st
}
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: { @@ -507,3 +507,79 @@ export async function signEvent(event: {
const sig = await schnorr.sign(id, event.pubkey);
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 @@ @@ -2,6 +2,7 @@
import { Heading, P } from "flowbite-svelte";
import { onMount } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import EventSearch from "$lib/components/EventSearch.svelte";
import EventDetails from "$lib/components/EventDetails.svelte";
@ -52,6 +53,10 @@ @@ -52,6 +53,10 @@
profile = null;
}
function handleClear() {
goto('/events', { replaceState: true });
}
function getSummary(event: NDKEvent): string | undefined {
return getMatchingTags(event, "summary")[0]?.[1];
}
@ -61,6 +66,10 @@ @@ -61,6 +66,10 @@
return getMatchingTags(event, "deferrel")[0]?.[1];
}
function onLoadingChange(val: boolean) {
loading = val;
}
$effect(() => {
const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get("d");
@ -75,6 +84,13 @@ @@ -75,6 +84,13 @@
dTagValue = dTag ? dTag.toLowerCase() : null;
searchValue = null;
}
// Reset state if both id and dTag are absent
if (!id && !dTag) {
event = null;
searchResults = [];
profile = null;
}
});
onMount(() => {
@ -108,6 +124,8 @@ @@ -108,6 +124,8 @@
{event}
onEventFound={handleEventFound}
onSearchResults={handleSearchResults}
onClear={handleClear}
onLoadingChange={onLoadingChange}
/>
{#if $isLoggedIn && !event && searchResults.length === 0}

Loading…
Cancel
Save