mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
feat(google-docs): store linked docs as .docx, edit in docx editor, sync via Drive
This commit is contained in:
parent
ccdfc0f6e9
commit
09b0a66fa5
7 changed files with 404 additions and 592 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue