mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +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 { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.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 { 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 { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||||
import { API_URL } from '@x/core/dist/config/env.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);
|
await versionHistory.restoreFile(args.path, args.oid);
|
||||||
return { ok: true };
|
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 handler
|
||||||
'search:query': async (_event, args) => {
|
'search:query': async (_event, args) => {
|
||||||
return search(args.query, args.limit, args.types);
|
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 type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import z from 'zod';
|
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 { cn } from '@/lib/utils';
|
||||||
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
||||||
import { ChatSidebar } from './components/chat-sidebar';
|
import { ChatSidebar } from './components/chat-sidebar';
|
||||||
|
|
@ -29,6 +29,7 @@ import { BgTasksView } from '@/components/bg-tasks-view';
|
||||||
import { EmailView } from '@/components/email-view';
|
import { EmailView } from '@/components/email-view';
|
||||||
import { WorkspaceView } from '@/components/workspace-view';
|
import { WorkspaceView } from '@/components/workspace-view';
|
||||||
import { KnowledgeView } from '@/components/knowledge-view';
|
import { KnowledgeView } from '@/components/knowledge-view';
|
||||||
|
import { GoogleDocPickerDialog } from '@/components/google-doc-picker-dialog';
|
||||||
import { ChatHistoryView } from '@/components/chat-history-view';
|
import { ChatHistoryView } from '@/components/chat-history-view';
|
||||||
import { HomeView } from '@/components/home-view';
|
import { HomeView } from '@/components/home-view';
|
||||||
import { MeetingsView } from '@/components/meetings-view';
|
import { MeetingsView } from '@/components/meetings-view';
|
||||||
|
|
@ -252,6 +253,43 @@ const stripKnowledgePrefixForWiki = (relPath: string) => {
|
||||||
const stripMarkdownExtensionForWiki = (wikiPath: string) =>
|
const stripMarkdownExtensionForWiki = (wikiPath: string) =>
|
||||||
wikiPath.toLowerCase().endsWith('.md') ? wikiPath.slice(0, -3) : wikiPath
|
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) =>
|
const wikiPathCompareKey = (wikiPath: string) =>
|
||||||
stripMarkdownExtensionForWiki(wikiPath).toLowerCase()
|
stripMarkdownExtensionForWiki(wikiPath).toLowerCase()
|
||||||
|
|
||||||
|
|
@ -768,6 +806,8 @@ function App() {
|
||||||
// Folder being browsed inside the knowledge view (null = root overview).
|
// Folder being browsed inside the knowledge view (null = root overview).
|
||||||
// Lives in ViewState so folder drill-down participates in back/forward history.
|
// Lives in ViewState so folder drill-down participates in back/forward history.
|
||||||
const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState<string | null>(null)
|
const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState<string | null>(null)
|
||||||
|
const [googleDocPickerOpen, setGoogleDocPickerOpen] = useState(false)
|
||||||
|
const [googleDocPickerTargetFolder, setGoogleDocPickerTargetFolder] = useState('knowledge')
|
||||||
const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
|
const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
|
||||||
// Default landing view: Home in the middle with the chat docked on the right.
|
// Default landing view: Home in the middle with the chat docked on the right.
|
||||||
const [isHomeOpen, setIsHomeOpen] = useState(true)
|
const [isHomeOpen, setIsHomeOpen] = useState(true)
|
||||||
|
|
@ -834,6 +874,7 @@ function App() {
|
||||||
// Auto-save state
|
// Auto-save state
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [lastSaved, setLastSaved] = useState<Date | null>(null)
|
const [lastSaved, setLastSaved] = useState<Date | null>(null)
|
||||||
|
const [googleDocSyncDirection, setGoogleDocSyncDirection] = useState<'up' | 'down' | null>(null)
|
||||||
const debouncedContent = useDebounce(editorContent, 500)
|
const debouncedContent = useDebounce(editorContent, 500)
|
||||||
const initialContentRef = useRef<string>('')
|
const initialContentRef = useRef<string>('')
|
||||||
const renameInProgressRef = useRef(false)
|
const renameInProgressRef = useRef(false)
|
||||||
|
|
@ -1352,6 +1393,30 @@ function App() {
|
||||||
return isRecent
|
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) => {
|
const handleEditorChange = useCallback((path: string, markdown: string) => {
|
||||||
setEditorCacheForPath(path, markdown)
|
setEditorCacheForPath(path, markdown)
|
||||||
const nextSelectedPath = selectedPathRef.current
|
const nextSelectedPath = selectedPathRef.current
|
||||||
|
|
@ -1365,6 +1430,49 @@ function App() {
|
||||||
editorContentRef.current = markdown
|
editorContentRef.current = markdown
|
||||||
setEditorContent(markdown)
|
setEditorContent(markdown)
|
||||||
}, [setEditorCacheForPath])
|
}, [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
|
// Keep processingRunIdsRef in sync for use in async callbacks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
processingRunIdsRef.current = processingRunIds
|
processingRunIdsRef.current = processingRunIds
|
||||||
|
|
@ -1656,6 +1764,7 @@ function App() {
|
||||||
const baseline = initialContentByPathRef.current.get(pathAtStart) ?? initialContentRef.current
|
const baseline = initialContentByPathRef.current.get(pathAtStart) ?? initialContentRef.current
|
||||||
if (debouncedContent === baseline) return
|
if (debouncedContent === baseline) return
|
||||||
if (!debouncedContent) return
|
if (!debouncedContent) return
|
||||||
|
if (selectedPathRef.current === pathAtStart && debouncedContent !== editorContentRef.current) return
|
||||||
|
|
||||||
const saveFile = async () => {
|
const saveFile = async () => {
|
||||||
const wasActiveAtStart = selectedPathRef.current === pathAtStart
|
const wasActiveAtStart = selectedPathRef.current === pathAtStart
|
||||||
|
|
@ -4517,6 +4626,10 @@ function App() {
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
addGoogleDoc: (parentPath: string = 'knowledge') => {
|
||||||
|
setGoogleDocPickerTargetFolder(parentPath)
|
||||||
|
setGoogleDocPickerOpen(true)
|
||||||
|
},
|
||||||
createFolder: async (parentPath: string = 'knowledge'): Promise<string> => {
|
createFolder: async (parentPath: string = 'knowledge'): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
let index = 1
|
let index = 1
|
||||||
|
|
@ -5255,7 +5368,10 @@ function App() {
|
||||||
}
|
}
|
||||||
return markdownTabs
|
return markdownTabs
|
||||||
}, [fileTabs, selectedPath])
|
}, [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 (
|
return (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
|
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
|
||||||
|
|
@ -5365,6 +5481,46 @@ function App() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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') && (
|
{selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -5553,6 +5709,7 @@ function App() {
|
||||||
tree={tree}
|
tree={tree}
|
||||||
actions={{
|
actions={{
|
||||||
createNote: knowledgeActions.createNote,
|
createNote: knowledgeActions.createNote,
|
||||||
|
addGoogleDoc: knowledgeActions.addGoogleDoc,
|
||||||
createFolder: knowledgeActions.createFolder,
|
createFolder: knowledgeActions.createFolder,
|
||||||
rename: knowledgeActions.rename,
|
rename: knowledgeActions.rename,
|
||||||
remove: knowledgeActions.remove,
|
remove: knowledgeActions.remove,
|
||||||
|
|
@ -6054,6 +6211,17 @@ function App() {
|
||||||
void window.ipc.invoke('oauth:connect', { provider: 'google' })
|
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}>
|
<Dialog open={showMeetingPermissions} onOpenChange={setShowMeetingPermissions}>
|
||||||
<DialogContent showCloseButton={false}>
|
<DialogContent showCloseButton={false}>
|
||||||
<DialogHeader>
|
<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 = {
|
export type KnowledgeViewActions = {
|
||||||
createNote: (parentPath?: string) => void
|
createNote: (parentPath?: string) => void
|
||||||
|
addGoogleDoc: (parentPath?: string) => void
|
||||||
createFolder: (parentPath?: string) => Promise<string>
|
createFolder: (parentPath?: string) => Promise<string>
|
||||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||||
remove: (path: string) => Promise<void>
|
remove: (path: string) => Promise<void>
|
||||||
|
|
@ -202,6 +203,14 @@ export function KnowledgeView({
|
||||||
<FilePlus className="size-4" />
|
<FilePlus className="size-4" />
|
||||||
<span>New note</span>
|
<span>New note</span>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -764,6 +773,10 @@ function RowContextMenu({
|
||||||
<FilePlus className="mr-2 size-4" />
|
<FilePlus className="mr-2 size-4" />
|
||||||
New Note
|
New Note
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => actions.addGoogleDoc(node.path)}>
|
||||||
|
<FileText className="mr-2 size-4" />
|
||||||
|
Add Google Doc
|
||||||
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onClick={() => void actions.createFolder(node.path)}>
|
<ContextMenuItem onClick={() => void actions.createFolder(node.path)}>
|
||||||
<FolderPlus className="mr-2 size-4" />
|
<FolderPlus className="mr-2 size-4" />
|
||||||
New Folder
|
New Folder
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ export function extractFrontmatterFields(raw: string | null): FrontmatterFields
|
||||||
* re-emitted by buildFrontmatter (callers must splice them back from the
|
* 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).
|
* 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.
|
* Extract editable top-level YAML key/value pairs from raw frontmatter.
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,8 @@ const providerConfigs: ProviderConfig = {
|
||||||
scopes: [
|
scopes: [
|
||||||
'https://www.googleapis.com/auth/gmail.modify',
|
'https://www.googleapis.com/auth/gmail.modify',
|
||||||
'https://www.googleapis.com/auth/calendar.events.readonly',
|
'https://www.googleapis.com/auth/calendar.events.readonly',
|
||||||
|
'https://www.googleapis.com/auth/drive.readonly',
|
||||||
|
'https://www.googleapis.com/auth/documents',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'fireflies-ai': {
|
'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({}),
|
req: z.object({}),
|
||||||
res: z.null(),
|
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 channels
|
||||||
'search:query': {
|
'search:query': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue