mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
add drive sync up and down
This commit is contained in:
parent
b89b91258e
commit
c548f6bd51
8 changed files with 788 additions and 3 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
227
apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx
Normal file
227
apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
300
apps/x/packages/core/src/knowledge/google_docs.ts
Normal file
300
apps/x/packages/core/src/knowledge/google_docs.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue