diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 8b07e970..75fb8f27 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -52,7 +52,7 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; -import { getGoogleDocsConnectionStatus, importGoogleDoc, listGoogleDocs, refreshGoogleDocSnapshot, syncLinkedGoogleDocFromMarkdown } from '@x/core/dist/knowledge/google_docs.js'; +import { getGoogleDocsConnectionStatus, importGoogleDoc, listGoogleDocs, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; @@ -822,10 +822,13 @@ export function setupIpcHandlers() { return importGoogleDoc(args.fileId, args.targetFolder); }, 'google-docs:refreshSnapshot': async (_event, args) => { - return refreshGoogleDocSnapshot(args.path); + return syncGoogleDocDown(args.path); }, 'google-docs:sync': async (_event, args) => { - return syncLinkedGoogleDocFromMarkdown(args.path, args.markdown, { force: args.force }); + return syncGoogleDocUp(args.path, { force: args.force }); + }, + 'google-docs:getLink': async (_event, args) => { + return { link: await getGoogleDocLink(args.path) }; }, // Search handler 'search:query': async (_event, args) => { diff --git a/apps/x/apps/renderer/src/components/docx-file-viewer.tsx b/apps/x/apps/renderer/src/components/docx-file-viewer.tsx index 415ae4a0..55acc6c1 100644 --- a/apps/x/apps/renderer/src/components/docx-file-viewer.tsx +++ b/apps/x/apps/renderer/src/components/docx-file-viewer.tsx @@ -1,6 +1,14 @@ -import { Suspense, lazy, useEffect, useRef, useState } from 'react' -import { ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react' +import { Suspense, lazy, useCallback, useEffect, useRef, useState } from 'react' +import { + CloudDownloadIcon, + ExternalLinkIcon, + FileTextIcon, + Loader2Icon, + UploadCloudIcon, +} from 'lucide-react' +import { toast } from 'sonner' import type { DocxEditorRef } from '@eigenpal/docx-editor-react' +import { formatRelativeTime } from '@/lib/relative-time' // The editor (and its CSS) is heavy and only needed when a .docx is open, so it // loads in its own chunk the first time a Word document is viewed. @@ -16,6 +24,14 @@ interface DocxFileViewerProps { path: string } +type GoogleDocLink = { + id: string + url: string + title: string + syncedAt: string + remoteModifiedTime?: string +} + type LoadState = 'loading' | 'ready' | 'error' type SaveState = 'idle' | 'saving' | 'saved' | 'error' @@ -51,6 +67,9 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) { const [loadState, setLoadState] = useState('loading') const [buffer, setBuffer] = useState(null) const [saveState, setSaveState] = useState('idle') + const [reloadNonce, setReloadNonce] = useState(0) + const [link, setLink] = useState(null) + const [syncing, setSyncing] = useState<'up' | 'down' | null>(null) const editorRef = useRef(null) const saveTimerRef = useRef | null>(null) @@ -59,7 +78,7 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) { const dirtyRef = useRef(false) const savingRef = useRef(false) - // Load the .docx bytes whenever the path changes. + // Load the .docx bytes whenever the path changes or a sync-down reloads it. useEffect(() => { let cancelled = false setLoadState('loading') @@ -87,10 +106,20 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) { cancelled = true if (armTimerRef.current) clearTimeout(armTimerRef.current) } + }, [path, reloadNonce]) + + // Is this file linked to a Google Doc? Drives the sync bar. + useEffect(() => { + let cancelled = false + setLink(null) + void window.ipc.invoke('google-docs:getLink', { path }) + .then((res) => { if (!cancelled) setLink(res.link) }) + .catch((err) => { console.error('Failed to read Google Doc link:', err) }) + return () => { cancelled = true } }, [path]) // Serialize the current document and write it back to disk. - const persist = async () => { + const persist = useCallback(async () => { const editor = editorRef.current if (!editor || savingRef.current) return savingRef.current = true @@ -115,7 +144,8 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) { // A change landed while we were saving — flush it. if (dirtyRef.current) scheduleSave() } - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [path]) const scheduleSave = () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) @@ -137,6 +167,66 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [path]) + // Write any pending edits to disk before a sync-up so we push the latest. + const flushPendingSave = useCallback(async () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + if (dirtyRef.current || savingRef.current) { + await persist() + } + }, [persist]) + + const handleSyncDown = useCallback(async () => { + if (syncing) return + setSyncing('down') + try { + await window.ipc.invoke('google-docs:refreshSnapshot', { path }) + // Reload the freshly-written bytes into the editor. + armedRef.current = false + dirtyRef.current = false + setReloadNonce((n) => n + 1) + const res = await window.ipc.invoke('google-docs:getLink', { path }) + setLink(res.link) + toast.success('Pulled latest from Google Docs') + } catch (err) { + console.error('Sync down failed:', err) + toast.error(err instanceof Error ? err.message : 'Failed to pull from Google Docs') + } finally { + setSyncing(null) + } + }, [path, syncing]) + + const handleSyncUp = useCallback(async () => { + if (syncing) return + setSyncing('up') + try { + await flushPendingSave() + let result = await window.ipc.invoke('google-docs:sync', { path }) + if (result.conflict) { + const overwrite = window.confirm( + 'This Google Doc changed since your last sync.\n\n' + + 'Overwrite it with your local version? Cancel to keep the remote copy ' + + '(use “Sync down” to pull it first).', + ) + if (!overwrite) { + toast.info('Sync up cancelled — remote Google Doc is unchanged') + return + } + result = await window.ipc.invoke('google-docs:sync', { path, force: true }) + } + if (!result.synced) { + throw new Error(result.error || 'This file is not linked to a Google Doc.') + } + const res = await window.ipc.invoke('google-docs:getLink', { path }) + setLink(res.link) + toast.success('Pushed changes to Google Docs') + } catch (err) { + console.error('Sync up failed:', err) + toast.error(err instanceof Error ? err.message : 'Failed to push to Google Docs') + } finally { + setSyncing(null) + } + }, [path, syncing, flushPendingSave]) + if (loadState === 'error') { return (
@@ -155,37 +245,75 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) { ) } - if (loadState === 'loading' || !buffer) { - return ( -
- -

Loading document…

-
- ) - } - return (
- - -

Loading editor…

+ {link && ( +
+ + {link.title} + + {syncing + ? syncing === 'up' ? 'Syncing up…' : 'Syncing down…' + : `Synced ${formatRelativeTime(link.syncedAt)}`} + +
+ + +
- } - > - { console.error('docx editor error:', err) }} - className="flex-1 min-h-0" - /> - +
+ )} + + {loadState === 'loading' || !buffer ? ( +
+ +

Loading document…

+
+ ) : ( + + +

Loading editor…

+
+ } + > + { console.error('docx editor error:', err) }} + className="flex-1 min-h-0" + /> + + )} {saveState !== 'idle' && (
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : 'Save failed'} @@ -194,3 +322,13 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
) } + +function GoogleDocsIcon({ className }: { className?: string }) { + return ( + + ) +} diff --git a/apps/x/packages/core/src/knowledge/google_docs.test.ts b/apps/x/packages/core/src/knowledge/google_docs.test.ts index 24ee2fbf..0c6e96ab 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.test.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.test.ts @@ -1,28 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /** - * Phase 1 — read-path fidelity. + * Google Docs ⇄ local .docx round-trip. * - * Google Docs are pulled in as Markdown (text/markdown export), not flattened - * to text/plain, so headings / bold / lists / links survive into the local - * note. Import and sync-down also record the Drive `modifiedTime` in - * frontmatter so a later sync-up can detect remote edits. + * Import exports the Doc as a Word document (full fidelity) and registers the + * link in a hidden JSON registry (a .docx can't carry frontmatter). Sync down + * re-exports and overwrites the file; sync up uploads the local .docx back into + * the same Google Doc, guarded against clobbering remote edits. */ -const MARKDOWN_SNAPSHOT = [ - '# Title', - '', - 'Some **bold** and a [link](https://example.com).', - '', - '- one', - '- two', -].join('\n'); +const REGISTRY_ABS = '/ws/knowledge/.assets/google-docs/links.json'; -// In-memory capture of the most recent writeFile. -let written: { path: string; content: string } | null = null; -let readFileContent = ''; -let exportCalls: Array<{ fileId: string; mimeType: string }> = []; -let batchUpdateCalls: Array<{ documentId: string; requests: unknown[] }> = []; +// Virtual filesystem: absolute path → contents. +let vfs: Map; +let exportCalls: Array<{ fileId: string; mimeType: string }>; +let updateCalls: Array<{ fileId: string }>; const driveFile = { id: 'doc-123', @@ -32,24 +24,38 @@ const driveFile = { owners: [{ displayName: 'Arjun', emailAddress: 'arjun@example.com' }], }; +const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; +const docxBytes = () => new TextEncoder().encode('DOCX_BYTES').buffer; + +function seedRegistry(entries: Record) { + vfs.set(REGISTRY_ABS, JSON.stringify(entries)); +} + +function readRegistry(): Record> { + const raw = vfs.get(REGISTRY_ABS); + return raw ? JSON.parse(raw as string) : {}; +} + beforeEach(() => { vi.resetModules(); - written = null; + vfs = new Map(); exportCalls = []; - batchUpdateCalls = []; + updateCalls = []; vi.doMock('node:fs/promises', () => ({ default: { - readFile: vi.fn(async () => readFileContent), - writeFile: vi.fn(async (path: string, content: string) => { written = { path, content }; }), + readFile: vi.fn(async (p: string) => { + if (!vfs.has(p)) throw new Error(`ENOENT: ${p}`); + return vfs.get(p); + }), + writeFile: vi.fn(async (p: string, data: string | Buffer) => { vfs.set(p, data); }), mkdir: vi.fn(async () => undefined), - access: vi.fn(async () => { throw new Error('ENOENT'); }), + access: vi.fn(async (p: string) => { if (!vfs.has(p)) throw new Error(`ENOENT: ${p}`); }), }, })); - vi.doMock('../config/config.js', () => ({ WorkDir: '/ws' })); vi.doMock('../workspace/workspace.js', () => ({ - resolveWorkspacePath: (rel: string) => `/ws/${rel}`, + resolveWorkspacePath: (rel: string) => `/ws/${rel.replace(/\\/g, '/')}`, })); vi.doMock('./google-client-factory.js', () => ({ @@ -68,145 +74,122 @@ beforeEach(() => { get: vi.fn(async () => ({ data: driveFile })), export: vi.fn(async (params: { fileId: string; mimeType: string }) => { exportCalls.push({ fileId: params.fileId, mimeType: params.mimeType }); - return { data: MARKDOWN_SNAPSHOT }; + return { data: docxBytes() }; }), list: vi.fn(async () => ({ data: { files: [driveFile] } })), - }, - }; - - const docsClient = { - documents: { - get: vi.fn(async () => ({ data: { body: { content: [{ endIndex: 12 }] } } })), - batchUpdate: vi.fn(async (params: { documentId: string; requestBody: { requests: unknown[] } }) => { - batchUpdateCalls.push({ documentId: params.documentId, requests: params.requestBody.requests }); + update: vi.fn(async (params: { fileId: string }) => { + updateCalls.push({ fileId: params.fileId }); return { data: {} }; }), }, }; vi.doMock('googleapis', () => ({ - google: { - drive: vi.fn(() => driveClient), - docs: vi.fn(() => docsClient), - }, + google: { drive: vi.fn(() => driveClient), docs: vi.fn(() => ({})) }, })); }); -function linkedMarkdown(remoteModifiedTime: string, body = '# Title\n\nhello **world**'): string { - return [ - '---', - 'source:', - ' - google-doc', - 'google_doc:', - ' id: "doc-123"', - ' url: "https://docs.google.com/document/d/doc-123/edit"', - ' title: "My Doc"', - ' syncedAt: "2026-05-20T00:00:00.000Z"', - ` remoteModifiedTime: ${JSON.stringify(remoteModifiedTime)}`, - '---', - '', - body, - '', - ].join('\n'); -} - -afterEach(() => { - vi.clearAllMocks(); -}); +afterEach(() => { vi.clearAllMocks(); }); describe('importGoogleDoc', () => { - it('exports as Markdown (not plain text) and keeps the formatting in the note body', async () => { + it('exports a .docx, writes it to the folder, and registers the link', async () => { const { importGoogleDoc } = await import('./google_docs.js'); const result = await importGoogleDoc('doc-123', 'knowledge'); - expect(exportCalls).toEqual([{ fileId: 'doc-123', mimeType: 'text/markdown' }]); - expect(result.path).toBe('knowledge/My Doc.md'); - expect(written).not.toBeNull(); + expect(exportCalls).toEqual([{ fileId: 'doc-123', mimeType: DOCX_MIME }]); + expect(result.path).toBe('knowledge/My Doc.docx'); - const content = written!.content; - // Markdown structure survives the import. - expect(content).toContain('# Title'); - expect(content).toContain('**bold**'); - expect(content).toContain('[link](https://example.com)'); - expect(content).toContain('- one'); - }); + // The .docx bytes landed on disk. + expect(vfs.has('/ws/knowledge/My Doc.docx')).toBe(true); + expect(Buffer.isBuffer(vfs.get('/ws/knowledge/My Doc.docx'))).toBe(true); - it('records the Drive modifiedTime in frontmatter for conflict detection', async () => { - const { importGoogleDoc } = await import('./google_docs.js'); - await importGoogleDoc('doc-123', 'knowledge'); - - expect(written!.content).toContain('remoteModifiedTime: "2026-05-28T10:00:00.000Z"'); - expect(written!.content).toContain('id: "doc-123"'); + // The link was recorded with the remote revision for conflict detection. + const link = readRegistry()['knowledge/My Doc.docx']; + expect(link).toMatchObject({ + id: 'doc-123', + title: 'My Doc', + remoteModifiedTime: '2026-05-28T10:00:00.000Z', + }); }); }); -describe('refreshGoogleDocSnapshot (sync down)', () => { - it('re-exports Markdown and refreshes remoteModifiedTime while preserving the link', async () => { - readFileContent = [ - '---', - 'source:', - ' - google-doc', - 'google_doc:', - ' id: "doc-123"', - ' url: "https://docs.google.com/document/d/doc-123/edit"', - ' title: "My Doc"', - ' syncedAt: "2026-05-20T00:00:00.000Z"', - ' remoteModifiedTime: "2026-05-20T00:00:00.000Z"', - '---', - '', - 'old body', - '', - ].join('\n'); +describe('getGoogleDocLink', () => { + it('returns the registered link, or null for an unlinked file', async () => { + seedRegistry({ + 'knowledge/My Doc.docx': { id: 'doc-123', url: 'u', title: 'My Doc', syncedAt: 's' }, + }); + const { getGoogleDocLink } = await import('./google_docs.js'); + expect(await getGoogleDocLink('knowledge/My Doc.docx')).toMatchObject({ id: 'doc-123' }); + expect(await getGoogleDocLink('knowledge/Other.docx')).toBeNull(); + }); +}); - const { refreshGoogleDocSnapshot } = await import('./google_docs.js'); - const result = await refreshGoogleDocSnapshot('knowledge/My Doc.md'); +describe('syncGoogleDocDown', () => { + it('re-exports the .docx and refreshes the stored revision', async () => { + seedRegistry({ + 'knowledge/My Doc.docx': { + id: 'doc-123', url: 'u', title: 'My Doc', + syncedAt: '2026-05-20T00:00:00.000Z', remoteModifiedTime: '2026-05-20T00:00:00.000Z', + }, + }); + vfs.set('/ws/knowledge/My Doc.docx', Buffer.from('OLD')); + + const { syncGoogleDocDown } = await import('./google_docs.js'); + const result = await syncGoogleDocDown('knowledge/My Doc.docx'); expect(result.ok).toBe(true); - expect(exportCalls).toEqual([{ fileId: 'doc-123', mimeType: 'text/markdown' }]); - // Body replaced with the fresh Markdown export. - expect(written!.content).toContain('# Title'); - expect(written!.content).not.toContain('old body'); - // modifiedTime advanced to the remote value. - expect(written!.content).toContain('remoteModifiedTime: "2026-05-28T10:00:00.000Z"'); + expect(exportCalls).toEqual([{ fileId: 'doc-123', mimeType: DOCX_MIME }]); + // File overwritten with fresh export, revision advanced. + expect((vfs.get('/ws/knowledge/My Doc.docx') as Buffer).toString()).toBe('DOCX_BYTES'); + expect(readRegistry()['knowledge/My Doc.docx'].remoteModifiedTime).toBe('2026-05-28T10:00:00.000Z'); }); }); -describe('syncLinkedGoogleDocFromMarkdown (sync up)', () => { +describe('syncGoogleDocUp', () => { + beforeEach(() => { vfs.set('/ws/knowledge/My Doc.docx', Buffer.from('LOCAL EDITS')); }); + it('blocks the push when the doc changed remotely since the last sync', async () => { - // Stored baseline is older than the doc's current modifiedTime (2026-05-28). - const markdown = linkedMarkdown('2026-05-20T00:00:00.000Z'); - const { syncLinkedGoogleDocFromMarkdown } = await import('./google_docs.js'); - const result = await syncLinkedGoogleDocFromMarkdown('knowledge/My Doc.md', markdown); + seedRegistry({ + 'knowledge/My Doc.docx': { + id: 'doc-123', url: 'u', title: 'My Doc', + syncedAt: '2026-05-20T00:00:00.000Z', remoteModifiedTime: '2026-05-20T00:00:00.000Z', + }, + }); + const { syncGoogleDocUp } = await import('./google_docs.js'); + const result = await syncGoogleDocUp('knowledge/My Doc.docx'); expect(result.synced).toBe(false); expect(result.conflict).toBe(true); - expect(batchUpdateCalls).toHaveLength(0); // remote was not touched + expect(updateCalls).toHaveLength(0); }); - it('overwrites on force even when the remote is ahead', async () => { - const markdown = linkedMarkdown('2026-05-20T00:00:00.000Z'); - const { syncLinkedGoogleDocFromMarkdown } = await import('./google_docs.js'); - const result = await syncLinkedGoogleDocFromMarkdown('knowledge/My Doc.md', markdown, { force: true }); + it('overwrites on force, uploading the local .docx back to the Google Doc', async () => { + seedRegistry({ + 'knowledge/My Doc.docx': { + id: 'doc-123', url: 'u', title: 'My Doc', + syncedAt: '2026-05-20T00:00:00.000Z', remoteModifiedTime: '2026-05-20T00:00:00.000Z', + }, + }); + const { syncGoogleDocUp } = await import('./google_docs.js'); + const result = await syncGoogleDocUp('knowledge/My Doc.docx', { force: true }); expect(result.synced).toBe(true); - expect(batchUpdateCalls).toHaveLength(1); + expect(updateCalls).toEqual([{ fileId: 'doc-123' }]); + expect(readRegistry()['knowledge/My Doc.docx'].remoteModifiedTime).toBe('2026-05-28T10:00:00.000Z'); }); - it('pushes structure-preserving requests and refreshes the stored revision', async () => { - // Baseline matches the remote, so there is no conflict. - const markdown = linkedMarkdown('2026-05-28T10:00:00.000Z'); - const { syncLinkedGoogleDocFromMarkdown } = await import('./google_docs.js'); - const result = await syncLinkedGoogleDocFromMarkdown('knowledge/My Doc.md', markdown); + it('pushes straight through when the baseline matches the remote', async () => { + seedRegistry({ + 'knowledge/My Doc.docx': { + id: 'doc-123', url: 'u', title: 'My Doc', + syncedAt: '2026-05-28T10:00:00.000Z', remoteModifiedTime: '2026-05-28T10:00:00.000Z', + }, + }); + const { syncGoogleDocUp } = await import('./google_docs.js'); + const result = await syncGoogleDocUp('knowledge/My Doc.docx'); expect(result.synced).toBe(true); - expect(batchUpdateCalls).toHaveLength(1); - const requests = batchUpdateCalls[0].requests as Array>; - // Old content cleared, then a heading style applied (structure, not flat text). - expect(requests.some((r) => 'deleteContentRange' in r)).toBe(true); - expect(requests.some((r) => 'updateParagraphStyle' in r)).toBe(true); - expect(requests.some((r) => 'updateTextStyle' in r)).toBe(true); - // Local note's baseline is bumped to the post-push revision. - expect(written!.content).toContain('remoteModifiedTime: "2026-05-28T10:00:00.000Z"'); + expect(updateCalls).toEqual([{ fileId: 'doc-123' }]); }); }); diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts index cdee0b23..8558f571 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -1,10 +1,9 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { google, drive_v3 as drive, docs_v1 } from 'googleapis'; -import { WorkDir } from '../config/config.js'; +import { Readable } from 'node:stream'; +import { google, drive_v3 as drive } from 'googleapis'; import { resolveWorkspacePath } from '../workspace/workspace.js'; import { GoogleClientFactory } from './google-client-factory.js'; -import { markdownToDocsRequests } from './markdown-to-docs.js'; export const GOOGLE_DOC_SCOPES = [ 'https://www.googleapis.com/auth/drive.readonly', @@ -19,25 +18,28 @@ export type GoogleDocListItem = { owner: string | null; }; -type GoogleDocFrontmatter = { +// Metadata linking a local .docx file to its source Google Doc. Stored in a +// registry (see LINKS_REL) because a .docx is binary and can't carry the +// frontmatter a markdown note would. +export type GoogleDocLink = { id: string; url: string; title: string; - syncedAt?: string; - // Drive `modifiedTime` (RFC3339) captured at the last sync, used to detect - // remote edits before a sync-up would overwrite them. + syncedAt: string; + // Drive `modifiedTime` (RFC3339) at the last sync — used to detect remote + // edits before a sync-up would overwrite them. remoteModifiedTime?: string; }; const GOOGLE_DOC_MIME = 'application/vnd.google-apps.document'; -// Google Docs natively export to Markdown, which preserves headings, bold, -// lists, links and tables on the way into the local note — far better fidelity -// than the old text/plain export. -const MARKDOWN_MIME = 'text/markdown'; +// The Google Doc is exported to / imported from a real Word document so the +// in-app docx editor round-trips it with full fidelity (tables, images, styles). +const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; -function yamlQuote(value: string): string { - return JSON.stringify(value); -} +// Hidden registry mapping workspace-relative .docx paths → their Google Doc. +// Lives under .assets so workspace:readdir (includeHidden:false) keeps it out +// of the Knowledge tree. +const LINKS_REL = 'knowledge/.assets/google-docs/links.json'; function sanitizeFilename(name: string): string { const cleaned = name @@ -52,8 +54,12 @@ function escapeDriveQueryValue(value: string): string { return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); } +function normalizeRel(relPath: string): string { + return relPath.replace(/\\/g, '/'); +} + function normalizeKnowledgeDir(targetFolder: string): string { - const normalized = targetFolder.replace(/\\/g, '/').replace(/\/+$/, ''); + const normalized = normalizeRel(targetFolder).replace(/\/+$/, ''); if (!normalized || normalized === 'knowledge') return 'knowledge'; if (!normalized.startsWith('knowledge/')) { throw new Error('Google Docs can only be added under knowledge/.'); @@ -61,78 +67,11 @@ function normalizeKnowledgeDir(targetFolder: string): string { return normalized; } -function buildStubContent(doc: GoogleDocFrontmatter, snapshot: string): string { - const syncedAt = doc.syncedAt ?? new Date().toISOString(); - const lines = [ - '---', - 'source:', - ' - google-doc', - 'google_doc:', - ` id: ${yamlQuote(doc.id)}`, - ` url: ${yamlQuote(doc.url)}`, - ` title: ${yamlQuote(doc.title)}`, - ` syncedAt: ${yamlQuote(syncedAt)}`, - ]; - if (doc.remoteModifiedTime) { - lines.push(` remoteModifiedTime: ${yamlQuote(doc.remoteModifiedTime)}`); - } - lines.push('---', '', snapshot.trimEnd(), ''); - return lines.join('\n'); -} - -function parseLinkedGoogleDoc(markdown: string): GoogleDocFrontmatter | null { - if (!markdown.startsWith('---')) return null; - const endIndex = markdown.indexOf('\n---', 3); - if (endIndex === -1) return null; - const raw = markdown.slice(0, endIndex + 4); - const lines = raw.split('\n'); - let inGoogleDoc = false; - const doc: Partial = {}; - - for (const line of lines) { - if (line === '---') { - inGoogleDoc = false; - continue; - } - const topLevel = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/); - if (topLevel) { - inGoogleDoc = topLevel[1] === 'google_doc'; - continue; - } - if (!inGoogleDoc) continue; - const nested = line.match(/^\s+([A-Za-z_][\w-]*):\s*(.*)$/); - if (!nested) continue; - const key = nested[1] as keyof GoogleDocFrontmatter; - let value = nested[2].trim(); - if (!['id', 'url', 'title', 'syncedAt', 'remoteModifiedTime'].includes(key)) continue; - try { - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - value = JSON.parse(value); - } - } catch { - value = value.replace(/^['"]|['"]$/g, ''); - } - doc[key] = value; - } - - if (!doc.id || !doc.url || !doc.title) return null; - return doc as GoogleDocFrontmatter; -} - -function bodyFromMarkdown(markdown: string): string { - if (!markdown.startsWith('---')) return markdown; - const endIndex = markdown.indexOf('\n---', 3); - if (endIndex === -1) return markdown; - let body = markdown.slice(endIndex + 4); - if (body.startsWith('\n')) body = body.slice(1); - return body; -} - /** * True when the Google Doc has been edited remotely since our last recorded * sync — i.e. a sync-up would clobber changes we never pulled. Missing - * timestamps (e.g. legacy notes with no baseline) are treated as "not ahead" - * so the push is allowed rather than blocked forever. + * timestamps (e.g. links created before a baseline existed) are treated as + * "not ahead" so the push is allowed rather than blocked forever. */ export function isRemoteAhead( remoteModifiedTime: string | null | undefined, @@ -145,25 +84,51 @@ export function isRemoteAhead( return remote > known; } +// --- Link registry --------------------------------------------------------- + +async function readLinks(): Promise> { + try { + const raw = await fs.readFile(resolveWorkspacePath(LINKS_REL), 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +async function writeLinks(map: Record): Promise { + const absPath = resolveWorkspacePath(LINKS_REL); + await fs.mkdir(path.dirname(absPath), { recursive: true }); + await fs.writeFile(absPath, JSON.stringify(map, null, 2), 'utf8'); +} + +async function setLink(relPath: string, link: GoogleDocLink): Promise { + const links = await readLinks(); + links[normalizeRel(relPath)] = link; + await writeLinks(links); +} + +/** The Google Doc linked to a local .docx, or null if the file isn't linked. */ +export async function getGoogleDocLink(relPath: string): Promise { + const links = await readLinks(); + return links[normalizeRel(relPath)] ?? null; +} + +// --- Drive / Docs clients -------------------------------------------------- + async function getDriveClient() { const auth = await GoogleClientFactory.getClient(); if (!auth) throw new Error('Google is not connected.'); return google.drive({ version: 'v3', auth }); } -async function getDocsClient() { - const auth = await GoogleClientFactory.getClient(); - if (!auth) throw new Error('Google is not connected.'); - return google.docs({ version: 'v1', auth }); -} - -async function exportDocMarkdown(fileId: string): Promise { +async function exportDocx(fileId: string): Promise { const driveClient = await getDriveClient(); const result = await driveClient.files.export( - { fileId, mimeType: MARKDOWN_MIME }, - { responseType: 'text' }, + { fileId, mimeType: DOCX_MIME }, + { responseType: 'arraybuffer' }, ); - return typeof result.data === 'string' ? result.data : String(result.data ?? ''); + return Buffer.from(result.data as ArrayBuffer); } async function getDocMetadata(fileId: string): Promise { @@ -187,15 +152,15 @@ function toGoogleDocListItem(file: drive.Schema$File): GoogleDocListItem { }; } -async function uniqueKnowledgePath(targetFolder: string, title: string): Promise { +async function uniqueDocxPath(targetFolder: string, title: string): Promise { const folder = normalizeKnowledgeDir(targetFolder); const base = sanitizeFilename(title); - let candidate = `${folder}/${base}.md`; + let candidate = `${folder}/${base}.docx`; let index = 1; while (true) { try { await fs.access(resolveWorkspacePath(candidate)); - candidate = `${folder}/${base}-${index}.md`; + candidate = `${folder}/${base}-${index}.docx`; index += 1; } catch { return candidate; @@ -203,6 +168,8 @@ async function uniqueKnowledgePath(targetFolder: string, title: string): Promise } } +// --- Public API ------------------------------------------------------------ + export async function getGoogleDocsConnectionStatus(): Promise<{ connected: boolean; hasRequiredScopes: boolean; @@ -232,6 +199,7 @@ export async function listGoogleDocs(query?: string): Promise<{ files: GoogleDoc return { files: (result.data.files ?? []).map(toGoogleDocListItem).filter((file) => file.id) }; } +/** Import a Google Doc as a local .docx and register the link. */ export async function importGoogleDoc(fileId: string, targetFolder: string): Promise<{ path: string; doc: GoogleDocListItem; @@ -241,54 +209,52 @@ export async function importGoogleDoc(fileId: string, targetFolder: string): Pro if (!status.hasRequiredScopes) throw new Error('Google is missing Drive/Docs scopes. Reconnect Google.'); const doc = await getDocMetadata(fileId); - const snapshot = await exportDocMarkdown(fileId); - const relPath = await uniqueKnowledgePath(targetFolder, doc.name); + const bytes = await exportDocx(fileId); + const relPath = await uniqueDocxPath(targetFolder, doc.name); const absPath = resolveWorkspacePath(relPath); await fs.mkdir(path.dirname(absPath), { recursive: true }); - await fs.writeFile(absPath, buildStubContent({ + await fs.writeFile(absPath, bytes); + await setLink(relPath, { id: doc.id, url: doc.url, title: doc.name, syncedAt: new Date().toISOString(), remoteModifiedTime: doc.modifiedTime ?? undefined, - }, snapshot), 'utf8'); + }); return { path: relPath, doc }; } -export async function refreshGoogleDocSnapshot(relPath: string): Promise<{ ok: true; syncedAt: string }> { - const absPath = resolveWorkspacePath(relPath); - const markdown = await fs.readFile(absPath, 'utf8'); - const linked = parseLinkedGoogleDoc(markdown); - if (!linked) throw new Error('This note is not linked to a Google Doc.'); +/** Pull the latest Google Doc and overwrite the local .docx. */ +export async function syncGoogleDocDown(relPath: string): Promise<{ ok: true; syncedAt: string }> { + const link = await getGoogleDocLink(relPath); + if (!link) throw new Error('This file is not linked to a Google Doc.'); - const [snapshot, meta] = await Promise.all([ - exportDocMarkdown(linked.id), - getDocMetadata(linked.id), - ]); + const [bytes, meta] = await Promise.all([exportDocx(link.id), getDocMetadata(link.id)]); + await fs.writeFile(resolveWorkspacePath(normalizeRel(relPath)), bytes); const syncedAt = new Date().toISOString(); - await fs.writeFile(absPath, buildStubContent({ - ...linked, + await setLink(relPath, { + id: link.id, + url: link.url, + title: link.title, syncedAt, - remoteModifiedTime: meta.modifiedTime ?? linked.remoteModifiedTime, - }, snapshot), 'utf8'); + remoteModifiedTime: meta.modifiedTime ?? link.remoteModifiedTime, + }); return { ok: true, syncedAt }; } -export async function syncLinkedGoogleDocFromMarkdown( +/** Push the local .docx back into the Google Doc (in place, preserving its id/URL). */ +export async function syncGoogleDocUp( relPath: string, - markdown: string, opts: { force?: boolean } = {}, ): Promise<{ synced: boolean; syncedAt?: string; conflict?: boolean; error?: string }> { try { - const normalized = relPath.replace(/\\/g, '/'); - if (!normalized.startsWith('knowledge/') || !normalized.endsWith('.md')) return { synced: false }; - const linked = parseLinkedGoogleDoc(markdown); - if (!linked) return { synced: false }; + const link = await getGoogleDocLink(relPath); + if (!link) return { synced: false, error: 'This file is not linked to a Google Doc.' }; // Conflict guard: don't silently overwrite remote edits we never pulled. if (!opts.force) { - const meta = await getDocMetadata(linked.id); - if (isRemoteAhead(meta.modifiedTime, linked.remoteModifiedTime)) { + const meta = await getDocMetadata(link.id); + if (isRemoteAhead(meta.modifiedTime, link.remoteModifiedTime)) { return { synced: false, conflict: true, @@ -297,40 +263,24 @@ export async function syncLinkedGoogleDocFromMarkdown( } } - const body = bodyFromMarkdown(markdown); - const docsClient = await getDocsClient(); - const current = await docsClient.documents.get({ - documentId: linked.id, - fields: 'body(content(endIndex))', + const bytes = await fs.readFile(resolveWorkspacePath(normalizeRel(relPath))); + const driveClient = await getDriveClient(); + // Uploading .docx media to a Google Doc converts it back into the existing + // doc, keeping the file's id, URL and Google-Doc type intact. + await driveClient.files.update({ + fileId: link.id, + media: { mimeType: DOCX_MIME, body: Readable.from(bytes) }, }); - const endIndex = current.data.body?.content?.at(-1)?.endIndex ?? 1; - const requests: docs_v1.Schema$Request[] = []; - if (endIndex > 2) { - requests.push({ - deleteContentRange: { - range: { startIndex: 1, endIndex: endIndex - 1 }, - }, - }); - } - // Recreate the body with structure preserved (headings, emphasis, lists, links). - requests.push(...markdownToDocsRequests(body, 1)); - if (requests.length > 0) { - await docsClient.documents.batchUpdate({ - documentId: linked.id, - requestBody: { requests }, - }); - } - // Re-read the revision so our stored baseline reflects this push and the - // next sync-up won't see a phantom conflict. - const meta = await getDocMetadata(linked.id); - const absPath = path.join(WorkDir, normalized); + const meta = await getDocMetadata(link.id); const syncedAt = new Date().toISOString(); - await fs.writeFile(absPath, buildStubContent({ - ...linked, + await setLink(relPath, { + id: link.id, + url: link.url, + title: link.title, syncedAt, - remoteModifiedTime: meta.modifiedTime ?? linked.remoteModifiedTime, - }, body), 'utf8'); + remoteModifiedTime: meta.modifiedTime ?? link.remoteModifiedTime, + }); return { synced: true, syncedAt }; } catch (error) { console.error('[GoogleDocs] Failed to sync linked Google Doc:', error); diff --git a/apps/x/packages/core/src/knowledge/markdown-to-docs.test.ts b/apps/x/packages/core/src/knowledge/markdown-to-docs.test.ts deleted file mode 100644 index 347876f1..00000000 --- a/apps/x/packages/core/src/knowledge/markdown-to-docs.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { markdownToDocsRequests, parseInline } from './markdown-to-docs.js'; - -describe('parseInline', () => { - it('extracts bold, italic and link ranges with offsets relative to the plain text', () => { - expect(parseInline('a **b** c')).toEqual({ - text: 'a b c', - ranges: [{ start: 2, end: 3, bold: true }], - }); - expect(parseInline('see [docs](https://x.dev) now')).toEqual({ - text: 'see docs now', - ranges: [{ start: 4, end: 8, link: 'https://x.dev' }], - }); - expect(parseInline('_em_')).toEqual({ - text: 'em', - ranges: [{ start: 0, end: 2, italic: true }], - }); - }); - - it('keeps inline code text without styling', () => { - expect(parseInline('run `npm test`')).toEqual({ text: 'run npm test', ranges: [] }); - }); -}); - -describe('markdownToDocsRequests', () => { - it('returns no requests for an empty body', () => { - expect(markdownToDocsRequests(' \n\n')).toEqual([]); - }); - - it('inserts the full text first, then layers styles at the right indices', () => { - const reqs = markdownToDocsRequests('# Hello\n\nworld **bold**'); - - // First request inserts all paragraph text at index 1. - expect(reqs[0]).toEqual({ - insertText: { location: { index: 1 }, text: 'Hello\n\nworld bold\n' }, - }); - - // Heading 1 applied to "Hello\n" → [1, 7). - expect(reqs).toContainEqual({ - updateParagraphStyle: { - range: { startIndex: 1, endIndex: 7 }, - paragraphStyle: { namedStyleType: 'HEADING_1' }, - fields: 'namedStyleType', - }, - }); - - // "bold" sits at [14, 18) in the inserted text. - expect(reqs).toContainEqual({ - updateTextStyle: { - range: { startIndex: 14, endIndex: 18 }, - textStyle: { bold: true }, - fields: 'bold', - }, - }); - }); - - it('maps bullet and numbered lists to the right bullet presets', () => { - const bullets = markdownToDocsRequests('- one\n- two'); - const bulletReqs = bullets.filter((r) => 'createParagraphBullets' in r); - expect(bulletReqs).toHaveLength(2); - expect(bulletReqs[0]).toMatchObject({ - createParagraphBullets: { bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE' }, - }); - - const numbered = markdownToDocsRequests('1. first\n2. second'); - const numberedReqs = numbered.filter((r) => 'createParagraphBullets' in r); - expect(numberedReqs).toHaveLength(2); - expect(numberedReqs[0]).toMatchObject({ - createParagraphBullets: { bulletPreset: 'NUMBERED_DECIMAL_ALPHA_ROMAN' }, - }); - }); - - it('emits a link textStyle request', () => { - const reqs = markdownToDocsRequests('see [docs](https://x.dev)'); - expect(reqs).toContainEqual({ - updateTextStyle: { - range: { startIndex: 5, endIndex: 9 }, - textStyle: { link: { url: 'https://x.dev' } }, - fields: 'link', - }, - }); - }); -}); diff --git a/apps/x/packages/core/src/knowledge/markdown-to-docs.ts b/apps/x/packages/core/src/knowledge/markdown-to-docs.ts deleted file mode 100644 index 6c4d3f85..00000000 --- a/apps/x/packages/core/src/knowledge/markdown-to-docs.ts +++ /dev/null @@ -1,195 +0,0 @@ -import type { docs_v1 } from 'googleapis'; - -/** - * Convert a Markdown note body into Google Docs API batchUpdate requests that - * recreate the content with structure preserved — headings, bold/italic, - * bullet & numbered lists, and links — instead of flattening everything to - * plain text. - * - * Strategy: the doc body is cleared first (see syncLinkedGoogleDocFromMarkdown), - * then we insert all paragraph text in one shot at `insertIndex` and layer - * paragraph/text styling on top using ranges computed against the inserted - * text. Style requests do not shift indices, so a single insertText followed by - * style updates stays index-stable within one batchUpdate. - * - * Out of scope (degrade to plain paragraphs): tables, images, code fences, - * blockquotes, nested lists. - */ - -type InlineRange = { - start: number; - end: number; - bold?: boolean; - italic?: boolean; - link?: string; -}; - -type Block = { - text: string; - ranges: InlineRange[]; - paragraph: 'normal' | 'heading'; - headingLevel?: number; - list?: 'bullet' | 'number'; -}; - -const HEADING_NAMED_STYLE: Record = { - 1: 'HEADING_1', - 2: 'HEADING_2', - 3: 'HEADING_3', - 4: 'HEADING_4', - 5: 'HEADING_5', - 6: 'HEADING_6', -}; - -/** - * Parse a single line's inline Markdown (bold, italic, code, links) into plain - * text plus the style ranges that apply to it. Offsets are relative to the - * returned text. Nested emphasis is not handled; inner markers are kept as-is. - */ -export function parseInline(raw: string): { text: string; ranges: InlineRange[] } { - let text = ''; - const ranges: InlineRange[] = []; - let i = 0; - - while (i < raw.length) { - const rest = raw.slice(i); - - // Link: [label](url) - const link = /^\[([^\]]+)\]\(([^)\s]+)\)/.exec(rest); - if (link) { - const start = text.length; - text += link[1]; - ranges.push({ start, end: text.length, link: link[2] }); - i += link[0].length; - continue; - } - - // Bold: **text** or __text__ - const bold = /^(\*\*|__)(.+?)\1/.exec(rest); - if (bold) { - const start = text.length; - text += bold[2]; - ranges.push({ start, end: text.length, bold: true }); - i += bold[0].length; - continue; - } - - // Italic: *text* or _text_ - const italic = /^(\*|_)([^*_]+?)\1/.exec(rest); - if (italic) { - const start = text.length; - text += italic[2]; - ranges.push({ start, end: text.length, italic: true }); - i += italic[0].length; - continue; - } - - // Inline code: `text` — kept as text, no monospace styling applied. - const code = /^`([^`]+)`/.exec(rest); - if (code) { - text += code[1]; - i += code[0].length; - continue; - } - - text += raw[i]; - i += 1; - } - - return { text, ranges }; -} - -function parseBlock(line: string): Block { - const heading = /^(#{1,6})\s+(.*)$/.exec(line); - if (heading) { - const { text, ranges } = parseInline(heading[2]); - return { text, ranges, paragraph: 'heading', headingLevel: heading[1].length }; - } - - const bullet = /^\s*[-*+]\s+(.*)$/.exec(line); - if (bullet) { - const { text, ranges } = parseInline(bullet[1]); - return { text, ranges, paragraph: 'normal', list: 'bullet' }; - } - - const numbered = /^\s*\d+\.\s+(.*)$/.exec(line); - if (numbered) { - const { text, ranges } = parseInline(numbered[1]); - return { text, ranges, paragraph: 'normal', list: 'number' }; - } - - const { text, ranges } = parseInline(line); - return { text, ranges, paragraph: 'normal' }; -} - -/** - * Build the batchUpdate requests for the given Markdown body. Each line becomes - * one paragraph (blank lines included, to preserve spacing). - */ -export function markdownToDocsRequests( - body: string, - insertIndex = 1, -): docs_v1.Schema$Request[] { - const trimmed = body.replace(/\s+$/, ''); - if (!trimmed) return []; - - const blocks = trimmed.split('\n').map(parseBlock); - - // Concatenate every block's text, each terminated by a newline that ends its - // paragraph. Track where each block starts in the inserted text. - let fullText = ''; - const starts: number[] = []; - for (const block of blocks) { - starts.push(insertIndex + fullText.length); - fullText += `${block.text}\n`; - } - - const requests: docs_v1.Schema$Request[] = [ - { insertText: { location: { index: insertIndex }, text: fullText } }, - ]; - - blocks.forEach((block, idx) => { - const start = starts[idx]; - const textEnd = start + block.text.length; - const paraEnd = textEnd + 1; // include the trailing newline - - if (block.paragraph === 'heading' && block.headingLevel) { - requests.push({ - updateParagraphStyle: { - range: { startIndex: start, endIndex: paraEnd }, - paragraphStyle: { namedStyleType: HEADING_NAMED_STYLE[block.headingLevel] }, - fields: 'namedStyleType', - }, - }); - } - - if (block.list && block.text.length > 0) { - requests.push({ - createParagraphBullets: { - range: { startIndex: start, endIndex: paraEnd }, - bulletPreset: block.list === 'number' - ? 'NUMBERED_DECIMAL_ALPHA_ROMAN' - : 'BULLET_DISC_CIRCLE_SQUARE', - }, - }); - } - - for (const r of block.ranges) { - if (r.end <= r.start) continue; - const range = { startIndex: start + r.start, endIndex: start + r.end }; - if (r.bold) { - requests.push({ updateTextStyle: { range, textStyle: { bold: true }, fields: 'bold' } }); - } - if (r.italic) { - requests.push({ updateTextStyle: { range, textStyle: { italic: true }, fields: 'italic' } }); - } - if (r.link) { - requests.push({ - updateTextStyle: { range, textStyle: { link: { url: r.link } }, fields: 'link' }, - }); - } - } - }); - - return requests; -} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 26a398af..221c2cfa 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -692,9 +692,10 @@ const ipcSchemas = { 'google-docs:sync': { req: z.object({ path: RelPath, - markdown: z.string(), // Overwrite the Google Doc even if it changed remotely since last sync. force: z.boolean().optional(), + // Legacy field from the markdown-link path; ignored by the .docx sync. + markdown: z.string().optional(), }), res: z.object({ synced: z.boolean(), @@ -704,6 +705,21 @@ const ipcSchemas = { error: z.string().optional(), }), }, + // Is this local .docx linked to a Google Doc? Drives the sync UI in the viewer. + 'google-docs:getLink': { + req: z.object({ + path: RelPath, + }), + res: z.object({ + link: z.object({ + id: z.string(), + url: z.string(), + title: z.string(), + syncedAt: z.string(), + remoteModifiedTime: z.string().optional(), + }).nullable(), + }), + }, // Search channels 'search:query': { req: z.object({