11 changed files with 938 additions and 127 deletions
@ -0,0 +1,363 @@ |
|||||||
|
<script lang='ts'> |
||||||
|
import { getTitleTagForEvent, getDTagForEvent, requiresDTag, hasDTag, validateNotAsciidoc, validateAsciiDoc, build30040EventSet, titleToDTag } 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 { standardRelays } from '$lib/consts'; |
||||||
|
|
||||||
|
let kind = $state<number>(30023); |
||||||
|
let tags = $state<[string, string][]>([]); |
||||||
|
let content = $state(''); |
||||||
|
let createdAt = $state<number>(Math.floor(Date.now() / 1000)); |
||||||
|
let loading = $state(false); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
let success = $state<string | null>(null); |
||||||
|
let publishedRelays = $state<string[]>([]); |
||||||
|
|
||||||
|
let pubkey = $state<string | null>(null); |
||||||
|
let title = $state(''); |
||||||
|
let dTag = $state(''); |
||||||
|
let titleManuallyEdited = $state(false); |
||||||
|
let dTagManuallyEdited = $state(false); |
||||||
|
let dTagError = $state(''); |
||||||
|
let lastPublishedEventId = $state<string | null>(null); |
||||||
|
$effect(() => { |
||||||
|
pubkey = get(activePubkey); |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* Extracts the first Markdown/AsciiDoc header as the title. |
||||||
|
*/ |
||||||
|
function extractTitleFromContent(content: string): string { |
||||||
|
// Match Markdown (# Title) or AsciiDoc (= Title) headers |
||||||
|
const match = content.match(/^(#|=)\s*(.+)$/m); |
||||||
|
return match ? match[2].trim() : ''; |
||||||
|
} |
||||||
|
|
||||||
|
function handleContentInput(e: Event) { |
||||||
|
content = (e.target as HTMLTextAreaElement).value; |
||||||
|
if (!titleManuallyEdited) { |
||||||
|
const extracted = extractTitleFromContent(content); |
||||||
|
title = extracted; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleTitleInput(e: Event) { |
||||||
|
title = (e.target as HTMLInputElement).value; |
||||||
|
titleManuallyEdited = true; |
||||||
|
} |
||||||
|
|
||||||
|
function handleDTagInput(e: Event) { |
||||||
|
dTag = (e.target as HTMLInputElement).value; |
||||||
|
dTagManuallyEdited = true; |
||||||
|
} |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (!dTagManuallyEdited) { |
||||||
|
dTag = titleToDTag(title); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
function updateTag(index: number, key: string, value: string): void { |
||||||
|
tags = tags.map((t, i) => i === index ? [key, value] : t); |
||||||
|
} |
||||||
|
function addTag(): void { |
||||||
|
tags = [...tags, ['', '']]; |
||||||
|
} |
||||||
|
function removeTag(index: number): void { |
||||||
|
tags = tags.filter((_, i) => i !== index); |
||||||
|
} |
||||||
|
|
||||||
|
function isValidKind(kind: number | string): boolean { |
||||||
|
const n = Number(kind); |
||||||
|
return Number.isInteger(n) && n >= 0 && n <= 65535; |
||||||
|
} |
||||||
|
|
||||||
|
function validate(): { valid: boolean; reason?: string } { |
||||||
|
if (!pubkey) return { valid: false, reason: 'Not logged in.' }; |
||||||
|
if (!content.trim()) return { valid: false, reason: 'Content required.' }; |
||||||
|
if (kind === 30023) { |
||||||
|
const v = validateNotAsciidoc(content); |
||||||
|
if (!v.valid) return v; |
||||||
|
} |
||||||
|
if (kind === 30040 || kind === 30041 || kind === 30818) { |
||||||
|
const v = validateAsciiDoc(content); |
||||||
|
if (!v.valid) return v; |
||||||
|
} |
||||||
|
return { valid: true }; |
||||||
|
} |
||||||
|
|
||||||
|
function handleSubmit(e: Event) { |
||||||
|
e.preventDefault(); |
||||||
|
dTagError = ''; |
||||||
|
if (!dTag || dTag.trim() === '') { |
||||||
|
dTagError = 'A d-tag is required.'; |
||||||
|
return; |
||||||
|
} |
||||||
|
handlePublish(); |
||||||
|
} |
||||||
|
|
||||||
|
async function handlePublish(): Promise<void> { |
||||||
|
error = null; |
||||||
|
success = null; |
||||||
|
publishedRelays = []; |
||||||
|
loading = true; |
||||||
|
createdAt = Math.floor(Date.now() / 1000); |
||||||
|
|
||||||
|
try { |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
if (!ndk || !pubkey) { |
||||||
|
error = 'NDK or pubkey missing.'; |
||||||
|
loading = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!/^[a-fA-F0-9]{64}$/.test(pubkey)) { |
||||||
|
error = 'Invalid public key: must be a 64-character hex string.'; |
||||||
|
loading = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Validate before proceeding |
||||||
|
const validation = validate(); |
||||||
|
if (!validation.valid) { |
||||||
|
error = validation.reason || 'Validation failed.'; |
||||||
|
loading = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const baseEvent = { pubkey, created_at: createdAt }; |
||||||
|
let events: NDKEvent[] = []; |
||||||
|
|
||||||
|
if (kind === 30040) { |
||||||
|
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); |
||||||
|
events = [indexEvent, ...sectionEvents]; |
||||||
|
} 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); |
||||||
|
|
||||||
|
if (generatedDTag) { |
||||||
|
if (dTagIndex >= 0) { |
||||||
|
// Update existing d-tag |
||||||
|
eventTags[dTagIndex] = ['d', generatedDTag]; |
||||||
|
} else { |
||||||
|
// Add new d-tag |
||||||
|
eventTags = [...eventTags, ['d', generatedDTag]]; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const title = getTitleTagForEvent(kind, content); |
||||||
|
if (title) { |
||||||
|
eventTags = [...eventTags, ['title', title]]; |
||||||
|
} |
||||||
|
|
||||||
|
// Create event with proper serialization |
||||||
|
const eventData = { |
||||||
|
kind, |
||||||
|
content, |
||||||
|
tags: eventTags, |
||||||
|
pubkey, |
||||||
|
created_at: createdAt, |
||||||
|
}; |
||||||
|
|
||||||
|
events = [new NDKEventClass(ndk, eventData)]; |
||||||
|
} |
||||||
|
|
||||||
|
let atLeastOne = false; |
||||||
|
let relaysPublished: string[] = []; |
||||||
|
|
||||||
|
for (const event of events) { |
||||||
|
try { |
||||||
|
// Always sign with a plain object if window.nostr is available |
||||||
|
// Create a completely plain object to avoid proxy cloning issues |
||||||
|
const plainEvent = { |
||||||
|
kind: Number(event.kind), |
||||||
|
pubkey: String(event.pubkey), |
||||||
|
created_at: Number(event.created_at ?? Math.floor(Date.now() / 1000)), |
||||||
|
tags: event.tags.map(tag => [String(tag[0]), String(tag[1])]), |
||||||
|
content: String(event.content), |
||||||
|
}; |
||||||
|
if (typeof window !== 'undefined' && window.nostr && window.nostr.signEvent) { |
||||||
|
const signed = await window.nostr.signEvent(plainEvent); |
||||||
|
event.sig = signed.sig; |
||||||
|
if ('id' in signed) { |
||||||
|
event.id = signed.id as string; |
||||||
|
} |
||||||
|
} else { |
||||||
|
await event.sign(); |
||||||
|
} |
||||||
|
|
||||||
|
// Use direct WebSocket publishing like CommentBox does |
||||||
|
const signedEvent = { |
||||||
|
...plainEvent, |
||||||
|
id: event.id, |
||||||
|
sig: event.sig, |
||||||
|
}; |
||||||
|
|
||||||
|
// Try to publish to relays directly |
||||||
|
const relays = ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol', ...standardRelays]; |
||||||
|
let published = false; |
||||||
|
|
||||||
|
for (const relayUrl of relays) { |
||||||
|
try { |
||||||
|
const ws = new WebSocket(relayUrl); |
||||||
|
await new Promise<void>((resolve, reject) => { |
||||||
|
const timeout = setTimeout(() => { |
||||||
|
ws.close(); |
||||||
|
reject(new Error("Timeout")); |
||||||
|
}, 5000); |
||||||
|
|
||||||
|
ws.onopen = () => { |
||||||
|
ws.send(JSON.stringify(["EVENT", signedEvent])); |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onmessage = (e) => { |
||||||
|
const [type, id, ok, message] = JSON.parse(e.data); |
||||||
|
if (type === "OK" && id === signedEvent.id) { |
||||||
|
clearTimeout(timeout); |
||||||
|
if (ok) { |
||||||
|
published = true; |
||||||
|
relaysPublished.push(relayUrl); |
||||||
|
ws.close(); |
||||||
|
resolve(); |
||||||
|
} else { |
||||||
|
ws.close(); |
||||||
|
reject(new Error(message)); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onerror = () => { |
||||||
|
clearTimeout(timeout); |
||||||
|
ws.close(); |
||||||
|
reject(new Error("WebSocket error")); |
||||||
|
}; |
||||||
|
}); |
||||||
|
if (published) break; |
||||||
|
} catch (e) { |
||||||
|
console.error(`Failed to publish to ${relayUrl}:`, e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (published) { |
||||||
|
atLeastOne = true; |
||||||
|
lastPublishedEventId = event.id; |
||||||
|
} |
||||||
|
} catch (signError) { |
||||||
|
console.error('Error signing/publishing event:', signError); |
||||||
|
error = `Failed to sign event: ${signError instanceof Error ? signError.message : 'Unknown error'}`; |
||||||
|
loading = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
loading = false; |
||||||
|
if (atLeastOne) { |
||||||
|
publishedRelays = relaysPublished; |
||||||
|
success = `Published to ${relaysPublished.length} relay(s).`; |
||||||
|
} else { |
||||||
|
error = 'Failed to publish to any relay.'; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('Error in handlePublish:', err); |
||||||
|
error = `Publishing failed: ${err instanceof Error ? err.message : 'Unknown error'}`; |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if pubkey} |
||||||
|
<div class='w-full max-w-2xl mx-auto my-8 p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg'> |
||||||
|
<h2 class='text-xl font-bold mb-4'>Publish Nostr Event</h2> |
||||||
|
<form class='space-y-4' onsubmit={handleSubmit}> |
||||||
|
<div> |
||||||
|
<label class='block font-medium mb-1' for='event-kind'>Kind</label> |
||||||
|
<input id='event-kind' type='text' class='input input-bordered w-full' bind:value={kind} required /> |
||||||
|
{#if !isValidKind(kind)} |
||||||
|
<div class="text-red-600 text-sm mt-1"> |
||||||
|
Kind must be an integer between 0 and 65535 (NIP-01). |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<label class='block font-medium mb-1' for='tags-container'>Tags</label> |
||||||
|
<div id='tags-container' class='space-y-2'> |
||||||
|
{#each tags as [key, value], i} |
||||||
|
<div class='flex gap-2'> |
||||||
|
<input type='text' class='input input-bordered flex-1' placeholder='tag' bind:value={tags[i][0]} oninput={e => updateTag(i, (e.target as HTMLInputElement).value, tags[i][1])} /> |
||||||
|
<input type='text' class='input input-bordered flex-1' placeholder='value' bind:value={tags[i][1]} oninput={e => updateTag(i, tags[i][0], (e.target as HTMLInputElement).value)} /> |
||||||
|
<button type='button' class='btn btn-error btn-sm' onclick={() => removeTag(i)} disabled={tags.length === 1}>×</button> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
<button type='button' class='btn btn-secondary btn-sm' onclick={addTag}>Add Tag</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<label class='block font-medium mb-1' for='event-content'>Content</label> |
||||||
|
<textarea |
||||||
|
id='event-content' |
||||||
|
bind:value={content} |
||||||
|
oninput={handleContentInput} |
||||||
|
placeholder='Content (start with a header for the title)' |
||||||
|
class='textarea textarea-bordered w-full h-40' |
||||||
|
required |
||||||
|
></textarea> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<label class='block font-medium mb-1' for='event-title'>Title</label> |
||||||
|
<input |
||||||
|
type='text' |
||||||
|
id='event-title' |
||||||
|
bind:value={title} |
||||||
|
oninput={handleTitleInput} |
||||||
|
placeholder='Title (auto-filled from header)' |
||||||
|
class='input input-bordered w-full' |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<label class='block font-medium mb-1' for='event-d-tag'>d-tag</label> |
||||||
|
<input |
||||||
|
type='text' |
||||||
|
id='event-d-tag' |
||||||
|
bind:value={dTag} |
||||||
|
oninput={handleDTagInput} |
||||||
|
placeholder='d-tag (auto-generated from title)' |
||||||
|
class='input input-bordered w-full' |
||||||
|
required |
||||||
|
/> |
||||||
|
{#if dTagError} |
||||||
|
<div class='text-red-600 text-sm mt-1'>{dTagError}</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
<button type='submit' class='btn btn-primary' disabled={loading}>Publish</button> |
||||||
|
{#if loading} |
||||||
|
<span class='ml-2 text-gray-500'>Publishing...</span> |
||||||
|
{/if} |
||||||
|
{#if error} |
||||||
|
<div class='mt-2 text-red-600'>{error}</div> |
||||||
|
{/if} |
||||||
|
{#if success} |
||||||
|
<div class='mt-2 text-green-600'>{success}</div> |
||||||
|
<div class='text-xs text-gray-500'>Relays: {publishedRelays.join(', ')}</div> |
||||||
|
{#if lastPublishedEventId} |
||||||
|
<div class='mt-2 text-green-700'> |
||||||
|
Event ID: <span class='font-mono'>{lastPublishedEventId}</span> |
||||||
|
<a |
||||||
|
href={'/events?id=' + lastPublishedEventId} |
||||||
|
class='text-primary-600 dark:text-primary-500 hover:underline ml-2' |
||||||
|
> |
||||||
|
View your event |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
import { writable, derived } from 'svelte/store'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Stores the user's public key if logged in, or null otherwise. |
||||||
|
*/ |
||||||
|
export const userPubkey = writable<string | null>(null); |
||||||
|
|
||||||
|
/** |
||||||
|
* Derived store indicating if the user is logged in. |
||||||
|
*/ |
||||||
|
export const isLoggedIn = derived(userPubkey, ($userPubkey) => !!$userPubkey);
|
||||||
@ -0,0 +1,219 @@ |
|||||||
|
import type { NDKEvent } from './nostrUtils'; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk'; |
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Validation
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
/** |
||||||
|
* Returns true if the event kind requires a d-tag (kinds 30000-39999). |
||||||
|
*/ |
||||||
|
export function requiresDTag(kind: number): boolean { |
||||||
|
return kind >= 30000 && kind <= 39999; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns true if the tags array contains at least one d-tag with a non-empty value. |
||||||
|
*/ |
||||||
|
export function hasDTag(tags: [string, string][]): boolean { |
||||||
|
return tags.some(([k, v]) => k === 'd' && v && v.trim() !== ''); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns true if the content contains AsciiDoc headers (lines starting with '=' or '=='). |
||||||
|
*/ |
||||||
|
function containsAsciiDocHeaders(content: string): boolean { |
||||||
|
return /^={1,}\s+/m.test(content); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates that content does NOT contain AsciiDoc headers (for kind 30023). |
||||||
|
* Returns { valid, reason }. |
||||||
|
*/ |
||||||
|
export function validateNotAsciidoc(content: string): { valid: boolean; reason?: string } { |
||||||
|
if (containsAsciiDocHeaders(content)) { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
reason: 'Kind 30023 must not contain AsciiDoc headers (lines starting with = or ==).', |
||||||
|
}; |
||||||
|
} |
||||||
|
return { valid: true }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates AsciiDoc content. Must start with '=' and contain at least one '==' section header. |
||||||
|
* Returns { valid, reason }. |
||||||
|
*/ |
||||||
|
export function validateAsciiDoc(content: string): { valid: boolean; reason?: string } { |
||||||
|
if (!content.trim().startsWith('=')) { |
||||||
|
return { valid: false, reason: 'AsciiDoc must start with a document title ("=").' }; |
||||||
|
} |
||||||
|
if (!/^==\s+/m.test(content)) { |
||||||
|
return { valid: false, reason: 'AsciiDoc must contain at least one section header ("==").' }; |
||||||
|
} |
||||||
|
return { valid: true }; |
||||||
|
} |
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Extraction & Normalization
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
/** |
||||||
|
* Normalize a string for use as a d-tag: lowercase, hyphens, alphanumeric only. |
||||||
|
*/ |
||||||
|
function normalizeDTagValue(header: string): string { |
||||||
|
return header |
||||||
|
.toLowerCase() |
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, '-') |
||||||
|
.replace(/^-+|-+$/g, ''); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Converts a title string to a valid d-tag (lowercase, hyphens, no punctuation). |
||||||
|
*/ |
||||||
|
export function titleToDTag(title: string): string { |
||||||
|
return title |
||||||
|
.toLowerCase() |
||||||
|
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
|
||||||
|
.replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extracts the first AsciiDoc document header (line starting with '= '). |
||||||
|
*/ |
||||||
|
function extractAsciiDocDocumentHeader(content: string): string | null { |
||||||
|
const match = content.match(/^=\s+(.+)$/m); |
||||||
|
return match ? match[1].trim() : null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extracts all section headers (lines starting with '== '). |
||||||
|
*/ |
||||||
|
function extractAsciiDocSectionHeaders(content: string): string[] { |
||||||
|
return Array.from(content.matchAll(/^==\s+(.+)$/gm)).map(m => m[1].trim()); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extracts the topmost Markdown # header (line starting with '# '). |
||||||
|
*/ |
||||||
|
function extractMarkdownTopHeader(content: string): string | null { |
||||||
|
const match = content.match(/^#\s+(.+)$/m); |
||||||
|
return match ? match[1].trim() : null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Splits AsciiDoc content into sections at each '==' header. Returns array of section strings. |
||||||
|
*/ |
||||||
|
function splitAsciiDocSections(content: string): string[] { |
||||||
|
const lines = content.split(/\r?\n/); |
||||||
|
const sections: string[] = []; |
||||||
|
let current: string[] = []; |
||||||
|
for (const line of lines) { |
||||||
|
if (/^==\s+/.test(line) && current.length > 0) { |
||||||
|
sections.push(current.join('\n').trim()); |
||||||
|
current = []; |
||||||
|
} |
||||||
|
current.push(line); |
||||||
|
} |
||||||
|
if (current.length > 0) { |
||||||
|
sections.push(current.join('\n').trim()); |
||||||
|
} |
||||||
|
return sections; |
||||||
|
} |
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Event Construction
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the current NDK instance from the store. |
||||||
|
*/ |
||||||
|
function getNdk() { |
||||||
|
return get(ndkInstance); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Builds a set of events for a 30040 publication: one 30040 index event and one 30041 event per section. |
||||||
|
* Each 30041 gets a d-tag (normalized section header) and a title tag (raw section header). |
||||||
|
* The 30040 index event references all 30041s by their d-tag. |
||||||
|
*/ |
||||||
|
export function build30040EventSet( |
||||||
|
content: string, |
||||||
|
tags: [string, string][], |
||||||
|
baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number } |
||||||
|
): { indexEvent: NDKEvent; sectionEvents: NDKEvent[] } { |
||||||
|
const ndk = getNdk(); |
||||||
|
const sections = splitAsciiDocSections(content); |
||||||
|
const sectionHeaders = extractAsciiDocSectionHeaders(content); |
||||||
|
const dTags = sectionHeaders.length === sections.length |
||||||
|
? sectionHeaders.map(normalizeDTagValue) |
||||||
|
: sections.map((_, i) => `section${i}`); |
||||||
|
const sectionEvents: NDKEvent[] = sections.map((section, i) => { |
||||||
|
const header = sectionHeaders[i] || `Section ${i + 1}`; |
||||||
|
const dTag = dTags[i]; |
||||||
|
return new NDKEventClass(ndk, { |
||||||
|
kind: 30041, |
||||||
|
content: section, |
||||||
|
tags: [ |
||||||
|
...tags, |
||||||
|
['d', dTag], |
||||||
|
['title', header], |
||||||
|
], |
||||||
|
pubkey: baseEvent.pubkey, |
||||||
|
created_at: baseEvent.created_at, |
||||||
|
}); |
||||||
|
}); |
||||||
|
const indexTags = [ |
||||||
|
...tags, |
||||||
|
...dTags.map(d => ['a', d] as [string, string]), |
||||||
|
]; |
||||||
|
const indexEvent: NDKEvent = new NDKEventClass(ndk, { |
||||||
|
kind: 30040, |
||||||
|
content: '', |
||||||
|
tags: indexTags, |
||||||
|
pubkey: baseEvent.pubkey, |
||||||
|
created_at: baseEvent.created_at, |
||||||
|
}); |
||||||
|
return { indexEvent, sectionEvents }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the appropriate title tag for a given event kind and content. |
||||||
|
* - 30041, 30818: AsciiDoc document header (first '= ' line) |
||||||
|
* - 30023: Markdown topmost '# ' header |
||||||
|
*/ |
||||||
|
export function getTitleTagForEvent(kind: number, content: string): string | null { |
||||||
|
if (kind === 30041 || kind === 30818) { |
||||||
|
return extractAsciiDocDocumentHeader(content); |
||||||
|
} |
||||||
|
if (kind === 30023) { |
||||||
|
return extractMarkdownTopHeader(content); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the appropriate d-tag value for a given event kind and content. |
||||||
|
* - 30023: Normalized markdown header |
||||||
|
* - 30041, 30818: Normalized AsciiDoc document header |
||||||
|
* - 30040: Uses existing d-tag or generates from content |
||||||
|
*/ |
||||||
|
export function getDTagForEvent(kind: number, content: string, existingDTag?: string): string | null { |
||||||
|
if (existingDTag && existingDTag.trim() !== '') { |
||||||
|
return existingDTag.trim(); |
||||||
|
} |
||||||
|
|
||||||
|
if (kind === 30023) { |
||||||
|
const title = extractMarkdownTopHeader(content); |
||||||
|
return title ? normalizeDTagValue(title) : null; |
||||||
|
} |
||||||
|
|
||||||
|
if (kind === 30041 || kind === 30818) { |
||||||
|
const title = extractAsciiDocDocumentHeader(content); |
||||||
|
return title ? normalizeDTagValue(title) : null; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
}
|
||||||
@ -0,0 +1,140 @@ |
|||||||
|
import { standardRelays, anonymousRelays, fallbackRelays } from '$lib/consts'; |
||||||
|
import NDK from '@nostr-dev-kit/ndk'; |
||||||
|
|
||||||
|
export interface RelayDiagnostic { |
||||||
|
url: string; |
||||||
|
connected: boolean; |
||||||
|
requiresAuth: boolean; |
||||||
|
error?: string; |
||||||
|
responseTime?: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Tests connection to a single relay |
||||||
|
*/ |
||||||
|
export async function testRelay(url: string): Promise<RelayDiagnostic> { |
||||||
|
const startTime = Date.now(); |
||||||
|
|
||||||
|
return new Promise((resolve) => { |
||||||
|
const ws = new WebSocket(url); |
||||||
|
let resolved = false; |
||||||
|
|
||||||
|
const timeout = setTimeout(() => { |
||||||
|
if (!resolved) { |
||||||
|
resolved = true; |
||||||
|
ws.close(); |
||||||
|
resolve({ |
||||||
|
url, |
||||||
|
connected: false, |
||||||
|
requiresAuth: false, |
||||||
|
error: 'Connection timeout', |
||||||
|
responseTime: Date.now() - startTime, |
||||||
|
}); |
||||||
|
} |
||||||
|
}, 5000); |
||||||
|
|
||||||
|
ws.onopen = () => { |
||||||
|
if (!resolved) { |
||||||
|
resolved = true; |
||||||
|
clearTimeout(timeout); |
||||||
|
ws.close(); |
||||||
|
resolve({ |
||||||
|
url, |
||||||
|
connected: true, |
||||||
|
requiresAuth: false, |
||||||
|
responseTime: Date.now() - startTime, |
||||||
|
}); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onerror = () => { |
||||||
|
if (!resolved) { |
||||||
|
resolved = true; |
||||||
|
clearTimeout(timeout); |
||||||
|
resolve({ |
||||||
|
url, |
||||||
|
connected: false, |
||||||
|
requiresAuth: false, |
||||||
|
error: 'WebSocket error', |
||||||
|
responseTime: Date.now() - startTime, |
||||||
|
}); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onmessage = (event) => { |
||||||
|
const data = JSON.parse(event.data); |
||||||
|
if (data[0] === 'NOTICE' && data[1]?.includes('auth-required')) { |
||||||
|
if (!resolved) { |
||||||
|
resolved = true; |
||||||
|
clearTimeout(timeout); |
||||||
|
ws.close(); |
||||||
|
resolve({ |
||||||
|
url, |
||||||
|
connected: true, |
||||||
|
requiresAuth: true, |
||||||
|
responseTime: Date.now() - startTime, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Tests all relays and returns diagnostic information |
||||||
|
*/ |
||||||
|
export async function testAllRelays(): Promise<RelayDiagnostic[]> { |
||||||
|
const allRelays = [...new Set([...standardRelays, ...anonymousRelays, ...fallbackRelays])]; |
||||||
|
|
||||||
|
console.log('[RelayDiagnostics] Testing', allRelays.length, 'relays...'); |
||||||
|
|
||||||
|
const results = await Promise.allSettled( |
||||||
|
allRelays.map(url => testRelay(url)) |
||||||
|
); |
||||||
|
|
||||||
|
return results.map((result, index) => { |
||||||
|
if (result.status === 'fulfilled') { |
||||||
|
return result.value; |
||||||
|
} else { |
||||||
|
return { |
||||||
|
url: allRelays[index], |
||||||
|
connected: false, |
||||||
|
requiresAuth: false, |
||||||
|
error: 'Test failed', |
||||||
|
}; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Gets working relays from diagnostic results |
||||||
|
*/ |
||||||
|
export function getWorkingRelays(diagnostics: RelayDiagnostic[]): string[] { |
||||||
|
return diagnostics |
||||||
|
.filter(d => d.connected) |
||||||
|
.map(d => d.url); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Logs relay diagnostic results to console |
||||||
|
*/ |
||||||
|
export function logRelayDiagnostics(diagnostics: RelayDiagnostic[]): void { |
||||||
|
console.group('[RelayDiagnostics] Results'); |
||||||
|
|
||||||
|
const working = diagnostics.filter(d => d.connected); |
||||||
|
const failed = diagnostics.filter(d => !d.connected); |
||||||
|
|
||||||
|
console.log(`✅ Working relays (${working.length}):`); |
||||||
|
working.forEach(d => { |
||||||
|
console.log(` - ${d.url}${d.requiresAuth ? ' (requires auth)' : ''}${d.responseTime ? ` (${d.responseTime}ms)` : ''}`); |
||||||
|
}); |
||||||
|
|
||||||
|
if (failed.length > 0) { |
||||||
|
console.log(`❌ Failed relays (${failed.length}):`); |
||||||
|
failed.forEach(d => { |
||||||
|
console.log(` - ${d.url}: ${d.error || 'Unknown error'}`); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
console.groupEnd(); |
||||||
|
}
|
||||||
Loading…
Reference in new issue