diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ed8b1a7c..5f246783 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 3c653b1b..88675b3e 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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 = {} + 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(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(null) + const [googleDocSyncDirection, setGoogleDocSyncDirection] = useState<'up' | 'down' | null>(null) const debouncedContent = useDebounce(editorContent, 500) const initialContentRef = useRef('') 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 => { 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 ( { @@ -5365,6 +5481,46 @@ function App() { ) : null} )} + {selectedLinkedGoogleDoc && ( + <> + + + + + Sync down from Google Doc + + + + + + Sync up to Google Doc + + + )} {selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && ( @@ -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' }) }} /> + { + const parentPath = path.split('/').slice(0, -1).join('/') || 'knowledge' + setExpandedPaths(prev => new Set([...prev, parentPath])) + void loadDirectory().then(setTree) + navigateToFile(path) + }} + /> diff --git a/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx b/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx new file mode 100644 index 00000000..d03f94de --- /dev/null +++ b/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx @@ -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(null) + const [query, setQuery] = useState('') + const [docs, setDocs] = useState([]) + const [loading, setLoading] = useState(false) + const [connecting, setConnecting] = useState(false) + const [importingId, setImportingId] = useState(null) + const [error, setError] = useState(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 ( + + + + Add Google Doc + + Select a Google Doc to link into {targetLabel}. + + + +
+ {!status ? ( +
+ + Checking Google connection... +
+ ) : !status.connected || !status.hasRequiredScopes ? ( +
+
+ {!status.connected + ? 'Connect Google to choose Docs from Drive.' + : 'Reconnect Google so Rowboat can read Drive metadata and edit Google Docs.'} +
+ {status.missingScopes.length > 0 && ( +
+ Missing scopes: {status.missingScopes.join(', ')} +
+ )} + +
+ ) : ( + <> +
+
+ + setQuery(e.target.value)} + placeholder="Search Google Docs" + className="pl-9" + autoFocus + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ {loading ? ( +
+ + Loading Docs... +
+ ) : docs.length === 0 ? ( +
+ No Google Docs found. +
+ ) : ( +
+ {docs.map((doc) => ( + + ))} +
+ )} +
+ + )} +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/knowledge-view.tsx b/apps/x/apps/renderer/src/components/knowledge-view.tsx index e7ebe780..f4123528 100644 --- a/apps/x/apps/renderer/src/components/knowledge-view.tsx +++ b/apps/x/apps/renderer/src/components/knowledge-view.tsx @@ -38,6 +38,7 @@ interface TreeNode { export type KnowledgeViewActions = { createNote: (parentPath?: string) => void + addGoogleDoc: (parentPath?: string) => void createFolder: (parentPath?: string) => Promise rename: (path: string, newName: string, isDir: boolean) => Promise remove: (path: string) => Promise @@ -202,6 +203,14 @@ export function KnowledgeView({ New note + @@ -764,6 +773,10 @@ function RowContextMenu({ New Note + actions.addGoogleDoc(node.path)}> + + Add Google Doc + void actions.createFolder(node.path)}> New Folder diff --git a/apps/x/apps/renderer/src/lib/frontmatter.ts b/apps/x/apps/renderer/src/lib/frontmatter.ts index 7da1c7c0..b0f64e05 100644 --- a/apps/x/apps/renderer/src/lib/frontmatter.ts +++ b/apps/x/apps/renderer/src/lib/frontmatter.ts @@ -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. diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index 732d56ab..edae93d7 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -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': { diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts new file mode 100644 index 00000000..59210f49 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -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 = {}; + + 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 { + 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 { + 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 { + 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) }; + } +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index d0cee9ca..ed8a2083 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -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({