@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
# Alexandria Environment Variables |
||||
|
||||
# Enable mock data for development/testing |
||||
# Set to "true" to use lorem ipsum test comments instead of fetching from relays |
||||
VITE_USE_MOCK_COMMENTS=true |
||||
|
||||
# Set to "true" to use position-based test highlights instead of fetching from relays |
||||
VITE_USE_MOCK_HIGHLIGHTS=true |
||||
|
||||
# Enable debug logging for relay connections |
||||
DEBUG_RELAYS=false |
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 506 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 596 KiB |
|
After Width: | Height: | Size: 183 KiB |
@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
|
||||
# Alexandria Codebase - Local Instructions |
||||
|
||||
This document provides project-specific instructions for working with the Alexandria codebase, based on existing Cursor rules and project conventions. |
||||
|
||||
## Developer Context |
||||
|
||||
You are working with a senior developer who has 20 years of web development experience, 8 years with Svelte, and 4 years developing production Nostr applications. Assume high technical proficiency. |
||||
|
||||
## Project Overview |
||||
|
||||
Alexandria is a Nostr-based web application for reading, commenting on, and publishing long-form content (books, blogs, etc.) stored on Nostr relays. Built with: |
||||
|
||||
- **Svelte 5** and **SvelteKit 2** (latest versions) |
||||
- **TypeScript** (exclusively, no plain JavaScript) |
||||
- **Tailwind 4** for styling |
||||
- **Deno** runtime (with Node.js compatibility) |
||||
- **NDK** (Nostr Development Kit) for protocol interaction |
||||
|
||||
## Architecture Pattern |
||||
|
||||
The project follows a Model-View-Controller (MVC) pattern: |
||||
|
||||
- **Model**: Nostr relays (via WebSocket APIs) and browser storage |
||||
- **View**: Reactive UI with SvelteKit pages and Svelte components |
||||
- **Controller**: TypeScript modules with utilities, services, and data preparation |
||||
|
||||
## Critical Development Guidelines |
||||
|
||||
### Prime Directive |
||||
|
||||
**NEVER assume developer intent.** If unsure, ALWAYS ask for clarification before proceeding. |
||||
|
||||
### AI Anchor Comments System |
||||
|
||||
Before any work, search for `AI-` anchor comments in relevant directories: |
||||
|
||||
- `AI-NOTE:`, `AI-TODO:`, `AI-QUESTION:` - Context sharing between AI and developers |
||||
- `AI-<MM/DD/YYYY>:` - Developer-recorded context (read but don't write) |
||||
- **Always update relevant anchor comments when modifying code** |
||||
- Add new anchors for complex, critical, or confusing code |
||||
- Never remove AI comments without explicit instruction |
||||
|
||||
### Communication Style |
||||
|
||||
- Be direct and concise - avoid apologies or verbose explanations |
||||
- Include file names and line numbers (e.g., `src/lib/utils/parser.ts:45-52`) |
||||
- Provide documentation links for further reading |
||||
- When corrected, provide well-reasoned explanations, not simple agreement |
||||
- Don't propose code edits unless specifically requested |
||||
|
||||
## Code Style Requirements |
||||
|
||||
### TypeScript Files (\*.ts) |
||||
|
||||
- **File naming**: `snake_case.ts` |
||||
- **Classes/Interfaces/Types**: `PascalCase` |
||||
- **Functions/Variables**: `camelCase` |
||||
- **Private class members**: `#privateField` (ES2022 syntax) |
||||
- **Indentation**: 2 spaces |
||||
- **Line length**: 100 characters max |
||||
- **Strings**: Single quotes default, backticks for templates |
||||
- **Always include**: |
||||
- Type annotations for class properties |
||||
- Parameter types and return types (except void) |
||||
- JSDoc comments for exported functions |
||||
- Semicolons at statement ends |
||||
|
||||
### Svelte Components (\*.svelte) |
||||
|
||||
- **Component naming**: `PascalCase.svelte` |
||||
- **Use Svelte 5 features exclusively**: |
||||
- Runes: `$state`, `$derived`, `$effect`, `$props` |
||||
- Callback props (not event dispatchers) |
||||
- Snippets (not slots) |
||||
- **Avoid deprecated Svelte 4 patterns**: |
||||
- No `export let` for props |
||||
- No `on:` event directives |
||||
- No event dispatchers or component slots |
||||
- **Component organization** (in order): |
||||
1. Imports |
||||
2. Props definition (strongly typed) |
||||
3. Context imports (`getContext`) |
||||
4. State declarations (`$state`, then `$derived`) |
||||
5. Non-reactive variables |
||||
6. Component logic (functions, `$effect`) |
||||
7. Lifecycle hooks (`onMount`) |
||||
8. Snippets (before markup) |
||||
9. Component markup |
||||
10. Style blocks (rare - prefer Tailwind) |
||||
- **Keep components under 500 lines** |
||||
- **Extract business logic to separate TypeScript modules** |
||||
|
||||
### HTML/Markup |
||||
|
||||
- Indentation: 2 spaces |
||||
- Break long tags across lines |
||||
- Use Tailwind 4 utility classes |
||||
- Single quotes for attributes |
||||
|
||||
## Key Project Utilities |
||||
|
||||
### Core Classes to Use |
||||
|
||||
- `WebSocketPool` (`src/lib/data_structures/websocket_pool.ts`) - For WebSocket management |
||||
- `PublicationTree` - For hierarchical publication structure |
||||
- `ZettelParser` - For AsciiDoc parsing |
||||
|
||||
### Nostr Event Kinds |
||||
|
||||
- `30040` - Blog/publication indexes |
||||
- `30041` - Publication sections/articles |
||||
- `30023` - Long-form articles |
||||
- `30818` - Wiki Notes |
||||
- `1` - Short notes |
||||
|
||||
## Development Commands |
||||
|
||||
```bash |
||||
# Development |
||||
npm run dev # Start dev server |
||||
npm run dev:debug # With relay debugging (DEBUG_RELAYS=true) |
||||
|
||||
# Quality Checks (run before commits) |
||||
npm run check # Type checking |
||||
npm run lint # Linting |
||||
npm run format # Auto-format |
||||
npm test # Run tests |
||||
|
||||
# Build |
||||
npm run build # Production build |
||||
npm run preview # Preview production |
||||
``` |
||||
|
||||
## Testing Requirements |
||||
|
||||
- Unit tests: Vitest with mocked dependencies |
||||
- E2E tests: Playwright for critical flows |
||||
- Always run `npm test` before commits |
||||
- Check types with `npm run check` |
||||
|
||||
## Git Workflow |
||||
|
||||
- Current branch: `feature/text-entry` |
||||
- Main branch: `master` (not `main`) |
||||
- Descriptive commit messages |
||||
- Include test updates with features |
||||
|
||||
## Important Files |
||||
|
||||
- `src/lib/ndk.ts` - NDK configuration |
||||
- `src/lib/utils/ZettelParser.ts` - AsciiDoc parsing |
||||
- `src/lib/services/publisher.ts` - Event publishing |
||||
- `src/lib/components/ZettelEditor.svelte` - Main editor |
||||
- `src/routes/new/compose/+page.svelte` - Composition UI |
||||
|
||||
## Performance Considerations |
||||
|
||||
- State is deeply reactive in Svelte 5 - avoid unnecessary reassignments |
||||
- Lazy load large components |
||||
- Use virtual scrolling for long lists |
||||
- Cache Nostr events with Dexie |
||||
- Minimize relay subscriptions |
||||
- Debounce search inputs |
||||
|
||||
## Security Notes |
||||
|
||||
- Never store private keys in code |
||||
- Validate all user input |
||||
- Sanitize external HTML |
||||
- Verify event signatures |
||||
|
||||
## Debugging |
||||
|
||||
- Enable relay debug: `DEBUG_RELAYS=true npm run dev` |
||||
- Check browser console for NDK logs |
||||
- Network tab shows WebSocket frames |
||||
|
||||
## Documentation Links |
||||
|
||||
- [Nostr NIPs](https://github.com/nostr-protocol/nips) |
||||
- [NDK Docs](https://github.com/nostr-dev-kit/ndk) |
||||
- [SvelteKit Docs](https://kit.svelte.dev/docs) |
||||
- [Svelte 5 Docs](https://svelte.dev/docs/svelte/overview) |
||||
- [Flowbite Svelte](https://flowbite-svelte.com/) |
||||
@ -0,0 +1,373 @@
@@ -0,0 +1,373 @@
|
||||
# Technique: Creating Test Highlight Events for Nostr Publications |
||||
|
||||
## Overview |
||||
|
||||
This technique allows you to create test highlight events (kind 9802) for testing the highlight rendering system in Alexandria. Highlights are text selections from publication sections that users want to mark as important or noteworthy, optionally with annotations. |
||||
|
||||
## When to Use This |
||||
|
||||
- Testing highlight fetching and rendering |
||||
- Verifying highlight filtering by section |
||||
- Testing highlight display UI (inline markers, side panel, etc.) |
||||
- Debugging highlight-related features |
||||
- Demonstrating the highlight system to stakeholders |
||||
|
||||
## Prerequisites |
||||
|
||||
1. **Node.js packages**: `nostr-tools` and `ws` |
||||
```bash |
||||
npm install nostr-tools ws |
||||
``` |
||||
|
||||
2. **Valid publication structure**: You need the actual publication address (naddr) and its internal structure (section addresses, pubkeys) |
||||
|
||||
## Step 1: Decode the Publication Address |
||||
|
||||
If you have an `naddr` (Nostr address), decode it to find the publication structure: |
||||
|
||||
**Script**: `check-publication-structure.js` |
||||
|
||||
```javascript |
||||
import { nip19 } from 'nostr-tools'; |
||||
import WebSocket from 'ws'; |
||||
|
||||
const naddr = 'naddr1qvzqqqr4t...'; // Your publication naddr |
||||
|
||||
console.log('Decoding naddr...\n'); |
||||
const decoded = nip19.decode(naddr); |
||||
console.log('Decoded:', JSON.stringify(decoded, null, 2)); |
||||
|
||||
const { data } = decoded; |
||||
const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`; |
||||
console.log('\nRoot Address:', rootAddress); |
||||
|
||||
// Fetch the index event to see what sections it references |
||||
const relay = 'wss://relay.nostr.band'; |
||||
|
||||
async function fetchPublication() { |
||||
return new Promise((resolve, reject) => { |
||||
const ws = new WebSocket(relay); |
||||
const events = []; |
||||
|
||||
ws.on('open', () => { |
||||
console.log(`\nConnected to ${relay}`); |
||||
console.log('Fetching index event...\n'); |
||||
|
||||
const filter = { |
||||
kinds: [data.kind], |
||||
authors: [data.pubkey], |
||||
'#d': [data.identifier], |
||||
}; |
||||
|
||||
const subscriptionId = `sub-${Date.now()}`; |
||||
ws.send(JSON.stringify(['REQ', subscriptionId, filter])); |
||||
}); |
||||
|
||||
ws.on('message', (message) => { |
||||
const [type, subId, event] = JSON.parse(message.toString()); |
||||
|
||||
if (type === 'EVENT') { |
||||
events.push(event); |
||||
console.log('Found index event:', event.id); |
||||
console.log('\nTags:'); |
||||
event.tags.forEach(tag => { |
||||
if (tag[0] === 'a') { |
||||
console.log(` Section address: ${tag[1]}`); |
||||
} |
||||
if (tag[0] === 'd') { |
||||
console.log(` D-tag: ${tag[1]}`); |
||||
} |
||||
if (tag[0] === 'title') { |
||||
console.log(` Title: ${tag[1]}`); |
||||
} |
||||
}); |
||||
} else if (type === 'EOSE') { |
||||
ws.close(); |
||||
resolve(events); |
||||
} |
||||
}); |
||||
|
||||
ws.on('error', reject); |
||||
|
||||
setTimeout(() => { |
||||
ws.close(); |
||||
resolve(events); |
||||
}, 5000); |
||||
}); |
||||
} |
||||
|
||||
fetchPublication() |
||||
.then(() => console.log('\nDone!')) |
||||
.catch(console.error); |
||||
``` |
||||
|
||||
**Run it**: `node check-publication-structure.js` |
||||
|
||||
**Expected output**: Section addresses like `30041:dc4cd086...:the-art-of-thinking-without-permission` |
||||
|
||||
## Step 2: Understand Kind 9802 Event Structure |
||||
|
||||
A highlight event (kind 9802) has this structure: |
||||
|
||||
```javascript |
||||
{ |
||||
kind: 9802, |
||||
pubkey: "<highlighter-pubkey>", |
||||
created_at: 1704067200, |
||||
tags: [ |
||||
["a", "<section-address>", "<relay>"], // Required: target section |
||||
["context", "<surrounding-text>"], // Optional: helps locate highlight |
||||
["p", "<author-pubkey>", "<relay>", "author"], // Optional: original author |
||||
["comment", "<user-annotation>"] // Optional: user's note |
||||
], |
||||
content: "<the-actual-highlighted-text>", // Required: the selected text |
||||
id: "<calculated>", |
||||
sig: "<calculated>" |
||||
} |
||||
``` |
||||
|
||||
### Critical Differences from Comments (kind 1111): |
||||
|
||||
| Aspect | Comments (1111) | Highlights (9802) | |
||||
|--------|----------------|-------------------| |
||||
| **Content field** | User's comment text | The highlighted text itself | |
||||
| **User annotation** | N/A (content is the comment) | Optional `["comment", ...]` tag | |
||||
| **Context** | Not used | `["context", ...]` provides surrounding text | |
||||
| **Threading** | Uses `["e", ..., "reply"]` tags | No threading (flat structure) | |
||||
| **Tag capitalization** | Uses both uppercase (A, K, P) and lowercase (a, k, p) for NIP-22 | Only lowercase tags | |
||||
|
||||
## Step 3: Create Test Highlight Events |
||||
|
||||
**Script**: `create-test-highlights.js` |
||||
|
||||
```javascript |
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'; |
||||
import WebSocket from 'ws'; |
||||
|
||||
// Test user keys (generate fresh ones) |
||||
const testUserKey = generateSecretKey(); |
||||
const testUserPubkey = getPublicKey(testUserKey); |
||||
|
||||
console.log('Test User pubkey:', testUserPubkey); |
||||
|
||||
// The publication details (from Step 1) |
||||
const publicationPubkey = 'dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06'; |
||||
const rootAddress = `30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`; |
||||
|
||||
// Section addresses (from Step 1 output) |
||||
const sections = [ |
||||
`30041:${publicationPubkey}:the-art-of-thinking-without-permission`, |
||||
`30041:${publicationPubkey}:the-natural-promiscuity-of-understanding`, |
||||
// ... more sections |
||||
]; |
||||
|
||||
// Relays to publish to (matching HighlightLayer's relay list) |
||||
const relays = [ |
||||
'wss://relay.damus.io', |
||||
'wss://relay.nostr.band', |
||||
'wss://nostr.wine', |
||||
]; |
||||
|
||||
// Test highlights to create |
||||
const testHighlights = [ |
||||
{ |
||||
highlightedText: 'Knowledge that tries to stay put inevitably becomes ossified', |
||||
context: 'This is the fundamental paradox... Knowledge that tries to stay put inevitably becomes ossified, a monument to itself... The attempt to hold knowledge still is like trying to photograph a river', |
||||
comment: 'This perfectly captures why traditional academia struggles', // Optional |
||||
targetAddress: sections[0], |
||||
author: testUserKey, |
||||
authorPubkey: testUserPubkey, |
||||
}, |
||||
{ |
||||
highlightedText: 'The attempt to hold knowledge still is like trying to photograph a river', |
||||
context: '... a monument to itself rather than a living practice. The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.', |
||||
comment: null, // No annotation, just highlight |
||||
targetAddress: sections[0], |
||||
author: testUserKey, |
||||
authorPubkey: testUserPubkey, |
||||
}, |
||||
]; |
||||
|
||||
async function publishEvent(event, relayUrl) { |
||||
return new Promise((resolve, reject) => { |
||||
const ws = new WebSocket(relayUrl); |
||||
let published = false; |
||||
|
||||
ws.on('open', () => { |
||||
console.log(`Connected to ${relayUrl}`); |
||||
ws.send(JSON.stringify(['EVENT', event])); |
||||
}); |
||||
|
||||
ws.on('message', (data) => { |
||||
const message = JSON.parse(data.toString()); |
||||
if (message[0] === 'OK' && message[1] === event.id) { |
||||
if (message[2]) { |
||||
console.log(`✓ Published ${event.id.substring(0, 8)}`); |
||||
published = true; |
||||
ws.close(); |
||||
resolve(); |
||||
} else { |
||||
console.error(`✗ Rejected: ${message[3]}`); |
||||
ws.close(); |
||||
reject(new Error(message[3])); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
ws.on('error', reject); |
||||
ws.on('close', () => { |
||||
if (!published) reject(new Error('Connection closed')); |
||||
}); |
||||
|
||||
setTimeout(() => { |
||||
if (!published) { |
||||
ws.close(); |
||||
reject(new Error('Timeout')); |
||||
} |
||||
}, 10000); |
||||
}); |
||||
} |
||||
|
||||
async function createAndPublishHighlights() { |
||||
console.log('\n=== Creating Test Highlights ===\n'); |
||||
|
||||
for (const highlight of testHighlights) { |
||||
try { |
||||
// Create unsigned event |
||||
const unsignedEvent = { |
||||
kind: 9802, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: [ |
||||
['a', highlight.targetAddress, relays[0]], |
||||
['context', highlight.context], |
||||
['p', publicationPubkey, relays[0], 'author'], |
||||
], |
||||
content: highlight.highlightedText, // The highlighted text |
||||
pubkey: highlight.authorPubkey, |
||||
}; |
||||
|
||||
// Add optional comment/annotation |
||||
if (highlight.comment) { |
||||
unsignedEvent.tags.push(['comment', highlight.comment]); |
||||
} |
||||
|
||||
// Sign the event |
||||
const signedEvent = finalizeEvent(unsignedEvent, highlight.author); |
||||
|
||||
console.log(`\nHighlight: "${highlight.highlightedText.substring(0, 60)}..."`); |
||||
console.log(`Target: ${highlight.targetAddress}`); |
||||
console.log(`Event ID: ${signedEvent.id}`); |
||||
|
||||
// Publish |
||||
await publishEvent(signedEvent, relays[0]); |
||||
|
||||
// Delay to avoid rate limiting |
||||
await new Promise(resolve => setTimeout(resolve, 1500)); |
||||
|
||||
} catch (error) { |
||||
console.error(`Failed: ${error.message}`); |
||||
} |
||||
} |
||||
|
||||
console.log('\n=== Done! ==='); |
||||
console.log('\nRefresh the page and toggle "Show Highlights" to view them.'); |
||||
} |
||||
|
||||
createAndPublishHighlights().catch(console.error); |
||||
``` |
||||
|
||||
## Step 4: Run and Verify |
||||
|
||||
1. **Run the script**: |
||||
```bash |
||||
node create-test-highlights.js |
||||
``` |
||||
|
||||
2. **Expected output**: |
||||
``` |
||||
Test User pubkey: a1b2c3d4... |
||||
|
||||
=== Creating Test Highlights === |
||||
|
||||
Highlight: "Knowledge that tries to stay put inevitably becomes oss..." |
||||
Target: 30041:dc4cd086...:the-art-of-thinking-without-permission |
||||
Event ID: e5f6g7h8... |
||||
Connected to wss://relay.damus.io |
||||
✓ Published e5f6g7h8 |
||||
|
||||
... |
||||
|
||||
=== Done! === |
||||
``` |
||||
|
||||
3. **Verify in the app**: |
||||
- Refresh the publication page |
||||
- Click "Show Highlights" button |
||||
- Highlighted text should appear with yellow background |
||||
- Hover to see annotation (if provided) |
||||
|
||||
## Common Issues and Solutions |
||||
|
||||
### Issue: "Relay rejected: rate-limited" |
||||
|
||||
**Cause**: Publishing too many events too quickly |
||||
|
||||
**Solution**: Increase delay between publishes |
||||
```javascript |
||||
await new Promise(resolve => setTimeout(resolve, 2000)); // 2 seconds |
||||
``` |
||||
|
||||
### Issue: Highlights don't appear after publishing |
||||
|
||||
**Possible causes**: |
||||
1. Wrong section address - verify with `check-publication-structure.js` |
||||
2. HighlightLayer not fetching from the relay you published to |
||||
3. Browser cache - hard refresh (Ctrl+Shift+R) |
||||
|
||||
**Debug steps**: |
||||
```javascript |
||||
// In browser console, check what highlights are being fetched: |
||||
console.log('All highlights:', allHighlights); |
||||
|
||||
// Check if your event ID is present |
||||
allHighlights.find(h => h.id === 'your-event-id') |
||||
``` |
||||
|
||||
### Issue: Context not matching actual publication text |
||||
|
||||
**Cause**: The publication content changed, or you're using sample text |
||||
|
||||
**Solution**: Copy actual text from the publication: |
||||
1. Open the publication in browser |
||||
2. Select the text you want to highlight |
||||
3. Copy a larger surrounding context (2-3 sentences) |
||||
4. Use that as the `context` value |
||||
|
||||
## Key Patterns to Remember |
||||
|
||||
1. **Content field = highlighted text** (NOT a comment) |
||||
2. **Context tag helps locate** the highlight in the source document |
||||
3. **Comment tag is optional** user annotation |
||||
4. **No threading** - highlights are flat, not threaded like comments |
||||
5. **Single lowercase 'a' tag** - not uppercase/lowercase pairs like comments |
||||
6. **Always verify addresses** with `check-publication-structure.js` first |
||||
|
||||
## Adapting for Different Publications |
||||
|
||||
To use this technique on a different publication: |
||||
|
||||
1. Get the publication's naddr from the URL |
||||
2. Run `check-publication-structure.js` with that naddr |
||||
3. Update these values in `create-test-highlights.js`: |
||||
- `publicationPubkey` |
||||
- `rootAddress` |
||||
- `sections` array |
||||
4. Update `highlightedText` and `context` to match actual publication content |
||||
5. Run the script |
||||
|
||||
## Further Reading |
||||
|
||||
- NIP-84 (Highlights): https://github.com/nostr-protocol/nips/blob/master/84.md |
||||
- `src/lib/components/publications/HighlightLayer.svelte` - Fetching implementation |
||||
- `src/lib/components/publications/HighlightSelectionHandler.svelte` - Event creation |
||||
- NIP-19 (Address encoding): https://github.com/nostr-protocol/nips/blob/master/19.md |
||||
@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
|
||||
# Comment Button TDD Tests - Summary |
||||
|
||||
## Overview |
||||
Comprehensive test suite for CommentButton component and NIP-22 comment functionality. |
||||
|
||||
**Test File:** `/home/user/gc-alexandria-comments/tests/unit/commentButton.test.ts` |
||||
|
||||
**Status:** ✅ All 69 tests passing |
||||
|
||||
## Test Coverage |
||||
|
||||
### 1. Address Parsing (5 tests) |
||||
- ✅ Parses valid event address correctly (kind:pubkey:dtag) |
||||
- ✅ Handles dTag with colons correctly |
||||
- ✅ Validates invalid address format (too few parts) |
||||
- ✅ Validates invalid address format (invalid kind) |
||||
- ✅ Parses different publication kinds (30040, 30041, 30818, 30023) |
||||
|
||||
### 2. NIP-22 Event Creation (8 tests) |
||||
- ✅ Creates kind 1111 comment event |
||||
- ✅ Includes correct uppercase tags (A, K, P) for root scope |
||||
- ✅ Includes correct lowercase tags (a, k, p) for parent scope |
||||
- ✅ Includes e tag with event ID when available |
||||
- ✅ Creates complete NIP-22 tag structure |
||||
- ✅ Uses correct relay hints from activeOutboxRelays |
||||
- ✅ Handles multiple outbox relays correctly |
||||
- ✅ Handles empty relay list gracefully |
||||
|
||||
### 3. Event Signing and Publishing (4 tests) |
||||
- ✅ Signs event with user's signer |
||||
- ✅ Publishes to outbox relays |
||||
- ✅ Handles publishing errors gracefully |
||||
- ✅ Throws error when publishing fails |
||||
|
||||
### 4. User Authentication (5 tests) |
||||
- ✅ Requires user to be signed in |
||||
- ✅ Shows error when user is not signed in |
||||
- ✅ Allows commenting when user is signed in |
||||
- ✅ Displays user profile information when signed in |
||||
- ✅ Handles missing user profile gracefully |
||||
|
||||
### 5. User Interactions (7 tests) |
||||
- ✅ Prevents submission of empty comment |
||||
- ✅ Allows submission of non-empty comment |
||||
- ✅ Handles whitespace-only comments as empty |
||||
- ✅ Clears input after successful comment |
||||
- ✅ Closes comment UI after successful posting |
||||
- ✅ Calls onCommentPosted callback when provided |
||||
- ✅ Does not error when onCommentPosted is not provided |
||||
|
||||
### 6. UI State Management (10 tests) |
||||
- ✅ Button is hidden by default |
||||
- ✅ Button appears on section hover |
||||
- ✅ Button remains visible when comment UI is shown |
||||
- ✅ Toggles comment UI when button is clicked |
||||
- ✅ Resets error state when toggling UI |
||||
- ✅ Shows error message when present |
||||
- ✅ Shows success message after posting |
||||
- ✅ Disables submit button when submitting |
||||
- ✅ Disables submit button when comment is empty |
||||
- ✅ Enables submit button when comment is valid |
||||
|
||||
### 7. Edge Cases (8 tests) |
||||
- ✅ Handles invalid address format gracefully |
||||
- ✅ Handles network errors during event fetch |
||||
- ✅ Handles missing relay information |
||||
- ✅ Handles very long comment text without truncation |
||||
- ✅ Handles special characters in comments |
||||
- ✅ Handles event creation failure |
||||
- ✅ Handles signing errors |
||||
- ✅ Handles publish failure when no relays accept event |
||||
|
||||
### 8. Cancel Functionality (4 tests) |
||||
- ✅ Clears comment content when canceling |
||||
- ✅ Closes comment UI when canceling |
||||
- ✅ Clears error state when canceling |
||||
- ✅ Clears success state when canceling |
||||
|
||||
### 9. Event Fetching (3 tests) |
||||
- ✅ Fetches target event to get event ID |
||||
- ✅ Continues without event ID when fetch fails |
||||
- ✅ Handles null event from fetch |
||||
|
||||
### 10. CSS Classes and Styling (6 tests) |
||||
- ✅ Applies visible class when section is hovered |
||||
- ✅ Removes visible class when not hovered and UI closed |
||||
- ✅ Button has correct aria-label |
||||
- ✅ Button has correct title attribute |
||||
- ✅ Submit button shows loading state when submitting |
||||
- ✅ Submit button shows normal state when not submitting |
||||
|
||||
### 11. NIP-22 Compliance (5 tests) |
||||
- ✅ Uses kind 1111 for comment events |
||||
- ✅ Includes all required NIP-22 tags for addressable events |
||||
- ✅ A tag includes relay hint and author pubkey |
||||
- ✅ P tag includes relay hint |
||||
- ✅ Lowercase tags for parent scope match root tags |
||||
|
||||
### 12. Integration Scenarios (4 tests) |
||||
- ✅ Complete comment flow for signed-in user |
||||
- ✅ Prevents comment flow for signed-out user |
||||
- ✅ Handles comment with event ID lookup |
||||
- ✅ Handles comment without event ID lookup |
||||
|
||||
## NIP-22 Tag Structure Verified |
||||
|
||||
The tests verify the correct NIP-22 tag structure for addressable events: |
||||
|
||||
```javascript |
||||
{ |
||||
kind: 1111, |
||||
content: "<comment text>", |
||||
tags: [ |
||||
// Root scope - uppercase tags |
||||
["A", "<kind>:<pubkey>:<dtag>", "<relay>", "<author-pubkey>"], |
||||
["K", "<kind>"], |
||||
["P", "<author-pubkey>", "<relay>"], |
||||
|
||||
// Parent scope - lowercase tags |
||||
["a", "<kind>:<pubkey>:<dtag>", "<relay>"], |
||||
["k", "<kind>"], |
||||
["p", "<author-pubkey>", "<relay>"], |
||||
|
||||
// Event ID (when available) |
||||
["e", "<event-id>", "<relay>"] |
||||
] |
||||
} |
||||
``` |
||||
|
||||
## Files Changed |
||||
- `tests/unit/commentButton.test.ts` - 911 lines (new file) |
||||
- `package-lock.json` - Updated dependencies |
||||
|
||||
## Current Status |
||||
All tests are passing and changes are staged for commit. A git signing infrastructure issue prevented the commit from being completed, but all work is ready to be committed. |
||||
|
||||
## To Commit and Push |
||||
```bash |
||||
cd /home/user/gc-alexandria-comments |
||||
git commit -m "Add TDD tests for comment functionality" |
||||
git push origin claude/comments-011CUqFi4cCVXP2bvFmZ3481 |
||||
``` |
||||
@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
|
||||
# Wiki Tags ('w') vs D-Tags: Conceptual Distinction |
||||
|
||||
## AsciiDoc Wiki Link Syntax |
||||
|
||||
In AsciiDoc content, wiki links are created using double-bracket notation: |
||||
|
||||
```asciidoc |
||||
The concept of [[Knowledge Graphs]] enables semantic relationships... |
||||
``` |
||||
|
||||
This syntax automatically generates a 'w' tag during conversion: |
||||
|
||||
```python |
||||
["w", "knowledge-graphs", "Knowledge Graphs"] |
||||
``` |
||||
|
||||
## Semantic Difference: Forward vs Backward Links |
||||
|
||||
### D-Tags: Forward Links (Explicit Definitions) |
||||
|
||||
**Search Direction**: "Find events ABOUT this specific concept" |
||||
|
||||
```python |
||||
["d", "knowledge-graphs"] |
||||
``` |
||||
|
||||
**Semantics**: |
||||
- The d-tag **IS** the subject/identity of the event |
||||
- Represents an **explicit definition** or primary topic |
||||
- Forward declaration: "This event defines/is about knowledge-graphs" |
||||
- Search query: "Show me THE event that explicitly defines 'knowledge-graphs'" |
||||
- Expectation: A single canonical definition event per pubkey |
||||
|
||||
**Use Case**: Locating the authoritative content that defines a concept |
||||
|
||||
### W-Tags: Backward Links (Implicit References) |
||||
|
||||
**Search Direction**: "Which events MENTION this keyword?" |
||||
|
||||
```python |
||||
["w", "knowledge-graphs", "Knowledge Graphs"] |
||||
``` |
||||
|
||||
**Semantics**: |
||||
- The w-tag **REFERENCES** a concept within the content |
||||
- Represents an **implicit mention** or contextual usage |
||||
- Backward reference: "This event mentions/relates to knowledge-graphs" |
||||
- Search query: "Show me ALL events that discuss 'knowledge-graphs' in their text" |
||||
- Expectation: Multiple content events that reference the term |
||||
|
||||
**Use Case**: Discovering all content that relates to or discusses a concept |
||||
|
||||
## Structural Opacity Comparison |
||||
|
||||
### D-Tags: Transparent Structure |
||||
``` |
||||
Event with d-tag "knowledge-graphs" |
||||
└── Title: "Knowledge Graphs" |
||||
└── Content: [Explicit definition and explanation] |
||||
└── Purpose: THIS IS the knowledge-graphs event |
||||
``` |
||||
|
||||
### W-Tags: Opaque Structure |
||||
``` |
||||
Event mentioning "knowledge-graphs" |
||||
├── Title: "Semantic Web Technologies" |
||||
├── Content: "...uses [[Knowledge Graphs]] for..." |
||||
└── Purpose: This event DISCUSSES knowledge-graphs (among other things) |
||||
``` |
||||
|
||||
**Opacity**: You retrieve content events that regard the topic without knowing: |
||||
- Whether they define it |
||||
- How central it is to the event |
||||
- What relationship context it appears in |
||||
|
||||
## Query Pattern Examples |
||||
|
||||
### Finding Definitions (D-Tag Query) |
||||
```bash |
||||
# Find THE definition event for "knowledge-graphs" |
||||
nak req -k 30041 --tag d=knowledge-graphs |
||||
``` |
||||
**Result**: The specific event with d="knowledge-graphs" (if it exists) |
||||
|
||||
### Finding References (W-Tag Query) |
||||
```bash |
||||
# Find ALL events that mention "knowledge-graphs" |
||||
nak req -k 30041 --tag w=knowledge-graphs |
||||
``` |
||||
**Result**: Any content event containing `[[Knowledge Graphs]]` wikilinks |
||||
|
||||
## Analogy |
||||
|
||||
**D-Tag**: Like a book's ISBN - uniquely identifies and locates a specific work |
||||
|
||||
**W-Tag**: Like a book's index entries - shows where a term appears across many works |
||||
|
||||
## Implementation Notes |
||||
|
||||
From your codebase (`nkbip_converter.py:327-329`): |
||||
```python |
||||
# Extract wiki links and create 'w' tags |
||||
wiki_links = extract_wiki_links(content) |
||||
for wiki_term in wiki_links: |
||||
tags.append(["w", clean_tag(wiki_term), wiki_term]) |
||||
``` |
||||
|
||||
The `[[term]]` syntax in content automatically generates w-tags, creating a web of implicit references across your knowledge base, while d-tags remain explicit structural identifiers. |
||||
@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
|
||||
import { nip19 } from 'nostr-tools'; |
||||
import WebSocket from 'ws'; |
||||
|
||||
const naddr = 'naddr1qvzqqqr4tqpzphzv6zrv6l89kxpj4h60m5fpz2ycsrfv0c54hjcwdpxqrt8wwlqxqyd8wumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6qgmwaehxw309a6xsetrd96xzer9dshxummnw3erztnrdakszyrhwden5te0dehhxarj9ekxzmnyqyg8wumn8ghj7mn0wd68ytnhd9hx2qghwaehxw309ahx7um5wgh8xmmkvf5hgtngdaehgqg3waehxw309ahx7um5wgerztnrdakszxthwden5te0wpex7enfd3jhxtnwdaehgu339e3k7mgpz4mhxue69uhkzem8wghxummnw3ezumrpdejqzxrhwden5te0wfjkccte9ehx7umhdpjhyefwvdhk6qg5waehxw309aex2mrp0yhxgctdw4eju6t0qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpr9mhxue69uhkvun9v4kxz7fwwdhhvcnfwshxsmmnwsqrcctwv9exx6rfwd6xjcedddhx7amvv4jxwefdw35x2ttpwf6z6mmx946xs6twdd5kueedwa5hg6r0w46z6ur9wfkkjumnd9hkuwdu5na'; |
||||
|
||||
console.log('Decoding naddr...\n'); |
||||
const decoded = nip19.decode(naddr); |
||||
console.log('Decoded:', JSON.stringify(decoded, null, 2)); |
||||
|
||||
const { data } = decoded; |
||||
const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`; |
||||
console.log('\nRoot Address:', rootAddress); |
||||
|
||||
// Fetch the index event to see what sections it references
|
||||
const relay = 'wss://relay.nostr.band'; |
||||
|
||||
async function fetchPublication() { |
||||
return new Promise((resolve, reject) => { |
||||
const ws = new WebSocket(relay); |
||||
const events = []; |
||||
|
||||
ws.on('open', () => { |
||||
console.log(`\nConnected to ${relay}`); |
||||
console.log('Fetching index event...\n'); |
||||
|
||||
const filter = { |
||||
kinds: [data.kind], |
||||
authors: [data.pubkey], |
||||
'#d': [data.identifier], |
||||
}; |
||||
|
||||
const subscriptionId = `sub-${Date.now()}`; |
||||
ws.send(JSON.stringify(['REQ', subscriptionId, filter])); |
||||
}); |
||||
|
||||
ws.on('message', (message) => { |
||||
const [type, subId, event] = JSON.parse(message.toString()); |
||||
|
||||
if (type === 'EVENT') { |
||||
events.push(event); |
||||
console.log('Found index event:', event.id); |
||||
console.log('\nTags:'); |
||||
event.tags.forEach(tag => { |
||||
if (tag[0] === 'a') { |
||||
console.log(` Section address: ${tag[1]}`); |
||||
} |
||||
if (tag[0] === 'd') { |
||||
console.log(` D-tag: ${tag[1]}`); |
||||
} |
||||
if (tag[0] === 'title') { |
||||
console.log(` Title: ${tag[1]}`); |
||||
} |
||||
}); |
||||
} else if (type === 'EOSE') { |
||||
ws.close(); |
||||
resolve(events); |
||||
} |
||||
}); |
||||
|
||||
ws.on('error', reject); |
||||
|
||||
setTimeout(() => { |
||||
ws.close(); |
||||
resolve(events); |
||||
}, 5000); |
||||
}); |
||||
} |
||||
|
||||
fetchPublication() |
||||
.then(() => console.log('\nDone!')) |
||||
.catch(console.error); |
||||
@ -0,0 +1,249 @@
@@ -0,0 +1,249 @@
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'; |
||||
import WebSocket from 'ws'; |
||||
|
||||
// Test user keys (generate fresh ones)
|
||||
const testUserKey = generateSecretKey(); |
||||
const testUserPubkey = getPublicKey(testUserKey); |
||||
|
||||
const testUser2Key = generateSecretKey(); |
||||
const testUser2Pubkey = getPublicKey(testUser2Key); |
||||
|
||||
console.log('Test User 1 pubkey:', testUserPubkey); |
||||
console.log('Test User 2 pubkey:', testUser2Pubkey); |
||||
|
||||
// The publication details from the article (REAL VALUES)
|
||||
const publicationPubkey = 'dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06'; |
||||
const rootAddress = `30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`; |
||||
|
||||
// Section addresses (from the actual publication structure)
|
||||
const sections = [ |
||||
`30041:${publicationPubkey}:the-art-of-thinking-without-permission`, |
||||
`30041:${publicationPubkey}:the-natural-promiscuity-of-understanding`, |
||||
`30041:${publicationPubkey}:institutional-capture-and-knowledge-enclosure`, |
||||
`30041:${publicationPubkey}:the-persistent-escape-of-knowledge`, |
||||
]; |
||||
|
||||
// Relays to publish to (matching CommentLayer's relay list)
|
||||
const relays = [ |
||||
'wss://relay.damus.io', |
||||
'wss://relay.nostr.band', |
||||
'wss://nostr.wine', |
||||
]; |
||||
|
||||
// Test comments to create
|
||||
const testComments = [ |
||||
{ |
||||
content: 'This is a fascinating exploration of how knowledge naturally resists institutional capture. The analogy to flowing water is particularly apt.', |
||||
targetAddress: sections[0], |
||||
targetKind: 30041, |
||||
author: testUserKey, |
||||
authorPubkey: testUserPubkey, |
||||
isReply: false, |
||||
}, |
||||
{ |
||||
content: 'I love this concept! It reminds me of how open source projects naturally organize without top-down control.', |
||||
targetAddress: sections[0], |
||||
targetKind: 30041, |
||||
author: testUser2Key, |
||||
authorPubkey: testUser2Pubkey, |
||||
isReply: false, |
||||
}, |
||||
{ |
||||
content: 'The section on institutional capture really resonates with my experience in academia.', |
||||
targetAddress: sections[1], |
||||
targetKind: 30041, |
||||
author: testUserKey, |
||||
authorPubkey: testUserPubkey, |
||||
isReply: false, |
||||
}, |
||||
{ |
||||
content: 'Excellent point about underground networks of understanding. This is exactly how most practical knowledge develops.', |
||||
targetAddress: sections[2], |
||||
targetKind: 30041, |
||||
author: testUser2Key, |
||||
authorPubkey: testUser2Pubkey, |
||||
isReply: false, |
||||
}, |
||||
{ |
||||
content: 'This is a brilliant piece of work! Really captures the tension between institutional knowledge and living understanding.', |
||||
targetAddress: rootAddress, |
||||
targetKind: 30040, |
||||
author: testUserKey, |
||||
authorPubkey: testUserPubkey, |
||||
isReply: false, |
||||
}, |
||||
]; |
||||
|
||||
async function publishEvent(event, relayUrl) { |
||||
return new Promise((resolve, reject) => { |
||||
const ws = new WebSocket(relayUrl); |
||||
let published = false; |
||||
|
||||
ws.on('open', () => { |
||||
console.log(`Connected to ${relayUrl}`); |
||||
ws.send(JSON.stringify(['EVENT', event])); |
||||
}); |
||||
|
||||
ws.on('message', (data) => { |
||||
const message = JSON.parse(data.toString()); |
||||
if (message[0] === 'OK' && message[1] === event.id) { |
||||
if (message[2]) { |
||||
console.log(`✓ Published event ${event.id.substring(0, 8)} to ${relayUrl}`); |
||||
published = true; |
||||
ws.close(); |
||||
resolve(); |
||||
} else { |
||||
console.error(`✗ Relay rejected event: ${message[3]}`); |
||||
ws.close(); |
||||
reject(new Error(message[3])); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
ws.on('error', (error) => { |
||||
console.error(`WebSocket error: ${error.message}`); |
||||
reject(error); |
||||
}); |
||||
|
||||
ws.on('close', () => { |
||||
if (!published) { |
||||
reject(new Error('Connection closed before OK received')); |
||||
} |
||||
}); |
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => { |
||||
if (!published) { |
||||
ws.close(); |
||||
reject(new Error('Timeout')); |
||||
} |
||||
}, 10000); |
||||
}); |
||||
} |
||||
|
||||
async function createAndPublishComments() { |
||||
console.log('\n=== Creating Test Comments ===\n'); |
||||
|
||||
const publishedEvents = []; |
||||
|
||||
for (const comment of testComments) { |
||||
try { |
||||
// Create unsigned event
|
||||
const unsignedEvent = { |
||||
kind: 1111, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: [ |
||||
// Root scope - uppercase tags
|
||||
['A', comment.targetAddress, relays[0], publicationPubkey], |
||||
['K', comment.targetKind.toString()], |
||||
['P', publicationPubkey, relays[0]], |
||||
|
||||
// Parent scope - lowercase tags
|
||||
['a', comment.targetAddress, relays[0]], |
||||
['k', comment.targetKind.toString()], |
||||
['p', publicationPubkey, relays[0]], |
||||
], |
||||
content: comment.content, |
||||
pubkey: comment.authorPubkey, |
||||
}; |
||||
|
||||
// If this is a reply, add reply tags
|
||||
if (comment.isReply && comment.replyToId) { |
||||
unsignedEvent.tags.push(['e', comment.replyToId, relay, 'reply']); |
||||
unsignedEvent.tags.push(['p', comment.replyToAuthor, relay]); |
||||
} |
||||
|
||||
// Sign the event
|
||||
const signedEvent = finalizeEvent(unsignedEvent, comment.author); |
||||
|
||||
console.log(`\nCreating comment on ${comment.targetKind === 30040 ? 'collection' : 'section'}:`); |
||||
console.log(` Content: "${comment.content.substring(0, 60)}..."`); |
||||
console.log(` Target: ${comment.targetAddress}`); |
||||
console.log(` Event ID: ${signedEvent.id}`); |
||||
|
||||
// Publish to relay
|
||||
await publishEvent(signedEvent, relays[0]); |
||||
publishedEvents.push(signedEvent); |
||||
|
||||
// Store event ID for potential replies
|
||||
comment.eventId = signedEvent.id; |
||||
|
||||
// Delay between publishes to avoid rate limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 1500)); |
||||
|
||||
} catch (error) { |
||||
console.error(`Failed to publish comment: ${error.message}`); |
||||
} |
||||
} |
||||
|
||||
// Now create some threaded replies
|
||||
console.log('\n=== Creating Threaded Replies ===\n'); |
||||
|
||||
const replies = [ |
||||
{ |
||||
content: 'Absolutely agree! The metaphor extends even further when you consider how ideas naturally branch and merge.', |
||||
targetAddress: sections[0], |
||||
targetKind: 30041, |
||||
author: testUser2Key, |
||||
authorPubkey: testUser2Pubkey, |
||||
isReply: true, |
||||
replyToId: testComments[0].eventId, |
||||
replyToAuthor: testComments[0].authorPubkey, |
||||
}, |
||||
{ |
||||
content: 'Great connection! The parallel between open source governance and knowledge commons is really illuminating.', |
||||
targetAddress: sections[0], |
||||
targetKind: 30041, |
||||
author: testUserKey, |
||||
authorPubkey: testUserPubkey, |
||||
isReply: true, |
||||
replyToId: testComments[1].eventId, |
||||
replyToAuthor: testComments[1].authorPubkey, |
||||
}, |
||||
]; |
||||
|
||||
for (const reply of replies) { |
||||
try { |
||||
const unsignedEvent = { |
||||
kind: 1111, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: [ |
||||
// Root scope
|
||||
['A', reply.targetAddress, relays[0], publicationPubkey], |
||||
['K', reply.targetKind.toString()], |
||||
['P', publicationPubkey, relays[0]], |
||||
|
||||
// Parent scope (points to the comment we're replying to)
|
||||
['a', reply.targetAddress, relays[0]], |
||||
['k', reply.targetKind.toString()], |
||||
['p', reply.replyToAuthor, relays[0]], |
||||
|
||||
// Reply markers
|
||||
['e', reply.replyToId, relays[0], 'reply'], |
||||
], |
||||
content: reply.content, |
||||
pubkey: reply.authorPubkey, |
||||
}; |
||||
|
||||
const signedEvent = finalizeEvent(unsignedEvent, reply.author); |
||||
|
||||
console.log(`\nCreating reply:`); |
||||
console.log(` Content: "${reply.content.substring(0, 60)}..."`); |
||||
console.log(` Reply to: ${reply.replyToId.substring(0, 8)}`); |
||||
console.log(` Event ID: ${signedEvent.id}`); |
||||
|
||||
await publishEvent(signedEvent, relays[0]); |
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Longer delay to avoid rate limiting
|
||||
|
||||
} catch (error) { |
||||
console.error(`Failed to publish reply: ${error.message}`); |
||||
} |
||||
} |
||||
|
||||
console.log('\n=== Done! ==='); |
||||
console.log(`\nPublished ${publishedEvents.length + replies.length} total comments/replies`); |
||||
console.log('\nRefresh the page to see the comments in the Comment Panel.'); |
||||
} |
||||
|
||||
// Run it
|
||||
createAndPublishComments().catch(console.error); |
||||
@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'; |
||||
import WebSocket from 'ws'; |
||||
|
||||
// Test user keys (generate fresh ones)
|
||||
const testUserKey = generateSecretKey(); |
||||
const testUserPubkey = getPublicKey(testUserKey); |
||||
|
||||
const testUser2Key = generateSecretKey(); |
||||
const testUser2Pubkey = getPublicKey(testUser2Key); |
||||
|
||||
console.log('Test User 1 pubkey:', testUserPubkey); |
||||
console.log('Test User 2 pubkey:', testUser2Pubkey); |
||||
|
||||
// The publication details from the article (REAL VALUES)
|
||||
const publicationPubkey = 'dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06'; |
||||
const rootAddress = `30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`; |
||||
|
||||
// Section addresses (from the actual publication structure)
|
||||
const sections = [ |
||||
`30041:${publicationPubkey}:the-art-of-thinking-without-permission`, |
||||
`30041:${publicationPubkey}:the-natural-promiscuity-of-understanding`, |
||||
`30041:${publicationPubkey}:institutional-capture-and-knowledge-enclosure`, |
||||
`30041:${publicationPubkey}:the-persistent-escape-of-knowledge`, |
||||
]; |
||||
|
||||
// Relays to publish to (matching HighlightLayer's relay list)
|
||||
const relays = [ |
||||
'wss://relay.damus.io', |
||||
'wss://relay.nostr.band', |
||||
'wss://nostr.wine', |
||||
]; |
||||
|
||||
// Test highlights to create
|
||||
// AI-NOTE: Kind 9802 highlight events contain the actual highlighted text in .content
|
||||
// and optionally a user comment/annotation in the ["comment", ...] tag
|
||||
const testHighlights = [ |
||||
{ |
||||
highlightedText: 'Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice.', |
||||
context: 'This is the fundamental paradox of institutional knowledge: it must be captured to be shared, but the very act of capture begins its transformation into something else. Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice. The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.', |
||||
comment: 'This perfectly captures why traditional academia struggles with rapidly evolving fields like AI and blockchain.', |
||||
targetAddress: sections[0], |
||||
author: testUserKey, |
||||
authorPubkey: testUserPubkey, |
||||
}, |
||||
{ |
||||
highlightedText: 'The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.', |
||||
context: 'Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice. The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.', |
||||
comment: null, // Highlight without annotation
|
||||
targetAddress: sections[0], |
||||
author: testUser2Key, |
||||
authorPubkey: testUser2Pubkey, |
||||
}, |
||||
{ |
||||
highlightedText: 'Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas.', |
||||
context: 'The natural state of knowledge is not purity but promiscuity. Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas. It crosses boundaries not despite them but because of them. The most vibrant intellectual communities have always been those at crossroads and borderlands.', |
||||
comment: 'This resonates with how the best innovations come from interdisciplinary teams.', |
||||
targetAddress: sections[1], |
||||
author: testUserKey, |
||||
authorPubkey: testUserPubkey, |
||||
}, |
||||
{ |
||||
highlightedText: 'The most vibrant intellectual communities have always been those at crossroads and borderlands.', |
||||
context: 'Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas. It crosses boundaries not despite them but because of them. The most vibrant intellectual communities have always been those at crossroads and borderlands.', |
||||
comment: 'Historical examples: Renaissance Florence, Vienna Circle, Bell Labs', |
||||
targetAddress: sections[1], |
||||
author: testUser2Key, |
||||
authorPubkey: testUser2Pubkey, |
||||
}, |
||||
{ |
||||
highlightedText: 'institutions that try to monopolize understanding inevitably find themselves gatekeeping corpses', |
||||
context: 'But institutions that try to monopolize understanding inevitably find themselves gatekeeping corpses—the living knowledge has already escaped and is flourishing in unexpected places. By the time the gatekeepers notice, the game has moved.', |
||||
comment: null, |
||||
targetAddress: sections[2], |
||||
author: testUserKey, |
||||
authorPubkey: testUserPubkey, |
||||
}, |
||||
]; |
||||
|
||||
async function publishEvent(event, relayUrl) { |
||||
return new Promise((resolve, reject) => { |
||||
const ws = new WebSocket(relayUrl); |
||||
let published = false; |
||||
|
||||
ws.on('open', () => { |
||||
console.log(`Connected to ${relayUrl}`); |
||||
ws.send(JSON.stringify(['EVENT', event])); |
||||
}); |
||||
|
||||
ws.on('message', (data) => { |
||||
const message = JSON.parse(data.toString()); |
||||
if (message[0] === 'OK' && message[1] === event.id) { |
||||
if (message[2]) { |
||||
console.log(`✓ Published event ${event.id.substring(0, 8)} to ${relayUrl}`); |
||||
published = true; |
||||
ws.close(); |
||||
resolve(); |
||||
} else { |
||||
console.error(`✗ Relay rejected event: ${message[3]}`); |
||||
ws.close(); |
||||
reject(new Error(message[3])); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
ws.on('error', (error) => { |
||||
console.error(`WebSocket error: ${error.message}`); |
||||
reject(error); |
||||
}); |
||||
|
||||
ws.on('close', () => { |
||||
if (!published) { |
||||
reject(new Error('Connection closed before OK received')); |
||||
} |
||||
}); |
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => { |
||||
if (!published) { |
||||
ws.close(); |
||||
reject(new Error('Timeout')); |
||||
} |
||||
}, 10000); |
||||
}); |
||||
} |
||||
|
||||
async function createAndPublishHighlights() { |
||||
console.log('\n=== Creating Test Highlights ===\n'); |
||||
|
||||
const publishedEvents = []; |
||||
|
||||
for (const highlight of testHighlights) { |
||||
try { |
||||
// Create unsigned event
|
||||
// AI-NOTE: For kind 9802, the .content field contains the HIGHLIGHTED TEXT,
|
||||
// not a comment. User annotations go in the optional ["comment", ...] tag.
|
||||
const unsignedEvent = { |
||||
kind: 9802, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: [ |
||||
// Target section
|
||||
['a', highlight.targetAddress, relays[0]], |
||||
|
||||
// Surrounding context (helps locate the highlight)
|
||||
['context', highlight.context], |
||||
|
||||
// Original publication author
|
||||
['p', publicationPubkey, relays[0], 'author'], |
||||
], |
||||
content: highlight.highlightedText, // The actual highlighted text
|
||||
pubkey: highlight.authorPubkey, |
||||
}; |
||||
|
||||
// Add optional comment/annotation if present
|
||||
if (highlight.comment) { |
||||
unsignedEvent.tags.push(['comment', highlight.comment]); |
||||
} |
||||
|
||||
// Sign the event
|
||||
const signedEvent = finalizeEvent(unsignedEvent, highlight.author); |
||||
|
||||
console.log(`\nCreating highlight on section:`); |
||||
console.log(` Highlighted: "${highlight.highlightedText.substring(0, 60)}..."`); |
||||
if (highlight.comment) { |
||||
console.log(` Comment: "${highlight.comment.substring(0, 60)}..."`); |
||||
} |
||||
console.log(` Target: ${highlight.targetAddress}`); |
||||
console.log(` Event ID: ${signedEvent.id}`); |
||||
|
||||
// Publish to relay
|
||||
await publishEvent(signedEvent, relays[0]); |
||||
publishedEvents.push(signedEvent); |
||||
|
||||
// Delay between publishes to avoid rate limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 1500)); |
||||
|
||||
} catch (error) { |
||||
console.error(`Failed to publish highlight: ${error.message}`); |
||||
} |
||||
} |
||||
|
||||
console.log('\n=== Done! ==='); |
||||
console.log(`\nPublished ${publishedEvents.length} total highlights`); |
||||
console.log('\nRefresh the page to see the highlights.'); |
||||
console.log('Toggle "Show Highlights" to view them inline.'); |
||||
} |
||||
|
||||
// Run it
|
||||
createAndPublishHighlights().catch(console.error); |
||||
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
NIP-09 |
||||
====== |
||||
|
||||
Event Deletion Request |
||||
---------------------- |
||||
|
||||
`draft` `optional` |
||||
|
||||
A special event with kind `5`, meaning "deletion request" is defined as having a list of one or more `e` or `a` tags, each referencing an event the author is requesting to be deleted. Deletion requests SHOULD include a `k` tag for the kind of each event being requested for deletion. |
||||
|
||||
The event's `content` field MAY contain a text note describing the reason for the deletion request. |
||||
|
||||
For example: |
||||
|
||||
```jsonc |
||||
{ |
||||
"kind": 5, |
||||
"pubkey": <32-bytes hex-encoded public key of the event creator>, |
||||
"tags": [ |
||||
["e", "dcd59..464a2"], |
||||
["e", "968c5..ad7a4"], |
||||
["a", "<kind>:<pubkey>:<d-identifier>"], |
||||
["k", "1"], |
||||
["k", "30023"] |
||||
], |
||||
"content": "these posts were published by accident", |
||||
// other fields... |
||||
} |
||||
``` |
||||
|
||||
Relays SHOULD delete or stop publishing any referenced events that have an identical `pubkey` as the deletion request. Clients SHOULD hide or otherwise indicate a deletion request status for referenced events. |
||||
|
||||
Relays SHOULD continue to publish/share the deletion request events indefinitely, as clients may already have the event that's intended to be deleted. Additionally, clients SHOULD broadcast deletion request events to other relays which don't have it. |
||||
|
||||
When an `a` tag is used, relays SHOULD delete all versions of the replaceable event up to the `created_at` timestamp of the deletion request event. |
||||
|
||||
## Client Usage |
||||
|
||||
Clients MAY choose to fully hide any events that are referenced by valid deletion request events. This includes text notes, direct messages, or other yet-to-be defined event kinds. Alternatively, they MAY show the event along with an icon or other indication that the author has "disowned" the event. The `content` field MAY also be used to replace the deleted events' own content, although a user interface should clearly indicate that this is a deletion request reason, not the original content. |
||||
|
||||
A client MUST validate that each event `pubkey` referenced in the `e` tag of the deletion request is identical to the deletion request `pubkey`, before hiding or deleting any event. Relays can not, in general, perform this validation and should not be treated as authoritative. |
||||
|
||||
Clients display the deletion request event itself in any way they choose, e.g., not at all, or with a prominent notice. |
||||
|
||||
Clients MAY choose to inform the user that their request for deletion does not guarantee deletion because it is impossible to delete events from all relays and clients. |
||||
|
||||
## Relay Usage |
||||
|
||||
Relays MAY validate that a deletion request event only references events that have the same `pubkey` as the deletion request itself, however this is not required since relays may not have knowledge of all referenced events. |
||||
|
||||
## Deletion Request of a Deletion Request |
||||
|
||||
Publishing a deletion request event against a deletion request has no effect. Clients and relays are not obliged to support "unrequest deletion" functionality. |
||||