Browse Source

Integrate highlights, comments, and delete functionality into UI

master
limina1 4 months ago
parent
commit
e6b4738f98
  1. 35
      src/lib/components/cards/BlogHeader.svelte
  2. 315
      src/lib/components/publications/Publication.svelte
  3. 33
      src/lib/components/publications/PublicationHeader.svelte
  4. 168
      src/lib/components/publications/PublicationSection.svelte
  5. 390
      src/lib/components/util/CardActions.svelte
  6. 11
      src/lib/components/util/Details.svelte
  7. 85
      src/lib/utils/asciidoc_ast_parser.ts
  8. 46
      src/lib/utils/publication_tree_factory.ts
  9. 139
      src/lib/utils/publication_tree_processor.ts

35
src/lib/components/cards/BlogHeader.svelte

@ -10,6 +10,7 @@
import LazyImage from "$components/util/LazyImage.svelte"; import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils"; import { generateDarkPastelColor } from "$lib/utils/image_utils";
import { getNdkContext } from "$lib/ndk"; import { getNdkContext } from "$lib/ndk";
import { deleteEvent } from "$lib/services/deletion";
const { const {
rootId, rootId,
@ -25,6 +26,38 @@
const ndk = getNdkContext(); const ndk = getNdkContext();
/**
* Handle deletion of this blog article
*/
async function handleDelete() {
const confirmed = confirm(
"Are you sure you want to delete this article? This action will publish a deletion request to all relays."
);
if (!confirmed) return;
try {
await deleteEvent({
eventAddress: event.tagAddress(),
eventKind: event.kind,
reason: "User deleted article",
onSuccess: (deletionEventId) => {
console.log("[BlogHeader] Deletion event published:", deletionEventId);
// Call onBlogUpdate if provided to refresh the list
if (onBlogUpdate) {
onBlogUpdate();
}
},
onError: (error) => {
console.error("[BlogHeader] Deletion failed:", error);
alert(`Failed to delete article: ${error}`);
},
}, ndk);
} catch (error) {
console.error("[BlogHeader] Deletion error:", error);
}
}
let title: string = $derived(event.getMatchingTags("title")[0]?.[1]); let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
let author: string = $derived( let author: string = $derived(
getMatchingTags(event, "author")[0]?.[1] ?? "unknown", getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
@ -106,7 +139,7 @@
<!-- Position CardActions at bottom-right --> <!-- Position CardActions at bottom-right -->
<div class="absolute bottom-2 right-2"> <div class="absolute bottom-2 right-2">
<CardActions {event} /> <CardActions {event} onDelete={handleDelete} />
</div> </div>
</div> </div>
</Card> </Card>

315
src/lib/components/publications/Publication.svelte

@ -24,6 +24,17 @@
import TableOfContents from "./TableOfContents.svelte"; import TableOfContents from "./TableOfContents.svelte";
import type { TableOfContents as TocType } from "./table_of_contents.svelte"; import type { TableOfContents as TocType } from "./table_of_contents.svelte";
import ArticleNav from "$components/util/ArticleNav.svelte"; import ArticleNav from "$components/util/ArticleNav.svelte";
import { deleteEvent } from "$lib/services/deletion";
import { getNdkContext, activeOutboxRelays } from "$lib/ndk";
import { goto } from "$app/navigation";
import HighlightLayer from "./HighlightLayer.svelte";
import { EyeOutline, EyeSlashOutline } from "flowbite-svelte-icons";
import HighlightButton from "./HighlightButton.svelte";
import HighlightSelectionHandler from "./HighlightSelectionHandler.svelte";
import CommentLayer from "./CommentLayer.svelte";
import CommentButton from "./CommentButton.svelte";
import { Textarea, P } from "flowbite-svelte";
import { userStore } from "$lib/stores/userStore";
let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{ let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{
rootAddress: string; rootAddress: string;
@ -33,6 +44,59 @@
toc: TocType; toc: TocType;
}>(); }>();
const ndk = getNdkContext();
// Highlight layer state
let highlightsVisible = $state(false);
let highlightLayerRef: any = null;
let publicationContentRef: HTMLElement | null = $state(null);
// Comment layer state
let commentsVisible = $state(true);
let comments = $state<NDKEvent[]>([]);
let commentLayerRef: any = null;
let showArticleCommentUI = $state(false);
let articleCommentContent = $state("");
let isSubmittingArticleComment = $state(false);
let articleCommentError = $state<string | null>(null);
let articleCommentSuccess = $state(false);
// Toggle between mock and real data for testing (DEBUG MODE)
// Can be controlled via VITE_USE_MOCK_COMMENTS and VITE_USE_MOCK_HIGHLIGHTS environment variables
let useMockComments = $state(import.meta.env.VITE_USE_MOCK_COMMENTS === "true");
let useMockHighlights = $state(import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true");
// Log initial state for debugging
console.log('[Publication] Mock data initialized:', {
useMockComments,
useMockHighlights,
envVars: {
VITE_USE_MOCK_COMMENTS: import.meta.env.VITE_USE_MOCK_COMMENTS,
VITE_USE_MOCK_HIGHLIGHTS: import.meta.env.VITE_USE_MOCK_HIGHLIGHTS,
}
});
// Derive all event IDs and addresses for highlight fetching
let allEventIds = $derived.by(() => {
const ids = [indexEvent.id];
leaves.forEach(leaf => {
if (leaf?.id) ids.push(leaf.id);
});
return ids;
});
let allEventAddresses = $derived.by(() => {
const addresses = [rootAddress];
leaves.forEach(leaf => {
if (leaf) {
const addr = leaf.tagAddress();
if (addr) addresses.push(addr);
}
});
return addresses;
});
// #region Loading // #region Loading
let leaves = $state<Array<NDKEvent | null>>([]); let leaves = $state<Array<NDKEvent | null>>([]);
let isLoading = $state(false); let isLoading = $state(false);
@ -41,6 +105,8 @@
let activeAddress = $state<string | null>(null); let activeAddress = $state<string | null>(null);
let loadedAddresses = $state<Set<string>>(new Set()); let loadedAddresses = $state<Set<string>>(new Set());
let hasInitialized = $state(false); let hasInitialized = $state(false);
let highlightModeActive = $state(false);
let publicationDeleted = $state(false);
let observer: IntersectionObserver; let observer: IntersectionObserver;
@ -184,6 +250,121 @@
return currentBlog && currentBlogEvent && window.innerWidth < 1140; return currentBlog && currentBlogEvent && window.innerWidth < 1140;
} }
function toggleHighlights() {
highlightsVisible = !highlightsVisible;
}
function toggleComments() {
commentsVisible = !commentsVisible;
}
function handleCommentPosted() {
console.log("[Publication] Comment posted, refreshing comment layer");
// Refresh the comment layer after a short delay to allow relay indexing
setTimeout(() => {
if (commentLayerRef) {
commentLayerRef.refresh();
}
}, 500);
}
async function submitArticleComment() {
if (!articleCommentContent.trim()) {
articleCommentError = "Comment cannot be empty";
return;
}
isSubmittingArticleComment = true;
articleCommentError = null;
articleCommentSuccess = false;
try {
// Parse the root address to get event details
const parts = rootAddress.split(":");
if (parts.length !== 3) {
throw new Error("Invalid address format");
}
const [kindStr, authorPubkey, dTag] = parts;
const kind = parseInt(kindStr);
// Create comment event (kind 1111)
const commentEvent = new (await import("@nostr-dev-kit/ndk")).NDKEvent(ndk);
commentEvent.kind = 1111;
commentEvent.content = articleCommentContent;
// Get relay hint
const relayHint = $activeOutboxRelays[0] || "";
// Add tags following NIP-22
commentEvent.tags = [
["A", rootAddress, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
["a", rootAddress, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
];
// Sign and publish
await commentEvent.sign();
await commentEvent.publish();
console.log("[Publication] Article comment published:", commentEvent.id);
articleCommentSuccess = true;
articleCommentContent = "";
// Close UI and refresh after delay
setTimeout(() => {
showArticleCommentUI = false;
articleCommentSuccess = false;
handleCommentPosted();
}, 1500);
} catch (err) {
console.error("[Publication] Error posting article comment:", err);
articleCommentError = err instanceof Error ? err.message : "Failed to post comment";
} finally {
isSubmittingArticleComment = false;
}
}
/**
* Handles deletion of the entire publication
*/
async function handleDeletePublication() {
const confirmed = confirm(
"Are you sure you want to delete this entire publication? This action will publish a deletion request to all relays."
);
if (!confirmed) return;
try {
await deleteEvent({
eventAddress: indexEvent.tagAddress(),
eventKind: indexEvent.kind,
reason: "User deleted publication",
onSuccess: (deletionEventId) => {
console.log("[Publication] Deletion event published:", deletionEventId);
publicationDeleted = true;
// Redirect after 2 seconds
setTimeout(() => {
goto("/publications");
}, 2000);
},
onError: (error) => {
console.error("[Publication] Failed to delete publication:", error);
alert(`Failed to delete publication: ${error}`);
},
});
} catch (error) {
console.error("[Publication] Error deleting publication:", error);
alert(`Error: ${error}`);
}
}
// #endregion // #endregion
/** /**
@ -249,6 +430,13 @@
}; };
}); });
// Setup highlight layer container reference
$effect(() => {
if (publicationContentRef && highlightLayerRef) {
highlightLayerRef.setContainer(publicationContentRef);
}
});
// #endregion // #endregion
</script> </script>
@ -260,6 +448,23 @@
rootId={indexEvent.id} rootId={indexEvent.id}
indexEvent={indexEvent} indexEvent={indexEvent}
/> />
<!-- Highlight selection handler -->
<HighlightSelectionHandler
isActive={highlightModeActive}
publicationEvent={indexEvent}
onHighlightCreated={() => {
highlightModeActive = false;
// Refresh highlights after a short delay to allow relay indexing
setTimeout(() => {
if (highlightLayerRef) {
console.log("[Publication] Refreshing highlights after creation");
highlightLayerRef.refresh();
}
}, 500);
}}
/>
<!-- Three-column row --> <!-- Three-column row -->
<div class="contents"> <div class="contents">
<!-- Table of contents --> <!-- Table of contents -->
@ -299,12 +504,95 @@
<!-- Default publications --> <!-- Default publications -->
{#if $publicationColumnVisibility.main} {#if $publicationColumnVisibility.main}
<!-- Remove overflow-auto so page scroll drives it --> <!-- Remove overflow-auto so page scroll drives it -->
<div class="flex flex-col p-4 space-y-4 max-w-3xl flex-grow-2 mx-auto"> <div class="flex flex-col p-4 space-y-4 max-w-3xl flex-grow-2 mx-auto" bind:this={publicationContentRef}>
<div <div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border" class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
> >
<Details event={indexEvent} /> <Details event={indexEvent} onDelete={handleDeletePublication} />
</div>
{#if publicationDeleted}
<Alert color="yellow" class="mb-4">
<ExclamationCircleOutline class="w-5 h-5 inline mr-2" />
Publication deleted. Redirecting to publications page...
</Alert>
{/if}
<!-- Action buttons row -->
<div class="flex justify-between gap-2 mb-4">
<div class="flex gap-2">
<Button
color="light"
size="sm"
onclick={() => showArticleCommentUI = !showArticleCommentUI}
>
{showArticleCommentUI ? 'Close Comment' : 'Comment On Article'}
</Button>
<HighlightButton bind:isActive={highlightModeActive} />
</div>
<div class="flex gap-2">
<Button
color="light"
size="sm"
onclick={toggleComments}
>
{#if commentsVisible}
<EyeSlashOutline class="w-4 h-4 mr-2" />
Hide Comments
{:else}
<EyeOutline class="w-4 h-4 mr-2" />
Show Comments
{/if}
</Button>
<Button
color="light"
size="sm"
onclick={toggleHighlights}
>
{#if highlightsVisible}
<EyeSlashOutline class="w-4 h-4 mr-2" />
Hide Highlights
{:else}
<EyeOutline class="w-4 h-4 mr-2" />
Show Highlights
{/if}
</Button>
</div>
</div> </div>
<!-- Article Comment UI -->
{#if showArticleCommentUI}
<div class="mb-4 border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
<div class="space-y-3">
<h4 class="font-semibold text-gray-900 dark:text-white">Comment on Article</h4>
<Textarea
bind:value={articleCommentContent}
placeholder="Write your comment on this article..."
rows={4}
disabled={isSubmittingArticleComment}
/>
{#if articleCommentError}
<P class="text-red-600 dark:text-red-400 text-sm">{articleCommentError}</P>
{/if}
{#if articleCommentSuccess}
<P class="text-green-600 dark:text-green-400 text-sm">Comment posted successfully!</P>
{/if}
<div class="flex gap-2">
<Button onclick={submitArticleComment} disabled={isSubmittingArticleComment}>
{isSubmittingArticleComment ? 'Posting...' : 'Post Comment'}
</Button>
<Button color="light" onclick={() => showArticleCommentUI = false}>
Cancel
</Button>
</div>
</div>
</div>
{/if}
<!-- Publication sections/cards --> <!-- Publication sections/cards -->
{#each leaves as leaf, i} {#each leaves as leaf, i}
{#if leaf == null} {#if leaf == null}
@ -320,6 +608,8 @@
{address} {address}
{publicationTree} {publicationTree}
{toc} {toc}
allComments={comments}
{commentsVisible}
ref={(el) => onPublicationSectionMounted(el, address)} ref={(el) => onPublicationSectionMounted(el, address)}
/> />
{/if} {/if}
@ -347,7 +637,7 @@
<div <div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border" class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
> >
<Details event={indexEvent} /> <Details event={indexEvent} onDelete={handleDeletePublication} />
</div> </div>
<!-- List blog excerpts --> <!-- List blog excerpts -->
{#each leaves as leaf, i} {#each leaves as leaf, i}
@ -427,3 +717,22 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Highlight Layer Component -->
<HighlightLayer
bind:this={highlightLayerRef}
eventIds={allEventIds}
eventAddresses={allEventAddresses}
bind:visible={highlightsVisible}
useMockHighlights={useMockHighlights}
/>
<!-- Comment Layer Component -->
<CommentLayer
bind:this={commentLayerRef}
eventIds={allEventIds}
eventAddresses={allEventAddresses}
bind:comments={comments}
useMockComments={useMockComments}
/>

33
src/lib/components/publications/PublicationHeader.svelte

@ -8,11 +8,42 @@
import LazyImage from "$components/util/LazyImage.svelte"; import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils"; import { generateDarkPastelColor } from "$lib/utils/image_utils";
import { indexKind } from "$lib/consts"; import { indexKind } from "$lib/consts";
import { deleteEvent } from "$lib/services/deletion";
const { event } = $props<{ event: NDKEvent }>(); const { event } = $props<{ event: NDKEvent }>();
const ndk = getNdkContext(); const ndk = getNdkContext();
/**
* Handle deletion of this publication
*/
async function handleDelete() {
const confirmed = confirm(
"Are you sure you want to delete this publication? This action will publish a deletion request to all relays."
);
if (!confirmed) return;
try {
await deleteEvent({
eventAddress: event.tagAddress(),
eventKind: event.kind,
reason: "User deleted publication",
onSuccess: (deletionEventId) => {
console.log("[PublicationHeader] Deletion event published:", deletionEventId);
// Optionally refresh the feed or remove the card
window.location.reload();
},
onError: (error) => {
console.error("[PublicationHeader] Deletion failed:", error);
alert(`Failed to delete publication: ${error}`);
},
}, ndk);
} catch (error) {
console.error("[PublicationHeader] Deletion error:", error);
}
}
function getRelayUrls(): string[] { function getRelayUrls(): string[] {
return $activeInboxRelays; return $activeInboxRelays;
} }
@ -86,7 +117,7 @@
<h3 class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-auto break-words overflow-hidden">version: {version}</h3> <h3 class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-auto break-words overflow-hidden">version: {version}</h3>
{/if} {/if}
<div class="flex ml-auto"> <div class="flex ml-auto">
<CardActions {event} /> <CardActions {event} onDelete={handleDelete} />
</div> </div>
</div> </div>
</div> </div>

168
src/lib/components/publications/PublicationSection.svelte

@ -13,6 +13,9 @@
import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor"; import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor";
import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser"; import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser";
import NDK from "@nostr-dev-kit/ndk"; import NDK from "@nostr-dev-kit/ndk";
import CardActions from "$components/util/CardActions.svelte";
import SectionComments from "./SectionComments.svelte";
import { deleteEvent } from "$lib/services/deletion";
let { let {
address, address,
@ -21,6 +24,8 @@
publicationTree, publicationTree,
toc, toc,
ref, ref,
allComments = [],
commentsVisible = true,
}: { }: {
address: string; address: string;
rootAddress: string; rootAddress: string;
@ -28,15 +33,37 @@
publicationTree: SveltePublicationTree; publicationTree: SveltePublicationTree;
toc: TocType; toc: TocType;
ref: (ref: HTMLElement) => void; ref: (ref: HTMLElement) => void;
allComments?: NDKEvent[];
commentsVisible?: boolean;
} = $props(); } = $props();
const asciidoctor: Asciidoctor = getContext("asciidoctor"); const asciidoctor: Asciidoctor = getContext("asciidoctor");
const ndk: NDK = getContext("ndk"); const ndk: NDK = getContext("ndk");
// Filter comments for this section
let sectionComments = $derived(
allComments.filter(comment => {
// Check if comment targets this section via #a tag
const aTag = comment.tags.find(t => t[0] === 'a');
return aTag && aTag[1] === address;
})
);
let leafEvent: Promise<NDKEvent | null> = $derived.by( let leafEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(address), async () => await publicationTree.getEvent(address),
); );
let leafEventId = $state<string>("");
$effect(() => {
leafEvent.then(e => {
if (e?.id) {
leafEventId = e.id;
console.log(`[PublicationSection] Set leafEventId for ${address}:`, e.id);
}
});
});
let rootEvent: Promise<NDKEvent | null> = $derived.by( let rootEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(rootAddress), async () => await publicationTree.getEvent(rootAddress),
); );
@ -134,37 +161,132 @@
let sectionRef: HTMLElement; let sectionRef: HTMLElement;
/**
* Handle deletion of this section
*/
async function handleDelete() {
const event = await leafEvent;
if (!event) return;
const confirmed = confirm(
"Are you sure you want to delete this section? This action will publish a deletion request to all relays."
);
if (!confirmed) return;
try {
await deleteEvent({
eventAddress: address,
eventKind: event.kind,
reason: "User deleted section",
onSuccess: (deletionEventId) => {
console.log("[PublicationSection] Deletion event published:", deletionEventId);
// Refresh the page to reflect the deletion
window.location.reload();
},
onError: (error) => {
console.error("[PublicationSection] Deletion failed:", error);
alert(`Failed to delete section: ${error}`);
},
}, ndk);
} catch (error) {
console.error("[PublicationSection] Deletion error:", error);
}
}
$effect(() => { $effect(() => {
if (!sectionRef) { if (!sectionRef) {
return; return;
} }
ref(sectionRef); ref(sectionRef);
// Log data attributes for debugging
console.log(`[PublicationSection] Section mounted:`, {
address,
leafEventId,
dataAddress: sectionRef.dataset.eventAddress,
dataEventId: sectionRef.dataset.eventId
});
}); });
</script> </script>
<section <!-- Wrapper for positioning context -->
id={address} <div class="relative w-full">
bind:this={sectionRef} <section
class="publication-leather content-visibility-auto" id={address}
> bind:this={sectionRef}
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )} class="publication-leather content-visibility-auto section-with-comment"
<TextPlaceholder size="xxl" /> data-event-address={address}
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} data-event-id={leafEventId}
{#each divergingBranches as [branch, depth]} >
{@render sectionHeading( {#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )}
getMatchingTags(branch, "title")[0]?.[1] ?? "", <TextPlaceholder size="xxl" />
depth, {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
)} <!-- Main content area - centered -->
{/each} <div class="section-content relative max-w-4xl mx-auto px-4">
{#if leafTitle} <!-- Mobile menu - shown only on smaller screens -->
{@const leafDepth = leafHierarchy.length - 1} <div class="xl:hidden absolute top-2 right-2 z-10">
{@render sectionHeading(leafTitle, leafDepth)} {#await leafEvent then event}
{#if event}
<CardActions {event} sectionAddress={address} onDelete={handleDelete} />
{/if}
{/await}
</div>
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",
depth,
)}
{/each}
{#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)}
{/if}
{@render contentParagraph(
leafContent.toString(),
publicationType ?? "article",
false,
)}
</div>
<!-- Mobile comments - shown below content on smaller screens -->
<div class="xl:hidden mt-8 max-w-4xl mx-auto px-4">
<SectionComments
sectionAddress={address}
comments={sectionComments}
visible={commentsVisible}
/>
</div>
{/await}
</section>
<!-- Right sidebar elements - positioned very close to content, responsive width -->
{#await leafEvent then event}
{#if event}
<!-- Three-dot menu - positioned at top-center on XL+ screens -->
<div class="hidden xl:block absolute left-[calc(50%+26rem)] top-[20%] z-10">
<CardActions {event} sectionAddress={address} onDelete={handleDelete} />
</div>
{/if} {/if}
{@render contentParagraph(
leafContent.toString(),
publicationType ?? "article",
false,
)}
{/await} {/await}
</section>
<!-- Comments area: positioned below menu, top-center of section -->
<div class="hidden xl:block absolute left-[calc(50%+26rem)] top-[calc(20%+3rem)] w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]">
<SectionComments
sectionAddress={address}
comments={sectionComments}
visible={commentsVisible}
/>
</div>
</div>
<style>
.section-with-comment {
position: relative;
}
.section-with-comment:hover :global(.single-line-button) {
opacity: 1 !important;
}
</style>

390
src/lib/components/util/CardActions.svelte

@ -1,27 +1,37 @@
<script lang="ts"> <script lang="ts">
import { Button, Modal, Popover } from "flowbite-svelte"; import { Button, Modal, Popover, Textarea, P } from "flowbite-svelte";
import { import {
DotsVerticalOutline, DotsVerticalOutline,
EyeOutline, EyeOutline,
ClipboardCleanOutline, ClipboardCleanOutline,
TrashBinOutline,
MessageDotsOutline,
ChevronDownOutline,
ChevronUpOutline,
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { neventEncode, naddrEncode } from "$lib/utils"; import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays, getNdkContext } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import LazyImage from "$components/util/LazyImage.svelte"; import LazyImage from "$components/util/LazyImage.svelte";
import { communityRelays } from "$lib/consts";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
// Component props // Component props
let { event } = $props<{ event: NDKEvent }>(); let { event, onDelete, sectionAddress } = $props<{
event: NDKEvent;
onDelete?: () => void;
sectionAddress?: string; // If provided, shows "Comment on section" option
}>();
const ndk = getNdkContext(); const ndk = getNdkContext();
// Subscribe to userStore // Subscribe to userStore (Svelte 5 runes pattern)
let user = $state($userStore); let user = $derived($userStore);
userStore.subscribe((val) => (user = val));
// Derive metadata from event // Derive metadata from event
let title = $derived( let title = $derived(
@ -62,6 +72,71 @@
let detailsModalOpen: boolean = $state(false); let detailsModalOpen: boolean = $state(false);
let isOpen: boolean = $state(false); let isOpen: boolean = $state(false);
// Comment modal state
let commentModalOpen: boolean = $state(false);
let commentContent: string = $state("");
let isSubmittingComment: boolean = $state(false);
let commentError: string | null = $state(null);
let commentSuccess: boolean = $state(false);
let showJsonPreview: boolean = $state(false);
// Build preview JSON for the comment event
let previewJson = $derived.by(() => {
if (!commentContent.trim() || !sectionAddress) return null;
const eventDetails = parseAddress(sectionAddress);
if (!eventDetails) return null;
const { kind, pubkey: authorPubkey, dTag } = eventDetails;
const relayHint = $activeOutboxRelays[0] || "";
return {
kind: 1111,
pubkey: user.pubkey || "<your-pubkey>",
created_at: Math.floor(Date.now() / 1000),
tags: [
["A", sectionAddress, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
["a", sectionAddress, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
],
content: commentContent,
id: "<calculated-on-signing>",
sig: "<calculated-on-signing>"
};
});
// Check if user can delete this event (must be the author)
let canDelete = $derived.by(() => {
const result = user.signedIn && user.pubkey === event.pubkey && onDelete !== undefined;
console.log('[CardActions] canDelete check:', {
userSignedIn: user.signedIn,
userPubkey: user.pubkey,
eventPubkey: event.pubkey,
onDeleteProvided: onDelete !== undefined,
canDelete: result
});
return result;
});
// Determine delete button text based on event kind
let deleteButtonText = $derived.by(() => {
if (event.kind === 30040) {
// Kind 30040 is an index/publication
return "Delete publication";
} else if (event.kind === 30041) {
// Kind 30041 is a section
return "Delete section";
} else if (event.kind === 30023) {
// Kind 30023 is a long-form article
return "Delete article";
} else {
return "Delete";
}
});
/** /**
* Selects the appropriate relay set based on user state and feed type * Selects the appropriate relay set based on user state and feed type
* - Uses active inbox relays from the new relay management system * - Uses active inbox relays from the new relay management system
@ -123,6 +198,200 @@
const nevent = getIdentifier("nevent"); const nevent = getIdentifier("nevent");
goto(`/events?id=${encodeURIComponent(nevent)}`); goto(`/events?id=${encodeURIComponent(nevent)}`);
} }
/**
* Opens the comment modal
*/
function openCommentModal() {
if (!user.signedIn) {
commentError = "You must be signed in to comment";
setTimeout(() => {
commentError = null;
}, 3000);
return;
}
closePopover();
commentModalOpen = true;
commentContent = "";
commentError = null;
commentSuccess = false;
showJsonPreview = false;
}
/**
* Parse address to get event details
*/
function parseAddress(address: string): { kind: number; pubkey: string; dTag: string } | null {
const parts = address.split(":");
if (parts.length !== 3) {
console.error("[CardActions] Invalid address format:", address);
return null;
}
const [kindStr, pubkey, dTag] = parts;
const kind = parseInt(kindStr);
if (isNaN(kind)) {
console.error("[CardActions] Invalid kind in address:", kindStr);
return null;
}
return { kind, pubkey, dTag };
}
/**
* Submit comment
*/
async function submitComment() {
if (!sectionAddress || !user.pubkey) {
commentError = "Invalid state - cannot submit comment";
return;
}
const eventDetails = parseAddress(sectionAddress);
if (!eventDetails) {
commentError = "Invalid event address";
return;
}
const { kind, pubkey: authorPubkey, dTag } = eventDetails;
isSubmittingComment = true;
commentError = null;
try {
// Get relay hint
const relayHint = $activeOutboxRelays[0] || "";
// Fetch target event to get its ID
let eventId = "";
try {
const targetEvent = await ndk.fetchEvent({
kinds: [kind],
authors: [authorPubkey],
"#d": [dTag],
});
if (targetEvent) {
eventId = targetEvent.id;
}
} catch (err) {
console.warn("[CardActions] Could not fetch target event ID:", err);
}
// Create comment event (NIP-22)
const commentEvent = new NDKEventClass(ndk);
commentEvent.kind = 1111;
commentEvent.content = commentContent;
commentEvent.pubkey = user.pubkey;
commentEvent.tags = [
["A", sectionAddress, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
["a", sectionAddress, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
];
if (eventId) {
commentEvent.tags.push(["e", eventId, relayHint]);
}
// Sign event
const plainEvent = {
kind: Number(commentEvent.kind),
pubkey: String(commentEvent.pubkey),
created_at: Number(commentEvent.created_at ?? Math.floor(Date.now() / 1000)),
tags: commentEvent.tags.map((tag) => tag.map(String)),
content: String(commentEvent.content),
};
if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) {
const signed = await window.nostr.signEvent(plainEvent);
commentEvent.sig = signed.sig;
if ("id" in signed) {
commentEvent.id = signed.id as string;
}
} else if (user.signer) {
await commentEvent.sign(user.signer);
}
// Publish to relays
const relays = [
...communityRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];
const uniqueRelays = Array.from(new Set(relays));
const signedEvent = {
...plainEvent,
id: commentEvent.id,
sig: commentEvent.sig,
};
let publishedCount = 0;
for (const relayUrl of uniqueRelays) {
try {
const ws = await WebSocketPool.instance.acquire(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
WebSocketPool.instance.release(ws);
reject(new Error("Timeout"));
}, 5000);
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === "OK" && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
publishedCount++;
WebSocketPool.instance.release(ws);
resolve();
} else {
WebSocketPool.instance.release(ws);
reject(new Error(message));
}
}
};
ws.send(JSON.stringify(["EVENT", signedEvent]));
});
} catch (e) {
console.error(`[CardActions] Failed to publish to ${relayUrl}:`, e);
}
}
if (publishedCount === 0) {
throw new Error("Failed to publish to any relays");
}
commentSuccess = true;
setTimeout(() => {
commentModalOpen = false;
commentSuccess = false;
commentContent = "";
showJsonPreview = false;
}, 2000);
} catch (err) {
console.error("[CardActions] Error submitting comment:", err);
commentError = err instanceof Error ? err.message : "Failed to post comment";
} finally {
isSubmittingComment = false;
}
}
/**
* Cancel comment
*/
function cancelComment() {
commentModalOpen = false;
commentContent = "";
commentError = null;
commentSuccess = false;
showJsonPreview = false;
}
</script> </script>
<div <div
@ -153,6 +422,16 @@
<div class="flex flex-row justify-between space-x-4"> <div class="flex flex-row justify-between space-x-4">
<div class="flex flex-col text-nowrap"> <div class="flex flex-col text-nowrap">
<ul class="space-y-2"> <ul class="space-y-2">
{#if sectionAddress}
<li>
<button
class="btn-leather w-full text-left"
onclick={openCommentModal}
>
<MessageDotsOutline class="inline mr-2" /> Comment on section
</button>
</li>
{/if}
<li> <li>
<button <button
class="btn-leather w-full text-left" class="btn-leather w-full text-left"
@ -175,6 +454,19 @@
icon={ClipboardCleanOutline} icon={ClipboardCleanOutline}
/> />
</li> </li>
{#if canDelete}
<li>
<button
class="btn-leather w-full text-left text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
onclick={() => {
closePopover();
onDelete?.();
}}
>
<TrashBinOutline class="inline mr-2" /> {deleteButtonText}
</button>
</li>
{/if}
</ul> </ul>
</div> </div>
</div> </div>
@ -265,4 +557,90 @@
</button> </button>
</div> </div>
</Modal> </Modal>
<!-- Comment Modal -->
{#if sectionAddress}
<Modal
class="modal-leather"
title="Add Comment"
bind:open={commentModalOpen}
autoclose={false}
outsideclose={true}
size="md"
>
<div class="space-y-4">
{#if user.profile}
<div class="flex items-center gap-3 pb-3 border-b border-gray-200 dark:border-gray-700">
{#if user.profile.picture}
<img
src={user.profile.picture}
alt={user.profile.displayName || user.profile.name || "User"}
class="w-10 h-10 rounded-full object-cover"
/>
{/if}
<span class="font-medium text-gray-900 dark:text-gray-100">
{user.profile.displayName || user.profile.name || "Anonymous"}
</span>
</div>
{/if}
<Textarea
bind:value={commentContent}
placeholder="Write your comment here..."
rows={6}
disabled={isSubmittingComment}
class="w-full"
/>
{#if commentError}
<P class="text-red-600 dark:text-red-400 text-sm">{commentError}</P>
{/if}
{#if commentSuccess}
<P class="text-green-600 dark:text-green-400 text-sm">Comment posted successfully!</P>
{/if}
<!-- JSON Preview Section -->
{#if showJsonPreview && previewJson}
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900">
<P class="text-sm font-semibold mb-2">Event JSON Preview:</P>
<pre class="text-xs bg-white dark:bg-gray-800 p-3 rounded overflow-x-auto border border-gray-200 dark:border-gray-700"><code>{JSON.stringify(previewJson, null, 2)}</code></pre>
</div>
{/if}
<div class="flex justify-between items-center gap-3 pt-2">
<Button
color="light"
size="sm"
onclick={() => showJsonPreview = !showJsonPreview}
class="flex items-center gap-1"
>
{#if showJsonPreview}
<ChevronUpOutline class="w-4 h-4" />
{:else}
<ChevronDownOutline class="w-4 h-4" />
{/if}
{showJsonPreview ? "Hide" : "Show"} JSON
</Button>
<div class="flex gap-3">
<Button
color="alternative"
onclick={cancelComment}
disabled={isSubmittingComment}
>
Cancel
</Button>
<Button
color="primary"
onclick={submitComment}
disabled={isSubmittingComment || !commentContent.trim()}
>
{isSubmittingComment ? "Posting..." : "Post Comment"}
</Button>
</div>
</div>
</div>
</Modal>
{/if}
</div> </div>

11
src/lib/components/util/Details.svelte

@ -14,7 +14,11 @@
// isModal // isModal
// - don't show interactions in modal view // - don't show interactions in modal view
// - don't show all the details when _not_ in modal view // - don't show all the details when _not_ in modal view
let { event, isModal = false } = $props(); let { event, isModal = false, onDelete } = $props<{
event: any;
isModal?: boolean;
onDelete?: () => void;
}>();
let title: string = $derived(getMatchingTags(event, "title")[0]?.[1]); let title: string = $derived(getMatchingTags(event, "title")[0]?.[1]);
let author: string = $derived( let author: string = $derived(
@ -43,6 +47,7 @@
); );
let rootId: string = $derived(getMatchingTags(event, "d")[0]?.[1] ?? null); let rootId: string = $derived(getMatchingTags(event, "d")[0]?.[1] ?? null);
let kind = $derived(event.kind); let kind = $derived(event.kind);
let address: string = $derived(`${kind}:${event.pubkey}:${rootId}`);
let authorTag: string = $derived( let authorTag: string = $derived(
getMatchingTags(event, "author")[0]?.[1] ?? "", getMatchingTags(event, "author")[0]?.[1] ?? "",
@ -67,7 +72,9 @@
<P class="text-base font-normal" <P class="text-base font-normal"
>{@render userBadge(event.pubkey, undefined, ndk)}</P >{@render userBadge(event.pubkey, undefined, ndk)}</P
> >
<CardActions {event}></CardActions> <div class="flex flex-row gap-2 items-center">
<CardActions {event} {onDelete}></CardActions>
</div>
</div> </div>
{/if} {/if}
<div <div

85
src/lib/utils/asciidoc_ast_parser.ts

@ -106,7 +106,7 @@ function extractSubsections(section: any, parseLevel: number): ASTSection[] {
export async function createPublicationTreeFromAST( export async function createPublicationTreeFromAST(
content: string, content: string,
ndk: NDK, ndk: NDK,
parseLevel: number = 2 parseLevel: number = 2,
): Promise<PublicationTree> { ): Promise<PublicationTree> {
const parsed = parseAsciiDocAST(content, parseLevel); const parsed = parseAsciiDocAST(content, parseLevel);
@ -114,9 +114,13 @@ export async function createPublicationTreeFromAST(
const rootEvent = createIndexEventFromAST(parsed, ndk); const rootEvent = createIndexEventFromAST(parsed, ndk);
const tree = new PublicationTree(rootEvent, ndk); const tree = new PublicationTree(rootEvent, ndk);
// Add sections as 30041 events // Add sections as 30041 events with proper namespacing
for (const section of parsed.sections) { for (const section of parsed.sections) {
const contentEvent = createContentEventFromSection(section, ndk); const contentEvent = createContentEventFromSection(
section,
ndk,
parsed.title,
);
await tree.addEvent(contentEvent, rootEvent); await tree.addEvent(contentEvent, rootEvent);
} }
@ -139,16 +143,24 @@ function createIndexEventFromAST(parsed: ASTParsedDocument, ndk: NDK): NDKEvent
["d", dTag], ["d", dTag],
mTag, mTag,
MTag, MTag,
["title", parsed.title] ["title", parsed.title],
]; ];
// Add document attributes as tags // Add document attributes as tags
addAttributesAsTags(tags, parsed.attributes); addAttributesAsTags(tags, parsed.attributes);
// Generate publication abbreviation for namespacing sections
const pubAbbrev = generateTitleAbbreviation(parsed.title);
// Add a-tags for each section (30041 content events) // Add a-tags for each section (30041 content events)
parsed.sections.forEach(section => { // Using new format: kind:pubkey:{abbv}-{section-d-tag}
parsed.sections.forEach((section) => {
const sectionDTag = generateDTag(section.title); const sectionDTag = generateDTag(section.title);
tags.push(["a", `30041:${ndk.activeUser?.pubkey || 'pubkey'}:${sectionDTag}`]); const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
tags.push([
"a",
`30041:${ndk.activeUser?.pubkey || "pubkey"}:${namespacedDTag}`,
]);
}); });
event.tags = tags; event.tags = tags;
@ -159,20 +171,35 @@ function createIndexEventFromAST(parsed: ASTParsedDocument, ndk: NDK): NDKEvent
/** /**
* Create a 30041 content event from an AST section * Create a 30041 content event from an AST section
* Note: This function needs the publication title for proper namespacing
* but the current implementation doesn't have access to it.
* Consider using createPublicationTreeFromAST instead which handles this correctly.
*/ */
function createContentEventFromSection(section: ASTSection, ndk: NDK): NDKEvent { function createContentEventFromSection(
section: ASTSection,
ndk: NDK,
publicationTitle?: string,
): NDKEvent {
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.kind = 30041; event.kind = 30041;
event.created_at = Math.floor(Date.now() / 1000); event.created_at = Math.floor(Date.now() / 1000);
const dTag = generateDTag(section.title); // Generate namespaced d-tag if publication title is provided
const sectionDTag = generateDTag(section.title);
let dTag = sectionDTag;
if (publicationTitle) {
const pubAbbrev = generateTitleAbbreviation(publicationTitle);
dTag = `${pubAbbrev}-${sectionDTag}`;
}
const [mTag, MTag] = getMimeTags(30041); const [mTag, MTag] = getMimeTags(30041);
const tags: string[][] = [ const tags: string[][] = [
["d", dTag], ["d", dTag],
mTag, mTag,
MTag, MTag,
["title", section.title] ["title", section.title],
]; ];
// Add section attributes as tags // Add section attributes as tags
@ -195,6 +222,32 @@ function generateDTag(title: string): string {
.replace(/^-|-$/g, ""); .replace(/^-|-$/g, "");
} }
/**
* Generate title abbreviation from first letters of each word
* Used for namespacing section a-tags
* @param title - The publication title
* @returns Abbreviation string (e.g., "My Test Article" "mta")
*/
function generateTitleAbbreviation(title: string): string {
if (!title || !title.trim()) {
return "u"; // "untitled"
}
// Split on non-alphanumeric characters and filter out empty strings
const words = title
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => word.length > 0);
if (words.length === 0) {
return "u";
}
// Take first letter of each word and join
return words
.map((word) => word.charAt(0).toLowerCase())
.join("");
}
/** /**
* Add AsciiDoc attributes as Nostr event tags, filtering out system attributes * Add AsciiDoc attributes as Nostr event tags, filtering out system attributes
*/ */
@ -252,20 +305,24 @@ export function createPublicationTreeProcessor(ndk: NDK, parseLevel: number = 2)
async function createPublicationTreeFromDocument( async function createPublicationTreeFromDocument(
document: Document, document: Document,
ndk: NDK, ndk: NDK,
parseLevel: number parseLevel: number,
): Promise<PublicationTree> { ): Promise<PublicationTree> {
const parsed: ASTParsedDocument = { const parsed: ASTParsedDocument = {
title: document.getTitle() || '', title: document.getTitle() || "",
content: document.getContent() || '', content: document.getContent() || "",
attributes: document.getAttributes(), attributes: document.getAttributes(),
sections: extractSectionsFromAST(document, parseLevel) sections: extractSectionsFromAST(document, parseLevel),
}; };
const rootEvent = createIndexEventFromAST(parsed, ndk); const rootEvent = createIndexEventFromAST(parsed, ndk);
const tree = new PublicationTree(rootEvent, ndk); const tree = new PublicationTree(rootEvent, ndk);
for (const section of parsed.sections) { for (const section of parsed.sections) {
const contentEvent = createContentEventFromSection(section, ndk); const contentEvent = createContentEventFromSection(
section,
ndk,
parsed.title,
);
await tree.addEvent(contentEvent, rootEvent); await tree.addEvent(contentEvent, rootEvent);
} }

46
src/lib/utils/publication_tree_factory.ts

@ -120,10 +120,15 @@ function createIndexEvent(parsed: any, ndk: NDK): NDKEvent {
// Add document attributes as tags // Add document attributes as tags
addDocumentAttributesToTags(tags, parsed.attributes, event.pubkey); addDocumentAttributesToTags(tags, parsed.attributes, event.pubkey);
// Generate publication abbreviation for namespacing sections
const pubAbbrev = generateTitleAbbreviation(parsed.title);
// Add a-tags for each section (30041 references) // Add a-tags for each section (30041 references)
// Using new format: kind:pubkey:{abbv}-{section-d-tag}
parsed.sections.forEach((section: any) => { parsed.sections.forEach((section: any) => {
const sectionDTag = generateDTag(section.title); const sectionDTag = generateDTag(section.title);
tags.push(["a", `30041:${event.pubkey}:${sectionDTag}`]); const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
tags.push(["a", `30041:${event.pubkey}:${namespacedDTag}`]);
}); });
event.tags = tags; event.tags = tags;
@ -147,10 +152,19 @@ function createContentEvent(
// Use placeholder pubkey for preview if no active user // Use placeholder pubkey for preview if no active user
event.pubkey = ndk.activeUser?.pubkey || "preview-placeholder-pubkey"; event.pubkey = ndk.activeUser?.pubkey || "preview-placeholder-pubkey";
const dTag = generateDTag(section.title); // Generate namespaced d-tag using publication abbreviation
const sectionDTag = generateDTag(section.title);
const pubAbbrev = generateTitleAbbreviation(documentParsed.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
const [mTag, MTag] = getMimeTags(30041); const [mTag, MTag] = getMimeTags(30041);
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", section.title]]; const tags: string[][] = [
["d", namespacedDTag],
mTag,
MTag,
["title", section.title],
];
// Add section-specific attributes // Add section-specific attributes
addSectionAttributesToTags(tags, section.attributes); addSectionAttributesToTags(tags, section.attributes);
@ -200,6 +214,32 @@ function generateDTag(title: string): string {
); );
} }
/**
* Generate title abbreviation from first letters of each word
* Used for namespacing section a-tags
* @param title - The publication title
* @returns Abbreviation string (e.g., "My Test Article" "mta")
*/
function generateTitleAbbreviation(title: string): string {
if (!title || !title.trim()) {
return "u"; // "untitled"
}
// Split on non-alphanumeric characters and filter out empty strings
const words = title
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => word.length > 0);
if (words.length === 0) {
return "u";
}
// Take first letter of each word and join
return words
.map((word) => word.charAt(0).toLowerCase())
.join("");
}
/** /**
* Add document attributes as Nostr tags * Add document attributes as Nostr tags
*/ */

139
src/lib/utils/publication_tree_processor.ts

@ -13,7 +13,7 @@ import type NDK from "@nostr-dev-kit/ndk";
import { getMimeTags } from "$lib/utils/mime"; import { getMimeTags } from "$lib/utils/mime";
// For debugging tree structure // For debugging tree structure
const DEBUG = process.env.DEBUG_TREE_PROCESSOR === false; const DEBUG = process.env.DEBUG_TREE_PROCESSOR === "true";
export interface ProcessorResult { export interface ProcessorResult {
tree: PublicationTree; tree: PublicationTree;
indexEvent: NDKEvent | null; indexEvent: NDKEvent | null;
@ -435,6 +435,7 @@ function buildScatteredNotesStructure(
const eventStructure: EventStructureNode[] = []; const eventStructure: EventStructureNode[] = [];
const firstSegment = segments[0]; const firstSegment = segments[0];
// No publication title for scattered notes
const rootEvent = createContentEvent(firstSegment, ndk); const rootEvent = createContentEvent(firstSegment, ndk);
const tree = new PublicationTree(rootEvent, ndk); const tree = new PublicationTree(rootEvent, ndk);
contentEvents.push(rootEvent); contentEvents.push(rootEvent);
@ -530,16 +531,22 @@ function buildLevel2Structure(
const level2Groups = groupSegmentsByLevel2(segments); const level2Groups = groupSegmentsByLevel2(segments);
console.log(`[TreeProcessor] Level 2 groups:`, level2Groups.length, level2Groups.map(g => g.title)); console.log(`[TreeProcessor] Level 2 groups:`, level2Groups.length, level2Groups.map(g => g.title));
// Generate publication abbreviation for namespacing
const pubAbbrev = generateTitleAbbreviation(title);
for (const group of level2Groups) { for (const group of level2Groups) {
const contentEvent = createContentEvent(group, ndk); const contentEvent = createContentEvent(group, ndk, title);
contentEvents.push(contentEvent); contentEvents.push(contentEvent);
const sectionDTag = generateDTag(group.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
const childNode = { const childNode = {
title: group.title, title: group.title,
level: group.level, level: group.level,
eventType: "content" as const, eventType: "content" as const,
eventKind: 30041 as const, eventKind: 30041 as const,
dTag: generateDTag(group.title), dTag: namespacedDTag,
children: [], children: [],
}; };
@ -590,7 +597,8 @@ function buildHierarchicalStructure(
rootNode, rootNode,
contentEvents, contentEvents,
ndk, ndk,
parseLevel parseLevel,
title
); );
return { tree, indexEvent, contentEvents, eventStructure }; return { tree, indexEvent, contentEvents, eventStructure };
@ -618,10 +626,15 @@ function createIndexEvent(
// Add document attributes as tags // Add document attributes as tags
addDocumentAttributesToTags(tags, attributes, event.pubkey); addDocumentAttributesToTags(tags, attributes, event.pubkey);
// Generate publication abbreviation for namespacing sections
const pubAbbrev = generateTitleAbbreviation(title);
// Add a-tags for each content section // Add a-tags for each content section
// Using new format: kind:pubkey:{abbv}-{section-d-tag}
segments.forEach((segment) => { segments.forEach((segment) => {
const sectionDTag = generateDTag(segment.title); const sectionDTag = generateDTag(segment.title);
tags.push(["a", `30041:${event.pubkey}:${sectionDTag}`]); const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
tags.push(["a", `30041:${event.pubkey}:${namespacedDTag}`]);
}); });
event.tags = tags; event.tags = tags;
@ -635,13 +648,25 @@ function createIndexEvent(
/** /**
* Create a 30041 content event from segment * Create a 30041 content event from segment
*/ */
function createContentEvent(segment: ContentSegment, ndk: NDK): NDKEvent { function createContentEvent(
segment: ContentSegment,
ndk: NDK,
publicationTitle?: string,
): NDKEvent {
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.kind = 30041; event.kind = 30041;
event.created_at = Math.floor(Date.now() / 1000); event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = ndk.activeUser?.pubkey || "preview-placeholder-pubkey"; event.pubkey = ndk.activeUser?.pubkey || "preview-placeholder-pubkey";
const dTag = generateDTag(segment.title); // Generate namespaced d-tag if publication title is provided
const sectionDTag = generateDTag(segment.title);
let dTag = sectionDTag;
if (publicationTitle) {
const pubAbbrev = generateTitleAbbreviation(publicationTitle);
dTag = `${pubAbbrev}-${sectionDTag}`;
}
const [mTag, MTag] = getMimeTags(30041); const [mTag, MTag] = getMimeTags(30041);
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", segment.title]]; const tags: string[][] = [["d", dTag], mTag, MTag, ["title", segment.title]];
@ -652,7 +677,6 @@ function createContentEvent(segment: ContentSegment, ndk: NDK): NDKEvent {
event.tags = tags; event.tags = tags;
event.content = segment.content; event.content = segment.content;
return event; return event;
} }
@ -690,6 +714,32 @@ function generateDTag(title: string): string {
); );
} }
/**
* Generate title abbreviation from first letters of each word
* Used for namespacing section a-tags
* @param title - The publication title
* @returns Abbreviation string (e.g., "My Test Article" "mta")
*/
function generateTitleAbbreviation(title: string): string {
if (!title || !title.trim()) {
return "u"; // "untitled"
}
// Split on non-alphanumeric characters and filter out empty strings
const words = title
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => word.length > 0);
if (words.length === 0) {
return "u";
}
// Take first letter of each word and join
return words
.map((word) => word.charAt(0).toLowerCase())
.join("");
}
/** /**
* Add document attributes as Nostr tags * Add document attributes as Nostr tags
*/ */
@ -925,21 +975,35 @@ function processHierarchicalGroup(
parentStructureNode: EventStructureNode, parentStructureNode: EventStructureNode,
contentEvents: NDKEvent[], contentEvents: NDKEvent[],
ndk: NDK, ndk: NDK,
parseLevel: number parseLevel: number,
publicationTitle: string,
): void { ): void {
const pubAbbrev = generateTitleAbbreviation(publicationTitle);
for (const node of nodes) { for (const node of nodes) {
if (node.hasChildren && node.segment.level < parseLevel) { if (node.hasChildren && node.segment.level < parseLevel) {
// This section has children and is not at parse level // This section has children and is not at parse level
// Create BOTH an index event AND a content event // Create BOTH an index event AND a content event
// 1. Create the index event (30040) // 1. Create the index event (30040)
const indexEvent = createIndexEventForHierarchicalNode(node, ndk); const indexEvent = createIndexEventForHierarchicalNode(
node,
ndk,
publicationTitle,
);
contentEvents.push(indexEvent); contentEvents.push(indexEvent);
// 2. Create the content event (30041) for the section's own content // 2. Create the content event (30041) for the section's own content
const contentEvent = createContentEvent(node.segment, ndk); const contentEvent = createContentEvent(
node.segment,
ndk,
publicationTitle,
);
contentEvents.push(contentEvent); contentEvents.push(contentEvent);
const sectionDTag = generateDTag(node.segment.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
// 3. Add index node to structure // 3. Add index node to structure
const indexNode: EventStructureNode = { const indexNode: EventStructureNode = {
title: node.segment.title, title: node.segment.title,
@ -957,7 +1021,7 @@ function processHierarchicalGroup(
level: node.segment.level, level: node.segment.level,
eventType: "content", eventType: "content",
eventKind: 30041, eventKind: 30041,
dTag: generateDTag(node.segment.title), dTag: namespacedDTag,
children: [], children: [],
}); });
@ -967,19 +1031,27 @@ function processHierarchicalGroup(
indexNode, indexNode,
contentEvents, contentEvents,
ndk, ndk,
parseLevel parseLevel,
publicationTitle,
); );
} else { } else {
// This is either a leaf node or at parse level - just create content event // This is either a leaf node or at parse level - just create content event
const contentEvent = createContentEvent(node.segment, ndk); const contentEvent = createContentEvent(
node.segment,
ndk,
publicationTitle,
);
contentEvents.push(contentEvent); contentEvents.push(contentEvent);
const sectionDTag = generateDTag(node.segment.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
parentStructureNode.children.push({ parentStructureNode.children.push({
title: node.segment.title, title: node.segment.title,
level: node.segment.level, level: node.segment.level,
eventType: "content", eventType: "content",
eventKind: 30041, eventKind: 30041,
dTag: generateDTag(node.segment.title), dTag: namespacedDTag,
children: [], children: [],
}); });
} }
@ -991,7 +1063,8 @@ function processHierarchicalGroup(
*/ */
function createIndexEventForHierarchicalNode( function createIndexEventForHierarchicalNode(
node: HierarchicalNode, node: HierarchicalNode,
ndk: NDK ndk: NDK,
publicationTitle: string,
): NDKEvent { ): NDKEvent {
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.kind = 30040; event.kind = 30040;
@ -1001,28 +1074,38 @@ function createIndexEventForHierarchicalNode(
const dTag = generateDTag(node.segment.title); const dTag = generateDTag(node.segment.title);
const [mTag, MTag] = getMimeTags(30040); const [mTag, MTag] = getMimeTags(30040);
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", node.segment.title]]; const tags: string[][] = [
["d", dTag],
mTag,
MTag,
["title", node.segment.title],
];
// Add section attributes as tags // Add section attributes as tags
addSectionAttributesToTags(tags, node.segment.attributes); addSectionAttributesToTags(tags, node.segment.attributes);
// Add a-tags for the section's own content event const pubAbbrev = generateTitleAbbreviation(publicationTitle);
tags.push(["a", `30041:${event.pubkey}:${dTag}`]);
// Add a-tags for each child section // Add a-tags for the section's own content event with namespace
const sectionDTag = generateDTag(node.segment.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
tags.push(["a", `30041:${event.pubkey}:${namespacedDTag}`]);
// Add a-tags for each child section with namespace
for (const child of node.children) { for (const child of node.children) {
const childDTag = generateDTag(child.segment.title); const childDTag = generateDTag(child.segment.title);
const namespacedChildDTag = `${pubAbbrev}-${childDTag}`;
if (child.hasChildren && child.segment.level < node.segment.level + 1) { if (child.hasChildren && child.segment.level < node.segment.level + 1) {
// Child will be an index // Child will be an index
tags.push(["a", `30040:${event.pubkey}:${childDTag}`]); tags.push(["a", `30040:${event.pubkey}:${childDTag}`]);
} else { } else {
// Child will be content // Child will be content with namespace
tags.push(["a", `30041:${event.pubkey}:${childDTag}`]); tags.push(["a", `30041:${event.pubkey}:${namespacedChildDTag}`]);
} }
} }
event.tags = tags; event.tags = tags;
event.content = ""; // NKBIP-01: Index events must have empty content event.content = ""; // NKBIP-01: Index events must have empty content
return event; return event;
} }
@ -1059,10 +1142,13 @@ function buildSegmentHierarchy(
/** /**
* Create a 30040 index event for a section with children * Create a 30040 index event for a section with children
* Note: This function appears to be unused in the current codebase
* but is updated for consistency with the new namespacing scheme
*/ */
function createIndexEventForSection( function createIndexEventForSection(
section: HierarchicalSegment, section: HierarchicalSegment,
ndk: NDK, ndk: NDK,
publicationTitle: string,
): NDKEvent { ): NDKEvent {
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.kind = 30040; event.kind = 30040;
@ -1077,10 +1163,13 @@ function createIndexEventForSection(
// Add section attributes as tags // Add section attributes as tags
addSectionAttributesToTags(tags, section.attributes); addSectionAttributesToTags(tags, section.attributes);
// Add a-tags for each child content section const pubAbbrev = generateTitleAbbreviation(publicationTitle);
// Add a-tags for each child content section with namespace
section.children.forEach((child) => { section.children.forEach((child) => {
const childDTag = generateDTag(child.title); const childDTag = generateDTag(child.title);
tags.push(["a", `30041:${event.pubkey}:${childDTag}`]); const namespacedChildDTag = `${pubAbbrev}-${childDTag}`;
tags.push(["a", `30041:${event.pubkey}:${namespacedChildDTag}`]);
}); });
event.tags = tags; event.tags = tags;

Loading…
Cancel
Save