add drive sync up and down

This commit is contained in:
Arjun 2026-05-27 21:10:49 +05:30
parent b89b91258e
commit c548f6bd51
8 changed files with 788 additions and 3 deletions

View file

@ -49,6 +49,7 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
import { getGoogleDocsConnectionStatus, importGoogleDoc, listGoogleDocs, refreshGoogleDocSnapshot, syncLinkedGoogleDocFromMarkdown } from '@x/core/dist/knowledge/google_docs.js';
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
import { API_URL } from '@x/core/dist/config/env.js';
@ -793,6 +794,21 @@ export function setupIpcHandlers() {
await versionHistory.restoreFile(args.path, args.oid);
return { ok: true };
},
'google-docs:getStatus': async () => {
return getGoogleDocsConnectionStatus();
},
'google-docs:list': async (_event, args) => {
return listGoogleDocs(args.query);
},
'google-docs:import': async (_event, args) => {
return importGoogleDoc(args.fileId, args.targetFolder);
},
'google-docs:refreshSnapshot': async (_event, args) => {
return refreshGoogleDocSnapshot(args.path);
},
'google-docs:sync': async (_event, args) => {
return syncLinkedGoogleDocFromMarkdown(args.path, args.markdown);
},
// Search handler
'search:query': async (_event, args) => {
return search(args.query, args.limit, args.types);

View file

@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, Plus, HistoryIcon } from 'lucide-react';
import { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, Plus, HistoryIcon, DownloadIcon, UploadCloud } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
import { ChatSidebar } from './components/chat-sidebar';
@ -29,6 +29,7 @@ import { BgTasksView } from '@/components/bg-tasks-view';
import { EmailView } from '@/components/email-view';
import { WorkspaceView } from '@/components/workspace-view';
import { KnowledgeView } from '@/components/knowledge-view';
import { GoogleDocPickerDialog } from '@/components/google-doc-picker-dialog';
import { ChatHistoryView } from '@/components/chat-history-view';
import { HomeView } from '@/components/home-view';
import { MeetingsView } from '@/components/meetings-view';
@ -252,6 +253,43 @@ const stripKnowledgePrefixForWiki = (relPath: string) => {
const stripMarkdownExtensionForWiki = (wikiPath: string) =>
wikiPath.toLowerCase().endsWith('.md') ? wikiPath.slice(0, -3) : wikiPath
type LinkedGoogleDocMeta = {
id: string
title: string
url?: string
syncedAt?: string
}
const parseLinkedGoogleDocFrontmatter = (raw: string | null | undefined): LinkedGoogleDocMeta | null => {
if (!raw?.includes('google_doc:')) return null
const doc: Partial<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()
@ -768,6 +806,8 @@ function App() {
// Folder being browsed inside the knowledge view (null = root overview).
// Lives in ViewState so folder drill-down participates in back/forward history.
const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState<string | null>(null)
const [googleDocPickerOpen, setGoogleDocPickerOpen] = useState(false)
const [googleDocPickerTargetFolder, setGoogleDocPickerTargetFolder] = useState('knowledge')
const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
// Default landing view: Home in the middle with the chat docked on the right.
const [isHomeOpen, setIsHomeOpen] = useState(true)
@ -834,6 +874,7 @@ function App() {
// Auto-save state
const [isSaving, setIsSaving] = useState(false)
const [lastSaved, setLastSaved] = useState<Date | null>(null)
const [googleDocSyncDirection, setGoogleDocSyncDirection] = useState<'up' | 'down' | null>(null)
const debouncedContent = useDebounce(editorContent, 500)
const initialContentRef = useRef<string>('')
const renameInProgressRef = useRef(false)
@ -1352,6 +1393,30 @@ function App() {
return isRecent
}, [])
const reloadMarkdownFileIntoEditor = useCallback(async (path: string) => {
const result = await window.ipc.invoke('workspace:readFile', { path, encoding: 'utf8' })
const { raw: fm, body } = splitFrontmatter(result.data)
frontmatterByPathRef.current.set(path, fm)
setFileContent(result.data)
setEditorContent(body)
setEditorCacheForPath(path, body)
editorContentRef.current = body
editorPathRef.current = path
initialContentByPathRef.current.set(path, body)
initialContentRef.current = body
setLastSaved(new Date())
setEditorSessionByTabId((prev) => {
let changed = false
const next = { ...prev }
for (const tab of fileTabs) {
if (tab.path !== path) continue
next[tab.id] = (next[tab.id] ?? 0) + 1
changed = true
}
return changed ? next : prev
})
}, [fileTabs, setEditorCacheForPath])
const handleEditorChange = useCallback((path: string, markdown: string) => {
setEditorCacheForPath(path, markdown)
const nextSelectedPath = selectedPathRef.current
@ -1365,6 +1430,49 @@ function App() {
editorContentRef.current = markdown
setEditorContent(markdown)
}, [setEditorCacheForPath])
const syncGoogleDocDown = useCallback(async () => {
const path = selectedPathRef.current
if (!path || !path.startsWith('knowledge/') || !path.endsWith('.md')) return
setGoogleDocSyncDirection('down')
markRecentLocalMarkdownWrite(path)
try {
await window.ipc.invoke('google-docs:refreshSnapshot', { path })
markRecentLocalMarkdownWrite(path)
await reloadMarkdownFileIntoEditor(path)
toast.success('Pulled latest Google Doc')
} catch (err) {
console.error('Failed to sync Google Doc down:', err)
toast.error(err instanceof Error ? err.message : 'Failed to pull Google Doc')
} finally {
setGoogleDocSyncDirection(null)
}
}, [markRecentLocalMarkdownWrite, reloadMarkdownFileIntoEditor])
const syncGoogleDocUp = useCallback(async () => {
const path = selectedPathRef.current
if (!path || !path.startsWith('knowledge/') || !path.endsWith('.md')) return
const body = editorContentByPathRef.current.get(path) ?? editorContentRef.current
const markdown = joinFrontmatter(frontmatterByPathRef.current.get(path) ?? null, body)
setGoogleDocSyncDirection('up')
markRecentLocalMarkdownWrite(path)
try {
const result = await window.ipc.invoke('google-docs:sync', { path, markdown })
if (!result.synced) {
throw new Error(result.error || 'This note is not linked to a Google Doc.')
}
markRecentLocalMarkdownWrite(path)
await reloadMarkdownFileIntoEditor(path)
toast.success('Pushed changes to Google Doc')
} catch (err) {
console.error('Failed to sync Google Doc up:', err)
toast.error(err instanceof Error ? err.message : 'Failed to push Google Doc')
} finally {
setGoogleDocSyncDirection(null)
}
}, [markRecentLocalMarkdownWrite, reloadMarkdownFileIntoEditor])
// Keep processingRunIdsRef in sync for use in async callbacks
useEffect(() => {
processingRunIdsRef.current = processingRunIds
@ -1656,6 +1764,7 @@ function App() {
const baseline = initialContentByPathRef.current.get(pathAtStart) ?? initialContentRef.current
if (debouncedContent === baseline) return
if (!debouncedContent) return
if (selectedPathRef.current === pathAtStart && debouncedContent !== editorContentRef.current) return
const saveFile = async () => {
const wasActiveAtStart = selectedPathRef.current === pathAtStart
@ -4517,6 +4626,10 @@ function App() {
throw err
}
},
addGoogleDoc: (parentPath: string = 'knowledge') => {
setGoogleDocPickerTargetFolder(parentPath)
setGoogleDocPickerOpen(true)
},
createFolder: async (parentPath: string = 'knowledge'): Promise<string> => {
try {
let index = 1
@ -5255,7 +5368,10 @@ function App() {
}
return markdownTabs
}, [fileTabs, selectedPath])
const selectedLinkedGoogleDoc = React.useMemo(() => {
if (!selectedPath?.startsWith('knowledge/') || !selectedPath.endsWith('.md')) return null
return parseLinkedGoogleDocFrontmatter(frontmatterByPathRef.current.get(selectedPath) ?? null)
}, [selectedPath, editorContent, editorContentByPath])
return (
<TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
@ -5365,6 +5481,46 @@ function App() {
) : null}
</div>
)}
{selectedLinkedGoogleDoc && (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => { void syncGoogleDocDown() }}
disabled={googleDocSyncDirection !== null || isSaving || Boolean(viewingHistoricalVersion)}
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0 disabled:pointer-events-none disabled:opacity-50"
aria-label="Sync down from Google Doc"
>
{googleDocSyncDirection === 'down' ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<DownloadIcon className="size-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Sync down from Google Doc</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => { void syncGoogleDocUp() }}
disabled={googleDocSyncDirection !== null || isSaving || Boolean(viewingHistoricalVersion)}
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0 disabled:pointer-events-none disabled:opacity-50"
aria-label="Sync up to Google Doc"
>
{googleDocSyncDirection === 'up' ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<UploadCloud className="size-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Sync up to Google Doc</TooltipContent>
</Tooltip>
</>
)}
{selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && (
<Tooltip>
<TooltipTrigger asChild>
@ -5553,6 +5709,7 @@ function App() {
tree={tree}
actions={{
createNote: knowledgeActions.createNote,
addGoogleDoc: knowledgeActions.addGoogleDoc,
createFolder: knowledgeActions.createFolder,
rename: knowledgeActions.rename,
remove: knowledgeActions.remove,
@ -6054,6 +6211,17 @@ function App() {
void window.ipc.invoke('oauth:connect', { provider: 'google' })
}}
/>
<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

@ -0,0 +1,227 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { FileText, Loader2, RefreshCw, Search } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { formatRelativeTime } from '@/lib/relative-time'
import { toast } from '@/lib/toast'
type GoogleDocListItem = {
id: string
name: string
url: string
modifiedTime: string | null
owner: string | null
}
type GoogleDocsStatus = {
connected: boolean
hasRequiredScopes: boolean
missingScopes: string[]
}
type GoogleDocPickerDialogProps = {
open: boolean
targetFolder: string
onOpenChange: (open: boolean) => void
onImported: (path: string) => void
}
function formatModified(modifiedTime: string | null): string {
if (!modifiedTime) return ''
return formatRelativeTime(modifiedTime)
}
export function GoogleDocPickerDialog({
open,
targetFolder,
onOpenChange,
onImported,
}: GoogleDocPickerDialogProps) {
const [status, setStatus] = useState<GoogleDocsStatus | null>(null)
const [query, setQuery] = useState('')
const [docs, setDocs] = useState<GoogleDocListItem[]>([])
const [loading, setLoading] = useState(false)
const [connecting, setConnecting] = useState(false)
const [importingId, setImportingId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const canList = Boolean(status?.connected && status.hasRequiredScopes)
const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder])
const loadStatus = useCallback(async () => {
try {
const result = await window.ipc.invoke('google-docs:getStatus', null)
setStatus(result)
} catch (err) {
setStatus(null)
setError(err instanceof Error ? err.message : 'Failed to check Google connection')
}
}, [])
const loadDocs = useCallback(async (searchQuery: string) => {
setLoading(true)
setError(null)
try {
const result = await window.ipc.invoke('google-docs:list', { query: searchQuery.trim() || undefined })
setDocs(result.files)
} catch (err) {
setDocs([])
setError(err instanceof Error ? err.message : 'Failed to load Google Docs')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!open) return
setQuery('')
setDocs([])
setError(null)
void loadStatus()
}, [loadStatus, open])
useEffect(() => {
if (!open || !canList) return
const timeout = window.setTimeout(() => {
void loadDocs(query)
}, 250)
return () => window.clearTimeout(timeout)
}, [canList, loadDocs, open, query])
const handleConnect = useCallback(async () => {
setConnecting(true)
setError(null)
try {
const result = await window.ipc.invoke('oauth:connect', { provider: 'google' })
if (!result.success) {
setError(result.error ?? 'Failed to start Google connection')
} else {
toast('Finish Google connection in the browser, then reopen the picker.', 'info')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start Google connection')
} finally {
setConnecting(false)
}
}, [])
const handleImport = useCallback(async (doc: GoogleDocListItem) => {
setImportingId(doc.id)
setError(null)
try {
const result = await window.ipc.invoke('google-docs:import', {
fileId: doc.id,
targetFolder,
})
toast('Google Doc added', 'success')
onImported(result.path)
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to import Google Doc')
} finally {
setImportingId(null)
}
}, [onImported, onOpenChange, targetFolder])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[min(720px,calc(100vh-4rem))] max-w-2xl 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>
Select a Google Doc to link into {targetLabel}.
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col">
{!status ? (
<div className="flex min-h-[320px] flex-1 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
Checking Google connection...
</div>
) : !status.connected || !status.hasRequiredScopes ? (
<div className="flex min-h-[360px] flex-1 flex-col items-center justify-center gap-4 overflow-y-auto px-8 py-8 text-center">
<div className="max-w-sm text-sm text-muted-foreground">
{!status.connected
? 'Connect Google to choose Docs from Drive.'
: 'Reconnect Google so Rowboat can read Drive metadata and edit Google Docs.'}
</div>
{status.missingScopes.length > 0 && (
<div className="max-w-md rounded-md border border-border bg-muted/30 px-3 py-2 text-left text-xs text-muted-foreground">
Missing scopes: {status.missingScopes.join(', ')}
</div>
)}
<Button onClick={handleConnect} disabled={connecting}>
{connecting ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
Connect Google
</Button>
</div>
) : (
<>
<div className="shrink-0 border-b border-border p-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search Google Docs"
className="pl-9"
autoFocus
/>
</div>
</div>
{error && (
<div className="mx-4 mt-4 shrink-0 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="min-h-[280px] flex-1 overflow-y-auto p-2">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
Loading Docs...
</div>
) : docs.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No Google Docs found.
</div>
) : (
<div className="space-y-1">
{docs.map((doc) => (
<button
key={doc.id}
type="button"
onClick={() => void handleImport(doc)}
disabled={importingId !== null}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-accent disabled:opacity-60"
>
<FileText className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{doc.name}</div>
<div className="truncate text-xs text-muted-foreground">
{[doc.owner, formatModified(doc.modifiedTime)].filter(Boolean).join(' · ')}
</div>
</div>
{importingId === doc.id && <Loader2 className="size-4 shrink-0 animate-spin" />}
</button>
))}
</div>
)}
</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>
@ -202,6 +203,14 @@ export function KnowledgeView({
<FilePlus className="size-4" />
<span>New note</span>
</button>
<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"
>
<FileText className="size-4" />
<span>Add Google Doc</span>
</button>
</div>
</div>
@ -764,6 +773,10 @@ function RowContextMenu({
<FilePlus className="mr-2 size-4" />
New Note
</ContextMenuItem>
<ContextMenuItem onClick={() => actions.addGoogleDoc(node.path)}>
<FileText 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

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

@ -77,6 +77,8 @@ const providerConfigs: ProviderConfig = {
scopes: [
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/calendar.events.readonly',
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/documents',
],
},
'fireflies-ai': {

View file

@ -0,0 +1,300 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { google, drive_v3 as drive } from 'googleapis';
import { WorkDir } from '../config/config.js';
import { resolveWorkspacePath } from '../workspace/workspace.js';
import { GoogleClientFactory } from './google-client-factory.js';
export const GOOGLE_DOC_SCOPES = [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/documents',
] as const;
export type GoogleDocListItem = {
id: string;
name: string;
url: string;
modifiedTime: string | null;
owner: string | null;
};
type GoogleDocFrontmatter = {
id: string;
url: string;
title: string;
syncedAt?: string;
};
const GOOGLE_DOC_MIME = 'application/vnd.google-apps.document';
const TEXT_MIME = 'text/plain';
function yamlQuote(value: string): string {
return JSON.stringify(value);
}
function sanitizeFilename(name: string): string {
const cleaned = name
.replace(/[\\/*?:"<>|]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
return cleaned || 'Google Doc';
}
function escapeDriveQueryValue(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
function normalizeKnowledgeDir(targetFolder: string): string {
const normalized = targetFolder.replace(/\\/g, '/').replace(/\/+$/, '');
if (!normalized || normalized === 'knowledge') return 'knowledge';
if (!normalized.startsWith('knowledge/')) {
throw new Error('Google Docs can only be added under knowledge/.');
}
return normalized;
}
function buildStubContent(doc: GoogleDocFrontmatter, snapshot: string): string {
const syncedAt = doc.syncedAt ?? new Date().toISOString();
return [
'---',
'source:',
' - google-doc',
'google_doc:',
` id: ${yamlQuote(doc.id)}`,
` url: ${yamlQuote(doc.url)}`,
` title: ${yamlQuote(doc.title)}`,
` syncedAt: ${yamlQuote(syncedAt)}`,
'---',
'',
snapshot.trimEnd(),
'',
].join('\n');
}
function parseLinkedGoogleDoc(markdown: string): GoogleDocFrontmatter | null {
if (!markdown.startsWith('---')) return null;
const endIndex = markdown.indexOf('\n---', 3);
if (endIndex === -1) return null;
const raw = markdown.slice(0, endIndex + 4);
const lines = raw.split('\n');
let inGoogleDoc = false;
const doc: Partial<GoogleDocFrontmatter> = {};
for (const line of lines) {
if (line === '---') {
inGoogleDoc = false;
continue;
}
const topLevel = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
if (topLevel) {
inGoogleDoc = topLevel[1] === 'google_doc';
continue;
}
if (!inGoogleDoc) continue;
const nested = line.match(/^\s+([A-Za-z_][\w-]*):\s*(.*)$/);
if (!nested) continue;
const key = nested[1] as keyof GoogleDocFrontmatter;
let value = nested[2].trim();
if (!['id', 'url', 'title', 'syncedAt'].includes(key)) continue;
try {
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = JSON.parse(value);
}
} catch {
value = value.replace(/^['"]|['"]$/g, '');
}
doc[key] = value;
}
if (!doc.id || !doc.url || !doc.title) return null;
return doc as GoogleDocFrontmatter;
}
function bodyFromMarkdown(markdown: string): string {
if (!markdown.startsWith('---')) return markdown;
const endIndex = markdown.indexOf('\n---', 3);
if (endIndex === -1) return markdown;
let body = markdown.slice(endIndex + 4);
if (body.startsWith('\n')) body = body.slice(1);
return body;
}
function markdownSnapshotToPlainText(markdown: string): string {
return bodyFromMarkdown(markdown)
.replace(/^#{1,6}\s+/gm, '')
.replace(/^\s*[-*]\s+/gm, '- ')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.trimEnd();
}
async function getDriveClient() {
const auth = await GoogleClientFactory.getClient();
if (!auth) throw new Error('Google is not connected.');
return google.drive({ version: 'v3', auth });
}
async function getDocsClient() {
const auth = await GoogleClientFactory.getClient();
if (!auth) throw new Error('Google is not connected.');
return google.docs({ version: 'v1', auth });
}
async function exportDocText(fileId: string): Promise<string> {
const driveClient = await getDriveClient();
const result = await driveClient.files.export(
{ fileId, mimeType: TEXT_MIME },
{ responseType: 'text' },
);
return typeof result.data === 'string' ? result.data : String(result.data ?? '');
}
async function getDocMetadata(fileId: string): Promise<GoogleDocListItem> {
const driveClient = await getDriveClient();
const result = await driveClient.files.get({
fileId,
fields: 'id,name,webViewLink,modifiedTime,owners(displayName,emailAddress)',
});
const file = result.data;
if (!file.id || !file.name) throw new Error('Selected Google Doc is missing metadata.');
return toGoogleDocListItem(file);
}
function toGoogleDocListItem(file: drive.Schema$File): GoogleDocListItem {
return {
id: file.id ?? '',
name: file.name ?? 'Untitled Google Doc',
url: file.webViewLink ?? `https://docs.google.com/document/d/${file.id}/edit`,
modifiedTime: file.modifiedTime ?? null,
owner: file.owners?.[0]?.displayName ?? file.owners?.[0]?.emailAddress ?? null,
};
}
async function uniqueKnowledgePath(targetFolder: string, title: string): Promise<string> {
const folder = normalizeKnowledgeDir(targetFolder);
const base = sanitizeFilename(title);
let candidate = `${folder}/${base}.md`;
let index = 1;
while (true) {
try {
await fs.access(resolveWorkspacePath(candidate));
candidate = `${folder}/${base}-${index}.md`;
index += 1;
} catch {
return candidate;
}
}
}
export async function getGoogleDocsConnectionStatus(): Promise<{
connected: boolean;
hasRequiredScopes: boolean;
missingScopes: string[];
}> {
return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]);
}
export async function listGoogleDocs(query?: string): Promise<{ files: GoogleDocListItem[] }> {
const status = await getGoogleDocsConnectionStatus();
if (!status.connected) throw new Error('Google is not connected.');
if (!status.hasRequiredScopes) throw new Error('Google is missing Drive/Docs scopes. Reconnect Google.');
const driveClient = await getDriveClient();
const clauses = [`mimeType='${GOOGLE_DOC_MIME}'`, 'trashed=false'];
const trimmed = query?.trim();
if (trimmed) {
clauses.push(`name contains '${escapeDriveQueryValue(trimmed)}'`);
}
const result = await driveClient.files.list({
q: clauses.join(' and '),
pageSize: 25,
orderBy: 'modifiedTime desc',
fields: 'files(id,name,webViewLink,modifiedTime,owners(displayName,emailAddress))',
});
return { files: (result.data.files ?? []).map(toGoogleDocListItem).filter((file) => file.id) };
}
export async function importGoogleDoc(fileId: string, targetFolder: string): Promise<{
path: string;
doc: GoogleDocListItem;
}> {
const status = await getGoogleDocsConnectionStatus();
if (!status.connected) throw new Error('Google is not connected.');
if (!status.hasRequiredScopes) throw new Error('Google is missing Drive/Docs scopes. Reconnect Google.');
const doc = await getDocMetadata(fileId);
const snapshot = await exportDocText(fileId);
const relPath = await uniqueKnowledgePath(targetFolder, doc.name);
const absPath = resolveWorkspacePath(relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, buildStubContent({
id: doc.id,
url: doc.url,
title: doc.name,
syncedAt: new Date().toISOString(),
}, snapshot), 'utf8');
return { path: relPath, doc };
}
export async function refreshGoogleDocSnapshot(relPath: string): Promise<{ ok: true; syncedAt: string }> {
const absPath = resolveWorkspacePath(relPath);
const markdown = await fs.readFile(absPath, 'utf8');
const linked = parseLinkedGoogleDoc(markdown);
if (!linked) throw new Error('This note is not linked to a Google Doc.');
const snapshot = await exportDocText(linked.id);
const syncedAt = new Date().toISOString();
await fs.writeFile(absPath, buildStubContent({ ...linked, syncedAt }, snapshot), 'utf8');
return { ok: true, syncedAt };
}
export async function syncLinkedGoogleDocFromMarkdown(relPath: string, markdown: string): Promise<{ synced: boolean; syncedAt?: string; error?: string }> {
try {
const normalized = relPath.replace(/\\/g, '/');
if (!normalized.startsWith('knowledge/') || !normalized.endsWith('.md')) return { synced: false };
const linked = parseLinkedGoogleDoc(markdown);
if (!linked) return { synced: false };
const text = markdownSnapshotToPlainText(markdown);
const docsClient = await getDocsClient();
const current = await docsClient.documents.get({
documentId: linked.id,
fields: 'body(content(endIndex))',
});
const endIndex = current.data.body?.content?.at(-1)?.endIndex ?? 1;
const requests = [];
if (endIndex > 2) {
requests.push({
deleteContentRange: {
range: { startIndex: 1, endIndex: endIndex - 1 },
},
});
}
if (text.trim()) {
requests.push({
insertText: {
location: { index: 1 },
text: `${text.trimEnd()}\n`,
},
});
}
if (requests.length > 0) {
await docsClient.documents.batchUpdate({
documentId: linked.id,
requestBody: { requests },
});
}
const absPath = path.join(WorkDir, normalized);
const syncedAt = new Date().toISOString();
await fs.writeFile(absPath, buildStubContent({ ...linked, syncedAt }, bodyFromMarkdown(markdown)), 'utf8');
return { synced: true, syncedAt };
} catch (error) {
console.error('[GoogleDocs] Failed to sync linked Google Doc:', error);
return { synced: false, error: error instanceof Error ? error.message : String(error) };
}
}

View file

@ -618,6 +618,65 @@ const ipcSchemas = {
req: z.object({}),
res: z.null(),
},
// Google Docs linked knowledge files
'google-docs:getStatus': {
req: z.null(),
res: z.object({
connected: z.boolean(),
hasRequiredScopes: z.boolean(),
missingScopes: z.array(z.string()),
}),
},
'google-docs:list': {
req: z.object({
query: z.string().optional(),
}),
res: z.object({
files: z.array(z.object({
id: z.string(),
name: z.string(),
url: z.string(),
modifiedTime: z.string().nullable(),
owner: z.string().nullable(),
})),
}),
},
'google-docs:import': {
req: z.object({
fileId: z.string().min(1),
targetFolder: RelPath,
}),
res: z.object({
path: RelPath,
doc: z.object({
id: z.string(),
name: z.string(),
url: z.string(),
modifiedTime: z.string().nullable(),
owner: z.string().nullable(),
}),
}),
},
'google-docs:refreshSnapshot': {
req: z.object({
path: RelPath,
}),
res: z.object({
ok: z.literal(true),
syncedAt: z.string(),
}),
},
'google-docs:sync': {
req: z.object({
path: RelPath,
markdown: z.string(),
}),
res: z.object({
synced: z.boolean(),
syncedAt: z.string().optional(),
error: z.string().optional(),
}),
},
// Search channels
'search:query': {
req: z.object({