From c548f6bd510b007969269cdfd995e4712463b52f Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 27 May 2026 21:10:49 +0530 Subject: [PATCH 01/18] add drive sync up and down --- apps/x/apps/main/src/ipc.ts | 16 + apps/x/apps/renderer/src/App.tsx | 172 +++++++++- .../components/google-doc-picker-dialog.tsx | 227 +++++++++++++ .../src/components/knowledge-view.tsx | 13 + apps/x/apps/renderer/src/lib/frontmatter.ts | 2 +- apps/x/packages/core/src/auth/providers.ts | 2 + .../core/src/knowledge/google_docs.ts | 300 ++++++++++++++++++ apps/x/packages/shared/src/ipc.ts | 59 ++++ 8 files changed, 788 insertions(+), 3 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx create mode 100644 apps/x/packages/core/src/knowledge/google_docs.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ed8b1a7cc..5f2467830 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -49,6 +49,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 { 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'; @@ -793,6 +794,21 @@ export function setupIpcHandlers() { await versionHistory.restoreFile(args.path, args.oid); return { ok: true }; }, + 'google-docs:getStatus': async () => { + return getGoogleDocsConnectionStatus(); + }, + 'google-docs:list': async (_event, args) => { + return listGoogleDocs(args.query); + }, + 'google-docs:import': async (_event, args) => { + return importGoogleDoc(args.fileId, args.targetFolder); + }, + 'google-docs:refreshSnapshot': async (_event, args) => { + return refreshGoogleDocSnapshot(args.path); + }, + 'google-docs:sync': async (_event, args) => { + return syncLinkedGoogleDocFromMarkdown(args.path, args.markdown); + }, // Search handler 'search:query': async (_event, args) => { return search(args.query, args.limit, args.types); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 3c653b1b0..88675b3ee 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, Plus, HistoryIcon } from 'lucide-react'; +import { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, Plus, HistoryIcon, DownloadIcon, UploadCloud } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -29,6 +29,7 @@ import { BgTasksView } from '@/components/bg-tasks-view'; import { EmailView } from '@/components/email-view'; import { WorkspaceView } from '@/components/workspace-view'; import { KnowledgeView } from '@/components/knowledge-view'; +import { GoogleDocPickerDialog } from '@/components/google-doc-picker-dialog'; import { ChatHistoryView } from '@/components/chat-history-view'; import { HomeView } from '@/components/home-view'; import { MeetingsView } from '@/components/meetings-view'; @@ -252,6 +253,43 @@ const stripKnowledgePrefixForWiki = (relPath: string) => { const stripMarkdownExtensionForWiki = (wikiPath: string) => wikiPath.toLowerCase().endsWith('.md') ? wikiPath.slice(0, -3) : wikiPath +type LinkedGoogleDocMeta = { + id: string + title: string + url?: string + syncedAt?: string +} + +const parseLinkedGoogleDocFrontmatter = (raw: string | null | undefined): LinkedGoogleDocMeta | null => { + if (!raw?.includes('google_doc:')) return null + const doc: Partial = {} + let inGoogleDoc = false + for (const line of raw.split('\n')) { + if (line.trim() === '---') { + 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 LinkedGoogleDocMeta + if (!['id', 'title', 'url', 'syncedAt'].includes(key)) continue + let value = nested[2].trim() + try { + value = JSON.parse(value) + } catch { + value = value.replace(/^['"]|['"]$/g, '') + } + doc[key] = value + } + return doc.id && doc.title ? doc as LinkedGoogleDocMeta : null +} + const wikiPathCompareKey = (wikiPath: string) => stripMarkdownExtensionForWiki(wikiPath).toLowerCase() @@ -768,6 +806,8 @@ function App() { // Folder being browsed inside the knowledge view (null = root overview). // Lives in ViewState so folder drill-down participates in back/forward history. const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState(null) + const [googleDocPickerOpen, setGoogleDocPickerOpen] = useState(false) + const [googleDocPickerTargetFolder, setGoogleDocPickerTargetFolder] = useState('knowledge') const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) // Default landing view: Home in the middle with the chat docked on the right. const [isHomeOpen, setIsHomeOpen] = useState(true) @@ -834,6 +874,7 @@ function App() { // Auto-save state const [isSaving, setIsSaving] = useState(false) const [lastSaved, setLastSaved] = useState(null) + const [googleDocSyncDirection, setGoogleDocSyncDirection] = useState<'up' | 'down' | null>(null) const debouncedContent = useDebounce(editorContent, 500) const initialContentRef = useRef('') const renameInProgressRef = useRef(false) @@ -1352,6 +1393,30 @@ function App() { return isRecent }, []) + const reloadMarkdownFileIntoEditor = useCallback(async (path: string) => { + const result = await window.ipc.invoke('workspace:readFile', { path, encoding: 'utf8' }) + const { raw: fm, body } = splitFrontmatter(result.data) + frontmatterByPathRef.current.set(path, fm) + setFileContent(result.data) + setEditorContent(body) + setEditorCacheForPath(path, body) + editorContentRef.current = body + editorPathRef.current = path + initialContentByPathRef.current.set(path, body) + initialContentRef.current = body + setLastSaved(new Date()) + setEditorSessionByTabId((prev) => { + let changed = false + const next = { ...prev } + for (const tab of fileTabs) { + if (tab.path !== path) continue + next[tab.id] = (next[tab.id] ?? 0) + 1 + changed = true + } + return changed ? next : prev + }) + }, [fileTabs, setEditorCacheForPath]) + const handleEditorChange = useCallback((path: string, markdown: string) => { setEditorCacheForPath(path, markdown) const nextSelectedPath = selectedPathRef.current @@ -1365,6 +1430,49 @@ function App() { editorContentRef.current = markdown setEditorContent(markdown) }, [setEditorCacheForPath]) + + const syncGoogleDocDown = useCallback(async () => { + const path = selectedPathRef.current + if (!path || !path.startsWith('knowledge/') || !path.endsWith('.md')) return + + setGoogleDocSyncDirection('down') + markRecentLocalMarkdownWrite(path) + try { + await window.ipc.invoke('google-docs:refreshSnapshot', { path }) + markRecentLocalMarkdownWrite(path) + await reloadMarkdownFileIntoEditor(path) + toast.success('Pulled latest Google Doc') + } catch (err) { + console.error('Failed to sync Google Doc down:', err) + toast.error(err instanceof Error ? err.message : 'Failed to pull Google Doc') + } finally { + setGoogleDocSyncDirection(null) + } + }, [markRecentLocalMarkdownWrite, reloadMarkdownFileIntoEditor]) + + const syncGoogleDocUp = useCallback(async () => { + const path = selectedPathRef.current + if (!path || !path.startsWith('knowledge/') || !path.endsWith('.md')) return + + const body = editorContentByPathRef.current.get(path) ?? editorContentRef.current + const markdown = joinFrontmatter(frontmatterByPathRef.current.get(path) ?? null, body) + setGoogleDocSyncDirection('up') + markRecentLocalMarkdownWrite(path) + try { + const result = await window.ipc.invoke('google-docs:sync', { path, markdown }) + if (!result.synced) { + throw new Error(result.error || 'This note is not linked to a Google Doc.') + } + markRecentLocalMarkdownWrite(path) + await reloadMarkdownFileIntoEditor(path) + toast.success('Pushed changes to Google Doc') + } catch (err) { + console.error('Failed to sync Google Doc up:', err) + toast.error(err instanceof Error ? err.message : 'Failed to push Google Doc') + } finally { + setGoogleDocSyncDirection(null) + } + }, [markRecentLocalMarkdownWrite, reloadMarkdownFileIntoEditor]) // Keep processingRunIdsRef in sync for use in async callbacks useEffect(() => { processingRunIdsRef.current = processingRunIds @@ -1656,6 +1764,7 @@ function App() { const baseline = initialContentByPathRef.current.get(pathAtStart) ?? initialContentRef.current if (debouncedContent === baseline) return if (!debouncedContent) return + if (selectedPathRef.current === pathAtStart && debouncedContent !== editorContentRef.current) return const saveFile = async () => { const wasActiveAtStart = selectedPathRef.current === pathAtStart @@ -4517,6 +4626,10 @@ function App() { throw err } }, + addGoogleDoc: (parentPath: string = 'knowledge') => { + setGoogleDocPickerTargetFolder(parentPath) + setGoogleDocPickerOpen(true) + }, createFolder: async (parentPath: string = 'knowledge'): Promise => { try { let index = 1 @@ -5255,7 +5368,10 @@ function App() { } return markdownTabs }, [fileTabs, selectedPath]) - + const selectedLinkedGoogleDoc = React.useMemo(() => { + if (!selectedPath?.startsWith('knowledge/') || !selectedPath.endsWith('.md')) return null + return parseLinkedGoogleDocFrontmatter(frontmatterByPathRef.current.get(selectedPath) ?? null) + }, [selectedPath, editorContent, editorContentByPath]) return ( { @@ -5365,6 +5481,46 @@ function App() { ) : null} )} + {selectedLinkedGoogleDoc && ( + <> + + + + + Sync down from Google Doc + + + + + + Sync up to Google Doc + + + )} {selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && ( @@ -5553,6 +5709,7 @@ function App() { tree={tree} actions={{ createNote: knowledgeActions.createNote, + addGoogleDoc: knowledgeActions.addGoogleDoc, createFolder: knowledgeActions.createFolder, rename: knowledgeActions.rename, remove: knowledgeActions.remove, @@ -6054,6 +6211,17 @@ function App() { void window.ipc.invoke('oauth:connect', { provider: 'google' }) }} /> + { + const parentPath = path.split('/').slice(0, -1).join('/') || 'knowledge' + setExpandedPaths(prev => new Set([...prev, parentPath])) + void loadDirectory().then(setTree) + navigateToFile(path) + }} + /> diff --git a/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx b/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx new file mode 100644 index 000000000..d03f94de5 --- /dev/null +++ b/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { FileText, Loader2, RefreshCw, Search } from 'lucide-react' + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { formatRelativeTime } from '@/lib/relative-time' +import { toast } from '@/lib/toast' + +type GoogleDocListItem = { + id: string + name: string + url: string + modifiedTime: string | null + owner: string | null +} + +type GoogleDocsStatus = { + connected: boolean + hasRequiredScopes: boolean + missingScopes: string[] +} + +type GoogleDocPickerDialogProps = { + open: boolean + targetFolder: string + onOpenChange: (open: boolean) => void + onImported: (path: string) => void +} + +function formatModified(modifiedTime: string | null): string { + if (!modifiedTime) return '' + return formatRelativeTime(modifiedTime) +} + +export function GoogleDocPickerDialog({ + open, + targetFolder, + onOpenChange, + onImported, +}: GoogleDocPickerDialogProps) { + const [status, setStatus] = useState(null) + const [query, setQuery] = useState('') + const [docs, setDocs] = useState([]) + const [loading, setLoading] = useState(false) + const [connecting, setConnecting] = useState(false) + const [importingId, setImportingId] = useState(null) + const [error, setError] = useState(null) + + const canList = Boolean(status?.connected && status.hasRequiredScopes) + const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder]) + + const loadStatus = useCallback(async () => { + try { + const result = await window.ipc.invoke('google-docs:getStatus', null) + setStatus(result) + } catch (err) { + setStatus(null) + setError(err instanceof Error ? err.message : 'Failed to check Google connection') + } + }, []) + + const loadDocs = useCallback(async (searchQuery: string) => { + setLoading(true) + setError(null) + try { + const result = await window.ipc.invoke('google-docs:list', { query: searchQuery.trim() || undefined }) + setDocs(result.files) + } catch (err) { + setDocs([]) + setError(err instanceof Error ? err.message : 'Failed to load Google Docs') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + if (!open) return + setQuery('') + setDocs([]) + setError(null) + void loadStatus() + }, [loadStatus, open]) + + useEffect(() => { + if (!open || !canList) return + const timeout = window.setTimeout(() => { + void loadDocs(query) + }, 250) + return () => window.clearTimeout(timeout) + }, [canList, loadDocs, open, query]) + + const handleConnect = useCallback(async () => { + setConnecting(true) + setError(null) + try { + const result = await window.ipc.invoke('oauth:connect', { provider: 'google' }) + if (!result.success) { + setError(result.error ?? 'Failed to start Google connection') + } else { + toast('Finish Google connection in the browser, then reopen the picker.', 'info') + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to start Google connection') + } finally { + setConnecting(false) + } + }, []) + + const handleImport = useCallback(async (doc: GoogleDocListItem) => { + setImportingId(doc.id) + setError(null) + try { + const result = await window.ipc.invoke('google-docs:import', { + fileId: doc.id, + targetFolder, + }) + toast('Google Doc added', 'success') + onImported(result.path) + onOpenChange(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to import Google Doc') + } finally { + setImportingId(null) + } + }, [onImported, onOpenChange, targetFolder]) + + return ( + + + + Add Google Doc + + Select a Google Doc to link into {targetLabel}. + + + +
+ {!status ? ( +
+ + Checking Google connection... +
+ ) : !status.connected || !status.hasRequiredScopes ? ( +
+
+ {!status.connected + ? 'Connect Google to choose Docs from Drive.' + : 'Reconnect Google so Rowboat can read Drive metadata and edit Google Docs.'} +
+ {status.missingScopes.length > 0 && ( +
+ Missing scopes: {status.missingScopes.join(', ')} +
+ )} + +
+ ) : ( + <> +
+
+ + setQuery(e.target.value)} + placeholder="Search Google Docs" + className="pl-9" + autoFocus + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ {loading ? ( +
+ + Loading Docs... +
+ ) : docs.length === 0 ? ( +
+ No Google Docs found. +
+ ) : ( +
+ {docs.map((doc) => ( + + ))} +
+ )} +
+ + )} +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/knowledge-view.tsx b/apps/x/apps/renderer/src/components/knowledge-view.tsx index e7ebe780f..f41235288 100644 --- a/apps/x/apps/renderer/src/components/knowledge-view.tsx +++ b/apps/x/apps/renderer/src/components/knowledge-view.tsx @@ -38,6 +38,7 @@ interface TreeNode { export type KnowledgeViewActions = { createNote: (parentPath?: string) => void + addGoogleDoc: (parentPath?: string) => void createFolder: (parentPath?: string) => Promise rename: (path: string, newName: string, isDir: boolean) => Promise remove: (path: string) => Promise @@ -202,6 +203,14 @@ export function KnowledgeView({ New note + @@ -764,6 +773,10 @@ function RowContextMenu({ New Note + actions.addGoogleDoc(node.path)}> + + Add Google Doc + void actions.createFolder(node.path)}> New Folder diff --git a/apps/x/apps/renderer/src/lib/frontmatter.ts b/apps/x/apps/renderer/src/lib/frontmatter.ts index 7da1c7c0e..b0f64e05f 100644 --- a/apps/x/apps/renderer/src/lib/frontmatter.ts +++ b/apps/x/apps/renderer/src/lib/frontmatter.ts @@ -139,7 +139,7 @@ export function extractFrontmatterFields(raw: string | null): FrontmatterFields * re-emitted by buildFrontmatter (callers must splice them back from the * original raw if they want to preserve them on save — see the helpers below). */ -const STRUCTURED_KEYS = new Set(['live']) +const STRUCTURED_KEYS = new Set(['live', 'google_doc']) /** * Extract editable top-level YAML key/value pairs from raw frontmatter. diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index 732d56abf..edae93d79 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -77,6 +77,8 @@ const providerConfigs: ProviderConfig = { scopes: [ 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/calendar.events.readonly', + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/documents', ], }, 'fireflies-ai': { diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts new file mode 100644 index 000000000..59210f49f --- /dev/null +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -0,0 +1,300 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { google, drive_v3 as drive } from 'googleapis'; +import { WorkDir } from '../config/config.js'; +import { resolveWorkspacePath } from '../workspace/workspace.js'; +import { GoogleClientFactory } from './google-client-factory.js'; + +export const GOOGLE_DOC_SCOPES = [ + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/documents', +] as const; + +export type GoogleDocListItem = { + id: string; + name: string; + url: string; + modifiedTime: string | null; + owner: string | null; +}; + +type GoogleDocFrontmatter = { + id: string; + url: string; + title: string; + syncedAt?: string; +}; + +const GOOGLE_DOC_MIME = 'application/vnd.google-apps.document'; +const TEXT_MIME = 'text/plain'; + +function yamlQuote(value: string): string { + return JSON.stringify(value); +} + +function sanitizeFilename(name: string): string { + const cleaned = name + .replace(/[\\/*?:"<>|]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120); + return cleaned || 'Google Doc'; +} + +function escapeDriveQueryValue(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +function normalizeKnowledgeDir(targetFolder: string): string { + const normalized = targetFolder.replace(/\\/g, '/').replace(/\/+$/, ''); + if (!normalized || normalized === 'knowledge') return 'knowledge'; + if (!normalized.startsWith('knowledge/')) { + throw new Error('Google Docs can only be added under knowledge/.'); + } + return normalized; +} + +function buildStubContent(doc: GoogleDocFrontmatter, snapshot: string): string { + const syncedAt = doc.syncedAt ?? new Date().toISOString(); + return [ + '---', + 'source:', + ' - google-doc', + 'google_doc:', + ` id: ${yamlQuote(doc.id)}`, + ` url: ${yamlQuote(doc.url)}`, + ` title: ${yamlQuote(doc.title)}`, + ` syncedAt: ${yamlQuote(syncedAt)}`, + '---', + '', + snapshot.trimEnd(), + '', + ].join('\n'); +} + +function parseLinkedGoogleDoc(markdown: string): GoogleDocFrontmatter | null { + if (!markdown.startsWith('---')) return null; + const endIndex = markdown.indexOf('\n---', 3); + if (endIndex === -1) return null; + const raw = markdown.slice(0, endIndex + 4); + const lines = raw.split('\n'); + let inGoogleDoc = false; + const doc: Partial = {}; + + for (const line of lines) { + if (line === '---') { + inGoogleDoc = false; + continue; + } + const topLevel = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/); + if (topLevel) { + inGoogleDoc = topLevel[1] === 'google_doc'; + continue; + } + if (!inGoogleDoc) continue; + const nested = line.match(/^\s+([A-Za-z_][\w-]*):\s*(.*)$/); + if (!nested) continue; + const key = nested[1] as keyof GoogleDocFrontmatter; + let value = nested[2].trim(); + if (!['id', 'url', 'title', 'syncedAt'].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; +} + +function markdownSnapshotToPlainText(markdown: string): string { + return bodyFromMarkdown(markdown) + .replace(/^#{1,6}\s+/gm, '') + .replace(/^\s*[-*]\s+/gm, '- ') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .trimEnd(); +} + +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 exportDocText(fileId: string): Promise { + const driveClient = await getDriveClient(); + const result = await driveClient.files.export( + { fileId, mimeType: TEXT_MIME }, + { responseType: 'text' }, + ); + return typeof result.data === 'string' ? result.data : String(result.data ?? ''); +} + +async function getDocMetadata(fileId: string): Promise { + const driveClient = await getDriveClient(); + const result = await driveClient.files.get({ + fileId, + fields: 'id,name,webViewLink,modifiedTime,owners(displayName,emailAddress)', + }); + const file = result.data; + if (!file.id || !file.name) throw new Error('Selected Google Doc is missing metadata.'); + return toGoogleDocListItem(file); +} + +function toGoogleDocListItem(file: drive.Schema$File): GoogleDocListItem { + return { + id: file.id ?? '', + name: file.name ?? 'Untitled Google Doc', + url: file.webViewLink ?? `https://docs.google.com/document/d/${file.id}/edit`, + modifiedTime: file.modifiedTime ?? null, + owner: file.owners?.[0]?.displayName ?? file.owners?.[0]?.emailAddress ?? null, + }; +} + +async function uniqueKnowledgePath(targetFolder: string, title: string): Promise { + const folder = normalizeKnowledgeDir(targetFolder); + const base = sanitizeFilename(title); + let candidate = `${folder}/${base}.md`; + let index = 1; + while (true) { + try { + await fs.access(resolveWorkspacePath(candidate)); + candidate = `${folder}/${base}-${index}.md`; + index += 1; + } catch { + return candidate; + } + } +} + +export async function getGoogleDocsConnectionStatus(): Promise<{ + connected: boolean; + hasRequiredScopes: boolean; + missingScopes: string[]; +}> { + return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]); +} + +export async function listGoogleDocs(query?: string): Promise<{ files: GoogleDocListItem[] }> { + const status = await getGoogleDocsConnectionStatus(); + if (!status.connected) throw new Error('Google is not connected.'); + if (!status.hasRequiredScopes) throw new Error('Google is missing Drive/Docs scopes. Reconnect Google.'); + + const driveClient = await getDriveClient(); + const clauses = [`mimeType='${GOOGLE_DOC_MIME}'`, 'trashed=false']; + const trimmed = query?.trim(); + if (trimmed) { + clauses.push(`name contains '${escapeDriveQueryValue(trimmed)}'`); + } + const result = await driveClient.files.list({ + q: clauses.join(' and '), + pageSize: 25, + orderBy: 'modifiedTime desc', + fields: 'files(id,name,webViewLink,modifiedTime,owners(displayName,emailAddress))', + }); + + return { files: (result.data.files ?? []).map(toGoogleDocListItem).filter((file) => file.id) }; +} + +export async function importGoogleDoc(fileId: string, targetFolder: string): Promise<{ + path: string; + doc: GoogleDocListItem; +}> { + const status = await getGoogleDocsConnectionStatus(); + if (!status.connected) throw new Error('Google is not connected.'); + if (!status.hasRequiredScopes) throw new Error('Google is missing Drive/Docs scopes. Reconnect Google.'); + + const doc = await getDocMetadata(fileId); + const snapshot = await exportDocText(fileId); + const relPath = await uniqueKnowledgePath(targetFolder, doc.name); + const absPath = resolveWorkspacePath(relPath); + await fs.mkdir(path.dirname(absPath), { recursive: true }); + await fs.writeFile(absPath, buildStubContent({ + id: doc.id, + url: doc.url, + title: doc.name, + syncedAt: new Date().toISOString(), + }, 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.'); + + const snapshot = await exportDocText(linked.id); + const syncedAt = new Date().toISOString(); + await fs.writeFile(absPath, buildStubContent({ ...linked, syncedAt }, snapshot), 'utf8'); + return { ok: true, syncedAt }; +} + +export async function syncLinkedGoogleDocFromMarkdown(relPath: string, markdown: string): Promise<{ synced: boolean; syncedAt?: string; 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 text = markdownSnapshotToPlainText(markdown); + const docsClient = await getDocsClient(); + const current = await docsClient.documents.get({ + documentId: linked.id, + fields: 'body(content(endIndex))', + }); + const endIndex = current.data.body?.content?.at(-1)?.endIndex ?? 1; + const requests = []; + if (endIndex > 2) { + requests.push({ + deleteContentRange: { + range: { startIndex: 1, endIndex: endIndex - 1 }, + }, + }); + } + if (text.trim()) { + requests.push({ + insertText: { + location: { index: 1 }, + text: `${text.trimEnd()}\n`, + }, + }); + } + if (requests.length > 0) { + await docsClient.documents.batchUpdate({ + documentId: linked.id, + requestBody: { requests }, + }); + } + + const absPath = path.join(WorkDir, normalized); + const syncedAt = new Date().toISOString(); + await fs.writeFile(absPath, buildStubContent({ ...linked, syncedAt }, bodyFromMarkdown(markdown)), 'utf8'); + return { synced: true, syncedAt }; + } catch (error) { + console.error('[GoogleDocs] Failed to sync linked Google Doc:', error); + return { synced: false, error: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index d0cee9ca0..ed8a20834 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -618,6 +618,65 @@ const ipcSchemas = { req: z.object({}), res: z.null(), }, + // Google Docs linked knowledge files + 'google-docs:getStatus': { + req: z.null(), + res: z.object({ + connected: z.boolean(), + hasRequiredScopes: z.boolean(), + missingScopes: z.array(z.string()), + }), + }, + 'google-docs:list': { + req: z.object({ + query: z.string().optional(), + }), + res: z.object({ + files: z.array(z.object({ + id: z.string(), + name: z.string(), + url: z.string(), + modifiedTime: z.string().nullable(), + owner: z.string().nullable(), + })), + }), + }, + 'google-docs:import': { + req: z.object({ + fileId: z.string().min(1), + targetFolder: RelPath, + }), + res: z.object({ + path: RelPath, + doc: z.object({ + id: z.string(), + name: z.string(), + url: z.string(), + modifiedTime: z.string().nullable(), + owner: z.string().nullable(), + }), + }), + }, + 'google-docs:refreshSnapshot': { + req: z.object({ + path: RelPath, + }), + res: z.object({ + ok: z.literal(true), + syncedAt: z.string(), + }), + }, + 'google-docs:sync': { + req: z.object({ + path: RelPath, + markdown: z.string(), + }), + res: z.object({ + synced: z.boolean(), + syncedAt: z.string().optional(), + error: z.string().optional(), + }), + }, // Search channels 'search:query': { req: z.object({ From b1e597ee3cf2ba6758bb1f75b0b26d785fc57dd3 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 27 May 2026 22:45:24 +0530 Subject: [PATCH 02/18] add drive button --- apps/x/apps/renderer/src/App.tsx | 69 +++++-------------- .../src/components/editor-toolbar.tsx | 55 +++++++++++++++ .../src/components/markdown-editor.tsx | 5 +- 3 files changed, 78 insertions(+), 51 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 88675b3ee..6619bbe41 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, Plus, HistoryIcon, DownloadIcon, UploadCloud } from 'lucide-react'; +import { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, Plus, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -1431,8 +1431,8 @@ function App() { setEditorContent(markdown) }, [setEditorCacheForPath]) - const syncGoogleDocDown = useCallback(async () => { - const path = selectedPathRef.current + const syncGoogleDocDown = useCallback(async (targetPath?: string) => { + const path = targetPath ?? selectedPathRef.current if (!path || !path.startsWith('knowledge/') || !path.endsWith('.md')) return setGoogleDocSyncDirection('down') @@ -1450,8 +1450,8 @@ function App() { } }, [markRecentLocalMarkdownWrite, reloadMarkdownFileIntoEditor]) - const syncGoogleDocUp = useCallback(async () => { - const path = selectedPathRef.current + const syncGoogleDocUp = useCallback(async (targetPath?: string) => { + const path = targetPath ?? selectedPathRef.current if (!path || !path.startsWith('knowledge/') || !path.endsWith('.md')) return const body = editorContentByPathRef.current.get(path) ?? editorContentRef.current @@ -5368,10 +5368,6 @@ function App() { } return markdownTabs }, [fileTabs, selectedPath]) - const selectedLinkedGoogleDoc = React.useMemo(() => { - if (!selectedPath?.startsWith('knowledge/') || !selectedPath.endsWith('.md')) return null - return parseLinkedGoogleDocFrontmatter(frontmatterByPathRef.current.get(selectedPath) ?? null) - }, [selectedPath, editorContent, editorContentByPath]) return ( { @@ -5481,46 +5477,6 @@ function App() { ) : null} )} - {selectedLinkedGoogleDoc && ( - <> - - - - - Sync down from Google Doc - - - - - - Sync up to Google Doc - - - )} {selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && ( @@ -5794,6 +5750,8 @@ function App() { ? tab.id === activeFileTabId || tab.path === selectedPath : tab.path === selectedPath const isViewingHistory = viewingHistoricalVersion && isActive && versionHistoryPath === tab.path + const tabFrontmatter = frontmatterByPathRef.current.get(tab.path) ?? null + const linkedGoogleDoc = parseLinkedGoogleDocFrontmatter(tabFrontmatter) const tabContent = isViewingHistory ? viewingHistoricalVersion.content : editorContentByPath[tab.path] @@ -5824,7 +5782,7 @@ function App() { wikiLinks={wikiLinkConfig} onImageUpload={handleImageUpload} editorSessionKey={editorSessionByTabId[tab.id] ?? 0} - frontmatter={frontmatterByPathRef.current.get(tab.path) ?? null} + frontmatter={tabFrontmatter} onFrontmatterChange={(newRaw) => { frontmatterByPathRef.current.set(tab.path, newRaw) // Write updated frontmatter to disk immediately @@ -5846,6 +5804,17 @@ function App() { } }} editable={!isViewingHistory} + googleDoc={linkedGoogleDoc && !isViewingHistory ? { + title: linkedGoogleDoc.title, + isSyncing: isActive ? googleDocSyncDirection : null, + onOpen: () => { + if (linkedGoogleDoc.url) { + window.open(linkedGoogleDoc.url, '_blank') + } + }, + onSyncDown: () => { void syncGoogleDocDown(tab.path) }, + onSyncUp: () => { void syncGoogleDocUp(tab.path) }, + } : undefined} onExport={async (format) => { const markdown = tabContent const title = getBaseName(tab.path) diff --git a/apps/x/apps/renderer/src/components/editor-toolbar.tsx b/apps/x/apps/renderer/src/components/editor-toolbar.tsx index b48cee494..0475e268c 100644 --- a/apps/x/apps/renderer/src/components/editor-toolbar.tsx +++ b/apps/x/apps/renderer/src/components/editor-toolbar.tsx @@ -26,15 +26,21 @@ import { Trash2Icon, ImageIcon, DownloadIcon, + ChevronDownIcon, FileTextIcon, FileIcon, FileTypeIcon, + CloudDownloadIcon, + LoaderIcon, + TriangleIcon, + UploadCloudIcon, Radio, } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' @@ -45,6 +51,15 @@ interface EditorToolbarProps { onExport?: (format: 'md' | 'pdf' | 'docx') => void onOpenLiveNote?: () => void liveState?: LivePillState + googleDoc?: GoogleDocToolbarState +} + +export interface GoogleDocToolbarState { + title: string + isSyncing?: 'up' | 'down' | null + onOpen: () => void + onSyncDown: () => void + onSyncUp: () => void } export type LivePillVariant = 'passive' | 'idle' | 'running' | 'error' @@ -67,6 +82,7 @@ export function EditorToolbar({ onExport, onOpenLiveNote, liveState, + googleDoc, }: EditorToolbarProps) { const [linkUrl, setLinkUrl] = useState('') const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false) @@ -404,6 +420,45 @@ export function EditorToolbar({ )} + {googleDoc && ( + <> +
+ + + + + + + + Open Google Doc + + + + + Sync down + + + + Sync up + + + + + )} + {/* Live Note pill — pushed to far right */} {onOpenLiveNote && liveState && ( - + Open Google Doc From 9456497e14ce86a1694a66ce3de33c30ac5a7ac2 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 10:18:35 +0530 Subject: [PATCH 04/18] icon changes --- .../src/components/editor-toolbar.tsx | 17 ++++++++++++++++- .../src/components/knowledge-view.tsx | 19 +++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/x/apps/renderer/src/components/editor-toolbar.tsx b/apps/x/apps/renderer/src/components/editor-toolbar.tsx index fff46297f..b7397376d 100644 --- a/apps/x/apps/renderer/src/components/editor-toolbar.tsx +++ b/apps/x/apps/renderer/src/components/editor-toolbar.tsx @@ -89,6 +89,21 @@ function GoogleDocsIcon({ className }: { className?: string }) { ) } +function GoogleDriveIcon({ className }: { className?: string }) { + return ( + + ) +} + export function EditorToolbar({ editor, onSelectionHighlight, @@ -456,7 +471,7 @@ export function EditorToolbar({ - + Open Google Doc diff --git a/apps/x/apps/renderer/src/components/knowledge-view.tsx b/apps/x/apps/renderer/src/components/knowledge-view.tsx index f41235288..1bc2ceae5 100644 --- a/apps/x/apps/renderer/src/components/knowledge-view.tsx +++ b/apps/x/apps/renderer/src/components/knowledge-view.tsx @@ -105,6 +105,21 @@ function latestMtime(node: TreeNode): number { return max } +function GoogleDriveIcon({ className }: { className?: string }) { + return ( + + ) +} + function sortNodes(nodes: TreeNode[]): TreeNode[] { return [...nodes].sort((a, b) => { if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1 @@ -208,7 +223,7 @@ export function KnowledgeView({ onClick={() => actions.addGoogleDoc(currentFolder?.path)} className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent" > - + Add Google Doc
@@ -774,7 +789,7 @@ function RowContextMenu({ New Note
actions.addGoogleDoc(node.path)}> - + Add Google Doc void actions.createFolder(node.path)}> From c0427ed285560bf340f4a1763e3b2179d9cb285f Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Fri, 29 May 2026 16:30:32 +0530 Subject: [PATCH 05/18] show error state with retry in google doc picker --- .../src/components/google-doc-picker-dialog.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx b/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx index d03f94de5..baa16dd4b 100644 --- a/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx +++ b/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx @@ -57,6 +57,7 @@ export function GoogleDocPickerDialog({ const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder]) const loadStatus = useCallback(async () => { + setError(null) try { const result = await window.ipc.invoke('google-docs:getStatus', null) setStatus(result) @@ -142,7 +143,15 @@ export function GoogleDocPickerDialog({
- {!status ? ( + {!status && error ? ( +
+
{error}
+ +
+ ) : !status ? (
Checking Google connection... From 8463c8ba57041da4a444368ea0adcd1cfd37decf Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Mon, 1 Jun 2026 02:16:51 +0530 Subject: [PATCH 06/18] feat(google-docs): import and sync down as Markdown, record remote revision --- .../core/src/knowledge/google_docs.test.ts | 142 ++++++++++++++++++ .../core/src/knowledge/google_docs.ts | 41 +++-- 2 files changed, 170 insertions(+), 13 deletions(-) create mode 100644 apps/x/packages/core/src/knowledge/google_docs.test.ts diff --git a/apps/x/packages/core/src/knowledge/google_docs.test.ts b/apps/x/packages/core/src/knowledge/google_docs.test.ts new file mode 100644 index 000000000..c5f037fe6 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/google_docs.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Phase 1 — read-path fidelity. + * + * 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. + */ + +const MARKDOWN_SNAPSHOT = [ + '# Title', + '', + 'Some **bold** and a [link](https://example.com).', + '', + '- one', + '- two', +].join('\n'); + +// 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 }> = []; + +const driveFile = { + id: 'doc-123', + name: 'My Doc', + webViewLink: 'https://docs.google.com/document/d/doc-123/edit', + modifiedTime: '2026-05-28T10:00:00.000Z', + owners: [{ displayName: 'Arjun', emailAddress: 'arjun@example.com' }], +}; + +beforeEach(() => { + vi.resetModules(); + written = null; + exportCalls = []; + + vi.doMock('node:fs/promises', () => ({ + default: { + readFile: vi.fn(async () => readFileContent), + writeFile: vi.fn(async (path: string, content: string) => { written = { path, content }; }), + mkdir: vi.fn(async () => undefined), + access: vi.fn(async () => { throw new Error('ENOENT'); }), + }, + })); + + vi.doMock('../config/config.js', () => ({ WorkDir: '/ws' })); + vi.doMock('../workspace/workspace.js', () => ({ + resolveWorkspacePath: (rel: string) => `/ws/${rel}`, + })); + + vi.doMock('./google-client-factory.js', () => ({ + GoogleClientFactory: { + getClient: vi.fn(async () => ({})), + getCredentialStatus: vi.fn(async () => ({ + connected: true, + hasRequiredScopes: true, + missingScopes: [], + })), + }, + })); + + const driveClient = { + files: { + 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 }; + }), + list: vi.fn(async () => ({ data: { files: [driveFile] } })), + }, + }; + + vi.doMock('googleapis', () => ({ + google: { + drive: vi.fn(() => driveClient), + docs: vi.fn(() => ({ documents: { get: vi.fn(), batchUpdate: vi.fn() } })), + }, + })); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('importGoogleDoc', () => { + it('exports as Markdown (not plain text) and keeps the formatting in the note body', 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(); + + 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'); + }); + + 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"'); + }); +}); + +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'); + + const { refreshGoogleDocSnapshot } = await import('./google_docs.js'); + const result = await refreshGoogleDocSnapshot('knowledge/My Doc.md'); + + 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"'); + }); +}); diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts index 59210f49f..9d29c8f1e 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -23,10 +23,16 @@ type GoogleDocFrontmatter = { 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. + remoteModifiedTime?: string; }; const GOOGLE_DOC_MIME = 'application/vnd.google-apps.document'; -const TEXT_MIME = 'text/plain'; +// 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'; function yamlQuote(value: string): string { return JSON.stringify(value); @@ -56,7 +62,7 @@ function normalizeKnowledgeDir(targetFolder: string): string { function buildStubContent(doc: GoogleDocFrontmatter, snapshot: string): string { const syncedAt = doc.syncedAt ?? new Date().toISOString(); - return [ + const lines = [ '---', 'source:', ' - google-doc', @@ -65,11 +71,12 @@ function buildStubContent(doc: GoogleDocFrontmatter, snapshot: string): string { ` url: ${yamlQuote(doc.url)}`, ` title: ${yamlQuote(doc.title)}`, ` syncedAt: ${yamlQuote(syncedAt)}`, - '---', - '', - snapshot.trimEnd(), - '', - ].join('\n'); + ]; + if (doc.remoteModifiedTime) { + lines.push(` remoteModifiedTime: ${yamlQuote(doc.remoteModifiedTime)}`); + } + lines.push('---', '', snapshot.trimEnd(), ''); + return lines.join('\n'); } function parseLinkedGoogleDoc(markdown: string): GoogleDocFrontmatter | null { @@ -96,7 +103,7 @@ function parseLinkedGoogleDoc(markdown: string): GoogleDocFrontmatter | null { if (!nested) continue; const key = nested[1] as keyof GoogleDocFrontmatter; let value = nested[2].trim(); - if (!['id', 'url', 'title', 'syncedAt'].includes(key)) continue; + if (!['id', 'url', 'title', 'syncedAt', 'remoteModifiedTime'].includes(key)) continue; try { if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = JSON.parse(value); @@ -143,10 +150,10 @@ async function getDocsClient() { return google.docs({ version: 'v1', auth }); } -async function exportDocText(fileId: string): Promise { +async function exportDocMarkdown(fileId: string): Promise { const driveClient = await getDriveClient(); const result = await driveClient.files.export( - { fileId, mimeType: TEXT_MIME }, + { fileId, mimeType: MARKDOWN_MIME }, { responseType: 'text' }, ); return typeof result.data === 'string' ? result.data : String(result.data ?? ''); @@ -227,7 +234,7 @@ 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 exportDocText(fileId); + const snapshot = await exportDocMarkdown(fileId); const relPath = await uniqueKnowledgePath(targetFolder, doc.name); const absPath = resolveWorkspacePath(relPath); await fs.mkdir(path.dirname(absPath), { recursive: true }); @@ -236,6 +243,7 @@ export async function importGoogleDoc(fileId: string, targetFolder: string): Pro url: doc.url, title: doc.name, syncedAt: new Date().toISOString(), + remoteModifiedTime: doc.modifiedTime ?? undefined, }, snapshot), 'utf8'); return { path: relPath, doc }; } @@ -246,9 +254,16 @@ export async function refreshGoogleDocSnapshot(relPath: string): Promise<{ ok: t const linked = parseLinkedGoogleDoc(markdown); if (!linked) throw new Error('This note is not linked to a Google Doc.'); - const snapshot = await exportDocText(linked.id); + const [snapshot, meta] = await Promise.all([ + exportDocMarkdown(linked.id), + getDocMetadata(linked.id), + ]); const syncedAt = new Date().toISOString(); - await fs.writeFile(absPath, buildStubContent({ ...linked, syncedAt }, snapshot), 'utf8'); + await fs.writeFile(absPath, buildStubContent({ + ...linked, + syncedAt, + remoteModifiedTime: meta.modifiedTime ?? linked.remoteModifiedTime, + }, snapshot), 'utf8'); return { ok: true, syncedAt }; } From 7e6ee040aa58a630f9c44fb2709796dfb4578273 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Mon, 1 Jun 2026 02:27:33 +0530 Subject: [PATCH 07/18] feat(google-docs): structure-preserving sync up with remote-conflict guard --- apps/x/apps/main/src/ipc.ts | 2 +- .../core/src/knowledge/google_docs.test.ts | 81 +++++++- .../core/src/knowledge/google_docs.ts | 68 ++++-- .../src/knowledge/markdown-to-docs.test.ts | 83 ++++++++ .../core/src/knowledge/markdown-to-docs.ts | 195 ++++++++++++++++++ apps/x/packages/shared/src/ipc.ts | 4 + 6 files changed, 409 insertions(+), 24 deletions(-) create mode 100644 apps/x/packages/core/src/knowledge/markdown-to-docs.test.ts create mode 100644 apps/x/packages/core/src/knowledge/markdown-to-docs.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 0e3662b66..8b07e970c 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -825,7 +825,7 @@ export function setupIpcHandlers() { return refreshGoogleDocSnapshot(args.path); }, 'google-docs:sync': async (_event, args) => { - return syncLinkedGoogleDocFromMarkdown(args.path, args.markdown); + return syncLinkedGoogleDocFromMarkdown(args.path, args.markdown, { force: args.force }); }, // Search handler 'search:query': async (_event, args) => { diff --git a/apps/x/packages/core/src/knowledge/google_docs.test.ts b/apps/x/packages/core/src/knowledge/google_docs.test.ts index c5f037fe6..24ee2fbf1 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.test.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.test.ts @@ -22,6 +22,7 @@ const MARKDOWN_SNAPSHOT = [ let written: { path: string; content: string } | null = null; let readFileContent = ''; let exportCalls: Array<{ fileId: string; mimeType: string }> = []; +let batchUpdateCalls: Array<{ documentId: string; requests: unknown[] }> = []; const driveFile = { id: 'doc-123', @@ -35,6 +36,7 @@ beforeEach(() => { vi.resetModules(); written = null; exportCalls = []; + batchUpdateCalls = []; vi.doMock('node:fs/promises', () => ({ default: { @@ -72,14 +74,42 @@ beforeEach(() => { }, }; + 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 }); + return { data: {} }; + }), + }, + }; + vi.doMock('googleapis', () => ({ google: { drive: vi.fn(() => driveClient), - docs: vi.fn(() => ({ documents: { get: vi.fn(), batchUpdate: vi.fn() } })), + docs: vi.fn(() => docsClient), }, })); }); +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(); }); @@ -140,3 +170,52 @@ describe('refreshGoogleDocSnapshot (sync down)', () => { expect(written!.content).toContain('remoteModifiedTime: "2026-05-28T10:00:00.000Z"'); }); }); + +describe('syncLinkedGoogleDocFromMarkdown (sync up)', () => { + 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); + + expect(result.synced).toBe(false); + expect(result.conflict).toBe(true); + expect(batchUpdateCalls).toHaveLength(0); // remote was not touched + }); + + 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 }); + + expect(result.synced).toBe(true); + expect(batchUpdateCalls).toHaveLength(1); + }); + + 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); + + expect(result.synced).toBe(true); + expect(batchUpdateCalls).toHaveLength(1); + const requests = batchUpdateCalls[0].requests as Array>; + // Old content cleared, then a heading style applied (structure, not flat text). + expect(requests.some((r) => 'deleteContentRange' in r)).toBe(true); + expect(requests.some((r) => 'updateParagraphStyle' in r)).toBe(true); + expect(requests.some((r) => 'updateTextStyle' in r)).toBe(true); + // Local note's baseline is bumped to the post-push revision. + expect(written!.content).toContain('remoteModifiedTime: "2026-05-28T10:00:00.000Z"'); + }); +}); + +describe('isRemoteAhead', () => { + it('detects a newer remote revision and tolerates missing baselines', async () => { + const { isRemoteAhead } = await import('./google_docs.js'); + expect(isRemoteAhead('2026-05-28T10:00:00.000Z', '2026-05-20T00:00:00.000Z')).toBe(true); + expect(isRemoteAhead('2026-05-20T00:00:00.000Z', '2026-05-28T10:00:00.000Z')).toBe(false); + expect(isRemoteAhead('2026-05-28T10:00:00.000Z', undefined)).toBe(false); + expect(isRemoteAhead(null, '2026-05-20T00:00:00.000Z')).toBe(false); + }); +}); diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts index 9d29c8f1e..cdee0b23c 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -1,9 +1,10 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { google, drive_v3 as drive } from 'googleapis'; +import { google, drive_v3 as drive, docs_v1 } from 'googleapis'; import { WorkDir } from '../config/config.js'; 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', @@ -127,15 +128,21 @@ function bodyFromMarkdown(markdown: string): string { return body; } -function markdownSnapshotToPlainText(markdown: string): string { - return bodyFromMarkdown(markdown) - .replace(/^#{1,6}\s+/gm, '') - .replace(/^\s*[-*]\s+/gm, '- ') - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/\*([^*]+)\*/g, '$1') - .replace(/`([^`]+)`/g, '$1') - .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - .trimEnd(); +/** + * 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. + */ +export function isRemoteAhead( + remoteModifiedTime: string | null | undefined, + lastKnownModifiedTime: string | undefined, +): boolean { + if (!remoteModifiedTime || !lastKnownModifiedTime) return false; + const remote = Date.parse(remoteModifiedTime); + const known = Date.parse(lastKnownModifiedTime); + if (Number.isNaN(remote) || Number.isNaN(known)) return false; + return remote > known; } async function getDriveClient() { @@ -267,21 +274,37 @@ export async function refreshGoogleDocSnapshot(relPath: string): Promise<{ ok: t return { ok: true, syncedAt }; } -export async function syncLinkedGoogleDocFromMarkdown(relPath: string, markdown: string): Promise<{ synced: boolean; syncedAt?: string; error?: string }> { +export async function syncLinkedGoogleDocFromMarkdown( + 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 text = markdownSnapshotToPlainText(markdown); + // 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)) { + return { + synced: false, + conflict: true, + error: 'The Google Doc changed since your last sync. Pull the latest, or overwrite it.', + }; + } + } + + const body = bodyFromMarkdown(markdown); const docsClient = await getDocsClient(); const current = await docsClient.documents.get({ documentId: linked.id, fields: 'body(content(endIndex))', }); const endIndex = current.data.body?.content?.at(-1)?.endIndex ?? 1; - const requests = []; + const requests: docs_v1.Schema$Request[] = []; if (endIndex > 2) { requests.push({ deleteContentRange: { @@ -289,14 +312,8 @@ export async function syncLinkedGoogleDocFromMarkdown(relPath: string, markdown: }, }); } - if (text.trim()) { - requests.push({ - insertText: { - location: { index: 1 }, - text: `${text.trimEnd()}\n`, - }, - }); - } + // 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, @@ -304,9 +321,16 @@ export async function syncLinkedGoogleDocFromMarkdown(relPath: string, markdown: }); } + // 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 syncedAt = new Date().toISOString(); - await fs.writeFile(absPath, buildStubContent({ ...linked, syncedAt }, bodyFromMarkdown(markdown)), 'utf8'); + await fs.writeFile(absPath, buildStubContent({ + ...linked, + syncedAt, + remoteModifiedTime: meta.modifiedTime ?? linked.remoteModifiedTime, + }, body), 'utf8'); return { synced: true, syncedAt }; } catch (error) { console.error('[GoogleDocs] Failed to sync linked Google Doc:', error); diff --git a/apps/x/packages/core/src/knowledge/markdown-to-docs.test.ts b/apps/x/packages/core/src/knowledge/markdown-to-docs.test.ts new file mode 100644 index 000000000..347876f1f --- /dev/null +++ b/apps/x/packages/core/src/knowledge/markdown-to-docs.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { markdownToDocsRequests, parseInline } from './markdown-to-docs.js'; + +describe('parseInline', () => { + it('extracts bold, italic and link ranges with offsets relative to the plain text', () => { + expect(parseInline('a **b** c')).toEqual({ + text: 'a b c', + ranges: [{ start: 2, end: 3, bold: true }], + }); + expect(parseInline('see [docs](https://x.dev) now')).toEqual({ + text: 'see docs now', + ranges: [{ start: 4, end: 8, link: 'https://x.dev' }], + }); + expect(parseInline('_em_')).toEqual({ + text: 'em', + ranges: [{ start: 0, end: 2, italic: true }], + }); + }); + + it('keeps inline code text without styling', () => { + expect(parseInline('run `npm test`')).toEqual({ text: 'run npm test', ranges: [] }); + }); +}); + +describe('markdownToDocsRequests', () => { + it('returns no requests for an empty body', () => { + expect(markdownToDocsRequests(' \n\n')).toEqual([]); + }); + + it('inserts the full text first, then layers styles at the right indices', () => { + const reqs = markdownToDocsRequests('# Hello\n\nworld **bold**'); + + // First request inserts all paragraph text at index 1. + expect(reqs[0]).toEqual({ + insertText: { location: { index: 1 }, text: 'Hello\n\nworld bold\n' }, + }); + + // Heading 1 applied to "Hello\n" → [1, 7). + expect(reqs).toContainEqual({ + updateParagraphStyle: { + range: { startIndex: 1, endIndex: 7 }, + paragraphStyle: { namedStyleType: 'HEADING_1' }, + fields: 'namedStyleType', + }, + }); + + // "bold" sits at [14, 18) in the inserted text. + expect(reqs).toContainEqual({ + updateTextStyle: { + range: { startIndex: 14, endIndex: 18 }, + textStyle: { bold: true }, + fields: 'bold', + }, + }); + }); + + it('maps bullet and numbered lists to the right bullet presets', () => { + const bullets = markdownToDocsRequests('- one\n- two'); + const bulletReqs = bullets.filter((r) => 'createParagraphBullets' in r); + expect(bulletReqs).toHaveLength(2); + expect(bulletReqs[0]).toMatchObject({ + createParagraphBullets: { bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE' }, + }); + + const numbered = markdownToDocsRequests('1. first\n2. second'); + const numberedReqs = numbered.filter((r) => 'createParagraphBullets' in r); + expect(numberedReqs).toHaveLength(2); + expect(numberedReqs[0]).toMatchObject({ + createParagraphBullets: { bulletPreset: 'NUMBERED_DECIMAL_ALPHA_ROMAN' }, + }); + }); + + it('emits a link textStyle request', () => { + const reqs = markdownToDocsRequests('see [docs](https://x.dev)'); + expect(reqs).toContainEqual({ + updateTextStyle: { + range: { startIndex: 5, endIndex: 9 }, + textStyle: { link: { url: 'https://x.dev' } }, + fields: 'link', + }, + }); + }); +}); diff --git a/apps/x/packages/core/src/knowledge/markdown-to-docs.ts b/apps/x/packages/core/src/knowledge/markdown-to-docs.ts new file mode 100644 index 000000000..6c4d3f856 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/markdown-to-docs.ts @@ -0,0 +1,195 @@ +import type { docs_v1 } from 'googleapis'; + +/** + * Convert a Markdown note body into Google Docs API batchUpdate requests that + * recreate the content with structure preserved — headings, bold/italic, + * bullet & numbered lists, and links — instead of flattening everything to + * plain text. + * + * Strategy: the doc body is cleared first (see syncLinkedGoogleDocFromMarkdown), + * then we insert all paragraph text in one shot at `insertIndex` and layer + * paragraph/text styling on top using ranges computed against the inserted + * text. Style requests do not shift indices, so a single insertText followed by + * style updates stays index-stable within one batchUpdate. + * + * Out of scope (degrade to plain paragraphs): tables, images, code fences, + * blockquotes, nested lists. + */ + +type InlineRange = { + start: number; + end: number; + bold?: boolean; + italic?: boolean; + link?: string; +}; + +type Block = { + text: string; + ranges: InlineRange[]; + paragraph: 'normal' | 'heading'; + headingLevel?: number; + list?: 'bullet' | 'number'; +}; + +const HEADING_NAMED_STYLE: Record = { + 1: 'HEADING_1', + 2: 'HEADING_2', + 3: 'HEADING_3', + 4: 'HEADING_4', + 5: 'HEADING_5', + 6: 'HEADING_6', +}; + +/** + * Parse a single line's inline Markdown (bold, italic, code, links) into plain + * text plus the style ranges that apply to it. Offsets are relative to the + * returned text. Nested emphasis is not handled; inner markers are kept as-is. + */ +export function parseInline(raw: string): { text: string; ranges: InlineRange[] } { + let text = ''; + const ranges: InlineRange[] = []; + let i = 0; + + while (i < raw.length) { + const rest = raw.slice(i); + + // Link: [label](url) + const link = /^\[([^\]]+)\]\(([^)\s]+)\)/.exec(rest); + if (link) { + const start = text.length; + text += link[1]; + ranges.push({ start, end: text.length, link: link[2] }); + i += link[0].length; + continue; + } + + // Bold: **text** or __text__ + const bold = /^(\*\*|__)(.+?)\1/.exec(rest); + if (bold) { + const start = text.length; + text += bold[2]; + ranges.push({ start, end: text.length, bold: true }); + i += bold[0].length; + continue; + } + + // Italic: *text* or _text_ + const italic = /^(\*|_)([^*_]+?)\1/.exec(rest); + if (italic) { + const start = text.length; + text += italic[2]; + ranges.push({ start, end: text.length, italic: true }); + i += italic[0].length; + continue; + } + + // Inline code: `text` — kept as text, no monospace styling applied. + const code = /^`([^`]+)`/.exec(rest); + if (code) { + text += code[1]; + i += code[0].length; + continue; + } + + text += raw[i]; + i += 1; + } + + return { text, ranges }; +} + +function parseBlock(line: string): Block { + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + if (heading) { + const { text, ranges } = parseInline(heading[2]); + return { text, ranges, paragraph: 'heading', headingLevel: heading[1].length }; + } + + const bullet = /^\s*[-*+]\s+(.*)$/.exec(line); + if (bullet) { + const { text, ranges } = parseInline(bullet[1]); + return { text, ranges, paragraph: 'normal', list: 'bullet' }; + } + + const numbered = /^\s*\d+\.\s+(.*)$/.exec(line); + if (numbered) { + const { text, ranges } = parseInline(numbered[1]); + return { text, ranges, paragraph: 'normal', list: 'number' }; + } + + const { text, ranges } = parseInline(line); + return { text, ranges, paragraph: 'normal' }; +} + +/** + * Build the batchUpdate requests for the given Markdown body. Each line becomes + * one paragraph (blank lines included, to preserve spacing). + */ +export function markdownToDocsRequests( + body: string, + insertIndex = 1, +): docs_v1.Schema$Request[] { + const trimmed = body.replace(/\s+$/, ''); + if (!trimmed) return []; + + const blocks = trimmed.split('\n').map(parseBlock); + + // Concatenate every block's text, each terminated by a newline that ends its + // paragraph. Track where each block starts in the inserted text. + let fullText = ''; + const starts: number[] = []; + for (const block of blocks) { + starts.push(insertIndex + fullText.length); + fullText += `${block.text}\n`; + } + + const requests: docs_v1.Schema$Request[] = [ + { insertText: { location: { index: insertIndex }, text: fullText } }, + ]; + + blocks.forEach((block, idx) => { + const start = starts[idx]; + const textEnd = start + block.text.length; + const paraEnd = textEnd + 1; // include the trailing newline + + if (block.paragraph === 'heading' && block.headingLevel) { + requests.push({ + updateParagraphStyle: { + range: { startIndex: start, endIndex: paraEnd }, + paragraphStyle: { namedStyleType: HEADING_NAMED_STYLE[block.headingLevel] }, + fields: 'namedStyleType', + }, + }); + } + + if (block.list && block.text.length > 0) { + requests.push({ + createParagraphBullets: { + range: { startIndex: start, endIndex: paraEnd }, + bulletPreset: block.list === 'number' + ? 'NUMBERED_DECIMAL_ALPHA_ROMAN' + : 'BULLET_DISC_CIRCLE_SQUARE', + }, + }); + } + + for (const r of block.ranges) { + if (r.end <= r.start) continue; + const range = { startIndex: start + r.start, endIndex: start + r.end }; + if (r.bold) { + requests.push({ updateTextStyle: { range, textStyle: { bold: true }, fields: 'bold' } }); + } + if (r.italic) { + requests.push({ updateTextStyle: { range, textStyle: { italic: true }, fields: 'italic' } }); + } + if (r.link) { + requests.push({ + updateTextStyle: { range, textStyle: { link: { url: r.link } }, fields: 'link' }, + }); + } + } + }); + + return requests; +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index c7af20bb5..26a398af0 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -693,10 +693,14 @@ const ipcSchemas = { req: z.object({ path: RelPath, markdown: z.string(), + // Overwrite the Google Doc even if it changed remotely since last sync. + force: z.boolean().optional(), }), res: z.object({ synced: z.boolean(), syncedAt: z.string().optional(), + // True when a remote edit was detected and the push was held back. + conflict: z.boolean().optional(), error: z.string().optional(), }), }, From ccdfc0f6e91935f87cec8357c64a300e7344b1d0 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Mon, 1 Jun 2026 02:35:29 +0530 Subject: [PATCH 08/18] feat(google-docs): overwrite-confirm on sync conflict and last-synced indicator --- apps/x/apps/renderer/src/App.tsx | 15 ++++++++++++++- .../renderer/src/components/editor-toolbar.tsx | 9 +++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index fc36df68d..1137aabed 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1454,7 +1454,19 @@ function App() { setGoogleDocSyncDirection('up') markRecentLocalMarkdownWrite(path) try { - const result = await window.ipc.invoke('google-docs:sync', { path, markdown }) + let result = await window.ipc.invoke('google-docs:sync', { path, markdown }) + 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, markdown, force: true }) + } if (!result.synced) { throw new Error(result.error || 'This note is not linked to a Google Doc.') } @@ -5776,6 +5788,7 @@ function App() { googleDoc={linkedGoogleDoc && !isViewingHistory ? { title: linkedGoogleDoc.title, isSyncing: isActive ? googleDocSyncDirection : null, + lastSyncedAt: linkedGoogleDoc.syncedAt, onOpen: () => { if (linkedGoogleDoc.url) { window.open(linkedGoogleDoc.url, '_blank') diff --git a/apps/x/apps/renderer/src/components/editor-toolbar.tsx b/apps/x/apps/renderer/src/components/editor-toolbar.tsx index b7397376d..9bacc1581 100644 --- a/apps/x/apps/renderer/src/components/editor-toolbar.tsx +++ b/apps/x/apps/renderer/src/components/editor-toolbar.tsx @@ -39,9 +39,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { formatRelativeTime } from '@/lib/relative-time' interface EditorToolbarProps { editor: Editor | null @@ -56,6 +58,7 @@ interface EditorToolbarProps { export interface GoogleDocToolbarState { title: string isSyncing?: 'up' | 'down' | null + lastSyncedAt?: string onOpen: () => void onSyncDown: () => void onSyncUp: () => void @@ -470,6 +473,12 @@ export function EditorToolbar({ + + {googleDoc.lastSyncedAt + ? `Last synced ${formatRelativeTime(googleDoc.lastSyncedAt)}` + : 'Not synced yet'} + + Open Google Doc From 09b0a66fa5c273194dc23414375b541a20f2c1cc Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Mon, 1 Jun 2026 13:13:41 +0530 Subject: [PATCH 09/18] feat(google-docs): store linked docs as .docx, edit in docx editor, sync via Drive --- apps/x/apps/main/src/ipc.ts | 9 +- .../src/components/docx-file-viewer.tsx | 204 +++++++++++--- .../core/src/knowledge/google_docs.test.ts | 235 ++++++++-------- .../core/src/knowledge/google_docs.ts | 256 +++++++----------- .../src/knowledge/markdown-to-docs.test.ts | 83 ------ .../core/src/knowledge/markdown-to-docs.ts | 195 ------------- apps/x/packages/shared/src/ipc.ts | 18 +- 7 files changed, 406 insertions(+), 594 deletions(-) delete mode 100644 apps/x/packages/core/src/knowledge/markdown-to-docs.test.ts delete mode 100644 apps/x/packages/core/src/knowledge/markdown-to-docs.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 8b07e970c..75fb8f27e 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -52,7 +52,7 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; -import { getGoogleDocsConnectionStatus, importGoogleDoc, listGoogleDocs, refreshGoogleDocSnapshot, syncLinkedGoogleDocFromMarkdown } from '@x/core/dist/knowledge/google_docs.js'; +import { getGoogleDocsConnectionStatus, importGoogleDoc, listGoogleDocs, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; @@ -822,10 +822,13 @@ export function setupIpcHandlers() { return importGoogleDoc(args.fileId, args.targetFolder); }, 'google-docs:refreshSnapshot': async (_event, args) => { - return refreshGoogleDocSnapshot(args.path); + return syncGoogleDocDown(args.path); }, 'google-docs:sync': async (_event, args) => { - return syncLinkedGoogleDocFromMarkdown(args.path, args.markdown, { force: args.force }); + return syncGoogleDocUp(args.path, { force: args.force }); + }, + 'google-docs:getLink': async (_event, args) => { + return { link: await getGoogleDocLink(args.path) }; }, // Search handler 'search:query': async (_event, args) => { diff --git a/apps/x/apps/renderer/src/components/docx-file-viewer.tsx b/apps/x/apps/renderer/src/components/docx-file-viewer.tsx index 415ae4a06..55acc6c1d 100644 --- a/apps/x/apps/renderer/src/components/docx-file-viewer.tsx +++ b/apps/x/apps/renderer/src/components/docx-file-viewer.tsx @@ -1,6 +1,14 @@ -import { Suspense, lazy, useEffect, useRef, useState } from 'react' -import { ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react' +import { Suspense, lazy, useCallback, useEffect, useRef, useState } from 'react' +import { + CloudDownloadIcon, + ExternalLinkIcon, + FileTextIcon, + Loader2Icon, + UploadCloudIcon, +} from 'lucide-react' +import { toast } from 'sonner' import type { DocxEditorRef } from '@eigenpal/docx-editor-react' +import { formatRelativeTime } from '@/lib/relative-time' // The editor (and its CSS) is heavy and only needed when a .docx is open, so it // loads in its own chunk the first time a Word document is viewed. @@ -16,6 +24,14 @@ interface DocxFileViewerProps { path: string } +type GoogleDocLink = { + id: string + url: string + title: string + syncedAt: string + remoteModifiedTime?: string +} + type LoadState = 'loading' | 'ready' | 'error' type SaveState = 'idle' | 'saving' | 'saved' | 'error' @@ -51,6 +67,9 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) { const [loadState, setLoadState] = useState('loading') const [buffer, setBuffer] = useState(null) const [saveState, setSaveState] = useState('idle') + const [reloadNonce, setReloadNonce] = useState(0) + const [link, setLink] = useState(null) + const [syncing, setSyncing] = useState<'up' | 'down' | null>(null) const editorRef = useRef(null) const saveTimerRef = useRef | null>(null) @@ -59,7 +78,7 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) { const dirtyRef = useRef(false) const savingRef = useRef(false) - // Load the .docx bytes whenever the path changes. + // Load the .docx bytes whenever the path changes or a sync-down reloads it. useEffect(() => { let cancelled = false setLoadState('loading') @@ -87,10 +106,20 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) { cancelled = true if (armTimerRef.current) clearTimeout(armTimerRef.current) } + }, [path, reloadNonce]) + + // Is this file linked to a Google Doc? Drives the sync bar. + useEffect(() => { + let cancelled = false + setLink(null) + void window.ipc.invoke('google-docs:getLink', { path }) + .then((res) => { if (!cancelled) setLink(res.link) }) + .catch((err) => { console.error('Failed to read Google Doc link:', err) }) + return () => { cancelled = true } }, [path]) // Serialize the current document and write it back to disk. - const persist = async () => { + const persist = useCallback(async () => { const editor = editorRef.current if (!editor || savingRef.current) return savingRef.current = true @@ -115,7 +144,8 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) { // A change landed while we were saving — flush it. if (dirtyRef.current) scheduleSave() } - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [path]) const scheduleSave = () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) @@ -137,6 +167,66 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [path]) + // Write any pending edits to disk before a sync-up so we push the latest. + const flushPendingSave = useCallback(async () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + if (dirtyRef.current || savingRef.current) { + await persist() + } + }, [persist]) + + const handleSyncDown = useCallback(async () => { + if (syncing) return + setSyncing('down') + try { + await window.ipc.invoke('google-docs:refreshSnapshot', { path }) + // Reload the freshly-written bytes into the editor. + armedRef.current = false + dirtyRef.current = false + setReloadNonce((n) => n + 1) + const res = await window.ipc.invoke('google-docs:getLink', { path }) + setLink(res.link) + toast.success('Pulled latest from Google Docs') + } catch (err) { + console.error('Sync down failed:', err) + toast.error(err instanceof Error ? err.message : 'Failed to pull from Google Docs') + } finally { + setSyncing(null) + } + }, [path, syncing]) + + const handleSyncUp = useCallback(async () => { + if (syncing) return + setSyncing('up') + try { + await flushPendingSave() + let result = await window.ipc.invoke('google-docs:sync', { path }) + if (result.conflict) { + const overwrite = window.confirm( + 'This Google Doc changed since your last sync.\n\n' + + 'Overwrite it with your local version? Cancel to keep the remote copy ' + + '(use “Sync down” to pull it first).', + ) + if (!overwrite) { + toast.info('Sync up cancelled — remote Google Doc is unchanged') + return + } + result = await window.ipc.invoke('google-docs:sync', { path, force: true }) + } + if (!result.synced) { + throw new Error(result.error || 'This file is not linked to a Google Doc.') + } + const res = await window.ipc.invoke('google-docs:getLink', { path }) + setLink(res.link) + toast.success('Pushed changes to Google Docs') + } catch (err) { + console.error('Sync up failed:', err) + toast.error(err instanceof Error ? err.message : 'Failed to push to Google Docs') + } finally { + setSyncing(null) + } + }, [path, syncing, flushPendingSave]) + if (loadState === 'error') { return (
@@ -155,37 +245,75 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) { ) } - if (loadState === 'loading' || !buffer) { - return ( -
- -

Loading document…

-
- ) - } - return (
- - -

Loading editor…

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

Loading document…

+
+ ) : ( + + +

Loading editor…

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

+ Managed sign-in may not grant Docs access yet. If it keeps asking for scopes, + connect a Google OAuth client (Desktop app) with the Drive API and Docs API enabled. +

) : ( <> @@ -232,5 +270,12 @@ export function GoogleDocPickerDialog({
+ + ) } From b35bd8fe2ed323fcb8f044c4b3ff9581a19ec973 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Mon, 1 Jun 2026 13:26:34 +0530 Subject: [PATCH 11/18] fix(google-docs): request full drive scope so .docx sync-up can write back --- apps/x/packages/core/src/auth/providers.ts | 6 ++++-- apps/x/packages/core/src/knowledge/google_docs.ts | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index edae93d79..40ef6f4a0 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -77,8 +77,10 @@ const providerConfigs: ProviderConfig = { scopes: [ 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/calendar.events.readonly', - 'https://www.googleapis.com/auth/drive.readonly', - 'https://www.googleapis.com/auth/documents', + // Full Drive access: read/export Google Docs to .docx AND write the edited + // .docx back into the original doc (files.update needs write, which + // drive.readonly does not grant). Covers list/get/export/update. + 'https://www.googleapis.com/auth/drive', ], }, 'fireflies-ai': { diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts index 8558f5717..0a694efd1 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -5,9 +5,10 @@ import { google, drive_v3 as drive } from 'googleapis'; import { resolveWorkspacePath } from '../workspace/workspace.js'; import { GoogleClientFactory } from './google-client-factory.js'; +// Full Drive scope: export Google Docs to .docx (read) and write the edited +// .docx back via files.update (write). drive.readonly can't do the write half. export const GOOGLE_DOC_SCOPES = [ - 'https://www.googleapis.com/auth/drive.readonly', - 'https://www.googleapis.com/auth/documents', + 'https://www.googleapis.com/auth/drive', ] as const; export type GoogleDocListItem = { @@ -181,7 +182,7 @@ export async function getGoogleDocsConnectionStatus(): Promise<{ export async function listGoogleDocs(query?: string): Promise<{ files: GoogleDocListItem[] }> { const status = await getGoogleDocsConnectionStatus(); if (!status.connected) throw new Error('Google is not connected.'); - if (!status.hasRequiredScopes) throw new Error('Google is missing Drive/Docs scopes. Reconnect Google.'); + if (!status.hasRequiredScopes) throw new Error('Google is missing Drive access. Reconnect Google.'); const driveClient = await getDriveClient(); const clauses = [`mimeType='${GOOGLE_DOC_MIME}'`, 'trashed=false']; @@ -206,7 +207,7 @@ export async function importGoogleDoc(fileId: string, targetFolder: string): Pro }> { const status = await getGoogleDocsConnectionStatus(); if (!status.connected) throw new Error('Google is not connected.'); - if (!status.hasRequiredScopes) throw new Error('Google is missing Drive/Docs scopes. Reconnect Google.'); + if (!status.hasRequiredScopes) throw new Error('Google is missing Drive access. Reconnect Google.'); const doc = await getDocMetadata(fileId); const bytes = await exportDocx(fileId); From 112bf461c9acea55d939c2f5f40a42cf1121fa8a Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Mon, 1 Jun 2026 13:53:23 +0530 Subject: [PATCH 12/18] fix(google-docs): search all drives in doc picker, log result count --- apps/x/packages/core/src/knowledge/google_docs.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts index 0a694efd1..81b75dd42 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -190,14 +190,21 @@ export async function listGoogleDocs(query?: string): Promise<{ files: GoogleDoc if (trimmed) { clauses.push(`name contains '${escapeDriveQueryValue(trimmed)}'`); } + const q = clauses.join(' and '); const result = await driveClient.files.list({ - q: clauses.join(' and '), + q, pageSize: 25, orderBy: 'modifiedTime desc', fields: 'files(id,name,webViewLink,modifiedTime,owners(displayName,emailAddress))', + // Also surface docs in shared drives and "Shared with me", not just My Drive. + corpora: 'allDrives', + includeItemsFromAllDrives: true, + supportsAllDrives: true, }); - return { files: (result.data.files ?? []).map(toGoogleDocListItem).filter((file) => file.id) }; + const files = (result.data.files ?? []).map(toGoogleDocListItem).filter((file) => file.id); + console.log(`[GoogleDocs] list q="${q}" → ${files.length} doc(s)`); + return { files }; } /** Import a Google Doc as a local .docx and register the link. */ From 8ecd2254b16ccd27c2ff1397ce5a2a1bc8afadcb Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Mon, 1 Jun 2026 14:02:37 +0530 Subject: [PATCH 13/18] feat(google-docs): import native Docs AND uploaded .docx files from Drive --- .../core/src/knowledge/google_docs.test.ts | 44 +++++++++++--- .../core/src/knowledge/google_docs.ts | 57 ++++++++++++++----- 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/apps/x/packages/core/src/knowledge/google_docs.test.ts b/apps/x/packages/core/src/knowledge/google_docs.test.ts index 0c6e96ab2..c29b5572e 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.test.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.test.ts @@ -15,17 +15,24 @@ const REGISTRY_ABS = '/ws/knowledge/.assets/google-docs/links.json'; let vfs: Map; let exportCalls: Array<{ fileId: string; mimeType: string }>; let updateCalls: Array<{ fileId: string }>; +let getMediaCalls: number; -const driveFile = { - id: 'doc-123', - name: 'My Doc', - webViewLink: 'https://docs.google.com/document/d/doc-123/edit', - modifiedTime: '2026-05-28T10:00:00.000Z', - owners: [{ displayName: 'Arjun', emailAddress: 'arjun@example.com' }], -}; - +const GDOC_MIME = 'application/vnd.google-apps.document'; const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +function makeDriveFile() { + return { + id: 'doc-123', + name: 'My Doc', + webViewLink: 'https://docs.google.com/document/d/doc-123/edit', + modifiedTime: '2026-05-28T10:00:00.000Z', + mimeType: GDOC_MIME, + owners: [{ displayName: 'Arjun', emailAddress: 'arjun@example.com' }], + }; +} +let driveFile = makeDriveFile(); const docxBytes = () => new TextEncoder().encode('DOCX_BYTES').buffer; +const downloadedBytes = () => new TextEncoder().encode('DOWNLOADED').buffer; function seedRegistry(entries: Record) { vfs.set(REGISTRY_ABS, JSON.stringify(entries)); @@ -41,6 +48,8 @@ beforeEach(() => { vfs = new Map(); exportCalls = []; updateCalls = []; + getMediaCalls = 0; + driveFile = makeDriveFile(); vi.doMock('node:fs/promises', () => ({ default: { @@ -71,7 +80,10 @@ beforeEach(() => { const driveClient = { files: { - get: vi.fn(async () => ({ data: driveFile })), + get: vi.fn(async (params: { alt?: string }) => { + if (params.alt === 'media') { getMediaCalls += 1; return { data: downloadedBytes() }; } + return { data: driveFile }; + }), export: vi.fn(async (params: { fileId: string; mimeType: string }) => { exportCalls.push({ fileId: params.fileId, mimeType: params.mimeType }); return { data: docxBytes() }; @@ -108,9 +120,23 @@ describe('importGoogleDoc', () => { expect(link).toMatchObject({ id: 'doc-123', title: 'My Doc', + mimeType: GDOC_MIME, remoteModifiedTime: '2026-05-28T10:00:00.000Z', }); }); + + it('downloads an uploaded .docx file directly (no export, no double extension)', async () => { + driveFile = { ...makeDriveFile(), name: 'Report.docx', mimeType: DOCX_MIME }; + const { importGoogleDoc } = await import('./google_docs.js'); + const result = await importGoogleDoc('doc-123', 'knowledge'); + + // Uploaded Word file → files.get(alt=media), not files.export. + expect(exportCalls).toHaveLength(0); + expect(getMediaCalls).toBe(1); + // No "Report.docx.docx". + expect(result.path).toBe('knowledge/Report.docx'); + expect(readRegistry()['knowledge/Report.docx'].mimeType).toBe(DOCX_MIME); + }); }); describe('getGoogleDocLink', () => { diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts index 81b75dd42..cb6d426e0 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -17,9 +17,12 @@ export type GoogleDocListItem = { url: string; modifiedTime: string | null; owner: string | null; + // Drive mimeType — distinguishes a native Google Doc (needs export) from an + // uploaded Word file (download its bytes directly). + mimeType: string; }; -// Metadata linking a local .docx file to its source Google Doc. Stored in a +// Metadata linking a local .docx file to its source Drive file. 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 = { @@ -27,14 +30,18 @@ export type GoogleDocLink = { url: string; title: string; syncedAt: string; + // Source Drive mimeType (native Google Doc vs uploaded .docx) — decides + // whether a pull exports or downloads. + mimeType?: 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'; -// 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). +// A native Google Doc is exported to / written back as a real Word document so +// the in-app docx editor round-trips it with full fidelity. Uploaded .docx +// files already are Word documents and are downloaded/uploaded as-is. const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; // Hidden registry mapping workspace-relative .docx paths → their Google Doc. @@ -123,10 +130,19 @@ async function getDriveClient() { return google.drive({ version: 'v3', auth }); } -async function exportDocx(fileId: string): Promise { +// Get the file as .docx bytes: a native Google Doc is exported; an uploaded +// Word file is downloaded as-is. +async function fetchAsDocx(fileId: string, mimeType: string | undefined): Promise { const driveClient = await getDriveClient(); - const result = await driveClient.files.export( - { fileId, mimeType: DOCX_MIME }, + if (!mimeType || mimeType === GOOGLE_DOC_MIME) { + const result = await driveClient.files.export( + { fileId, mimeType: DOCX_MIME }, + { responseType: 'arraybuffer' }, + ); + return Buffer.from(result.data as ArrayBuffer); + } + const result = await driveClient.files.get( + { fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'arraybuffer' }, ); return Buffer.from(result.data as ArrayBuffer); @@ -136,7 +152,8 @@ async function getDocMetadata(fileId: string): Promise { const driveClient = await getDriveClient(); const result = await driveClient.files.get({ fileId, - fields: 'id,name,webViewLink,modifiedTime,owners(displayName,emailAddress)', + fields: 'id,name,webViewLink,modifiedTime,mimeType,owners(displayName,emailAddress)', + supportsAllDrives: true, }); const file = result.data; if (!file.id || !file.name) throw new Error('Selected Google Doc is missing metadata.'); @@ -150,12 +167,14 @@ function toGoogleDocListItem(file: drive.Schema$File): GoogleDocListItem { url: file.webViewLink ?? `https://docs.google.com/document/d/${file.id}/edit`, modifiedTime: file.modifiedTime ?? null, owner: file.owners?.[0]?.displayName ?? file.owners?.[0]?.emailAddress ?? null, + mimeType: file.mimeType ?? GOOGLE_DOC_MIME, }; } async function uniqueDocxPath(targetFolder: string, title: string): Promise { const folder = normalizeKnowledgeDir(targetFolder); - const base = sanitizeFilename(title); + // Strip an existing .docx so an uploaded "Report.docx" doesn't become "Report.docx.docx". + const base = sanitizeFilename(title.replace(/\.docx$/i, '')); let candidate = `${folder}/${base}.docx`; let index = 1; while (true) { @@ -185,7 +204,9 @@ export async function listGoogleDocs(query?: string): Promise<{ files: GoogleDoc if (!status.hasRequiredScopes) throw new Error('Google is missing Drive access. Reconnect Google.'); const driveClient = await getDriveClient(); - const clauses = [`mimeType='${GOOGLE_DOC_MIME}'`, 'trashed=false']; + // Native Google Docs (exportable) and uploaded Word files (downloadable). + const typeClause = `(mimeType='${GOOGLE_DOC_MIME}' or mimeType='${DOCX_MIME}')`; + const clauses = [typeClause, 'trashed=false']; const trimmed = query?.trim(); if (trimmed) { clauses.push(`name contains '${escapeDriveQueryValue(trimmed)}'`); @@ -195,7 +216,7 @@ export async function listGoogleDocs(query?: string): Promise<{ files: GoogleDoc q, pageSize: 25, orderBy: 'modifiedTime desc', - fields: 'files(id,name,webViewLink,modifiedTime,owners(displayName,emailAddress))', + fields: 'files(id,name,webViewLink,modifiedTime,mimeType,owners(displayName,emailAddress))', // Also surface docs in shared drives and "Shared with me", not just My Drive. corpora: 'allDrives', includeItemsFromAllDrives: true, @@ -217,7 +238,7 @@ export async function importGoogleDoc(fileId: string, targetFolder: string): Pro if (!status.hasRequiredScopes) throw new Error('Google is missing Drive access. Reconnect Google.'); const doc = await getDocMetadata(fileId); - const bytes = await exportDocx(fileId); + const bytes = await fetchAsDocx(fileId, doc.mimeType); const relPath = await uniqueDocxPath(targetFolder, doc.name); const absPath = resolveWorkspacePath(relPath); await fs.mkdir(path.dirname(absPath), { recursive: true }); @@ -227,6 +248,7 @@ export async function importGoogleDoc(fileId: string, targetFolder: string): Pro url: doc.url, title: doc.name, syncedAt: new Date().toISOString(), + mimeType: doc.mimeType, remoteModifiedTime: doc.modifiedTime ?? undefined, }); return { path: relPath, doc }; @@ -237,7 +259,10 @@ export async function syncGoogleDocDown(relPath: string): Promise<{ ok: true; sy const link = await getGoogleDocLink(relPath); if (!link) throw new Error('This file is not linked to a Google Doc.'); - const [bytes, meta] = await Promise.all([exportDocx(link.id), getDocMetadata(link.id)]); + const [bytes, meta] = await Promise.all([ + fetchAsDocx(link.id, link.mimeType), + getDocMetadata(link.id), + ]); await fs.writeFile(resolveWorkspacePath(normalizeRel(relPath)), bytes); const syncedAt = new Date().toISOString(); await setLink(relPath, { @@ -245,6 +270,7 @@ export async function syncGoogleDocDown(relPath: string): Promise<{ ok: true; sy url: link.url, title: link.title, syncedAt, + mimeType: link.mimeType ?? meta.mimeType, remoteModifiedTime: meta.modifiedTime ?? link.remoteModifiedTime, }); return { ok: true, syncedAt }; @@ -273,11 +299,13 @@ export async function syncGoogleDocUp( 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. + // For a native Google Doc, uploading .docx media converts it back into the + // existing doc (id/URL/type preserved). For an uploaded .docx file, it just + // replaces the bytes. await driveClient.files.update({ fileId: link.id, media: { mimeType: DOCX_MIME, body: Readable.from(bytes) }, + supportsAllDrives: true, }); const meta = await getDocMetadata(link.id); @@ -287,6 +315,7 @@ export async function syncGoogleDocUp( url: link.url, title: link.title, syncedAt, + mimeType: link.mimeType ?? meta.mimeType, remoteModifiedTime: meta.modifiedTime ?? link.remoteModifiedTime, }); return { synced: true, syncedAt }; From 505a9a27e8eb5760448c8518a95fbdbdebaba9e5 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Mon, 1 Jun 2026 14:08:07 +0530 Subject: [PATCH 14/18] chore(google-docs): drop dev-only test file --- .../core/src/knowledge/google_docs.test.ts | 230 ------------------ 1 file changed, 230 deletions(-) delete mode 100644 apps/x/packages/core/src/knowledge/google_docs.test.ts diff --git a/apps/x/packages/core/src/knowledge/google_docs.test.ts b/apps/x/packages/core/src/knowledge/google_docs.test.ts deleted file mode 100644 index c29b5572e..000000000 --- a/apps/x/packages/core/src/knowledge/google_docs.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -/** - * Google Docs ⇄ local .docx round-trip. - * - * 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 REGISTRY_ABS = '/ws/knowledge/.assets/google-docs/links.json'; - -// Virtual filesystem: absolute path → contents. -let vfs: Map; -let exportCalls: Array<{ fileId: string; mimeType: string }>; -let updateCalls: Array<{ fileId: string }>; -let getMediaCalls: number; - -const GDOC_MIME = 'application/vnd.google-apps.document'; -const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; - -function makeDriveFile() { - return { - id: 'doc-123', - name: 'My Doc', - webViewLink: 'https://docs.google.com/document/d/doc-123/edit', - modifiedTime: '2026-05-28T10:00:00.000Z', - mimeType: GDOC_MIME, - owners: [{ displayName: 'Arjun', emailAddress: 'arjun@example.com' }], - }; -} -let driveFile = makeDriveFile(); -const docxBytes = () => new TextEncoder().encode('DOCX_BYTES').buffer; -const downloadedBytes = () => new TextEncoder().encode('DOWNLOADED').buffer; - -function seedRegistry(entries: Record) { - vfs.set(REGISTRY_ABS, JSON.stringify(entries)); -} - -function readRegistry(): Record> { - const raw = vfs.get(REGISTRY_ABS); - return raw ? JSON.parse(raw as string) : {}; -} - -beforeEach(() => { - vi.resetModules(); - vfs = new Map(); - exportCalls = []; - updateCalls = []; - getMediaCalls = 0; - driveFile = makeDriveFile(); - - vi.doMock('node:fs/promises', () => ({ - default: { - 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 (p: string) => { if (!vfs.has(p)) throw new Error(`ENOENT: ${p}`); }), - }, - })); - - vi.doMock('../workspace/workspace.js', () => ({ - resolveWorkspacePath: (rel: string) => `/ws/${rel.replace(/\\/g, '/')}`, - })); - - vi.doMock('./google-client-factory.js', () => ({ - GoogleClientFactory: { - getClient: vi.fn(async () => ({})), - getCredentialStatus: vi.fn(async () => ({ - connected: true, - hasRequiredScopes: true, - missingScopes: [], - })), - }, - })); - - const driveClient = { - files: { - get: vi.fn(async (params: { alt?: string }) => { - if (params.alt === 'media') { getMediaCalls += 1; return { data: downloadedBytes() }; } - return { data: driveFile }; - }), - export: vi.fn(async (params: { fileId: string; mimeType: string }) => { - exportCalls.push({ fileId: params.fileId, mimeType: params.mimeType }); - return { data: docxBytes() }; - }), - list: vi.fn(async () => ({ data: { files: [driveFile] } })), - 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(() => ({})) }, - })); -}); - -afterEach(() => { vi.clearAllMocks(); }); - -describe('importGoogleDoc', () => { - 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: DOCX_MIME }]); - expect(result.path).toBe('knowledge/My Doc.docx'); - - // 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); - - // 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', - mimeType: GDOC_MIME, - remoteModifiedTime: '2026-05-28T10:00:00.000Z', - }); - }); - - it('downloads an uploaded .docx file directly (no export, no double extension)', async () => { - driveFile = { ...makeDriveFile(), name: 'Report.docx', mimeType: DOCX_MIME }; - const { importGoogleDoc } = await import('./google_docs.js'); - const result = await importGoogleDoc('doc-123', 'knowledge'); - - // Uploaded Word file → files.get(alt=media), not files.export. - expect(exportCalls).toHaveLength(0); - expect(getMediaCalls).toBe(1); - // No "Report.docx.docx". - expect(result.path).toBe('knowledge/Report.docx'); - expect(readRegistry()['knowledge/Report.docx'].mimeType).toBe(DOCX_MIME); - }); -}); - -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(); - }); -}); - -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: 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('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 () => { - 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(updateCalls).toHaveLength(0); - }); - - 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(updateCalls).toEqual([{ fileId: 'doc-123' }]); - expect(readRegistry()['knowledge/My Doc.docx'].remoteModifiedTime).toBe('2026-05-28T10:00:00.000Z'); - }); - - 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(updateCalls).toEqual([{ fileId: 'doc-123' }]); - }); -}); - -describe('isRemoteAhead', () => { - it('detects a newer remote revision and tolerates missing baselines', async () => { - const { isRemoteAhead } = await import('./google_docs.js'); - expect(isRemoteAhead('2026-05-28T10:00:00.000Z', '2026-05-20T00:00:00.000Z')).toBe(true); - expect(isRemoteAhead('2026-05-20T00:00:00.000Z', '2026-05-28T10:00:00.000Z')).toBe(false); - expect(isRemoteAhead('2026-05-28T10:00:00.000Z', undefined)).toBe(false); - expect(isRemoteAhead(null, '2026-05-20T00:00:00.000Z')).toBe(false); - }); -}); From d08bf49d5ad7bfcb6f1e95edf784c50a8e2a2a0e Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Mon, 1 Jun 2026 23:47:27 +0530 Subject: [PATCH 15/18] feat(google-docs): use Google Picker + drive.file scope instead of full-drive listing --- apps/x/apps/main/src/ipc.ts | 6 +- .../components/google-doc-picker-dialog.tsx | 203 ++++++++---------- apps/x/apps/renderer/src/lib/google-picker.ts | 117 ++++++++++ apps/x/packages/core/src/auth/providers.ts | 8 +- .../core/src/knowledge/google_docs.ts | 46 +--- apps/x/packages/shared/src/ipc.ts | 15 +- 6 files changed, 225 insertions(+), 170 deletions(-) create mode 100644 apps/x/apps/renderer/src/lib/google-picker.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 75fb8f27e..eb6e1c7a6 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -52,7 +52,7 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; -import { getGoogleDocsConnectionStatus, importGoogleDoc, listGoogleDocs, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; +import { getGoogleDocsConnectionStatus, importGoogleDoc, getGoogleAccessToken, 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'; @@ -815,8 +815,8 @@ export function setupIpcHandlers() { 'google-docs:getStatus': async () => { return getGoogleDocsConnectionStatus(); }, - 'google-docs:list': async (_event, args) => { - return listGoogleDocs(args.query); + 'google-docs:getAccessToken': async () => { + return { accessToken: await getGoogleAccessToken() }; }, 'google-docs:import': async (_event, args) => { return importGoogleDoc(args.fileId, args.targetFolder); diff --git a/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx b/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx index 5876e3bde..d3fbd7dde 100644 --- a/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx +++ b/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { FileText, Loader2, RefreshCw, Search } from 'lucide-react' +import { FileText, Loader2, RefreshCw } from 'lucide-react' import { Dialog, @@ -12,17 +12,9 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { GoogleClientIdModal } from '@/components/google-client-id-modal' import { setGoogleCredentials } from '@/lib/google-credentials-store' -import { formatRelativeTime } from '@/lib/relative-time' +import { openGooglePicker, getStoredPickerApiKey, setStoredPickerApiKey } from '@/lib/google-picker' import { toast } from '@/lib/toast' -type GoogleDocListItem = { - id: string - name: string - url: string - modifiedTime: string | null - owner: string | null -} - type GoogleDocsStatus = { connected: boolean hasRequiredScopes: boolean @@ -36,11 +28,6 @@ type GoogleDocPickerDialogProps = { onImported: (path: string) => void } -function formatModified(modifiedTime: string | null): string { - if (!modifiedTime) return '' - return formatRelativeTime(modifiedTime) -} - export function GoogleDocPickerDialog({ open, targetFolder, @@ -48,15 +35,13 @@ export function GoogleDocPickerDialog({ onImported, }: GoogleDocPickerDialogProps) { const [status, setStatus] = useState(null) - const [query, setQuery] = useState('') - const [docs, setDocs] = useState([]) - const [loading, setLoading] = useState(false) const [connecting, setConnecting] = useState(false) - const [importingId, setImportingId] = useState(null) + const [opening, setOpening] = useState(false) const [error, setError] = useState(null) const [byokOpen, setByokOpen] = useState(false) + const [apiKey, setApiKey] = useState('') - const canList = Boolean(status?.connected && status.hasRequiredScopes) + const canPick = Boolean(status?.connected && status.hasRequiredScopes) const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder]) const loadStatus = useCallback(async () => { @@ -70,36 +55,13 @@ export function GoogleDocPickerDialog({ } }, []) - const loadDocs = useCallback(async (searchQuery: string) => { - setLoading(true) - setError(null) - try { - const result = await window.ipc.invoke('google-docs:list', { query: searchQuery.trim() || undefined }) - setDocs(result.files) - } catch (err) { - setDocs([]) - setError(err instanceof Error ? err.message : 'Failed to load Google Docs') - } finally { - setLoading(false) - } - }, []) - useEffect(() => { if (!open) return - setQuery('') - setDocs([]) setError(null) + setApiKey(getStoredPickerApiKey()) void loadStatus() }, [loadStatus, open]) - useEffect(() => { - if (!open || !canList) return - const timeout = window.setTimeout(() => { - void loadDocs(query) - }, 250) - return () => window.clearTimeout(timeout) - }, [canList, loadDocs, open, query]) - const handleConnect = useCallback(async () => { setConnecting(true) setError(null) @@ -117,9 +79,9 @@ export function GoogleDocPickerDialog({ } }, []) - // BYOK: connect Google with the user's own OAuth client. Unlike the managed - // (rowboat) sign-in, this local flow requests the Drive + Docs scopes, so a - // signed-in user can actually grant Docs access without a backend change. + // BYOK: connect Google with the user's own OAuth client, which requests the + // drive.file scope locally (managed sign-in can't grant it without a backend + // change). const handleByokSubmit = useCallback((clientId: string, clientSecret: string) => { setGoogleCredentials(clientId, clientSecret) setByokOpen(false) @@ -144,38 +106,68 @@ export function GoogleDocPickerDialog({ return cleanup }, [open, loadStatus]) - const handleImport = useCallback(async (doc: GoogleDocListItem) => { - setImportingId(doc.id) + const handleChoose = useCallback(async () => { setError(null) + const key = apiKey.trim() + if (!key) { + setError('Enter your Google Picker API key first.') + return + } + setStoredPickerApiKey(key) + setOpening(true) + + let accessToken: string | null = null try { - const result = await window.ipc.invoke('google-docs:import', { - fileId: doc.id, - targetFolder, + const res = await window.ipc.invoke('google-docs:getAccessToken', null) + accessToken = res.accessToken + } catch (err) { + setOpening(false) + setError(err instanceof Error ? err.message : 'Failed to get a Google access token') + return + } + if (!accessToken) { + setOpening(false) + setError('Google access token unavailable — reconnect Google.') + return + } + + // Hand off to Google's Picker; close our modal so it isn't trapped behind it. + onOpenChange(false) + try { + await openGooglePicker({ + accessToken, + apiKey: key, + onPicked: async (file) => { + try { + const result = await window.ipc.invoke('google-docs:import', { fileId: file.id, targetFolder }) + toast(`Added “${file.name}”`, 'success') + onImported(result.path) + } catch (err) { + toast(err instanceof Error ? err.message : 'Failed to import the document', 'error') + } + }, }) - toast('Google Doc added', 'success') - onImported(result.path) - onOpenChange(false) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to import Google Doc') + toast(err instanceof Error ? err.message : 'Failed to open the Google Picker', 'error') } finally { - setImportingId(null) + setOpening(false) } - }, [onImported, onOpenChange, targetFolder]) + }, [apiKey, targetFolder, onImported, onOpenChange]) return ( <> - + Add Google Doc - Select a Google Doc to link into {targetLabel}. + Link a Google Doc or Word file from Drive into {targetLabel}.
{!status && error ? ( -
+
{error}
) : !status ? ( -
+
Checking Google connection...
- ) : !status.connected || !status.hasRequiredScopes ? ( -
+ ) : !canPick ? ( +
- To choose Google Docs, Rowboat needs Drive + Docs access. + To choose a document, Rowboat needs per-file Drive access (the drive.file scope).
{status.missingScopes.length > 0 && (
@@ -207,65 +199,42 @@ export function GoogleDocPickerDialog({

- Managed sign-in may not grant Docs access yet. If it keeps asking for scopes, - connect a Google OAuth client (Desktop app) with the Drive API and Docs API enabled. + Managed sign-in may not grant Drive access yet. If it keeps asking for scopes, + connect a Google OAuth client (Desktop app) with the Drive API and Picker API enabled.

) : ( - <> -
-
- - setQuery(e.target.value)} - placeholder="Search Google Docs" - className="pl-9" - autoFocus - /> -
+
+
+ Pick a Google Doc or Word file from your Drive. It imports as an editable + .docx and stays linked for two-way sync. +
+
+ + setApiKey(e.target.value)} + placeholder="AIza…" + className="font-mono text-xs" + /> +

+ From your Google Cloud project (APIs & Services → Credentials → API key), + with the Picker API enabled. Stored locally. +

- {error && ( -
+
{error}
)} - -
- {loading ? ( -
- - Loading Docs... -
- ) : docs.length === 0 ? ( -
- No Google Docs found. -
- ) : ( -
- {docs.map((doc) => ( - - ))} -
- )} -
- + +
)}
@@ -274,7 +243,7 @@ export function GoogleDocPickerDialog({ open={byokOpen} onOpenChange={setByokOpen} onSubmit={handleByokSubmit} - description="Enter a Google OAuth client (Desktop app) with the Drive API and Docs API enabled to grant Docs access." + description="Enter a Google OAuth client (Desktop app) with the Drive API and Picker API enabled." /> ) diff --git a/apps/x/apps/renderer/src/lib/google-picker.ts b/apps/x/apps/renderer/src/lib/google-picker.ts new file mode 100644 index 000000000..d11a83fb1 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/google-picker.ts @@ -0,0 +1,117 @@ +// Loader + thin wrapper around the Google Picker JS API. File selection happens +// inside Google's hosted Picker (so the app needs only drive.file, not a broad +// listing scope); the Picker hands back the chosen file id. + +export type PickedFile = { id: string; name: string; mimeType: string } + +type GapiGlobal = { + load: (lib: string, config: { callback: () => void; onerror?: () => void }) => void +} + +type PickerView = { + setIncludeFolders: (b: boolean) => PickerView + setOwnedByMe: (b: boolean) => PickerView + setMimeTypes: (m: string) => PickerView +} + +type PickerCallbackData = { + action: string + docs?: Array<{ id: string; name: string; mimeType: string }> +} + +type PickerBuilder = { + addView: (v: PickerView) => PickerBuilder + setOAuthToken: (t: string) => PickerBuilder + setDeveloperKey: (k: string) => PickerBuilder + setAppId: (id: string) => PickerBuilder + setTitle: (t: string) => PickerBuilder + setCallback: (cb: (data: PickerCallbackData) => void) => PickerBuilder + build: () => { setVisible: (v: boolean) => void } +} + +type GooglePickerNS = { + DocsView: new () => PickerView + PickerBuilder: new () => PickerBuilder + Action: { PICKED: string; CANCEL: string } +} + +declare global { + interface Window { + gapi?: GapiGlobal + google?: { picker?: GooglePickerNS } + } +} + +const API_JS = 'https://apis.google.com/js/api.js' +const DOC_MIME = 'application/vnd.google-apps.document' +const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' +const API_KEY_STORAGE = 'rowboat:google-picker-api-key' + +let pickerLoaded: Promise | null = null + +function loadPickerApi(): Promise { + if (pickerLoaded) return pickerLoaded + pickerLoaded = new Promise((resolve, reject) => { + const start = () => { + if (!window.gapi) { reject(new Error('Google API failed to initialize')); return } + window.gapi.load('picker', { + callback: () => resolve(), + onerror: () => reject(new Error('Google Picker failed to load')), + }) + } + const existing = document.querySelector(`script[src="${API_JS}"]`) + if (existing) { + if (window.gapi) start() + else existing.addEventListener('load', start) + return + } + const script = document.createElement('script') + script.src = API_JS + script.async = true + script.onload = start + script.onerror = () => reject(new Error('Failed to load the Google API script')) + document.head.appendChild(script) + }) + return pickerLoaded +} + +export function getStoredPickerApiKey(): string { + try { return localStorage.getItem(API_KEY_STORAGE) ?? '' } catch { return '' } +} + +export function setStoredPickerApiKey(key: string): void { + try { localStorage.setItem(API_KEY_STORAGE, key.trim()) } catch { /* ignore */ } +} + +export async function openGooglePicker(opts: { + accessToken: string + apiKey?: string + appId?: string + onPicked: (file: PickedFile) => void + onCancel?: () => void +}): Promise { + await loadPickerApi() + const picker = window.google?.picker + if (!picker) throw new Error('Google Picker is unavailable') + + const view = new picker.DocsView() + .setIncludeFolders(false) + .setOwnedByMe(false) + .setMimeTypes(`${DOC_MIME},${DOCX_MIME}`) + + const builder = new picker.PickerBuilder() + .addView(view) + .setOAuthToken(opts.accessToken) + .setTitle('Choose a document to sync') + .setCallback((data) => { + if (data.action === picker.Action.PICKED && data.docs?.[0]) { + const d = data.docs[0] + opts.onPicked({ id: d.id, name: d.name, mimeType: d.mimeType }) + } else if (data.action === picker.Action.CANCEL) { + opts.onCancel?.() + } + }) + if (opts.apiKey) builder.setDeveloperKey(opts.apiKey) + if (opts.appId) builder.setAppId(opts.appId) + builder.build().setVisible(true) +} diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index 40ef6f4a0..a695cf562 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -77,10 +77,10 @@ const providerConfigs: ProviderConfig = { scopes: [ 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/calendar.events.readonly', - // Full Drive access: read/export Google Docs to .docx AND write the edited - // .docx back into the original doc (files.update needs write, which - // drive.readonly does not grant). Covers list/get/export/update. - 'https://www.googleapis.com/auth/drive', + // Per-file Drive access (non-restricted): the user grants read/write to a + // specific doc by choosing it in the Google Picker. Enough to export/ + // download and write back, without the restricted full-drive scope. + 'https://www.googleapis.com/auth/drive.file', ], }, 'fireflies-ai': { diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts index cb6d426e0..e169bb864 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -5,10 +5,11 @@ import { google, drive_v3 as drive } from 'googleapis'; import { resolveWorkspacePath } from '../workspace/workspace.js'; import { GoogleClientFactory } from './google-client-factory.js'; -// Full Drive scope: export Google Docs to .docx (read) and write the edited -// .docx back via files.update (write). drive.readonly can't do the write half. +// Per-file Drive scope (non-restricted). The user picks a doc via the Google +// Picker, which grants this app read/write to that file — enough to export/ +// download it and write edits back, without the restricted full-drive scope. export const GOOGLE_DOC_SCOPES = [ - 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/drive.file', ] as const; export type GoogleDocListItem = { @@ -58,10 +59,6 @@ function sanitizeFilename(name: string): string { return cleaned || 'Google Doc'; } -function escapeDriveQueryValue(value: string): string { - return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); -} - function normalizeRel(relPath: string): string { return relPath.replace(/\\/g, '/'); } @@ -198,34 +195,13 @@ export async function getGoogleDocsConnectionStatus(): Promise<{ return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]); } -export async function listGoogleDocs(query?: string): Promise<{ files: GoogleDocListItem[] }> { - const status = await getGoogleDocsConnectionStatus(); - if (!status.connected) throw new Error('Google is not connected.'); - if (!status.hasRequiredScopes) throw new Error('Google is missing Drive access. Reconnect Google.'); - - const driveClient = await getDriveClient(); - // Native Google Docs (exportable) and uploaded Word files (downloadable). - const typeClause = `(mimeType='${GOOGLE_DOC_MIME}' or mimeType='${DOCX_MIME}')`; - const clauses = [typeClause, 'trashed=false']; - const trimmed = query?.trim(); - if (trimmed) { - clauses.push(`name contains '${escapeDriveQueryValue(trimmed)}'`); - } - const q = clauses.join(' and '); - const result = await driveClient.files.list({ - q, - pageSize: 25, - orderBy: 'modifiedTime desc', - fields: 'files(id,name,webViewLink,modifiedTime,mimeType,owners(displayName,emailAddress))', - // Also surface docs in shared drives and "Shared with me", not just My Drive. - corpora: 'allDrives', - includeItemsFromAllDrives: true, - supportsAllDrives: true, - }); - - const files = (result.data.files ?? []).map(toGoogleDocListItem).filter((file) => file.id); - console.log(`[GoogleDocs] list q="${q}" → ${files.length} doc(s)`); - return { files }; +/** + * The live Google OAuth access token, for the renderer to drive the Google + * Picker (file selection happens client-side; the app never lists Drive). + */ +export async function getGoogleAccessToken(): Promise { + const auth = await GoogleClientFactory.getClient(); + return auth?.credentials?.access_token ?? null; } /** Import a Google Doc as a local .docx and register the link. */ diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 221c2cfaf..97f79e24c 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -650,18 +650,11 @@ const ipcSchemas = { missingScopes: z.array(z.string()), }), }, - 'google-docs:list': { - req: z.object({ - query: z.string().optional(), - }), + // Live Google OAuth access token for driving the Google Picker in the renderer. + 'google-docs:getAccessToken': { + req: z.null(), res: z.object({ - files: z.array(z.object({ - id: z.string(), - name: z.string(), - url: z.string(), - modifiedTime: z.string().nullable(), - owner: z.string().nullable(), - })), + accessToken: z.string().nullable(), }), }, 'google-docs:import': { From bfcffa7d3aba661807eed446f23c88811db9da57 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Tue, 9 Jun 2026 01:25:09 +0530 Subject: [PATCH 16/18] fix(google-oauth): request offline access so BYOK tokens refresh BYOK never requested access_type=offline/prompt=consent so no refresh token was issued and tokens died after ~1h; also stop handing back expired tokens and extend the connect timeout to 10m. --- apps/x/apps/main/src/oauth-handler.ts | 12 ++++++++++-- apps/x/packages/core/src/knowledge/google_docs.ts | 10 +++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 1048d9b8e..4fe5d8d06 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -420,15 +420,23 @@ export async function connectProvider(provider: string, credentials?: { clientId scope: scopes.join(' '), code_challenge: codeChallenge, state, + // Google only returns a refresh_token when offline access is requested, + // and only re-issues one when re-consent is forced. Without these, a + // BYOK token expires after ~1h with no way to refresh (it goes stale and + // every Google call — including the Picker — starts failing). + ...(provider === 'google' ? { access_type: 'offline', prompt: 'consent' } : {}), }); - // Set timeout to clean up abandoned flows (2 minutes) + // Set timeout to clean up abandoned flows. Generous (10 min) because a + // first-time connect can involve creating/locating OAuth credentials in + // the Cloud Console mid-flow; a short window tears down the callback + // server before the user finishes consent, silently dropping the token. const cleanupTimeout = setTimeout(() => { if (activeFlow?.state === state) { console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); cancelActiveFlow('timed_out'); } - }, 2 * 60 * 1000); + }, 10 * 60 * 1000); activeFlow = { provider, diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts index e169bb864..a2268ede6 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -200,8 +200,16 @@ export async function getGoogleDocsConnectionStatus(): Promise<{ * Picker (file selection happens client-side; the app never lists Drive). */ export async function getGoogleAccessToken(): Promise { + // getClient() refreshes an expired token when it can. If it returns a token + // that's still past expiry, the refresh failed (e.g. no refresh_token) — hand + // back null rather than a dead token, so the UI prompts a reconnect instead + // of silently passing an expired token to the Picker (which 403s on it). const auth = await GoogleClientFactory.getClient(); - return auth?.credentials?.access_token ?? null; + const token = auth?.credentials?.access_token ?? null; + if (!token) return null; + const expiry = auth?.credentials?.expiry_date; + if (typeof expiry === 'number' && expiry <= Date.now()) return null; + return token; } /** Import a Google Doc as a local .docx and register the link. */ From 875b65d27974f2d56b1bdd4113f83a5eeb14cb4d Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Tue, 9 Jun 2026 01:29:05 +0530 Subject: [PATCH 17/18] feat(google-docs): pick docs via system-browser Google Picker Runs the Picker in the user's real browser (it 403s inside Electron), sets appId so the drive.file grant attaches to the picked file, and downloads + opens the selected doc. --- apps/x/apps/main/src/ipc.ts | 116 +++++++++++++++++- .../components/google-doc-picker-dialog.tsx | 46 ++++--- apps/x/apps/renderer/src/lib/google-picker.ts | 3 +- apps/x/packages/shared/src/ipc.ts | 14 +++ 4 files changed, 162 insertions(+), 17 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index eb6e1c7a6..3c035b95c 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -818,8 +818,122 @@ export function setupIpcHandlers() { 'google-docs:getAccessToken': async () => { return { accessToken: await getGoogleAccessToken() }; }, + 'google-docs:openPicker': async (_event, args) => { + const { accessToken, apiKey } = args; + // Run the Picker in the user's real system browser (Chrome) rather than + // inside Electron. Google's Picker / sign-in 403s in an Electron window + // (non-standard browser), but works in a real browser. We serve the + // Picker page from a localhost server, open it via the OS browser, and + // the page reports the selection back to that same server (OAuth-style + // loopback). Token/key are injected server-side so they never hit history. + const DOC_MIME = 'application/vnd.google-apps.document'; + const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + + // setAppId is REQUIRED for the drive.file scope: it tells Google which + // Cloud project (app) the picked file should be shared with. Without it, + // the selected file is never granted to our OAuth client and the later + // export/download 404s. The project number is the prefix of the OAuth + // client id (e.g. "916714831831-xxx.apps.googleusercontent.com"). + let appId = args.appId; + if (!appId) { + try { + const oauthJson = JSON.parse( + await fs.readFile(path.join(WorkDir, 'config', 'oauth.json'), 'utf8') + ); + const cid: string = oauthJson?.providers?.google?.clientId ?? ''; + const proj = cid.split('-')[0]; + if (/^\d+$/.test(proj)) appId = proj; + } catch { /* fall through — picker still works for native Google Docs */ } + } + console.log(`[Picker] opening with appId=${appId ?? '(none)'} apiKey=${apiKey ? 'set' : 'none'}`); + const pickerHtml = `Choose a document to sync + +
Loading Google Picker…
+ +`; + + const donePage = `Done + +

✓ Selection sent to Rowboat

You can close this tab and return to the app.

`; + + const { createServer } = await import('node:http'); + + return new Promise<{ id: string; name: string; mimeType: string } | null>((resolve) => { + let settled = false; + const finish = (result: { id: string; name: string; mimeType: string } | null) => { + if (settled) return; + settled = true; + server.close(); + // Bring the app back to the foreground after the browser hand-off. + const w = BrowserWindow.getAllWindows()[0]; + if (w) { if (w.isMinimized()) w.restore(); w.focus(); } + resolve(result); + }; + + const server = createServer((req, res) => { + const u = new URL(req.url ?? '/', 'http://localhost'); + if (u.pathname === '/result') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(donePage); + if (u.searchParams.get('action') === 'picked') { + finish({ + id: u.searchParams.get('fileId') ?? '', + name: u.searchParams.get('name') ?? '', + mimeType: u.searchParams.get('mimeType') ?? '', + }); + } else { + finish(null); + } + return; + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(pickerHtml); + }); + + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as { port: number }).port; + // Opens in the user's default browser (Chrome) — a trusted browser + // for Google, so the Picker and any sign-in work without the 403. + shell.openExternal(`http://localhost:${port}/`); + }); + + // Safety: don't leak the server/promise if the user never finishes. + setTimeout(() => finish(null), 5 * 60 * 1000); + }); + }, 'google-docs:import': async (_event, args) => { - return importGoogleDoc(args.fileId, args.targetFolder); + console.log(`[GoogleDocs] import fileId=${args.fileId} -> ${args.targetFolder}`); + try { + const result = await importGoogleDoc(args.fileId, args.targetFolder); + console.log(`[GoogleDocs] import OK -> ${result.path}`); + return result; + } catch (err) { + console.error('[GoogleDocs] import FAILED:', err instanceof Error ? err.message : err); + throw err; + } }, 'google-docs:refreshSnapshot': async (_event, args) => { return syncGoogleDocDown(args.path); diff --git a/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx b/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx index d3fbd7dde..79fd83fe3 100644 --- a/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx +++ b/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx @@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { GoogleClientIdModal } from '@/components/google-client-id-modal' import { setGoogleCredentials } from '@/lib/google-credentials-store' -import { openGooglePicker, getStoredPickerApiKey, setStoredPickerApiKey } from '@/lib/google-picker' +import { getStoredPickerApiKey, setStoredPickerApiKey } from '@/lib/google-picker' import { toast } from '@/lib/toast' type GoogleDocsStatus = { @@ -131,24 +131,34 @@ export function GoogleDocPickerDialog({ return } - // Hand off to Google's Picker; close our modal so it isn't trapped behind it. + // Open the Picker in the user's real browser (Chrome) via a localhost + // loopback in the main process. Google 403s the Picker inside Electron; + // a real browser is a trusted context. Close our modal during the hand-off. onOpenChange(false) + toast('Continue in your browser to choose a document…', 'info') + let picked: { id: string; name: string; mimeType: string } | null = null try { - await openGooglePicker({ + picked = await window.ipc.invoke('google-docs:openPicker', { accessToken, - apiKey: key, - onPicked: async (file) => { - try { - const result = await window.ipc.invoke('google-docs:import', { fileId: file.id, targetFolder }) - toast(`Added “${file.name}”`, 'success') - onImported(result.path) - } catch (err) { - toast(err instanceof Error ? err.message : 'Failed to import the document', 'error') - } - }, + apiKey: key || undefined, }) } catch (err) { + setOpening(false) toast(err instanceof Error ? err.message : 'Failed to open the Google Picker', 'error') + return + } + + if (!picked) { + setOpening(false) + return + } + + try { + const result = await window.ipc.invoke('google-docs:import', { fileId: picked.id, targetFolder }) + toast(`Added “${picked.name}”`, 'success') + onImported(result.path) + } catch (err) { + toast(err instanceof Error ? err.message : 'Failed to import the document', 'error') } finally { setOpening(false) } @@ -226,8 +236,14 @@ export function GoogleDocPickerDialog({

{error && ( -
- {error} +
+
+ {error} +
+
)}
- ) : !status ? ( + ) : signedIn === null ? (
- Checking Google connection... + Checking your Rowboat sign-in…
- ) : !canPick ? ( -
+ ) : !signedIn ? ( +
- To choose a document, Rowboat needs per-file Drive access (the drive.file scope). + Sign in to Rowboat to add Google Docs from Drive. The picker uses your + Rowboat account — no Google credentials or API key needed.
- {status.missingScopes.length > 0 && ( -
- Missing scopes: {status.missingScopes.join(', ')} -
- )} -
- - -
-

- Managed sign-in may not grant Drive access yet. If it keeps asking for scopes, - connect a Google OAuth client (Desktop app) with the Drive API and Picker API enabled. -

+
) : (
@@ -219,31 +120,13 @@ export function GoogleDocPickerDialog({ Pick a Google Doc or Word file from your Drive. It imports as an editable .docx and stays linked for two-way sync.
-
- - setApiKey(e.target.value)} - placeholder="AIza…" - className="font-mono text-xs" - /> -

- From your Google Cloud project (APIs & Services → Credentials → API key), - with the Picker API enabled. Stored locally. -

-
+

+ You'll continue in your browser to grant access and choose a document — no + API key or setup needed. +

{error && ( -
-
- {error} -
- +
+ {error}
)}
- - ) } diff --git a/apps/x/apps/renderer/src/lib/google-picker.ts b/apps/x/apps/renderer/src/lib/google-picker.ts deleted file mode 100644 index d22a36b9e..000000000 --- a/apps/x/apps/renderer/src/lib/google-picker.ts +++ /dev/null @@ -1,118 +0,0 @@ -// Loader + thin wrapper around the Google Picker JS API. File selection happens -// inside Google's hosted Picker (so the app needs only drive.file, not a broad -// listing scope); the Picker hands back the chosen file id. - -export type PickedFile = { id: string; name: string; mimeType: string } - -type GapiGlobal = { - load: (lib: string, config: { callback: () => void; onerror?: () => void }) => void -} - -type PickerView = { - setIncludeFolders: (b: boolean) => PickerView - setOwnedByMe: (b: boolean) => PickerView - setMimeTypes: (m: string) => PickerView -} - -type PickerCallbackData = { - action: string - docs?: Array<{ id: string; name: string; mimeType: string }> -} - -type PickerBuilder = { - addView: (v: PickerView) => PickerBuilder - setOrigin: (o: string) => PickerBuilder - setOAuthToken: (t: string) => PickerBuilder - setDeveloperKey: (k: string) => PickerBuilder - setAppId: (id: string) => PickerBuilder - setTitle: (t: string) => PickerBuilder - setCallback: (cb: (data: PickerCallbackData) => void) => PickerBuilder - build: () => { setVisible: (v: boolean) => void } -} - -type GooglePickerNS = { - DocsView: new () => PickerView - PickerBuilder: new () => PickerBuilder - Action: { PICKED: string; CANCEL: string } -} - -declare global { - interface Window { - gapi?: GapiGlobal - google?: { picker?: GooglePickerNS } - } -} - -const API_JS = 'https://apis.google.com/js/api.js' -const DOC_MIME = 'application/vnd.google-apps.document' -const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' -const API_KEY_STORAGE = 'rowboat:google-picker-api-key' - -let pickerLoaded: Promise | null = null - -function loadPickerApi(): Promise { - if (pickerLoaded) return pickerLoaded - pickerLoaded = new Promise((resolve, reject) => { - const start = () => { - if (!window.gapi) { reject(new Error('Google API failed to initialize')); return } - window.gapi.load('picker', { - callback: () => resolve(), - onerror: () => reject(new Error('Google Picker failed to load')), - }) - } - const existing = document.querySelector(`script[src="${API_JS}"]`) - if (existing) { - if (window.gapi) start() - else existing.addEventListener('load', start) - return - } - const script = document.createElement('script') - script.src = API_JS - script.async = true - script.onload = start - script.onerror = () => reject(new Error('Failed to load the Google API script')) - document.head.appendChild(script) - }) - return pickerLoaded -} - -export function getStoredPickerApiKey(): string { - try { return localStorage.getItem(API_KEY_STORAGE) ?? '' } catch { return '' } -} - -export function setStoredPickerApiKey(key: string): void { - try { localStorage.setItem(API_KEY_STORAGE, key.trim()) } catch { /* ignore */ } -} - -export async function openGooglePicker(opts: { - accessToken: string - apiKey?: string - appId?: string - onPicked: (file: PickedFile) => void - onCancel?: () => void -}): Promise { - await loadPickerApi() - const picker = window.google?.picker - if (!picker) throw new Error('Google Picker is unavailable') - - const view = new picker.DocsView() - .setIncludeFolders(false) - .setMimeTypes(`${DOC_MIME},${DOCX_MIME}`) - - const builder = new picker.PickerBuilder() - .addView(view) - .setOrigin(window.location.protocol + '//' + window.location.host) - .setOAuthToken(opts.accessToken) - .setTitle('Choose a document to sync') - .setCallback((data) => { - if (data.action === picker.Action.PICKED && data.docs?.[0]) { - const d = data.docs[0] - opts.onPicked({ id: d.id, name: d.name, mimeType: d.mimeType }) - } else if (data.action === picker.Action.CANCEL) { - opts.onCancel?.() - } - }) - if (opts.apiKey) builder.setDeveloperKey(opts.apiKey) - if (opts.appId) builder.setAppId(opts.appId) - builder.build().setVisible(true) -} diff --git a/apps/x/packages/core/src/auth/google-backend-oauth.ts b/apps/x/packages/core/src/auth/google-backend-oauth.ts index b3d77c42e..1b1e1fb57 100644 --- a/apps/x/packages/core/src/auth/google-backend-oauth.ts +++ b/apps/x/packages/core/src/auth/google-backend-oauth.ts @@ -107,6 +107,29 @@ export async function claimTokensViaBackend(state: string): Promise return toOAuthTokens(body); } +/** + * Claim what the user selected in the managed OAuth-redirect Picker, parked + * under `session` by the webapp picker callback. Returns the picked file ids + * plus a fresh drive.file access token — the picker runs a standalone + * drive.file authorization (the main connection doesn't carry drive.file), so + * the desktop downloads the picked files with this token, not the main one. + */ +export async function claimPickedFilesViaBackend( + session: string, +): Promise<{ fileIds: string[]; accessToken: string }> { + const res = await postWithBearer("/v1/google-oauth/claim-picked", { session }); + if (!res.ok) { + const err = await readError(res); + throw new Error(`claim picked files failed: ${res.status} ${err.error ?? ""}`.trim()); + } + const body = (await res.json()) as { fileIds?: unknown; tokens?: { access_token?: unknown } }; + const fileIds = Array.isArray(body.fileIds) + ? body.fileIds.filter((id): id is string => typeof id === "string" && id.length > 0) + : []; + const accessToken = typeof body.tokens?.access_token === "string" ? body.tokens.access_token : ""; + return { fileIds, accessToken }; +} + /** * Refresh an access token via the api. Preserves caller's `refreshToken` and * `existingScopes` when Google omits them on the refresh response. diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts index a2268ede6..7d5d6f076 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -127,27 +127,43 @@ async function getDriveClient() { return google.drive({ version: 'v3', auth }); } +// Build a Drive client from a raw OAuth access token, bypassing the stored +// connection. Used by the OAuth-redirect Picker (trigger_onepick), which runs +// its own standalone drive.file authorization and hands back a fresh token — +// no stored connection, Picker API key, or appId involved. Read/export only; +// the token is short-lived and used immediately, so no refresh is wired up. +function driveClientFromToken(accessToken: string) { + const auth = new google.auth.OAuth2(); + auth.setCredentials({ access_token: accessToken }); + return google.drive({ version: 'v3', auth }); +} + // Get the file as .docx bytes: a native Google Doc is exported; an uploaded -// Word file is downloaded as-is. -async function fetchAsDocx(fileId: string, mimeType: string | undefined): Promise { - const driveClient = await getDriveClient(); +// Word file is downloaded as-is. Pass `driveClient` to use a specific token +// (e.g. the OAuth-redirect Picker's); omit it to use the stored connection. +async function fetchAsDocx( + fileId: string, + mimeType: string | undefined, + driveClient?: drive.Drive, +): Promise { + const dc = driveClient ?? await getDriveClient(); if (!mimeType || mimeType === GOOGLE_DOC_MIME) { - const result = await driveClient.files.export( + const result = await dc.files.export( { fileId, mimeType: DOCX_MIME }, { responseType: 'arraybuffer' }, ); return Buffer.from(result.data as ArrayBuffer); } - const result = await driveClient.files.get( + const result = await dc.files.get( { fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'arraybuffer' }, ); return Buffer.from(result.data as ArrayBuffer); } -async function getDocMetadata(fileId: string): Promise { - const driveClient = await getDriveClient(); - const result = await driveClient.files.get({ +async function getDocMetadata(fileId: string, driveClient?: drive.Drive): Promise { + const dc = driveClient ?? await getDriveClient(); + const result = await dc.files.get({ fileId, fields: 'id,name,webViewLink,modifiedTime,mimeType,owners(displayName,emailAddress)', supportsAllDrives: true, @@ -195,21 +211,26 @@ export async function getGoogleDocsConnectionStatus(): Promise<{ return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]); } -/** - * The live Google OAuth access token, for the renderer to drive the Google - * Picker (file selection happens client-side; the app never lists Drive). - */ -export async function getGoogleAccessToken(): Promise { - // getClient() refreshes an expired token when it can. If it returns a token - // that's still past expiry, the refresh failed (e.g. no refresh_token) — hand - // back null rather than a dead token, so the UI prompts a reconnect instead - // of silently passing an expired token to the Picker (which 403s on it). - const auth = await GoogleClientFactory.getClient(); - const token = auth?.credentials?.access_token ?? null; - if (!token) return null; - const expiry = auth?.credentials?.expiry_date; - if (typeof expiry === 'number' && expiry <= Date.now()) return null; - return token; +// Write the exported .docx bytes into the knowledge folder and record the +// Drive link. Shared by both import paths (stored connection / explicit token). +async function writeDocxAndLink( + doc: GoogleDocListItem, + bytes: Buffer, + targetFolder: string, +): Promise { + const relPath = await uniqueDocxPath(targetFolder, doc.name); + const absPath = resolveWorkspacePath(relPath); + await fs.mkdir(path.dirname(absPath), { recursive: true }); + await fs.writeFile(absPath, bytes); + await setLink(relPath, { + id: doc.id, + url: doc.url, + title: doc.name, + syncedAt: new Date().toISOString(), + mimeType: doc.mimeType, + remoteModifiedTime: doc.modifiedTime ?? undefined, + }); + return relPath; } /** Import a Google Doc as a local .docx and register the link. */ @@ -223,18 +244,26 @@ export async function importGoogleDoc(fileId: string, targetFolder: string): Pro const doc = await getDocMetadata(fileId); const bytes = await fetchAsDocx(fileId, doc.mimeType); - const relPath = await uniqueDocxPath(targetFolder, doc.name); - const absPath = resolveWorkspacePath(relPath); - await fs.mkdir(path.dirname(absPath), { recursive: true }); - await fs.writeFile(absPath, bytes); - await setLink(relPath, { - id: doc.id, - url: doc.url, - title: doc.name, - syncedAt: new Date().toISOString(), - mimeType: doc.mimeType, - remoteModifiedTime: doc.modifiedTime ?? undefined, - }); + const relPath = await writeDocxAndLink(doc, bytes, targetFolder); + return { path: relPath, doc }; +} + +/** + * Import a Google Doc using an explicit OAuth access token instead of the + * stored Google connection. Powers the OAuth-redirect Picker (trigger_onepick): + * that flow runs its own standalone drive.file authorization, so the picked + * file is granted to a fresh token that has no other scopes and isn't persisted + * as the user's main connection. No Picker API key or appId is involved. + */ +export async function importGoogleDocWithToken( + fileId: string, + targetFolder: string, + accessToken: string, +): Promise<{ path: string; doc: GoogleDocListItem }> { + const driveClient = driveClientFromToken(accessToken); + const doc = await getDocMetadata(fileId, driveClient); + const bytes = await fetchAsDocx(fileId, doc.mimeType, driveClient); + const relPath = await writeDocxAndLink(doc, bytes, targetFolder); return { path: relPath, doc }; } diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 4dc3e61a0..05844e7b7 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -650,30 +650,27 @@ const ipcSchemas = { missingScopes: z.array(z.string()), }), }, - // Live Google OAuth access token for driving the Google Picker in the renderer. - 'google-docs:getAccessToken': { - req: z.null(), - res: z.object({ - accessToken: z.string().nullable(), - }), - }, - // Open a Google Picker in a dedicated BrowserWindow (avoids session-cookie - // issues when running the Picker widget inside the renderer iframe). - 'google-docs:openPicker': { + 'google-docs:import': { req: z.object({ - accessToken: z.string(), - apiKey: z.string().optional(), - appId: z.string().optional(), + fileId: z.string().min(1), + targetFolder: RelPath, }), res: z.object({ - id: z.string(), - name: z.string(), - mimeType: z.string(), - }).nullable(), + path: RelPath, + doc: z.object({ + id: z.string(), + name: z.string(), + url: z.string(), + modifiedTime: z.string().nullable(), + owner: z.string().nullable(), + }), + }), }, - 'google-docs:import': { + // Managed OAuth-redirect Picker: the Rowboat backend runs the pick with the + // company Google client; the desktop opens the start URL, waits for the deep + // link, and imports with the existing managed token. No API key or BYOK creds. + 'google-docs:pickViaManaged': { req: z.object({ - fileId: z.string().min(1), targetFolder: RelPath, }), res: z.object({ @@ -685,7 +682,7 @@ const ipcSchemas = { modifiedTime: z.string().nullable(), owner: z.string().nullable(), }), - }), + }).nullable(), }, 'google-docs:refreshSnapshot': { req: z.object({