diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts index aaaaa3bc0..68877975e 100644 --- a/apps/x/apps/main/src/deeplink.ts +++ b/apps/x/apps/main/src/deeplink.ts @@ -39,6 +39,8 @@ export function extractDeepLinkFromArgv(argv: readonly string[]): string | null export function dispatchUrl(url: string): void { if (parseAction(url)) { void dispatchAction(url); + } else if (parsePickerCompletion(url)) { + void dispatchPickerCompletion(url); } else if (parseOAuthCompletion(url)) { void dispatchOAuthCompletion(url); } else { @@ -158,6 +160,41 @@ async function dispatchOAuthCompletion(url: string): Promise { await completeRowboatGoogleConnect(parsed.state); } +// --- Managed OAuth-redirect Picker completion --- + +interface PickerCompletion { + state: string; +} + +/** + * Match rowboat://oauth/google/picker/done?session=. Distinct from the + * connect completion above (oauth/google/done) by the extra `picker` segment. + */ +function parsePickerCompletion(url: string): PickerCompletion | null { + if (!url.startsWith(URL_PREFIX)) return null; + const rest = url.slice(URL_PREFIX.length); + const queryIdx = rest.indexOf("?"); + const path = queryIdx >= 0 ? rest.slice(0, queryIdx) : rest; + const parts = path.split("/").filter(Boolean); + if (parts.length !== 4) return null; + if (parts[0] !== "oauth" || parts[1] !== "google" || parts[2] !== "picker" || parts[3] !== "done") return null; + const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); + const state = params.get("session"); + return state ? { state } : null; +} + +async function dispatchPickerCompletion(url: string): Promise { + const parsed = parsePickerCompletion(url); + if (!parsed) return; + + const win = mainWindowRef; + if (win && !win.isDestroyed()) focusWindow(win); + + // Lazy-import to keep deeplink.ts free of the picker's OAuth/knowledge deps. + const { completeManagedGooglePick } = await import("./google-picker-managed.js"); + await completeManagedGooglePick(parsed.state); +} + function focusWindow(win: BrowserWindow): void { if (win.isMinimized()) win.restore(); win.show(); diff --git a/apps/x/apps/main/src/google-picker-managed.ts b/apps/x/apps/main/src/google-picker-managed.ts new file mode 100644 index 000000000..a9790ad9e --- /dev/null +++ b/apps/x/apps/main/src/google-picker-managed.ts @@ -0,0 +1,120 @@ +import { shell, BrowserWindow } from 'electron'; +import { getWebappUrl } from '@x/core/dist/config/remote-config.js'; +import { claimPickedFilesViaBackend } from '@x/core/dist/auth/google-backend-oauth.js'; +import { importGoogleDocWithToken } from '@x/core/dist/knowledge/google_docs.js'; +import type { GoogleDocListItem } from '@x/core/dist/knowledge/google_docs.js'; + +// Managed (rowboat-mode) OAuth-redirect Picker. Unlike BYOK, the OAuth runs on +// the Rowboat backend with the COMPANY Google client — the desktop never holds +// a client_id/secret or an API key. The desktop just opens the start URL, waits +// for the deep link, claims the picked file ids, and downloads them with the +// user's EXISTING managed Google token (which already holds drive.file from the +// main connect). No Picker API key, appId, ngrok, or local OAuth. +// +// Backend contract (Rowboat webapp/api — NOT this repo). Mirrors the existing +// managed Google-connect (start URL → park under session → deep-link back): +// +// GET ${webappUrl}/oauth/google/picker/start +// Runs Google OAuth with the company client, scope=drive.file ONLY, +// trigger_onepick=true, prompt=consent. Tied to the logged-in web +// session (cookies), exactly like /oauth/google/start. +// +// GET ${webappUrl}/oauth/google/picker/callback +// Google returns `picked_file_ids` (+ code). Park the ids under a +// one-shot `session` ticket, then deep-link the desktop: +// rowboat://oauth/google/picker/done?session= +// (No need to exchange the code: the file is granted to the company +// client, so the desktop's existing managed token can read it.) +// +// POST ${API_URL}/v1/google-oauth/claim-picked body { session } +// Authenticated with the user's Rowboat bearer. Returns +// { fileIds: string[], tokens: { access_token, ... } } — a fresh +// drive.file token minted during the picker's own authorization. + +export interface ManagedPickResult { + path: string; + doc: GoogleDocListItem; +} + +interface PendingPick { + targetFolder: string; + resolve: (result: ManagedPickResult | null) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +} + +// Single in-flight pick (matches the one-at-a-time OAuth flow model). The deep +// link can't carry our targetFolder, so we stash it here for completion. +let pending: PendingPick | null = null; +const TIMEOUT_MS = 10 * 60 * 1000; + +function clearPending(): void { + if (pending) { + clearTimeout(pending.timer); + pending = null; + } +} + +function focusApp(): void { + const win = BrowserWindow.getAllWindows()[0]; + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); + } +} + +/** + * Open the managed picker in the browser and resolve once the deep link comes + * back with the user's selection (or null on cancel/timeout). The actual import + * happens in completeManagedGooglePick, fired by the deep-link dispatcher. + */ +export async function startManagedGooglePick(targetFolder: string): Promise { + // Supersede any abandoned flow so a stale deep link can't resolve this one. + if (pending) { + const stale = pending; + clearPending(); + stale.resolve(null); + } + + const webappUrl = await getWebappUrl(); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (pending) { + clearPending(); + resolve(null); + } + }, TIMEOUT_MS); + pending = { targetFolder, resolve, reject, timer }; + void shell.openExternal(`${webappUrl}/oauth/google/picker/start`); + }); +} + +/** + * Deep-link handler for rowboat://oauth/google/picker/done?session=. + * Claims the picked file ids from the backend and imports the first one with + * the existing managed token, resolving the promise startManagedGooglePick + * returned. + */ +export async function completeManagedGooglePick(session: string): Promise { + const current = pending; + if (!current) { + console.warn('[Picker] managed pick completion with no pending flow (timed out or already handled)'); + return; + } + clearPending(); + focusApp(); + + try { + const { fileIds, accessToken } = await claimPickedFilesViaBackend(session); + if (fileIds.length === 0 || !accessToken) { + current.resolve(null); + return; + } + // Download with the picker's own fresh drive.file token (the main + // connection doesn't carry drive.file). + const result = await importGoogleDocWithToken(fileIds[0], current.targetFolder, accessToken); + current.resolve(result); + } catch (error) { + current.reject(error instanceof Error ? error : new Error(String(error))); + } +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 94f966638..3ed9f6a0e 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -72,6 +72,8 @@ import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js'; import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js'; +import { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; +import { startManagedGooglePick } from './google-picker-managed.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'; @@ -1416,6 +1418,40 @@ export function setupIpcHandlers() { await versionHistory.restoreFile(args.path, args.oid); return { ok: true }; }, + 'google-docs:getStatus': async () => { + return getGoogleDocsConnectionStatus(); + }, + 'google-docs:import': async (_event, args) => { + 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; + } + }, + // Managed (rowboat-mode) 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 the picked doc with the existing + // managed token. No API key, appId, or local credentials. + 'google-docs:pickViaManaged': async (_event, args) => { + console.log(`[GoogleDocs] managed pick -> ${args.targetFolder}`); + const result = await startManagedGooglePick(args.targetFolder); + if (!result) return null; + console.log(`[GoogleDocs] managed pick import OK -> ${result.path}`); + return result; + }, + 'google-docs:refreshSnapshot': async (_event, args) => { + return syncGoogleDocDown(args.path); + }, + 'google-docs:sync': async (_event, args) => { + 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) => { return search(args.query, args.limit, args.types); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index dabdb0ae1..e0d4b2fcc 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/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 03e8e9982..9d3335817 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -31,6 +31,7 @@ import { EmailView } from '@/components/email-view'; import { WorkspaceView } from '@/components/workspace-view'; import { CodingRunBlock } from '@/components/coding-run'; import { KnowledgeView, type KnowledgeViewMode } 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'; @@ -255,6 +256,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() @@ -784,6 +822,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 with the chat docked according to appearance settings. const [isHomeOpen, setIsHomeOpen] = useState(true) @@ -850,6 +890,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) @@ -1389,6 +1430,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 @@ -1402,6 +1467,61 @@ function App() { editorContentRef.current = markdown setEditorContent(markdown) }, [setEditorCacheForPath]) + + const syncGoogleDocDown = useCallback(async (targetPath?: string) => { + const path = targetPath ?? 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 (targetPath?: string) => { + const path = targetPath ?? 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 { + 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.') + } + 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 @@ -1693,6 +1813,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 @@ -4716,6 +4837,10 @@ function App() { throw err } }, + addGoogleDoc: (parentPath: string = 'knowledge') => { + setGoogleDocPickerTargetFolder(parentPath) + setGoogleDocPickerOpen(true) + }, createFolder: async (parentPath: string = 'knowledge'): Promise => { try { let index = 1 @@ -5470,7 +5595,6 @@ function App() { } return markdownTabs }, [fileTabs, selectedPath]) - return ( { @@ -5761,6 +5885,7 @@ function App() { tree={tree} actions={{ createNote: knowledgeActions.createNote, + addGoogleDoc: knowledgeActions.addGoogleDoc, createFolder: knowledgeActions.createFolder, rename: knowledgeActions.rename, remove: knowledgeActions.remove, @@ -5877,6 +6002,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] @@ -5907,7 +6034,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 @@ -5929,6 +6056,18 @@ function App() { } }} editable={!isViewingHistory} + googleDoc={linkedGoogleDoc && !isViewingHistory ? { + title: linkedGoogleDoc.title, + isSyncing: isActive ? googleDocSyncDirection : null, + lastSyncedAt: linkedGoogleDoc.syncedAt, + 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) @@ -6350,6 +6489,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/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/apps/renderer/src/components/editor-toolbar.tsx b/apps/x/apps/renderer/src/components/editor-toolbar.tsx index b48cee494..9bacc1581 100644 --- a/apps/x/apps/renderer/src/components/editor-toolbar.tsx +++ b/apps/x/apps/renderer/src/components/editor-toolbar.tsx @@ -26,17 +26,24 @@ import { Trash2Icon, ImageIcon, DownloadIcon, + ChevronDownIcon, FileTextIcon, FileIcon, FileTypeIcon, + CloudDownloadIcon, + LoaderIcon, + UploadCloudIcon, Radio, } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { formatRelativeTime } from '@/lib/relative-time' interface EditorToolbarProps { editor: Editor | null @@ -45,6 +52,16 @@ interface EditorToolbarProps { onExport?: (format: 'md' | 'pdf' | 'docx') => void onOpenLiveNote?: () => void liveState?: LivePillState + googleDoc?: GoogleDocToolbarState +} + +export interface GoogleDocToolbarState { + title: string + isSyncing?: 'up' | 'down' | null + lastSyncedAt?: string + onOpen: () => void + onSyncDown: () => void + onSyncUp: () => void } export type LivePillVariant = 'passive' | 'idle' | 'running' | 'error' @@ -60,6 +77,36 @@ const LIVE_PILL_VARIANT_CLASS: Record = { error: 'text-amber-600 dark:text-amber-400 bg-amber-500/10 hover:bg-amber-500/15', } +function GoogleDocsIcon({ className }: { className?: string }) { + return ( + + ) +} + +function GoogleDriveIcon({ className }: { className?: string }) { + return ( + + ) +} + export function EditorToolbar({ editor, onSelectionHighlight, @@ -67,6 +114,7 @@ export function EditorToolbar({ onExport, onOpenLiveNote, liveState, + googleDoc, }: EditorToolbarProps) { const [linkUrl, setLinkUrl] = useState('') const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false) @@ -404,6 +452,51 @@ export function EditorToolbar({ )} + {googleDoc && ( + <> +
+ + + + + + + {googleDoc.lastSyncedAt + ? `Last synced ${formatRelativeTime(googleDoc.lastSyncedAt)}` + : 'Not synced yet'} + + + + + Open Google Doc + + + + + Sync down + + + + Sync up + + + + + )} + {/* Live Note pill — pushed to far right */} {onOpenLiveNote && liveState && ( +
+ ) : signedIn === null ? ( +
+ + Checking your Rowboat sign-in… +
+ ) : !signedIn ? ( +
+
+ Sign in to Rowboat to add Google Docs from Drive. The picker uses your + Rowboat account — no Google credentials or API key needed. +
+ +
+ ) : ( +
+
+ Pick a Google Doc or Word file from your Drive. It imports as an editable + .docx and stays linked for two-way sync. +
+

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

+ {error && ( +
+ {error} +
+ )} + +
+ )} +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/knowledge-view.tsx b/apps/x/apps/renderer/src/components/knowledge-view.tsx index 0945ffcfc..b1fa48fda 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 @@ -108,6 +109,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 @@ -218,6 +234,14 @@ export function KnowledgeView({ /> + @@ -796,6 +820,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/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 7b7420083..2af7aaa61 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -292,7 +292,7 @@ function computeWithinBlockOffset( return 0 } } -import { EditorToolbar, type LivePillState } from './editor-toolbar' +import { EditorToolbar, type GoogleDocToolbarState, type LivePillState } from './editor-toolbar' import { useLiveNoteForPath } from '@/hooks/use-live-note-for-path' import { formatRelativeTime } from '@/lib/relative-time' import { FrontmatterProperties } from './frontmatter-properties' @@ -448,6 +448,7 @@ interface MarkdownEditorProps { onFrontmatterChange?: (raw: string | null) => void onExport?: (format: 'md' | 'pdf' | 'docx') => void notePath?: string + googleDoc?: GoogleDocToolbarState } type WikiLinkMatch = { @@ -645,6 +646,7 @@ export const MarkdownEditor = forwardRef(null) @@ -1628,6 +1630,7 @@ export const MarkdownEditor = forwardRef { window.dispatchEvent(new CustomEvent('rowboat:open-live-note-panel', { detail: { filePath: notePath }, 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/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/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index 732d56abf..a695cf562 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -77,6 +77,10 @@ const providerConfigs: ProviderConfig = { scopes: [ 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/calendar.events.readonly', + // 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 new file mode 100644 index 000000000..7d5d6f076 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -0,0 +1,339 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +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'; + +// 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.file', +] as const; + +export type GoogleDocListItem = { + id: string; + name: string; + 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 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 = { + id: string; + 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'; +// 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. +// 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 + .replace(/[\\/*?:"<>|]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120); + return cleaned || 'Google Doc'; +} + +function normalizeRel(relPath: string): string { + return relPath.replace(/\\/g, '/'); +} + +function normalizeKnowledgeDir(targetFolder: string): string { + 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/.'); + } + return normalized; +} + +/** + * 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. 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, + 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; +} + +// --- Link registry --------------------------------------------------------- + +async function readLinks(): Promise> { + try { + const raw = await fs.readFile(resolveWorkspacePath(LINKS_REL), 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +async function writeLinks(map: Record): Promise { + const absPath = resolveWorkspacePath(LINKS_REL); + await fs.mkdir(path.dirname(absPath), { recursive: true }); + await fs.writeFile(absPath, JSON.stringify(map, null, 2), 'utf8'); +} + +async function setLink(relPath: string, link: GoogleDocLink): Promise { + const links = await readLinks(); + links[normalizeRel(relPath)] = link; + await writeLinks(links); +} + +/** The Google Doc linked to a local .docx, or null if the file isn't linked. */ +export async function getGoogleDocLink(relPath: string): Promise { + const links = await readLinks(); + return links[normalizeRel(relPath)] ?? null; +} + +// --- Drive / Docs clients -------------------------------------------------- + +async function getDriveClient() { + const auth = await GoogleClientFactory.getClient(); + if (!auth) throw new Error('Google is not connected.'); + return google.drive({ version: 'v3', auth }); +} + +// 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. 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 dc.files.export( + { fileId, mimeType: DOCX_MIME }, + { responseType: 'arraybuffer' }, + ); + return Buffer.from(result.data as ArrayBuffer); + } + 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, 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, + }); + 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, + mimeType: file.mimeType ?? GOOGLE_DOC_MIME, + }; +} + +async function uniqueDocxPath(targetFolder: string, title: string): Promise { + const folder = normalizeKnowledgeDir(targetFolder); + // 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) { + try { + await fs.access(resolveWorkspacePath(candidate)); + candidate = `${folder}/${base}-${index}.docx`; + index += 1; + } catch { + return candidate; + } + } +} + +// --- Public API ------------------------------------------------------------ + +export async function getGoogleDocsConnectionStatus(): Promise<{ + connected: boolean; + hasRequiredScopes: boolean; + missingScopes: string[]; +}> { + return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]); +} + +// 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. */ +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 access. Reconnect Google.'); + + const doc = await getDocMetadata(fileId); + const bytes = await fetchAsDocx(fileId, doc.mimeType); + 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 }; +} + +/** 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([ + fetchAsDocx(link.id, link.mimeType), + getDocMetadata(link.id), + ]); + await fs.writeFile(resolveWorkspacePath(normalizeRel(relPath)), bytes); + const syncedAt = new Date().toISOString(); + await setLink(relPath, { + id: link.id, + url: link.url, + title: link.title, + syncedAt, + mimeType: link.mimeType ?? meta.mimeType, + remoteModifiedTime: meta.modifiedTime ?? link.remoteModifiedTime, + }); + return { ok: true, syncedAt }; +} + +/** Push the local .docx back into the Google Doc (in place, preserving its id/URL). */ +export async function syncGoogleDocUp( + relPath: string, + opts: { force?: boolean } = {}, +): Promise<{ synced: boolean; syncedAt?: string; conflict?: boolean; error?: string }> { + try { + 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(link.id); + if (isRemoteAhead(meta.modifiedTime, link.remoteModifiedTime)) { + return { + synced: false, + conflict: true, + error: 'The Google Doc changed since your last sync. Pull the latest, or overwrite it.', + }; + } + } + + const bytes = await fs.readFile(resolveWorkspacePath(normalizeRel(relPath))); + const driveClient = await getDriveClient(); + // 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); + const syncedAt = new Date().toISOString(); + await setLink(relPath, { + id: link.id, + url: link.url, + title: link.title, + syncedAt, + mimeType: link.mimeType ?? meta.mimeType, + remoteModifiedTime: meta.modifiedTime ?? link.remoteModifiedTime, + }); + 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 e0d7cf19e..d15430066 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -1096,6 +1096,89 @@ 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: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(), + }), + }), + }, + // 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({ + 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(), + }), + }).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, + // 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(), + 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(), + }), + }, + // 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({