Skip to content

Book Sync Design

Design document for Nostr-based book announcements and sync.

Overview

SvelteReader extends its annotation-first sync to include book announcements: - Books can be "announced" to Nostr with metadata and cover image - Annotations reference their parent book announcement via NIP-01 a tag - Ghost books can be created from synced book announcements (metadata only, no EPUB) - Users can "complete" ghost books by uploading matching EPUBs (SHA-256 verified)

User Flow

1. Book Upload & Announcement

User uploads EPUB
Extract metadata (title, author, cover, SHA-256)
Show "Book Announcement" modal
  - Preview/edit title, author
  - Crop cover to ~128x192 (2:3 ratio, ~15KB base64)
  - Choose: "Publish" or "Local Only"
If "Publish":
  - Create addressable book event (kind 30801)
  - Set book.isPublic = true
  - All annotations for this book auto-publish to Nostr
If "Local Only":
  - No Nostr event created
  - book.isPublic = false
  - All annotations for this book stay local

2. Sync & Ghost Books

User clicks "Sync" button
Fetch user's book announcements (kind 30801)
Fetch user's annotations (kind 30800)
For each book announcement:
  - If SHA-256 matches local book → update metadata
  - If SHA-256 not found locally → create ghost book
Ghost book appears in library with:
  - Cover image (from announcement)
  - Title/author metadata
  - Ghost icon overlay
  - "..." menu with "Upload EPUB" option

3. Completing Ghost Books

User clicks "Upload EPUB" on ghost book
File picker opens
Compute SHA-256 of uploaded file
If SHA-256 matches ghost book:
  - Store EPUB data
  - Set hasEpubData = true
  - Book becomes fully readable
If SHA-256 doesn't match:
  - Show error: "This EPUB doesn't match the expected book"
  - Suggest: "Expected SHA: abc123..."

4. Editing Book Metadata

User opens book settings
Edit title, author, cover
If book.isPublic:
  - Republish book announcement event
  - Relays replace old event (addressable)
If book is local only:
  - Update local metadata only

Data Structures

Extended Book Type

interface Book extends BookIdentity, BookLocal {
  // Existing fields...

  // New Nostr sync fields
  isPublic?: boolean;              // Published to Nostr
  nostrEventId?: string;           // Book announcement event ID
  nostrCreatedAt?: number;         // Event created_at for LWW
  relays?: string[];               // Relay URLs where published
  syncPending?: boolean;           // Local changes not yet published
}

Book Announcement Event (kind 30801)

Addressable event for book metadata:

{
  "kind": 30801,
  "pubkey": "<user-pubkey>",
  "created_at": 1703100000,
  "tags": [
    ["d", "<sha256>"],                    // Book SHA-256 as unique identifier
    ["title", "The Great Gatsby"],
    ["author", "F. Scott Fitzgerald"],
    ["isbn", "978-0743273565"],           // Optional
    ["year", "1925"],                     // Optional
    ["image", "data:image/jpeg;base64,/9j/4AAQ..."],  // Cover image (data URL)
    ["r", "wss://relay1.example"],        // Relay hints
    ["r", "wss://relay2.example"]
  ],
  "content": "",                          // Empty or optional description
  "sig": "<signature>"
}

Why kind 30801? - Addressable range (30000-39999) - Adjacent to annotation kind 30800 - Unique per pubkey + kind + d-tag (SHA-256)

Cover Image Constraints: - Format: JPEG (best compression for photos) - Dimensions: ~128x192 pixels (2:3 aspect ratio, book cover standard) - Max size: ~20KB base64 encoded - Embedded as data URL per NIP-100 discussion

Updated Annotation Event (kind 30800)

Annotations now reference their book announcement:

{
  "kind": 30800,
  "pubkey": "<user-pubkey>",
  "created_at": 1703100000,
  "tags": [
    ["d", "<book-sha256>:<cfi-range>"],   // Composite unique identifier
    ["a", "30801:<pubkey>:<book-sha256>", "wss://relay.example"],  // Reference to book
    ["color", "yellow"],                   // Optional: highlight color
    ["r", "wss://relay1.example"],
    ["r", "wss://relay2.example"]
  ],
  "content": "{\"text\":\"selected text\",\"note\":\"user note\"}",
  "sig": "<signature>"
}

The a tag: - Format: ["a", "<kind>:<pubkey>:<d-tag>", "<relay-hint>"] - Links annotation to its book announcement - Enables clients to fetch book metadata for annotations - Per NIP-01 standard for addressable event references

Book Deletion (Tombstone)

{
  "kind": 30801,
  "pubkey": "<user-pubkey>",
  "created_at": 1703100001,
  "tags": [
    ["d", "<sha256>"]
  ],
  "content": "{\"deleted\":true}",
  "sig": "<signature>"
}

UI Components

1. Book Announcement Modal

New modal shown after EPUB upload:

┌─────────────────────────────────────┐
│  Announce Book                   ✕  │
├─────────────────────────────────────┤
│  ┌─────────┐                        │
│  │  Cover  │  Title: [___________]  │
│  │  Image  │  Author: [__________]  │
│  │ (crop)  │  Year: [____]          │
│  └─────────┘  ISBN: [___________]   │
│                                     │
│  ○ Publish to Nostr                 │
│    Annotations will be synced       │
│                                     │
│  ○ Local Only                       │
│    Keep book private                │
│                                     │
│  [Cancel]              [Save Book]  │
└─────────────────────────────────────┘

Cover Cropping: - Use a simple cropper (e.g., svelte-easy-crop or custom) - Fixed 2:3 aspect ratio - Output: 128x192 JPEG, quality ~0.7 - Show file size estimate

