* add drive sync up and down

* add drive button

* google doc icon

* icon changes

* show error state with retry in google doc picker

* feat(google-docs): import and sync down as Markdown, record remote revision

* feat(google-docs): structure-preserving sync up with remote-conflict guard

* feat(google-docs): overwrite-confirm on sync conflict and last-synced indicator

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

* feat(google-docs): offer BYOK connect in picker so signed-in users can grant Drive/Docs scopes

* fix(google-docs): request full drive scope so .docx sync-up can write back

* fix(google-docs): search all drives in doc picker, log result count

* feat(google-docs): import native Docs AND uploaded .docx files from Drive

* chore(google-docs): drop dev-only test file

* feat(google-docs): use Google Picker + drive.file scope instead of full-drive listing

* 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.

* 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.

* feat(google-docs): managed OAuth-redirect Picker (no API key, no BYOK) (#620)

* feat(google-docs): managed OAuth-redirect Picker (no API key, no BYOK)

Adds the managed (rowboat-mode) Google Docs picker via Google's trigger_onepick
flow. The Rowboat backend runs a standalone drive.file OAuth with the company
client, renders the Picker inside the browser consent screen, and deep-links the
selection back; the desktop downloads the picked doc with the fresh drive.file
token the backend returns. No Picker API key, appId, or BYOK credentials on the
desktop.

- core: importGoogleDocWithToken downloads a picked doc with an explicit token;
  fetch/metadata helpers take an optional Drive client and share writeDocxAndLink.
  claimPickedFilesViaBackend claims the parked file ids + token from the api.
- main: google-picker-managed.ts opens the backend start URL and resolves on the
  rowboat://oauth/google/picker/done deep link; deeplink.ts routes that completion.
- ipc: google-docs:pickViaManaged.
- renderer: the picker dialog gates on Rowboat sign-in (the picker grants
  drive.file per-file, so no pre-existing connection or scope is required).

Backend contract: rowboatlabs/rowboatx-backend#7
(GET /oauth/google/picker/{start,callback}, POST /v1/google-oauth/claim-picked).

* chore(google-docs): remove the dead API-key/system-browser Picker

The managed picker replaced the only consumer (the picker dialog), so the
experimental API-key Picker is now unused. Removes:
- main: google-docs:openPicker handler (system-browser loopback Picker)
- shared: google-docs:openPicker + google-docs:getAccessToken IPC schemas
- core: getGoogleAccessToken (token plumbing for the client-side Picker)
- renderer: lib/google-picker.ts (Picker JS SDK loader)

Kept GoogleClientIdModal / google-credentials-store — still used by the
general BYOK Google connect in onboarding, connectors, and settings.

---------

Co-authored-by: Gagancreates <gaganp000999@gmail.com>
This commit is contained in:
arkml 2026-06-23 03:13:58 +05:30 committed by GitHub
parent a12bf4837b
commit be81ffb27b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1243 additions and 39 deletions

View file

@ -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<void> {
await completeRowboatGoogleConnect(parsed.state);
}
// --- Managed OAuth-redirect Picker completion ---
interface PickerCompletion {
state: string;
}
/**
* Match rowboat://oauth/google/picker/done?session=<state>. 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<void> {
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();

View file

@ -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=<state>
// (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<ManagedPickResult | null> {
// 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<ManagedPickResult | null>((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=<state>.
* 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<void> {
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)));
}
}

View file

@ -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);

View file

@ -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,

View file

@ -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<LinkedGoogleDocMeta> = {}
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<string | null>(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<Date | null>(null)
const [googleDocSyncDirection, setGoogleDocSyncDirection] = useState<'up' | 'down' | null>(null)
const debouncedContent = useDebounce(editorContent, 500)
const initialContentRef = useRef<string>('')
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<string> => {
try {
let index = 1
@ -5470,7 +5595,6 @@ function App() {
}
return markdownTabs
}, [fileTabs, selectedPath])
return (
<TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
@ -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' })
}}
/>
<GoogleDocPickerDialog
open={googleDocPickerOpen}
targetFolder={googleDocPickerTargetFolder}
onOpenChange={setGoogleDocPickerOpen}
onImported={(path) => {
const parentPath = path.split('/').slice(0, -1).join('/') || 'knowledge'
setExpandedPaths(prev => new Set([...prev, parentPath]))
void loadDirectory().then(setTree)
navigateToFile(path)
}}
/>
<Dialog open={showMeetingPermissions} onOpenChange={setShowMeetingPermissions}>
<DialogContent showCloseButton={false}>
<DialogHeader>

View file

@ -1,6 +1,14 @@
import { Suspense, lazy, useEffect, useRef, useState } from 'react'
import { ExternalLinkIcon, FileTextIcon, Loader2Icon } from 'lucide-react'
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from 'react'
import {
CloudDownloadIcon,
ExternalLinkIcon,
FileTextIcon,
Loader2Icon,
UploadCloudIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import type { DocxEditorRef } from '@eigenpal/docx-editor-react'
import { formatRelativeTime } from '@/lib/relative-time'
// The editor (and its CSS) is heavy and only needed when a .docx is open, so it
// loads in its own chunk the first time a Word document is viewed.
@ -16,6 +24,14 @@ interface DocxFileViewerProps {
path: string
}
type GoogleDocLink = {
id: string
url: string
title: string
syncedAt: string
remoteModifiedTime?: string
}
type LoadState = 'loading' | 'ready' | 'error'
type SaveState = 'idle' | 'saving' | 'saved' | 'error'
@ -51,6 +67,9 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
const [loadState, setLoadState] = useState<LoadState>('loading')
const [buffer, setBuffer] = useState<ArrayBuffer | null>(null)
const [saveState, setSaveState] = useState<SaveState>('idle')
const [reloadNonce, setReloadNonce] = useState(0)
const [link, setLink] = useState<GoogleDocLink | null>(null)
const [syncing, setSyncing] = useState<'up' | 'down' | null>(null)
const editorRef = useRef<DocxEditorRef>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@ -59,7 +78,7 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
const dirtyRef = useRef(false)
const savingRef = useRef(false)
// Load the .docx bytes whenever the path changes.
// Load the .docx bytes whenever the path changes or a sync-down reloads it.
useEffect(() => {
let cancelled = false
setLoadState('loading')
@ -87,10 +106,20 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
cancelled = true
if (armTimerRef.current) clearTimeout(armTimerRef.current)
}
}, [path, reloadNonce])
// Is this file linked to a Google Doc? Drives the sync bar.
useEffect(() => {
let cancelled = false
setLink(null)
void window.ipc.invoke('google-docs:getLink', { path })
.then((res) => { if (!cancelled) setLink(res.link) })
.catch((err) => { console.error('Failed to read Google Doc link:', err) })
return () => { cancelled = true }
}, [path])
// Serialize the current document and write it back to disk.
const persist = async () => {
const persist = useCallback(async () => {
const editor = editorRef.current
if (!editor || savingRef.current) return
savingRef.current = true
@ -115,7 +144,8 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
// A change landed while we were saving — flush it.
if (dirtyRef.current) scheduleSave()
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path])
const scheduleSave = () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
@ -137,6 +167,66 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path])
// Write any pending edits to disk before a sync-up so we push the latest.
const flushPendingSave = useCallback(async () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
if (dirtyRef.current || savingRef.current) {
await persist()
}
}, [persist])
const handleSyncDown = useCallback(async () => {
if (syncing) return
setSyncing('down')
try {
await window.ipc.invoke('google-docs:refreshSnapshot', { path })
// Reload the freshly-written bytes into the editor.
armedRef.current = false
dirtyRef.current = false
setReloadNonce((n) => n + 1)
const res = await window.ipc.invoke('google-docs:getLink', { path })
setLink(res.link)
toast.success('Pulled latest from Google Docs')
} catch (err) {
console.error('Sync down failed:', err)
toast.error(err instanceof Error ? err.message : 'Failed to pull from Google Docs')
} finally {
setSyncing(null)
}
}, [path, syncing])
const handleSyncUp = useCallback(async () => {
if (syncing) return
setSyncing('up')
try {
await flushPendingSave()
let result = await window.ipc.invoke('google-docs:sync', { path })
if (result.conflict) {
const overwrite = window.confirm(
'This Google Doc changed since your last sync.\n\n' +
'Overwrite it with your local version? Cancel to keep the remote copy ' +
'(use “Sync down” to pull it first).',
)
if (!overwrite) {
toast.info('Sync up cancelled — remote Google Doc is unchanged')
return
}
result = await window.ipc.invoke('google-docs:sync', { path, force: true })
}
if (!result.synced) {
throw new Error(result.error || 'This file is not linked to a Google Doc.')
}
const res = await window.ipc.invoke('google-docs:getLink', { path })
setLink(res.link)
toast.success('Pushed changes to Google Docs')
} catch (err) {
console.error('Sync up failed:', err)
toast.error(err instanceof Error ? err.message : 'Failed to push to Google Docs')
} finally {
setSyncing(null)
}
}, [path, syncing, flushPendingSave])
if (loadState === 'error') {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center text-muted-foreground">
@ -155,37 +245,75 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
)
}
if (loadState === 'loading' || !buffer) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Loading document</p>
</div>
)
}
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
<Suspense
fallback={
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Loading editor</p>
{link && (
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-muted/30 px-3 py-1.5 text-xs">
<GoogleDocsIcon className="size-4 shrink-0" />
<span className="truncate font-medium text-foreground">{link.title}</span>
<span className="truncate text-muted-foreground">
{syncing
? syncing === 'up' ? 'Syncing up…' : 'Syncing down…'
: `Synced ${formatRelativeTime(link.syncedAt)}`}
</span>
<div className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={() => { void handleSyncDown() }}
disabled={Boolean(syncing)}
title="Pull latest from Google Docs"
className="inline-flex items-center gap-1 rounded-md px-2 py-1 font-medium text-foreground hover:bg-accent disabled:opacity-50"
>
<CloudDownloadIcon className="size-3.5" /> Sync down
</button>
<button
type="button"
onClick={() => { void handleSyncUp() }}
disabled={Boolean(syncing)}
title="Push your changes to Google Docs"
className="inline-flex items-center gap-1 rounded-md px-2 py-1 font-medium text-foreground hover:bg-accent disabled:opacity-50"
>
<UploadCloudIcon className="size-3.5" /> Sync up
</button>
<button
type="button"
onClick={() => { window.open(link.url, '_blank') }}
title="Open in Google Docs"
className="inline-flex items-center rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ExternalLinkIcon className="size-3.5" />
</button>
</div>
}
>
<LazyDocxEditor
key={path}
ref={editorRef}
documentBuffer={buffer}
mode="editing"
documentName={baseName(path)}
documentNameEditable={false}
onChange={handleChange}
onError={(err) => { console.error('docx editor error:', err) }}
className="flex-1 min-h-0"
/>
</Suspense>
</div>
)}
{loadState === 'loading' || !buffer ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Loading document</p>
</div>
) : (
<Suspense
fallback={
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2Icon className="size-6 animate-spin" />
<p className="text-sm">Loading editor</p>
</div>
}
>
<LazyDocxEditor
key={`${path}:${reloadNonce}`}
ref={editorRef}
documentBuffer={buffer}
mode="editing"
documentName={baseName(path)}
documentNameEditable={false}
onChange={handleChange}
onError={(err) => { console.error('docx editor error:', err) }}
className="flex-1 min-h-0"
/>
</Suspense>
)}
{saveState !== 'idle' && (
<div className="pointer-events-none absolute bottom-3 right-4 z-10 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur">
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : 'Save failed'}
@ -194,3 +322,13 @@ export function DocxFileViewer({ path }: DocxFileViewerProps) {
</div>
)
}
function GoogleDocsIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" focusable="false">
<path fill="#4285F4" d="M6 2h8l5 5v15H6V2Z" />
<path fill="#AECBFA" d="M14 2v5h5l-5-5Z" />
<path fill="#FFFFFF" d="M8.5 11h7v1.2h-7V11Zm0 2.6h7v1.2h-7v-1.2Zm0 2.6h5.2v1.2H8.5v-1.2Z" />
</svg>
)
}

View file

@ -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<LivePillVariant, string> = {
error: 'text-amber-600 dark:text-amber-400 bg-amber-500/10 hover:bg-amber-500/15',
}
function GoogleDocsIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
className={className}
aria-hidden="true"
focusable="false"
>
<path fill="#4285F4" d="M6 2h8l5 5v15H6V2Z" />
<path fill="#AECBFA" d="M14 2v5h5l-5-5Z" />
<path fill="#FFFFFF" d="M8.5 11h7v1.2h-7V11Zm0 2.6h7v1.2h-7v-1.2Zm0 2.6h5.2v1.2H8.5v-1.2Z" />
</svg>
)
}
function GoogleDriveIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
className={className}
aria-hidden="true"
focusable="false"
>
<path fill="#1FA463" d="M8.52 3.5h6.96l6.95 12.04h-6.96L8.52 3.5Z" />
<path fill="#FFD04B" d="M1.57 15.54 8.52 3.5l3.48 6.02-3.48 6.02H1.57Z" />
<path fill="#4688F1" d="M8.52 15.54h13.91L18.95 21H5.05l3.47-5.46Z" />
</svg>
)
}
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 && (
<>
<div className="separator" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2"
title={`Google Doc: ${googleDoc.title}`}
disabled={Boolean(googleDoc.isSyncing)}
>
{googleDoc.isSyncing ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<GoogleDocsIcon className="size-4" />
)}
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel className="font-normal text-muted-foreground">
{googleDoc.lastSyncedAt
? `Last synced ${formatRelativeTime(googleDoc.lastSyncedAt)}`
: 'Not synced yet'}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={googleDoc.onOpen}>
<GoogleDriveIcon className="size-4 mr-2" />
Open Google Doc
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={googleDoc.onSyncDown} disabled={Boolean(googleDoc.isSyncing)}>
<CloudDownloadIcon className="size-4 mr-2" />
Sync down
</DropdownMenuItem>
<DropdownMenuItem onClick={googleDoc.onSyncUp} disabled={Boolean(googleDoc.isSyncing)}>
<UploadCloudIcon className="size-4 mr-2" />
Sync up
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{/* Live Note pill — pushed to far right */}
{onOpenLiveNote && liveState && (
<button

View file

@ -0,0 +1,142 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { FileText, Loader2, RefreshCw } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { toast } from '@/lib/toast'
type GoogleDocPickerDialogProps = {
open: boolean
targetFolder: string
onOpenChange: (open: boolean) => void
onImported: (path: string) => void
}
export function GoogleDocPickerDialog({
open,
targetFolder,
onOpenChange,
onImported,
}: GoogleDocPickerDialogProps) {
// The managed picker runs its own drive.file OAuth in the browser, gated on
// the Rowboat web session. So the only desktop prerequisite is being signed
// in to Rowboat — it needs NO prior Google connection and NO drive.file scope
// on the main grant (the picker grants drive.file per-file as you choose).
const [signedIn, setSignedIn] = useState<boolean | null>(null)
const [opening, setOpening] = useState(false)
const [error, setError] = useState<string | null>(null)
const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder])
const loadStatus = useCallback(async () => {
try {
const account = await window.ipc.invoke('account:getRowboat', null)
setSignedIn(account.signedIn)
setError(null)
} catch (err) {
setSignedIn(null)
setError(err instanceof Error ? err.message : 'Failed to check your Rowboat sign-in')
}
}, [])
useEffect(() => {
if (!open) return
void loadStatus()
}, [loadStatus, open])
const handleChoose = useCallback(async () => {
setError(null)
setOpening(true)
// Managed pick: the Rowboat backend runs the whole grant + pick in the
// browser with the company Google client, then deep-links the selection
// back. No API key, BYOK creds, or redirect URL to configure. Close our
// modal during the hand-off.
onOpenChange(false)
toast('Continue in your browser: grant access and pick a document…', 'info')
let result: { path: string; doc: { name: string } } | null = null
try {
result = await window.ipc.invoke('google-docs:pickViaManaged', { targetFolder })
} catch (err) {
setOpening(false)
toast(err instanceof Error ? err.message : 'Failed to open the Google Picker', 'error')
return
}
if (!result) {
setOpening(false)
return
}
toast(`Added “${result.doc.name}`, 'success')
onImported(result.path)
setOpening(false)
}, [targetFolder, onImported, onOpenChange])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[min(720px,calc(100vh-4rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
<DialogTitle>Add Google Doc</DialogTitle>
<DialogDescription>
Link a Google Doc or Word file from Drive into {targetLabel}.
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col">
{signedIn === null && error ? (
<div className="flex min-h-[280px] flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
<div className="max-w-sm text-sm text-destructive">{error}</div>
<Button variant="outline" onClick={() => void loadStatus()}>
<RefreshCw className="size-4" />
Retry
</Button>
</div>
) : signedIn === null ? (
<div className="flex min-h-[280px] flex-1 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
Checking your Rowboat sign-in
</div>
) : !signedIn ? (
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
<div className="max-w-sm text-sm text-muted-foreground">
Sign in to Rowboat to add Google Docs from Drive. The picker uses your
Rowboat account no Google credentials or API key needed.
</div>
<Button variant="outline" onClick={() => void loadStatus()}>
<RefreshCw className="size-4" />
I&apos;ve signed in retry
</Button>
</div>
) : (
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
<div className="max-w-sm text-sm text-muted-foreground">
Pick a Google Doc or Word file from your Drive. It imports as an editable
<code> .docx</code> and stays linked for two-way sync.
</div>
<p className="max-w-sm text-xs text-muted-foreground">
You&apos;ll continue in your browser to grant access and choose a document no
API key or setup needed.
</p>
{error && (
<div className="w-full max-w-sm rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<Button onClick={() => void handleChoose()} disabled={opening}>
{opening ? <Loader2 className="size-4 animate-spin" /> : <FileText className="size-4" />}
Choose from Google Drive
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -38,6 +38,7 @@ interface TreeNode {
export type KnowledgeViewActions = {
createNote: (parentPath?: string) => void
addGoogleDoc: (parentPath?: string) => void
createFolder: (parentPath?: string) => Promise<string>
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
remove: (path: string) => Promise<void>
@ -108,6 +109,21 @@ function latestMtime(node: TreeNode): number {
return max
}
function GoogleDriveIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
className={className}
aria-hidden="true"
focusable="false"
>
<path fill="#1FA463" d="M8.52 3.5h6.96l6.95 12.04h-6.96L8.52 3.5Z" />
<path fill="#FFD04B" d="M1.57 15.54 8.52 3.5l3.48 6.02-3.48 6.02H1.57Z" />
<path fill="#4688F1" d="M8.52 15.54h13.91L18.95 21H5.05l3.47-5.46Z" />
</svg>
)
}
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({
/>
</div>
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
<button
type="button"
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"
>
<GoogleDriveIcon className="size-4" />
<span>Add Google Doc</span>
</button>
</div>
</div>
@ -796,6 +820,10 @@ function RowContextMenu({
<FilePlus className="mr-2 size-4" />
New Note
</ContextMenuItem>
<ContextMenuItem onClick={() => actions.addGoogleDoc(node.path)}>
<GoogleDriveIcon className="mr-2 size-4" />
Add Google Doc
</ContextMenuItem>
<ContextMenuItem onClick={() => void actions.createFolder(node.path)}>
<FolderPlus className="mr-2 size-4" />
New Folder

View file

@ -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<MarkdownEditorHandle, MarkdownEditorPro
onFrontmatterChange,
onExport,
notePath,
googleDoc,
}, ref) {
const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null)
@ -1628,6 +1630,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
onSelectionHighlight={setSelectionHighlight}
onImageUpload={handleImageUploadWithPlaceholder}
onExport={onExport}
googleDoc={googleDoc}
onOpenLiveNote={notePath ? () => {
window.dispatchEvent(new CustomEvent('rowboat:open-live-note-panel', {
detail: { filePath: notePath },

View file

@ -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.

View file

@ -107,6 +107,29 @@ export async function claimTokensViaBackend(state: string): Promise<OAuthTokens>
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.

View file

@ -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': {

View file

@ -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<Record<string, GoogleDocLink>> {
try {
const raw = await fs.readFile(resolveWorkspacePath(LINKS_REL), 'utf8');
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
async function writeLinks(map: Record<string, GoogleDocLink>): Promise<void> {
const absPath = resolveWorkspacePath(LINKS_REL);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, JSON.stringify(map, null, 2), 'utf8');
}
async function setLink(relPath: string, link: GoogleDocLink): Promise<void> {
const links = await readLinks();
links[normalizeRel(relPath)] = link;
await writeLinks(links);
}
/** The Google Doc linked to a local .docx, or null if the file isn't linked. */
export async function getGoogleDocLink(relPath: string): Promise<GoogleDocLink | null> {
const links = await readLinks();
return links[normalizeRel(relPath)] ?? null;
}
// --- Drive / Docs clients --------------------------------------------------
async function getDriveClient() {
const auth = await GoogleClientFactory.getClient();
if (!auth) throw new Error('Google is not connected.');
return google.drive({ version: 'v3', auth });
}
// 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<Buffer> {
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<GoogleDocListItem> {
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<string> {
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<string> {
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) };
}
}

View file

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