feat(google-docs): store linked docs as .docx, edit in docx editor, sync via Drive

This commit is contained in:
Gagancreates 2026-06-01 13:13:41 +05:30
parent ccdfc0f6e9
commit 09b0a66fa5
7 changed files with 404 additions and 592 deletions

View file

@ -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) => {

View file

@ -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<LoadState>('loading')
const [buffer, setBuffer] = useState<ArrayBuffer | null>(null)
const [saveState, setSaveState] = useState<SaveState>('idle')
const [reloadNonce, setReloadNonce] = useState(0)
const [link, setLink] = useState<GoogleDocLink | null>(null)
const [syncing, setSyncing] = useState<'up' | 'down' | null>(null)
const editorRef = useRef<DocxEditorRef>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
@ -155,37 +245,75 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
)
}
if (loadState === 'loading' || !buffer) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Loading document</p>
</div>
)
}
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
<Suspense
fallback={
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Loading editor</p>
{link && (
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-muted/30 px-3 py-1.5 text-xs">
<GoogleDocsIcon className="size-4 shrink-0" />
<span className="truncate font-medium text-foreground">{link.title}</span>
<span className="truncate text-muted-foreground">
{syncing
? syncing === 'up' ? 'Syncing up…' : 'Syncing down…'
: `Synced ${formatRelativeTime(link.syncedAt)}`}
</span>
<div className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={() => { void handleSyncDown() }}
disabled={Boolean(syncing)}
title="Pull latest from Google Docs"
className="inline-flex items-center gap-1 rounded-md px-2 py-1 font-medium text-foreground hover:bg-accent disabled:opacity-50"
>
<CloudDownloadIcon className="size-3.5" /> Sync down
</button>
<button
type="button"
onClick={() => { void handleSyncUp() }}
disabled={Boolean(syncing)}
title="Push your changes to Google Docs"
className="inline-flex items-center gap-1 rounded-md px-2 py-1 font-medium text-foreground hover:bg-accent disabled:opacity-50"
>
<UploadCloudIcon className="size-3.5" /> Sync up
</button>
<button
type="button"
onClick={() => { window.open(link.url, '_blank') }}
title="Open in Google Docs"
className="inline-flex items-center rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ExternalLinkIcon className="size-3.5" />
</button>
</div>
}
>
<LazyDocxEditor
key={path}
ref={editorRef}
documentBuffer={buffer}
mode="editing"
documentName={baseName(path)}
documentNameEditable={false}
onChange={handleChange}
onError={(err) => { console.error('docx editor error:', err) }}
className="flex-1 min-h-0"
/>
</Suspense>
</div>
)}
{loadState === 'loading' || !buffer ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Loading document</p>
</div>
) : (
<Suspense
fallback={
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Loading editor</p>
</div>
}
>
<LazyDocxEditor
key={`${path}:${reloadNonce}`}
ref={editorRef}
documentBuffer={buffer}
mode="editing"
documentName={baseName(path)}
documentNameEditable={false}
onChange={handleChange}
onError={(err) => { console.error('docx editor error:', err) }}
className="flex-1 min-h-0"
/>
</Suspense>
)}
{saveState !== 'idle' && (
<div className="pointer-events-none absolute bottom-3 right-4 z-10 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur">
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : 'Save failed'}
@ -194,3 +322,13 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
</div>
)
}
function GoogleDocsIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" focusable="false">
<path fill="#4285F4" d="M6 2h8l5 5v15H6V2Z" />
<path fill="#AECBFA" d="M14 2v5h5l-5-5Z" />
<path fill="#FFFFFF" d="M8.5 11h7v1.2h-7V11Zm0 2.6h7v1.2h-7v-1.2Zm0 2.6h5.2v1.2H8.5v-1.2Z" />
</svg>
)
}

View file

@ -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<string, string | Buffer>;
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<string, unknown>) {
vfs.set(REGISTRY_ABS, JSON.stringify(entries));
}
function readRegistry(): Record<string, Record<string, unknown>> {
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<Record<string, unknown>>;
// 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' }]);
});
});

View file

@ -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<GoogleDocFrontmatter> = {};
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<Record<string, GoogleDocLink>> {
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<string, GoogleDocLink>): Promise<void> {
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<void> {
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<GoogleDocLink | null> {
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<string> {
async function exportDocx(fileId: string): Promise<Buffer> {
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<GoogleDocListItem> {
@ -187,15 +152,15 @@ function toGoogleDocListItem(file: drive.Schema$File): GoogleDocListItem {
};
}
async function uniqueKnowledgePath(targetFolder: string, title: string): Promise<string> {
async function uniqueDocxPath(targetFolder: string, title: string): Promise<string> {
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);

View file

@ -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',
},
});
});
});

View file

@ -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<number, string> = {
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;
}

View file

@ -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({