2. Extended BookCard Menu

For ghost books, add "Upload EPUB" option:

┌──────────────────┐
│ 📤 Upload EPUB   │  ← New option for ghost books
│ ───────────────  │
│ 🗑️ Delete...     │
└──────────────────┘

For published books, add "Edit Metadata" option:

┌──────────────────┐
│ ✏️ Edit Metadata │  ← Opens announcement modal
│ 🔄 Republish     │  ← Force republish
│ ───────────────  │
│ 🗑️ Delete...     │
└──────────────────┘

3. Enhanced Sync Status

Update SyncStatusButton popover:

┌─────────────────────────────────┐
│  Nostr Sync           Connected │
├─────────────────────────────────┤
│  Last sync: 5m ago              │
│  Books: 12 synced               │  ← New
│  Annotations: 47 synced         │
│  Ghost books: 2                 │  ← New
│                                 │
│  [Sync Now]                     │
└─────────────────────────────────┘

4. Settings Panel

The global settings panel now shows informational text about per-book sync:

┌─────────────────────────────────┐
│  Settings                       │
├─────────────────────────────────┤
│  ℹ️ Nostr Sync                   │
│                                 │
│  Annotation syncing is          │
│  controlled per-book. Choose    │
│  "Publish to Nostr" or "Local   │
│  Only" when importing a book.   │
│                                 │
│  Change via book card menu →    │
│  "Edit Metadata"                │
└─────────────────────────────────┘

Implementation Plan

Phase 1: Core Types & Services

  1. Update types (frontend/src/lib/types/index.ts)
  2. Add isPublic, nostrEventId, nostrCreatedAt, relays, syncPending to Book

  3. Create book Nostr types (frontend/src/lib/types/nostr.ts)

  4. Add BOOK_EVENT_KIND = 30801
  5. Add bookToEvent(), eventToBook() functions
  6. Add bookDeletionEvent() function

  7. Extend nostrService (frontend/src/lib/services/nostrService.ts)

  8. Add publishBook(), fetchBooks(), publishBookDeletion()

  9. Update annotation events

  10. Add a tag reference to book announcement in annotationToEvent()

Phase 2: Sync Store Updates

  1. Extend syncStore (frontend/src/lib/stores/sync.svelte.ts)
  2. Fetch both books and annotations
  3. Track book sync stats
  4. Create ghost books for unknown SHA-256s

  5. Update books store (frontend/src/lib/stores/books.ts)

  6. Add mergeFromNostr() for book sync
  7. Add setCyphertap() for publishing
  8. Add publishBook(), republishBook() methods

Phase 3: UI Components

  1. BookAnnouncementModal (new component)
  2. Cover cropper
  3. Metadata editor
  4. Publish/Local toggle

  5. Update BookCard

  6. Add "Upload EPUB" menu item for ghost books
  7. Add "Edit Metadata" menu item for published books

  8. Update SyncStatusButton

  9. Show book sync stats
  10. Show ghost book count

  11. Update ImportButton

  12. Trigger BookAnnouncementModal after EPUB processing

Phase 4: Ghost Book Completion

  1. EPUB upload for ghost books
  2. File picker in BookCard menu
  3. SHA-256 verification
  4. Error handling for mismatches

  5. Update library page

  6. Visual distinction for ghost books
  7. Upload prompt on ghost book click

Cover Image Processing

Cropping & Compression

async function processCoverImage(
  imageBlob: Blob,
  targetWidth: number = 128,
  targetHeight: number = 192
): Promise<string> {
  // 1. Load image
  const img = await createImageBitmap(imageBlob);

  // 2. Create canvas with target dimensions
  const canvas = document.createElement('canvas');
  canvas.width = targetWidth;
  canvas.height = targetHeight;
  const ctx = canvas.getContext('2d')!;

  // 3. Calculate crop (center crop to 2:3 ratio)
  const sourceRatio = img.width / img.height;
  const targetRatio = targetWidth / targetHeight;

  let sx = 0, sy = 0, sw = img.width, sh = img.height;
  if (sourceRatio > targetRatio) {
    // Source is wider - crop sides
    sw = img.height * targetRatio;
    sx = (img.width - sw) / 2;
  } else {
    // Source is taller - crop top/bottom
    sh = img.width / targetRatio;
    sy = (img.height - sh) / 2;
  }

  // 4. Draw cropped & scaled image
  ctx.drawImage(img, sx, sy, sw, sh, 0, 0, targetWidth, targetHeight);

  // 5. Export as JPEG data URL
  return canvas.toDataURL('image/jpeg', 0.7);
}

Size Estimation

  • 128x192 JPEG at quality 0.7 ≈ 5-15KB
  • Base64 encoding adds ~33% overhead
  • Final data URL: ~7-20KB
  • Well within Nostr event size limits (~64KB typical)

Conflict Resolution

Same as annotations: Last Write Wins (LWW) using created_at.

  • Book metadata updates replace previous versions
  • Relays keep only latest event per pubkey+kind+d-tag
  • Local changes marked syncPending until published

Migration

No migration needed - new fields are optional and additive.

Security Considerations

  1. Cover images: Validate MIME type, limit size before base64 encoding
  2. SHA-256 verification: Always verify uploaded EPUBs match expected hash
  3. Private books: isPublic: false books never publish events
  4. Relay selection: Use user's configured relays, not hardcoded

Future Enhancements

  1. Social discovery: Browse other users' book announcements
  2. Book recommendations: Based on shared annotations
  3. Reading groups: Shared book lists with friends
  4. EPUB sharing: Optional encrypted EPUB sharing (separate NIP)