mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Drive (#583)
* 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:
parent
a12bf4837b
commit
be81ffb27b
15 changed files with 1243 additions and 39 deletions
|
|
@ -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();
|
||||
|
|
|
|||
120
apps/x/apps/main/src/google-picker-managed.ts
Normal file
120
apps/x/apps/main/src/google-picker-managed.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
142
apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx
Normal file
142
apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx
Normal 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'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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
339
apps/x/packages/core/src/knowledge/google_docs.ts
Normal file
339
apps/x/packages/core/src/knowledge/google_docs.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue