From 4b9cfaf07e6db31f827f252c833368e59bbe519c Mon Sep 17 00:00:00 2001 From: noteser-agent Date: Fri, 12 Jun 2026 10:23:00 +0300 Subject: [PATCH] feat(collab): share-session link + vault-synced collabId (multi-user join) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two complementary ways for a second person to join the same live-collab room, where room = the note's stable collabId UUID. Feature A — share-session link (works for anyone, no shared repo): - A "Share" button in the status bar, visible only when collab is configured (getConfiguredUrl() non-null). It mints/reuses the note's collabId, copies https:///?collab=&title= to the clipboard, and toasts. The UUID room id is the only credential. - On load, ?collab=<id> opens the matching local note, or creates an EMPTY one seeded with that collabId (joiner receives content over the CRDT wire — no local seed), then strips the params via replaceState. Feature B — vault-synced collabId (repo-sharers auto-share the room): - serializeNote emits a `collabId:` YAML frontmatter block ONLY for notes that carry one; parseNote reads it back. Round-trip is lossless (no blank line after the closing ---). Sync-safety: only collab notes re-serialize, so normal notes see zero churn (a note that gains a collabId is a one-time clean metadata update). The two-SHA classifier treats "gained a collabId" as a clean remoteUpdated, never a conflict; canonicalLocalSha now threads the collabId so baselines match and a re-pull settles to `unchanged`. A remote collabId wins so collaborators converge (local id kept when remote has none). The autoMerged + conflict-resolution apply paths re-parse merged bytes so frontmatter never leaks into the body. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/__tests__/collabExtension.test.ts | 25 +++ src/__tests__/collabIdFrontmatter.test.ts | 193 ++++++++++++++++++++++ src/__tests__/collabShare.test.ts | 131 +++++++++++++++ src/app/page.tsx | 32 ++++ src/components/editor/EditorFooter.tsx | 50 +++++- src/utils/backgroundFill.ts | 18 +- src/utils/collabShare.ts | 45 +++++ src/utils/githubSync/internal.ts | 48 +++++- src/utils/githubSync/syncClassify.ts | 12 +- src/utils/githubSync/syncPull.ts | 34 +++- src/utils/syncApply.ts | 52 ++++-- 11 files changed, 614 insertions(+), 26 deletions(-) create mode 100644 src/__tests__/collabIdFrontmatter.test.ts create mode 100644 src/__tests__/collabShare.test.ts create mode 100644 src/utils/collabShare.ts diff --git a/src/__tests__/collabExtension.test.ts b/src/__tests__/collabExtension.test.ts index 1f9095fd..0a8afa90 100644 --- a/src/__tests__/collabExtension.test.ts +++ b/src/__tests__/collabExtension.test.ts @@ -156,6 +156,31 @@ describe('createCollabBinding', () => { binding.destroy() }) + test('JOINER: empty initialContent is never seeded, even after sync', () => { + // A share-link joiner opens an EMPTY local note (content ''). It must + // receive the room's content over the wire and NEVER push its own empty + // body into the shared doc — otherwise two joiners could blank the room. + const binding = createCollabBinding({ + url: 'wss://x', + room: 'shared-room', + initialContent: '', + user: null, + providerFactory: fakeFactory, + }) + // Another client's content arrives, then we reach sync. + binding.ytext.insert(0, 'real shared content') + FakeProvider.last!.fireSync(true) + expect(binding.ytext.toString()).toBe('real shared content') + // Even a sync against a still-empty room leaves it empty (nothing to seed). + const empty = createCollabBinding({ + url: 'wss://x', room: 'fresh', initialContent: '', user: null, providerFactory: fakeFactory, + }) + FakeProvider.last!.fireSync(true) + expect(empty.ytext.toString()).toBe('') + binding.destroy() + empty.destroy() + }) + test('SEED-ON-EMPTY: does nothing on a not-yet-synced event', () => { const binding = createCollabBinding({ url: 'wss://x', diff --git a/src/__tests__/collabIdFrontmatter.test.ts b/src/__tests__/collabIdFrontmatter.test.ts new file mode 100644 index 00000000..80b31b74 --- /dev/null +++ b/src/__tests__/collabIdFrontmatter.test.ts @@ -0,0 +1,193 @@ +/** + * @jest-environment node + * + * collabIdFrontmatter.test.ts + * + * Feature B (vault-synced collabId) sync-safety guards: + * + * 1. serializeNote emits a `collabId:` frontmatter block ONLY for notes that + * carry one, and parseNote reads it back — a lossless round-trip with and + * without tags also present. + * 2. A note that differs ONLY by gaining a collabId classifies as a clean + * `remoteUpdated`, never a `conflict` — and after apply a re-pull settles + * to `unchanged` (no churn). + * 3. Remote collabId convergence: when local + remote hold DIFFERENT room ids + * but identical bodies, the repo's id wins without a content conflict. + * + * Strategy mirrors githubSyncRoundtrip.test.ts: node env (real crypto.subtle), + * REAL gitBlobSha / serializeNote, only the github.ts network surface mocked. + */ + +jest.mock('idb-keyval', () => ({ + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + del: jest.fn().mockResolvedValue(undefined), + keys: jest.fn().mockResolvedValue([]), +})) + +jest.mock('../utils/attachments', () => ({ + isAttachmentPath: () => false, + listAttachmentPaths: async () => [], + getAttachmentBlob: async () => null, + getAttachmentGitSha: async () => null, + getAttachmentTombstones: async () => [], + clearAttachmentTombstones: async () => undefined, + putAttachmentAtPath: async () => undefined, +})) + +const mockGetBranchRefSha = jest.fn() +const mockGetCommitTreeSha = jest.fn() +const mockGetTreeMap = jest.fn() +const mockGetBlobContent = jest.fn() + +jest.mock('../utils/github', () => { + const actual = jest.requireActual('../utils/github') + return { + ...actual, + getBranchRefSha: (...a: unknown[]) => mockGetBranchRefSha(...a), + getCommitTreeSha: (...a: unknown[]) => mockGetCommitTreeSha(...a), + getTreeMap: (...a: unknown[]) => mockGetTreeMap(...a), + getBlobContent: (...a: unknown[]) => mockGetBlobContent(...a), + // gitBlobSha stays REAL. + } +}) + +import { pullFromGitHub, serializeNote, parseNote } from '../utils/githubSync' +import { applyNonConflicts } from '../utils/syncApply' +import { gitBlobSha } from '../utils/github' +import { useNoteStore } from '../stores/noteStore' +import type { Note, SyncRepo } from '@/types' + +const REPO: SyncRepo = { owner: 'me', name: 'vault', branch: 'main', isPrivate: false } + +beforeEach(async () => { + jest.clearAllMocks() + mockGetBranchRefSha.mockResolvedValue('headsha') + mockGetCommitTreeSha.mockResolvedValue('treesha') + useNoteStore.setState({ notes: [], selectedNoteId: null }) + const { useSettingsStore } = await import('../stores/settingsStore') + useSettingsStore.setState({ localGitignoreOverlay: '' }) +}) + +// ── 1. serialize/parse round-trip ─────────────────────────────────────────── + +describe('serializeNote / parseNote collabId round-trip', () => { + test('a note WITHOUT a collabId serializes body-only (no frontmatter)', () => { + const out = serializeNote({ content: 'Hello world' } as Note) + expect(out).toBe('Hello world\n') + expect(out.startsWith('---')).toBe(false) + }) + + test('a note WITH a collabId emits a collabId frontmatter block (no blank line)', () => { + const out = serializeNote({ content: 'Hello world', collabId: 'room-1' } as Note) + expect(out).toBe('---\ncollabId: room-1\n---\nHello world\n') + const parsed = parseNote(out) + expect(parsed.collabId).toBe('room-1') + expect(parsed.body).toBe('Hello world\n') + expect(parsed.tags).toEqual([]) + }) + + test('round-trip is lossless: re-serializing the parsed body reproduces identical bytes', () => { + const original = serializeNote({ content: 'A\nB\nC', collabId: 'abc-123' } as Note) + const parsed = parseNote(original) + const reserialized = serializeNote({ content: parsed.body, collabId: parsed.collabId } as Note) + expect(reserialized).toBe(original) + }) + + test('collabId coexists with a tags frontmatter line (Obsidian-authored file)', () => { + const raw = '---\ncollabId: room-7\ntags: [alpha, "beta gamma"]\n---\nBody text\n' + const parsed = parseNote(raw) + expect(parsed.collabId).toBe('room-7') + expect(parsed.tags).toEqual(['alpha', 'beta gamma']) + expect(parsed.body).toBe('Body text\n') + }) + + test('an empty-body collab note round-trips', () => { + const out = serializeNote({ content: '', collabId: 'room-9' } as Note) + expect(out).toBe('---\ncollabId: room-9\n---\n') + expect(parseNote(out).collabId).toBe('room-9') + expect(parseNote(out).body).toBe('') + }) +}) + +// ── 2. gaining a collabId is a clean update, not a conflict ────────────────── + +test('a note that differs ONLY by gaining a collabId classifies as remoteUpdated (not conflict) and re-pulls unchanged', async () => { + // Seed a synced body-only note (no collabId). + const rawOriginal = 'Hello\n' + const originalSha = await gitBlobSha(rawOriginal) + mockGetTreeMap.mockResolvedValue(new Map([['Note.md', originalSha]])) + mockGetBlobContent.mockResolvedValue(rawOriginal) + const first = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] }) + await applyNonConflicts(first.classifications) + const noteId = useNoteStore.getState().notes[0].id + expect(useNoteStore.getState().notes[0].collabId).toBeUndefined() + + // Remote gains a collabId frontmatter (a collaborator shared + pushed it). + const rawWithCollab = '---\ncollabId: shared-room\n---\nHello\n' + const collabSha = await gitBlobSha(rawWithCollab) + mockGetTreeMap.mockResolvedValue(new Map([['Note.md', collabSha]])) + mockGetBlobContent.mockImplementation(async (_t, _o, _n, sha: string) => + sha === collabSha ? rawWithCollab : rawOriginal, + ) + + const second = await pullFromGitHub({ + token: 't', repo: REPO, notes: useNoteStore.getState().notes, folders: [], + }) + expect(second.classifications).toHaveLength(1) + expect(second.classifications[0]).toMatchObject({ + kind: 'remoteUpdated', noteId, collabId: 'shared-room', + }) + + // Apply → the note adopts the room id, body stays clean (no frontmatter leak). + await applyNonConflicts(second.classifications) + const after = useNoteStore.getState().notes[0] + expect(after.collabId).toBe('shared-room') + expect(after.content).toBe('Hello\n') + + // Third pull: nothing changed → unchanged (round-trip lossless, no churn). + mockGetTreeMap.mockResolvedValue(new Map([['Note.md', collabSha]])) + mockGetBlobContent.mockResolvedValue(rawWithCollab) + const third = await pullFromGitHub({ + token: 't', repo: REPO, notes: useNoteStore.getState().notes, folders: [], + }) + expect(third.classifications).toEqual([{ kind: 'unchanged', noteId }]) +}) + +// ── 3. remote collabId convergence (different ids, same body) ──────────────── + +test('when local and remote hold DIFFERENT collabIds but identical bodies, the remote id wins without a conflict', async () => { + // Seed a synced body-only note, then mint a LOCAL collabId that was never + // pushed (so localChanged will be true on the next pull). + const rawOriginal = 'Same body\n' + const originalSha = await gitBlobSha(rawOriginal) + mockGetTreeMap.mockResolvedValue(new Map([['Note.md', originalSha]])) + mockGetBlobContent.mockResolvedValue(rawOriginal) + const first = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] }) + await applyNonConflicts(first.classifications) + const noteId = useNoteStore.getState().notes[0].id + const localRoom = useNoteStore.getState().ensureCollabId(noteId) + expect(localRoom).toBeTruthy() + + // Remote independently gained a DIFFERENT collabId (same body). + const rawRemote = '---\ncollabId: remote-room\n---\nSame body\n' + const remoteSha = await gitBlobSha(rawRemote) + mockGetTreeMap.mockResolvedValue(new Map([['Note.md', remoteSha]])) + mockGetBlobContent.mockImplementation(async (_t, _o, _n, sha: string) => + sha === remoteSha ? rawRemote : rawOriginal, + ) + + const second = await pullFromGitHub({ + token: 't', repo: REPO, notes: useNoteStore.getState().notes, folders: [], + }) + // Bodies match, only the metadata differs → clean remoteUpdated, NOT conflict. + expect(second.classifications).toHaveLength(1) + expect(second.classifications[0]).toMatchObject({ + kind: 'remoteUpdated', noteId, collabId: 'remote-room', + }) + + await applyNonConflicts(second.classifications) + // Repo's id wins so collaborators converge. + expect(useNoteStore.getState().notes[0].collabId).toBe('remote-room') + expect(useNoteStore.getState().notes[0].content).toBe('Same body\n') +}) diff --git a/src/__tests__/collabShare.test.ts b/src/__tests__/collabShare.test.ts new file mode 100644 index 00000000..5e438a70 --- /dev/null +++ b/src/__tests__/collabShare.test.ts @@ -0,0 +1,131 @@ +/** + * @jest-environment jsdom + * + * Feature A (share-session link) unit tests: + * - buildCollabShareLink produces the right origin + collabId + encoded title. + * - parseCollabParam reads `?collab` / `&title` back, tolerating absence. + * - The join decision (create-or-open) seeds a joiner's note EMPTY so the + * collab binding never seeds local content over the wire. + */ + +const idb = new Map<string, unknown>() +jest.mock('idb-keyval', () => ({ + get: jest.fn((key: string) => Promise.resolve(idb.get(key))), + set: jest.fn((key: string, value: unknown) => { idb.set(key, value); return Promise.resolve() }), + del: jest.fn((key: string) => { idb.delete(key); return Promise.resolve() }), + keys: jest.fn(() => Promise.resolve([...idb.keys()])), +})) + +import { buildCollabShareLink, parseCollabParam } from '../utils/collabShare' +import { useNoteStore } from '../stores/noteStore' + +beforeEach(() => { + idb.clear() + useNoteStore.setState({ notes: [], selectedNoteId: null }) +}) + +describe('buildCollabShareLink', () => { + test('encodes origin + collabId + url-encoded title', () => { + const link = buildCollabShareLink('https://noteser.app', 'room-abc', 'My Note: Draft') + const url = new URL(link) + expect(url.origin).toBe('https://noteser.app') + expect(url.pathname).toBe('/') + expect(url.searchParams.get('collab')).toBe('room-abc') + expect(url.searchParams.get('title')).toBe('My Note: Draft') + }) + + test('omits the title param entirely when blank', () => { + expect(buildCollabShareLink('https://noteser.app', 'room-abc')).toBe( + 'https://noteser.app/?collab=room-abc', + ) + expect(buildCollabShareLink('https://noteser.app', 'room-abc', ' ')).toBe( + 'https://noteser.app/?collab=room-abc', + ) + }) + + test('tolerates a trailing slash on the origin', () => { + expect(buildCollabShareLink('https://noteser.app/', 'r1')).toBe( + 'https://noteser.app/?collab=r1', + ) + }) + + test('collab comes before title in the query string', () => { + const link = buildCollabShareLink('https://x.dev', 'r1', 'Hello') + expect(link).toBe('https://x.dev/?collab=r1&title=Hello') + }) +}) + +describe('parseCollabParam', () => { + test('parses collab + title', () => { + expect(parseCollabParam('?collab=room-1&title=Hello%20World')).toEqual({ + collabId: 'room-1', title: 'Hello World', + }) + }) + + test('parses collab with no title', () => { + expect(parseCollabParam('?collab=room-1')).toEqual({ collabId: 'room-1', title: null }) + }) + + test('blank title becomes null', () => { + expect(parseCollabParam('?collab=room-1&title=')).toEqual({ collabId: 'room-1', title: null }) + }) + + test('returns null when no collab param is present', () => { + expect(parseCollabParam('')).toBeNull() + expect(parseCollabParam('?import=xyz')).toBeNull() + }) + + test('round-trips a built link', () => { + const link = buildCollabShareLink('https://noteser.app', 'room-xyz', 'A Title') + const search = new URL(link).search + expect(parseCollabParam(search)).toEqual({ collabId: 'room-xyz', title: 'A Title' }) + }) +}) + +// The create-or-open decision the page join effect performs. Modelled directly +// against the real note store so we pin the contract the effect relies on. +describe('join-collab create-or-open decision', () => { + function join(search: string): string | null { + const parsed = parseCollabParam(search) + if (!parsed) return null + const existing = useNoteStore.getState().notes.find( + n => n.collabId === parsed.collabId && !n.isDeleted, + ) + if (existing) return existing.id + return useNoteStore.getState().addNote({ + title: parsed.title || 'Shared note', + folderId: null, + content: '', + collabId: parsed.collabId, + }).id + } + + test('creates an EMPTY note seeded with the collabId + title (joiner does NOT seed content)', () => { + const id = join('?collab=room-join&title=Team%20Doc') + const note = useNoteStore.getState().notes.find(n => n.id === id) + expect(note).toBeDefined() + expect(note!.collabId).toBe('room-join') + expect(note!.title).toBe('Team Doc') + // EMPTY body: the joiner receives the room's content over the CRDT wire, so + // createCollabBinding's seed-on-empty (which only fires for non-empty local + // content) must never run on their side. + expect(note!.content).toBe('') + }) + + test('opens the existing note when one already carries that collabId (no duplicate)', () => { + const created = useNoteStore.getState().addNote({ + title: 'Existing', content: 'keep me', collabId: 'room-dup', + }) + const id = join('?collab=room-dup') + expect(id).toBe(created.id) + // No second note was created. + expect(useNoteStore.getState().notes.filter(n => n.collabId === 'room-dup')).toHaveLength(1) + // Its content is untouched. + expect(useNoteStore.getState().notes.find(n => n.id === id)!.content).toBe('keep me') + }) + + test('falls back to a default title when the link carries none', () => { + const id = join('?collab=room-untitled') + expect(useNoteStore.getState().notes.find(n => n.id === id)!.title).toBe('Shared note') + }) +}) diff --git a/src/app/page.tsx b/src/app/page.tsx index 7af4cac3..7ed822a2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -320,6 +320,38 @@ export default function Home() { })() }, [hydrated]) + // Join-collab-session (Feature A): when the URL has `?collab=<id>` open (or + // create) the local note bound to that room. If a note with this collabId + // already exists we just open it; otherwise we materialise an EMPTY local + // note seeded with the collabId (+ the title from the link, if any) and let + // the live-collab binding pull the room's current content over the wire — we + // deliberately do NOT seed any local body for a joiner, so the seed-on-empty + // logic in collabExtension never fires on their side. The params are stripped + // afterwards so a refresh does not re-trigger the join. + useEffect(() => { + if (!hydrated) return + if (typeof window === 'undefined') return + const open = async () => { + const { parseCollabParam } = await import('@/utils/collabShare') + const parsed = parseCollabParam(window.location.search) + if (!parsed) return + const existing = useNoteStore.getState().notes.find( + n => n.collabId === parsed.collabId && !n.isDeleted, + ) + const noteId = existing + ? existing.id + : useNoteStore.getState().addNote({ + title: parsed.title || 'Shared note', + folderId: null, + content: '', + collabId: parsed.collabId, + }).id + useWorkspaceStore.getState().openNote(noteId, { preview: false }) + window.history.replaceState({}, '', window.location.pathname) + } + void open() + }, [hydrated]) + // Migrate old data on first load. Async-yielding migration so a // legacy vault with hundreds of notes does not block first paint on // iOS (the watchdog kills any task held longer than its window). diff --git a/src/components/editor/EditorFooter.tsx b/src/components/editor/EditorFooter.tsx index dc4d02c8..12c34fb9 100644 --- a/src/components/editor/EditorFooter.tsx +++ b/src/components/editor/EditorFooter.tsx @@ -1,12 +1,14 @@ 'use client' import { useMemo } from 'react' -import { ArrowPathIcon, FireIcon, SignalIcon, SignalSlashIcon } from '@heroicons/react/24/outline' +import { ArrowPathIcon, FireIcon, SignalIcon, SignalSlashIcon, ShareIcon } from '@heroicons/react/24/outline' import { extractTags } from '@/utils/tags' import { useGitHubStore, useNoteStore, useUIStore, useSettingsStore, useFolderStore, useWorkspaceStore } from '@/stores' +import { useToastStore } from '@/stores/toastStore' import { classifyPendingChanges, totalPendingCount } from '@/utils/syncChanges' import { computeStreakFromDateStrings, dailyDateSet } from '@/utils/dailyStreak' -import { useCollaboration } from '@/hooks/useCollaboration' +import { useCollaboration, getConfiguredUrl } from '@/hooks/useCollaboration' +import { buildCollabShareLink } from '@/utils/collabShare' import { useHydration } from '@/hooks' // App-wide status bar — ONE slim strip across the bottom of the window @@ -125,6 +127,7 @@ export const EditorFooter = () => { <div className="flex items-center gap-4 shrink-0"> {hydrated && ( <> + <ShareCollabButton noteId={activeNoteId} /> <CollabPill /> {streak.length >= 2 && ( <span @@ -153,6 +156,49 @@ export const EditorFooter = () => { ) } +// Feature A — "Share" affordance. Visible ONLY when live collaboration is +// configured (getConfiguredUrl() non-null) AND a note is open. Clicking it +// mints (or reuses) the note's stable collabId, copies a share link to the +// clipboard, and confirms with a toast. Anyone who opens that link joins the +// same room and can edit the note live — the UUID room id is the only +// credential, so nothing else is leaked. +function ShareCollabButton({ noteId }: { noteId: string | null }) { + const url = getConfiguredUrl() + if (!url || !noteId) return null + + const onShare = async () => { + const collabId = useNoteStore.getState().ensureCollabId(noteId) + if (!collabId) return + const note = useNoteStore.getState().notes.find(n => n.id === noteId) + const origin = typeof window !== 'undefined' ? window.location.origin : '' + const link = buildCollabShareLink(origin, collabId, note?.title) + try { + await navigator.clipboard.writeText(link) + } catch { + // Clipboard API unavailable (insecure context / older browser) — fall + // back to a prompt so the user can still copy the link by hand. + window.prompt('Copy this collaboration link:', link) + } + useToastStore.getState().addToast({ + kind: 'success', + message: 'Collaboration link copied. Anyone with the link can edit this note live.', + }) + } + + return ( + <button + type="button" + onClick={onShare} + className="flex items-center gap-1 hover:text-obsidianText transition-colors" + title="Copy a live-collaboration link for this note. Anyone with the link can edit it live." + data-testid="status-bar-collab-share" + > + <ShareIcon className="w-3 h-3" /> + <span>Share</span> + </button> + ) +} + // Tiny presence pill — shows the live-collab WebSocket health when // NEXT_PUBLIC_YJS_WS_URL is configured. Hidden when collab is off so // the footer stays uncluttered for the default single-user case. diff --git a/src/utils/backgroundFill.ts b/src/utils/backgroundFill.ts index dfb8dae7..6b80caa8 100644 --- a/src/utils/backgroundFill.ts +++ b/src/utils/backgroundFill.ts @@ -30,8 +30,8 @@ import { withTokenRefresh } from './tokenRefresh' // widening that module's surface. serializeNote/normalizeForPush is the exact // canonicaliser the push path uses, so the SHA matches a clean re-push and the // next pull reads the note as `unchanged`. -function canonicalLocalSha(content: string): Promise<string> { - return gitBlobSha(serializeNote({ content } as Note)) +function canonicalLocalSha(content: string, collabId?: string): Promise<string> { + return gitBlobSha(serializeNote({ content, collabId } as Note)) } // Decrypt a raw remote blob on read. Pass-through when unencrypted. Throws @@ -99,6 +99,10 @@ async function loadOneShell( const content = await maybeDecrypt(raw) const parsed = parseNote(content) const body = bodyWithInlineTags(parsed.body, parsed.tags) + // Feature B: a shell whose remote file carries a collabId adopts it as its + // room id when the body streams in, so a cloned-vault note joins the same + // live-collab room. Undefined for the common (non-collab) note. + const collabId = parsed.collabId // Re-read inside the patch: the user may have started editing the shell // between the fetch starting and landing (the on-open path lets them type @@ -112,11 +116,17 @@ async function loadOneShell( // every freshly-cloned note look "modified" in the pending-changes count // (the "530 pending" right after a clone). The note is in sync with remote // (gitLastPushedSha = canonical of the loaded body), so updatedAt stays as-is. - const loadedSha = await canonicalLocalSha(body) + const loadedSha = await canonicalLocalSha(body, collabId) useNoteStore.setState(state => ({ notes: state.notes.map(n => n.id === noteId - ? { ...n, content: body, gitLastPushedSha: loadedSha, contentLoaded: true } + ? { + ...n, + content: body, + ...(collabId ? { collabId } : {}), + gitLastPushedSha: loadedSha, + contentLoaded: true, + } : n, ), })) diff --git a/src/utils/collabShare.ts b/src/utils/collabShare.ts new file mode 100644 index 00000000..9b7d25d7 --- /dev/null +++ b/src/utils/collabShare.ts @@ -0,0 +1,45 @@ +// Feature A — share-session links for live collaboration. +// +// A share link encodes the note's stable collab room id (its `collabId`, an +// unguessable UUID) plus an optional human-readable title into a normal URL: +// +// https://<origin>/?collab=<collabId>&title=<url-encoded title> +// +// Anyone who opens the link joins the SAME y-websocket room and can edit the +// note live. No shared GitHub repo is required — the room id IS the credential. +// Because the id is a v4 UUID it is not enumerable; the link grants edit access +// to that one room and leaks nothing else. + +export interface CollabParam { + collabId: string + // The optional note title carried in the link, so a fresh joiner can seed a + // sensible title before the CRDT content arrives. Null when absent/blank. + title: string | null +} + +// Build the shareable URL. `origin` is normally `window.location.origin` +// (e.g. "https://noteser.app"); passed in so the helper stays pure + testable. +// A trailing slash on the origin is tolerated. The title is omitted entirely +// when blank so we never emit a dangling `&title=`. +export function buildCollabShareLink( + origin: string, + collabId: string, + title?: string | null, +): string { + const base = origin.replace(/\/+$/, '') + const params = new URLSearchParams() + params.set('collab', collabId) + if (title && title.trim() !== '') params.set('title', title) + return `${base}/?${params.toString()}` +} + +// Parse a `?collab=…&title=…` query string (e.g. `window.location.search`). +// Returns null when no `collab` param is present, so the caller can cheaply +// short-circuit on a normal load. +export function parseCollabParam(search: string): CollabParam | null { + const params = new URLSearchParams(search) + const collabId = params.get('collab') + if (!collabId) return null + const title = params.get('title') + return { collabId, title: title && title.trim() !== '' ? title : null } +} diff --git a/src/utils/githubSync/internal.ts b/src/utils/githubSync/internal.ts index a9ea4180..d9cabcfb 100644 --- a/src/utils/githubSync/internal.ts +++ b/src/utils/githubSync/internal.ts @@ -183,9 +183,14 @@ export function guessMimeFromPath(path: string): string { } // ── Note serialization ────────────────────────────────────────────────────── -// We write the body verbatim — no YAML frontmatter. Tags now live as `#word` -// patterns inline in the body, so there's nothing to round-trip in a header. -// Round-trip identity uses the file path (Phase 4 pull matches by gitPath). +// We write the body verbatim. Tags live as `#word` patterns inline in the body, +// so there is nothing to round-trip in a header FOR TAGS. The ONLY frontmatter +// key noteser ever emits is `collabId` — the stable live-collaboration room id — +// and ONLY for notes that actually carry one (a note that has entered a collab +// session via ensureCollabId / a Share link). A note without a collabId +// serializes byte-for-byte as before (body only, no frontmatter), so adding this +// feature causes ZERO churn for the overwhelming majority of notes; only a note +// that gained a collabId re-serializes — a one-time clean metadata update. // // IMPORTANT: we normalise to LF line endings + a single trailing newline so // our blob SHA matches what Obsidian (and most editors that follow the POSIX @@ -193,8 +198,17 @@ export function guessMimeFromPath(path: string): string { // this, every Obsidian-side save would re-touch the file and noteser would see // the trailing-newline difference as drift, re-uploading every blob on each // sync (the storm bug). See `normalizeForPush` for the canonical form. +// +// We deliberately emit NO blank line between the closing `---` and the body so +// the round-trip is lossless: parseNote returns exactly the body bytes back, and +// re-serializing them (after normalizeForPush) reproduces identical bytes — no +// phantom leading-blank-line drift that would re-churn the blob. export function serializeNote(note: Note): string { - return normalizeForPush(note.content ?? '') + const body = normalizeForPush(note.content ?? '') + const cid = note.collabId + if (!cid) return body + const fm = `---\ncollabId: ${cid}\n---\n` + return body === '' ? fm : `${fm}${body}` } // Canonical wire form: CRLF → LF, ensure exactly one trailing \n, drop a @@ -238,6 +252,11 @@ export interface ParsedNote { tags: string[] aliases: string[] body: string + // Stable live-collaboration room id, parsed from a `collabId: <uuid>` line in + // the frontmatter when present. Undefined for the common case (no collab + // frontmatter). Threaded back onto the local note so two clients syncing the + // same vault repo converge on the same room (Feature B). + collabId?: string } // Parse a single-line YAML inline-array field (e.g. `tags: [a, "b", c]`) out @@ -269,6 +288,24 @@ function parseInlineArrayField(fmBlock: string, fieldName: string): string[] { return out } +// Parse a single-line scalar YAML field (e.g. `collabId: 1234-…`) out of the +// given frontmatter block. Returns undefined when the field is absent or empty. +// Strips a single layer of matching surrounding quotes so `collabId: "x"` and +// `collabId: x` both round-trip. +function parseScalarField(fmBlock: string, fieldName: string): string | undefined { + const re = new RegExp(`^${fieldName}:\\s*(.+?)\\s*$`, 'm') + const m = fmBlock.match(re) + if (!m) return undefined + let v = m[1].trim() + if ( + v.length >= 2 && + ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) + ) { + v = v.slice(1, -1) + } + return v === '' ? undefined : v +} + export function parseNote(raw: string): ParsedNote { // No frontmatter — everything is body. if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) { @@ -284,7 +321,8 @@ export function parseNote(raw: string): ParsedNote { const tags = parseInlineArrayField(fmBlock, 'tags') const aliases = parseInlineArrayField(fmBlock, 'aliases') - return { tags, aliases, body } + const collabId = parseScalarField(fmBlock, 'collabId') + return { tags, aliases, body, collabId } } // ── Common sync return shapes ─────────────────────────────────────────────── diff --git a/src/utils/githubSync/syncClassify.ts b/src/utils/githubSync/syncClassify.ts index f13067a4..74eda06e 100644 --- a/src/utils/githubSync/syncClassify.ts +++ b/src/utils/githubSync/syncClassify.ts @@ -29,10 +29,12 @@ export type PullClassification = // then creates a SHELL note (content '', contentLoaded false) so the sidebar // populates instantly; the body streams in afterwards. `shell` is absent/false // for an incremental pull's remoteCreated, which carries the real body as today. - | { kind: 'remoteCreated'; path: string; remoteSha: string; remoteContent: string; tags: string[]; body: string; shell?: boolean } + | { kind: 'remoteCreated'; path: string; remoteSha: string; remoteContent: string; tags: string[]; body: string; shell?: boolean; collabId?: string } // Local exists, remote changed since our last push, local has NOT changed - // since last sync — accept the remote version. - | { kind: 'remoteUpdated'; noteId: string; remoteSha: string; remoteContent: string; tags: string[]; body: string; adoptPath?: string } + // since last sync — accept the remote version. `collabId` carries the room id + // parsed from the remote frontmatter (Feature B): apply adopts it so two + // clients syncing the same vault converge on the same live-collab room. + | { kind: 'remoteUpdated'; noteId: string; remoteSha: string; remoteContent: string; tags: string[]; body: string; adoptPath?: string; collabId?: string } // We previously pushed this note, but the file is gone from the repo and // we haven't edited it locally since — soft-delete it locally. | { kind: 'remoteDeleted'; noteId: string } @@ -47,6 +49,10 @@ export type PullClassification = remoteTags: string[] remoteBody: string adoptPath?: string + // Room id parsed from the remote frontmatter (Feature B). When the user + // resolves the conflict in favour of remote we adopt this so collaborators + // converge on the same live-collab room. + remoteCollabId?: string } // Both sides changed but the line-level edits don't overlap, so we 3-way // merged automatically. Apply writes the merged content + pins diff --git a/src/utils/githubSync/syncPull.ts b/src/utils/githubSync/syncPull.ts index 623a547c..fe8e9e0e 100644 --- a/src/utils/githubSync/syncPull.ts +++ b/src/utils/githubSync/syncPull.ts @@ -39,9 +39,20 @@ import { parseNote, guessMimeFromPath, isForeignVaultFile, + isUnchangedModuloNormalization, } from './internal' import type { PullClassification, PullOutcome } from './syncClassify' +// Order-insensitive equality for two tag lists. Used by the collabId-only +// convergence guard so a note whose bodies match but whose tag sets differ is +// NOT silently overwritten (it falls through to the merge/conflict path). +function sameTagSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false + const sa = [...a].sort() + const sb = [...b].sort() + return sa.every((v, i) => v === sb[i]) +} + export async function pullFromGitHub(input: { token: string repo: SyncRepo @@ -307,7 +318,7 @@ export async function pullFromGitHub(input: { } const content = await loadRemote() const parsed = parseNote(content) - out.push({ kind: 'remoteCreated', path, remoteSha, remoteContent: content, tags: parsed.tags, body: parsed.body }) + out.push({ kind: 'remoteCreated', path, remoteSha, remoteContent: content, tags: parsed.tags, body: parsed.body, collabId: parsed.collabId }) continue } @@ -350,11 +361,28 @@ export async function pullFromGitHub(input: { } else if (remoteChanged && !localChanged) { const content = await loadRemote() const parsed = parseNote(content) - out.push({ kind: 'remoteUpdated', noteId: localMatch.id, remoteSha, remoteContent: content, tags: parsed.tags, body: parsed.body, ...(adoptPath ? { adoptPath } : {}) }) + out.push({ kind: 'remoteUpdated', noteId: localMatch.id, remoteSha, remoteContent: content, tags: parsed.tags, body: parsed.body, collabId: parsed.collabId, ...(adoptPath ? { adoptPath } : {}) }) } else if (remoteChanged && localChanged) { const content = await loadRemote() const parsed = parseNote(content) + // collabId-only convergence (Feature B sync-safety): if the local and + // remote BODIES (modulo line-ending/trailing-newline normalization) AND + // tag sets are identical, the ONLY thing that differs is the collabId + // frontmatter metadata — NOT user content. That is not a real conflict: + // take the remote version so the repo's collabId wins and collaborators + // converge on one room, WITHOUT prompting a content-conflict tab. The + // body-equality guard means this can never fire when the user actually + // edited text (bodies would differ → fall through to merge/conflict). + const localParsed = parseNote(localContent) + if ( + isUnchangedModuloNormalization(localParsed.body, parsed.body) && + sameTagSet(localParsed.tags, parsed.tags) + ) { + out.push({ kind: 'remoteUpdated', noteId: localMatch.id, remoteSha, remoteContent: content, tags: parsed.tags, body: parsed.body, collabId: parsed.collabId, ...(adoptPath ? { adoptPath } : {}) }) + continue + } + // Try a line-level 3-way merge before bothering the user. If the local // and remote edits don't overlap line-wise we can auto-merge and the // user never sees the conflict tab. The common ancestor is the REMOTE @@ -393,6 +421,7 @@ export async function pullFromGitHub(input: { remoteContent: content, remoteTags: parsed.tags, remoteBody: parsed.body, + remoteCollabId: parsed.collabId, ...(adoptPath ? { adoptPath } : {}), }) } @@ -739,6 +768,7 @@ export async function pullFromZipball(input: { remoteContent: content, tags: parsed.tags, body: parsed.body, + collabId: parsed.collabId, }) continue } diff --git a/src/utils/syncApply.ts b/src/utils/syncApply.ts index 33ba4cf1..d3c1af02 100644 --- a/src/utils/syncApply.ts +++ b/src/utils/syncApply.ts @@ -63,8 +63,14 @@ export interface ApplyCounts { // the push path uses, so the SHA matches what a clean re-push would produce and // the next pull classifies the untouched note as `unchanged`. See the // two-SHA-split fix in src/types/index.ts (Note.gitRemoteBaseSha). -function canonicalLocalSha(content: string): Promise<string> { - return gitBlobSha(serializeNote({ content } as Note)) +// `collabId` MUST be threaded in when the stored note will carry one: a note +// with a collabId serializes WITH a `collabId:` frontmatter block, so its +// canonical SHA differs from the body-only form. Omitting it here would pin a +// baseline that never matches serializeNote(note) → the next pull would read a +// phantom local edit and re-push every sync (churn). Undefined collabId keeps +// the exact body-only behaviour for the overwhelming majority of notes. +function canonicalLocalSha(content: string, collabId?: string): Promise<string> { + return gitBlobSha(serializeNote({ content, collabId } as Note)) } export async function applyNonConflicts(classifications: PullClassification[]): Promise<ApplyCounts> { @@ -236,10 +242,14 @@ export async function applyNonConflicts(classifications: PullClassification[]): content, folderId, gitPath: c.path, + // Feature B: adopt the room id parsed from the remote frontmatter (if + // any) so a client cloning the same vault joins the same live-collab + // room. Undefined for the common (non-collab) note. + ...(c.collabId ? { collabId: c.collabId } : {}), // localChanged baseline: SHA of the canonical LOCAL bytes we just - // stored (transformed body), so an untouched note round-trips to - // `unchanged` on the next pull. - gitLastPushedSha: await canonicalLocalSha(content), + // stored (transformed body + collabId frontmatter, if present), so an + // untouched note round-trips to `unchanged` on the next pull. + gitLastPushedSha: await canonicalLocalSha(content, c.collabId), // Merge ancestor: the actual remote blob SHA, fetchable via // getBlobContent. Distinct from gitLastPushedSha for frontmatter notes. gitRemoteBaseSha: c.remoteSha, @@ -263,10 +273,15 @@ export async function applyNonConflicts(classifications: PullClassification[]): const existing = byId.get(c.noteId) if (!existing) continue const content = bodyWithInlineTags(c.body, c.tags) + // Feature B: the repo's collabId wins so collaborators converge. When the + // remote carries NO collabId we KEEP the local one (a stable id we may + // have already shared) rather than clobbering it with undefined. + const collabId = c.collabId ?? existing.collabId byId.set(c.noteId, { ...existing, content, - gitLastPushedSha: await canonicalLocalSha(content), + ...(collabId ? { collabId } : {}), + gitLastPushedSha: await canonicalLocalSha(content, collabId), gitRemoteBaseSha: c.remoteSha, // pull-dedupe-by-path: link gitPath for a reconciled unlinked note. ...(c.adoptPath ? { gitPath: c.adoptPath } : {}), @@ -279,14 +294,23 @@ export async function applyNonConflicts(classifications: PullClassification[]): if (c.kind === 'autoMerged') { const existing = byId.get(c.noteId) if (!existing) continue + // The merged bytes are in the RAW FILE form (the 3-way merge ran on + // serialized content), so they may carry a collabId frontmatter block — + // re-parse to strip it back out of the body and adopt the room id, exactly + // like the manual-merge path (applyMergedConflict). For a body-only note + // (the norm) parseNote is a no-op pass-through, so behaviour is unchanged. + const parsed = parseNote(c.mergedContent) + const content = bodyWithInlineTags(parsed.body, parsed.tags) + const collabId = parsed.collabId ?? existing.collabId byId.set(c.noteId, { ...existing, - content: c.mergedContent, + content, + ...(collabId ? { collabId } : {}), // The merged bytes are the new local content; pin the baseline to // their canonical SHA. The remote base stays the remote SHA we merged // against — the next push will upload the union edit and re-coincide // the two SHAs. - gitLastPushedSha: await canonicalLocalSha(c.mergedContent), + gitLastPushedSha: await canonicalLocalSha(content, collabId), gitRemoteBaseSha: c.remoteSha, // pull-dedupe-by-path: link gitPath for a reconciled unlinked note. ...(c.adoptPath ? { gitPath: c.adoptPath } : {}), @@ -340,12 +364,16 @@ export function applyMergedConflict( c: Extract<PullClassification, { kind: 'conflict' }>, mergedRawFile: string, ): void { - const { updateNote } = useNoteStore.getState() + const { updateNote, getNoteById } = useNoteStore.getState() // The diff was on the raw file content (possibly with legacy frontmatter). - // Re-parse to strip any tags block; merge those tags into the body. + // Re-parse to strip any tags block; merge those tags into the body. A + // `collabId:` block is parsed back out here too (Feature B), so a resolved + // merge keeps the note's body clean and adopts the room id from the file. const parsed = parseNote(mergedRawFile) + const collabId = parsed.collabId ?? getNoteById(c.noteId)?.collabId updateNote(c.noteId, { content: bodyWithInlineTags(parsed.body, parsed.tags), + ...(collabId ? { collabId } : {}), gitLastPushedSha: c.remoteSha, gitRemoteBaseSha: c.remoteSha, // pull-dedupe-by-path: link gitPath for a reconciled unlinked note that @@ -377,8 +405,12 @@ export function applyConflictResolution( // its gitPath on resolution regardless of which side the user picks. const adopt = c.adoptPath ? { gitPath: c.adoptPath } : {} if (choice === 'remote') { + // Feature B: adopt the remote room id (repo wins); fall back to the + // existing local one when the remote frontmatter carried none. + const collabId = c.remoteCollabId ?? useNoteStore.getState().getNoteById(c.noteId)?.collabId updateNote(c.noteId, { content: bodyWithInlineTags(c.remoteBody, c.remoteTags), + ...(collabId ? { collabId } : {}), gitLastPushedSha: c.remoteSha, gitRemoteBaseSha: c.remoteSha, ...adopt,