mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-27 20:29:44 +02:00
feat(google-docs): use Google Picker + drive.file scope instead of full-drive listing
This commit is contained in:
parent
505a9a27e8
commit
d08bf49d5a
6 changed files with 229 additions and 174 deletions
|
|
@ -52,7 +52,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, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js';
|
import { getGoogleDocsConnectionStatus, importGoogleDoc, getGoogleAccessToken, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } 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';
|
||||||
|
|
@ -815,8 +815,8 @@ export function setupIpcHandlers() {
|
||||||
'google-docs:getStatus': async () => {
|
'google-docs:getStatus': async () => {
|
||||||
return getGoogleDocsConnectionStatus();
|
return getGoogleDocsConnectionStatus();
|
||||||
},
|
},
|
||||||
'google-docs:list': async (_event, args) => {
|
'google-docs:getAccessToken': async () => {
|
||||||
return listGoogleDocs(args.query);
|
return { accessToken: await getGoogleAccessToken() };
|
||||||
},
|
},
|
||||||
'google-docs:import': async (_event, args) => {
|
'google-docs:import': async (_event, args) => {
|
||||||
return importGoogleDoc(args.fileId, args.targetFolder);
|
return importGoogleDoc(args.fileId, args.targetFolder);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { FileText, Loader2, RefreshCw, Search } from 'lucide-react'
|
import { FileText, Loader2, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -12,17 +12,9 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { GoogleClientIdModal } from '@/components/google-client-id-modal'
|
import { GoogleClientIdModal } from '@/components/google-client-id-modal'
|
||||||
import { setGoogleCredentials } from '@/lib/google-credentials-store'
|
import { setGoogleCredentials } from '@/lib/google-credentials-store'
|
||||||
import { formatRelativeTime } from '@/lib/relative-time'
|
import { openGooglePicker, getStoredPickerApiKey, setStoredPickerApiKey } from '@/lib/google-picker'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
type GoogleDocListItem = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
modifiedTime: string | null
|
|
||||||
owner: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type GoogleDocsStatus = {
|
type GoogleDocsStatus = {
|
||||||
connected: boolean
|
connected: boolean
|
||||||
hasRequiredScopes: boolean
|
hasRequiredScopes: boolean
|
||||||
|
|
@ -36,11 +28,6 @@ type GoogleDocPickerDialogProps = {
|
||||||
onImported: (path: string) => void
|
onImported: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatModified(modifiedTime: string | null): string {
|
|
||||||
if (!modifiedTime) return ''
|
|
||||||
return formatRelativeTime(modifiedTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GoogleDocPickerDialog({
|
export function GoogleDocPickerDialog({
|
||||||
open,
|
open,
|
||||||
targetFolder,
|
targetFolder,
|
||||||
|
|
@ -48,15 +35,13 @@ export function GoogleDocPickerDialog({
|
||||||
onImported,
|
onImported,
|
||||||
}: GoogleDocPickerDialogProps) {
|
}: GoogleDocPickerDialogProps) {
|
||||||
const [status, setStatus] = useState<GoogleDocsStatus | null>(null)
|
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 [connecting, setConnecting] = useState(false)
|
||||||
const [importingId, setImportingId] = useState<string | null>(null)
|
const [opening, setOpening] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [byokOpen, setByokOpen] = useState(false)
|
const [byokOpen, setByokOpen] = useState(false)
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
|
||||||
const canList = Boolean(status?.connected && status.hasRequiredScopes)
|
const canPick = Boolean(status?.connected && status.hasRequiredScopes)
|
||||||
const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder])
|
const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder])
|
||||||
|
|
||||||
const loadStatus = useCallback(async () => {
|
const loadStatus = useCallback(async () => {
|
||||||
|
|
@ -70,36 +55,13 @@ export function GoogleDocPickerDialog({
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setQuery('')
|
|
||||||
setDocs([])
|
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setApiKey(getStoredPickerApiKey())
|
||||||
void loadStatus()
|
void loadStatus()
|
||||||
}, [loadStatus, open])
|
}, [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 () => {
|
const handleConnect = useCallback(async () => {
|
||||||
setConnecting(true)
|
setConnecting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
@ -117,9 +79,9 @@ export function GoogleDocPickerDialog({
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// BYOK: connect Google with the user's own OAuth client. Unlike the managed
|
// BYOK: connect Google with the user's own OAuth client, which requests the
|
||||||
// (rowboat) sign-in, this local flow requests the Drive + Docs scopes, so a
|
// drive.file scope locally (managed sign-in can't grant it without a backend
|
||||||
// signed-in user can actually grant Docs access without a backend change.
|
// change).
|
||||||
const handleByokSubmit = useCallback((clientId: string, clientSecret: string) => {
|
const handleByokSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||||
setGoogleCredentials(clientId, clientSecret)
|
setGoogleCredentials(clientId, clientSecret)
|
||||||
setByokOpen(false)
|
setByokOpen(false)
|
||||||
|
|
@ -144,38 +106,68 @@ export function GoogleDocPickerDialog({
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [open, loadStatus])
|
}, [open, loadStatus])
|
||||||
|
|
||||||
const handleImport = useCallback(async (doc: GoogleDocListItem) => {
|
const handleChoose = useCallback(async () => {
|
||||||
setImportingId(doc.id)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
const key = apiKey.trim()
|
||||||
const result = await window.ipc.invoke('google-docs:import', {
|
if (!key) {
|
||||||
fileId: doc.id,
|
setError('Enter your Google Picker API key first.')
|
||||||
targetFolder,
|
return
|
||||||
})
|
|
||||||
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])
|
setStoredPickerApiKey(key)
|
||||||
|
setOpening(true)
|
||||||
|
|
||||||
|
let accessToken: string | null = null
|
||||||
|
try {
|
||||||
|
const res = await window.ipc.invoke('google-docs:getAccessToken', null)
|
||||||
|
accessToken = res.accessToken
|
||||||
|
} catch (err) {
|
||||||
|
setOpening(false)
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to get a Google access token')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!accessToken) {
|
||||||
|
setOpening(false)
|
||||||
|
setError('Google access token unavailable — reconnect Google.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand off to Google's Picker; close our modal so it isn't trapped behind it.
|
||||||
|
onOpenChange(false)
|
||||||
|
try {
|
||||||
|
await openGooglePicker({
|
||||||
|
accessToken,
|
||||||
|
apiKey: key,
|
||||||
|
onPicked: async (file) => {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('google-docs:import', { fileId: file.id, targetFolder })
|
||||||
|
toast(`Added “${file.name}”`, 'success')
|
||||||
|
onImported(result.path)
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof Error ? err.message : 'Failed to import the document', 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof Error ? err.message : 'Failed to open the Google Picker', 'error')
|
||||||
|
} finally {
|
||||||
|
setOpening(false)
|
||||||
|
}
|
||||||
|
}, [apiKey, targetFolder, onImported, onOpenChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<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">
|
<DialogContent className="flex max-h-[min(720px,calc(100vh-4rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
||||||
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
|
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
|
||||||
<DialogTitle>Add Google Doc</DialogTitle>
|
<DialogTitle>Add Google Doc</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Select a Google Doc to link into {targetLabel}.
|
Link a Google Doc or Word file from Drive into {targetLabel}.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{!status && error ? (
|
{!status && error ? (
|
||||||
<div className="flex min-h-[320px] flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
<div className="flex min-h-[280px] flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||||
<div className="max-w-sm text-sm text-destructive">{error}</div>
|
<div className="max-w-sm text-sm text-destructive">{error}</div>
|
||||||
<Button variant="outline" onClick={() => void loadStatus()}>
|
<Button variant="outline" onClick={() => void loadStatus()}>
|
||||||
<RefreshCw className="size-4" />
|
<RefreshCw className="size-4" />
|
||||||
|
|
@ -183,14 +175,14 @@ export function GoogleDocPickerDialog({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : !status ? (
|
) : !status ? (
|
||||||
<div className="flex min-h-[320px] flex-1 items-center justify-center text-sm text-muted-foreground">
|
<div className="flex min-h-[280px] flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
Checking Google connection...
|
Checking Google connection...
|
||||||
</div>
|
</div>
|
||||||
) : !status.connected || !status.hasRequiredScopes ? (
|
) : !canPick ? (
|
||||||
<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="flex min-h-[300px] 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">
|
<div className="max-w-sm text-sm text-muted-foreground">
|
||||||
To choose Google Docs, Rowboat needs Drive + Docs access.
|
To choose a document, Rowboat needs per-file Drive access (the <code>drive.file</code> scope).
|
||||||
</div>
|
</div>
|
||||||
{status.missingScopes.length > 0 && (
|
{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">
|
<div className="max-w-md rounded-md border border-border bg-muted/30 px-3 py-2 text-left text-xs text-muted-foreground">
|
||||||
|
|
@ -207,65 +199,42 @@ export function GoogleDocPickerDialog({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="max-w-sm text-xs text-muted-foreground">
|
<p className="max-w-sm text-xs text-muted-foreground">
|
||||||
Managed sign-in may not grant Docs access yet. If it keeps asking for scopes,
|
Managed sign-in may not grant Drive access yet. If it keeps asking for scopes,
|
||||||
connect a Google OAuth client (Desktop app) with the Drive API and Docs API enabled.
|
connect a Google OAuth client (Desktop app) with the Drive API and Picker API enabled.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
|
||||||
<div className="shrink-0 border-b border-border p-4">
|
<div className="max-w-sm text-sm text-muted-foreground">
|
||||||
<div className="relative">
|
Pick a Google Doc or Word file from your Drive. It imports as an editable
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<code> .docx</code> and stays linked for two-way sync.
|
||||||
<Input
|
</div>
|
||||||
value={query}
|
<div className="flex w-full max-w-sm flex-col gap-1.5 text-left">
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
<label htmlFor="picker-api-key" className="text-xs font-medium text-muted-foreground">
|
||||||
placeholder="Search Google Docs"
|
Google Picker API key
|
||||||
className="pl-9"
|
</label>
|
||||||
autoFocus
|
<Input
|
||||||
/>
|
id="picker-api-key"
|
||||||
</div>
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder="AIza…"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
From your Google Cloud project (APIs & Services → Credentials → API key),
|
||||||
|
with the Picker API enabled. Stored locally.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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">
|
<div className="max-w-sm rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<Button onClick={() => void handleChoose()} disabled={opening}>
|
||||||
<div className="min-h-[280px] flex-1 overflow-y-auto p-2">
|
{opening ? <Loader2 className="size-4 animate-spin" /> : <FileText className="size-4" />}
|
||||||
{loading ? (
|
Choose from Google Drive
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
</Button>
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
</div>
|
||||||
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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
@ -274,7 +243,7 @@ export function GoogleDocPickerDialog({
|
||||||
open={byokOpen}
|
open={byokOpen}
|
||||||
onOpenChange={setByokOpen}
|
onOpenChange={setByokOpen}
|
||||||
onSubmit={handleByokSubmit}
|
onSubmit={handleByokSubmit}
|
||||||
description="Enter a Google OAuth client (Desktop app) with the Drive API and Docs API enabled to grant Docs access."
|
description="Enter a Google OAuth client (Desktop app) with the Drive API and Picker API enabled."
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
117
apps/x/apps/renderer/src/lib/google-picker.ts
Normal file
117
apps/x/apps/renderer/src/lib/google-picker.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
// Loader + thin wrapper around the Google Picker JS API. File selection happens
|
||||||
|
// inside Google's hosted Picker (so the app needs only drive.file, not a broad
|
||||||
|
// listing scope); the Picker hands back the chosen file id.
|
||||||
|
|
||||||
|
export type PickedFile = { id: string; name: string; mimeType: string }
|
||||||
|
|
||||||
|
type GapiGlobal = {
|
||||||
|
load: (lib: string, config: { callback: () => void; onerror?: () => void }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type PickerView = {
|
||||||
|
setIncludeFolders: (b: boolean) => PickerView
|
||||||
|
setOwnedByMe: (b: boolean) => PickerView
|
||||||
|
setMimeTypes: (m: string) => PickerView
|
||||||
|
}
|
||||||
|
|
||||||
|
type PickerCallbackData = {
|
||||||
|
action: string
|
||||||
|
docs?: Array<{ id: string; name: string; mimeType: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
type PickerBuilder = {
|
||||||
|
addView: (v: PickerView) => PickerBuilder
|
||||||
|
setOAuthToken: (t: string) => PickerBuilder
|
||||||
|
setDeveloperKey: (k: string) => PickerBuilder
|
||||||
|
setAppId: (id: string) => PickerBuilder
|
||||||
|
setTitle: (t: string) => PickerBuilder
|
||||||
|
setCallback: (cb: (data: PickerCallbackData) => void) => PickerBuilder
|
||||||
|
build: () => { setVisible: (v: boolean) => void }
|
||||||
|
}
|
||||||
|
|
||||||
|
type GooglePickerNS = {
|
||||||
|
DocsView: new () => PickerView
|
||||||
|
PickerBuilder: new () => PickerBuilder
|
||||||
|
Action: { PICKED: string; CANCEL: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
gapi?: GapiGlobal
|
||||||
|
google?: { picker?: GooglePickerNS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_JS = 'https://apis.google.com/js/api.js'
|
||||||
|
const DOC_MIME = 'application/vnd.google-apps.document'
|
||||||
|
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
const API_KEY_STORAGE = 'rowboat:google-picker-api-key'
|
||||||
|
|
||||||
|
let pickerLoaded: Promise<void> | null = null
|
||||||
|
|
||||||
|
function loadPickerApi(): Promise<void> {
|
||||||
|
if (pickerLoaded) return pickerLoaded
|
||||||
|
pickerLoaded = new Promise<void>((resolve, reject) => {
|
||||||
|
const start = () => {
|
||||||
|
if (!window.gapi) { reject(new Error('Google API failed to initialize')); return }
|
||||||
|
window.gapi.load('picker', {
|
||||||
|
callback: () => resolve(),
|
||||||
|
onerror: () => reject(new Error('Google Picker failed to load')),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>(`script[src="${API_JS}"]`)
|
||||||
|
if (existing) {
|
||||||
|
if (window.gapi) start()
|
||||||
|
else existing.addEventListener('load', start)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = API_JS
|
||||||
|
script.async = true
|
||||||
|
script.onload = start
|
||||||
|
script.onerror = () => reject(new Error('Failed to load the Google API script'))
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
return pickerLoaded
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredPickerApiKey(): string {
|
||||||
|
try { return localStorage.getItem(API_KEY_STORAGE) ?? '' } catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredPickerApiKey(key: string): void {
|
||||||
|
try { localStorage.setItem(API_KEY_STORAGE, key.trim()) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openGooglePicker(opts: {
|
||||||
|
accessToken: string
|
||||||
|
apiKey?: string
|
||||||
|
appId?: string
|
||||||
|
onPicked: (file: PickedFile) => void
|
||||||
|
onCancel?: () => void
|
||||||
|
}): Promise<void> {
|
||||||
|
await loadPickerApi()
|
||||||
|
const picker = window.google?.picker
|
||||||
|
if (!picker) throw new Error('Google Picker is unavailable')
|
||||||
|
|
||||||
|
const view = new picker.DocsView()
|
||||||
|
.setIncludeFolders(false)
|
||||||
|
.setOwnedByMe(false)
|
||||||
|
.setMimeTypes(`${DOC_MIME},${DOCX_MIME}`)
|
||||||
|
|
||||||
|
const builder = new picker.PickerBuilder()
|
||||||
|
.addView(view)
|
||||||
|
.setOAuthToken(opts.accessToken)
|
||||||
|
.setTitle('Choose a document to sync')
|
||||||
|
.setCallback((data) => {
|
||||||
|
if (data.action === picker.Action.PICKED && data.docs?.[0]) {
|
||||||
|
const d = data.docs[0]
|
||||||
|
opts.onPicked({ id: d.id, name: d.name, mimeType: d.mimeType })
|
||||||
|
} else if (data.action === picker.Action.CANCEL) {
|
||||||
|
opts.onCancel?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (opts.apiKey) builder.setDeveloperKey(opts.apiKey)
|
||||||
|
if (opts.appId) builder.setAppId(opts.appId)
|
||||||
|
builder.build().setVisible(true)
|
||||||
|
}
|
||||||
|
|
@ -77,10 +77,10 @@ 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',
|
||||||
// Full Drive access: read/export Google Docs to .docx AND write the edited
|
// Per-file Drive access (non-restricted): the user grants read/write to a
|
||||||
// .docx back into the original doc (files.update needs write, which
|
// specific doc by choosing it in the Google Picker. Enough to export/
|
||||||
// drive.readonly does not grant). Covers list/get/export/update.
|
// download and write back, without the restricted full-drive scope.
|
||||||
'https://www.googleapis.com/auth/drive',
|
'https://www.googleapis.com/auth/drive.file',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'fireflies-ai': {
|
'fireflies-ai': {
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ import { google, drive_v3 as drive } from 'googleapis';
|
||||||
import { resolveWorkspacePath } from '../workspace/workspace.js';
|
import { resolveWorkspacePath } from '../workspace/workspace.js';
|
||||||
import { GoogleClientFactory } from './google-client-factory.js';
|
import { GoogleClientFactory } from './google-client-factory.js';
|
||||||
|
|
||||||
// Full Drive scope: export Google Docs to .docx (read) and write the edited
|
// Per-file Drive scope (non-restricted). The user picks a doc via the Google
|
||||||
// .docx back via files.update (write). drive.readonly can't do the write half.
|
// Picker, which grants this app read/write to that file — enough to export/
|
||||||
|
// download it and write edits back, without the restricted full-drive scope.
|
||||||
export const GOOGLE_DOC_SCOPES = [
|
export const GOOGLE_DOC_SCOPES = [
|
||||||
'https://www.googleapis.com/auth/drive',
|
'https://www.googleapis.com/auth/drive.file',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type GoogleDocListItem = {
|
export type GoogleDocListItem = {
|
||||||
|
|
@ -58,10 +59,6 @@ function sanitizeFilename(name: string): string {
|
||||||
return cleaned || 'Google Doc';
|
return cleaned || 'Google Doc';
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeDriveQueryValue(value: string): string {
|
|
||||||
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRel(relPath: string): string {
|
function normalizeRel(relPath: string): string {
|
||||||
return relPath.replace(/\\/g, '/');
|
return relPath.replace(/\\/g, '/');
|
||||||
}
|
}
|
||||||
|
|
@ -198,34 +195,13 @@ export async function getGoogleDocsConnectionStatus(): Promise<{
|
||||||
return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]);
|
return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listGoogleDocs(query?: string): Promise<{ files: GoogleDocListItem[] }> {
|
/**
|
||||||
const status = await getGoogleDocsConnectionStatus();
|
* The live Google OAuth access token, for the renderer to drive the Google
|
||||||
if (!status.connected) throw new Error('Google is not connected.');
|
* Picker (file selection happens client-side; the app never lists Drive).
|
||||||
if (!status.hasRequiredScopes) throw new Error('Google is missing Drive access. Reconnect Google.');
|
*/
|
||||||
|
export async function getGoogleAccessToken(): Promise<string | null> {
|
||||||
const driveClient = await getDriveClient();
|
const auth = await GoogleClientFactory.getClient();
|
||||||
// Native Google Docs (exportable) and uploaded Word files (downloadable).
|
return auth?.credentials?.access_token ?? null;
|
||||||
const typeClause = `(mimeType='${GOOGLE_DOC_MIME}' or mimeType='${DOCX_MIME}')`;
|
|
||||||
const clauses = [typeClause, 'trashed=false'];
|
|
||||||
const trimmed = query?.trim();
|
|
||||||
if (trimmed) {
|
|
||||||
clauses.push(`name contains '${escapeDriveQueryValue(trimmed)}'`);
|
|
||||||
}
|
|
||||||
const q = clauses.join(' and ');
|
|
||||||
const result = await driveClient.files.list({
|
|
||||||
q,
|
|
||||||
pageSize: 25,
|
|
||||||
orderBy: 'modifiedTime desc',
|
|
||||||
fields: 'files(id,name,webViewLink,modifiedTime,mimeType,owners(displayName,emailAddress))',
|
|
||||||
// Also surface docs in shared drives and "Shared with me", not just My Drive.
|
|
||||||
corpora: 'allDrives',
|
|
||||||
includeItemsFromAllDrives: true,
|
|
||||||
supportsAllDrives: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const files = (result.data.files ?? []).map(toGoogleDocListItem).filter((file) => file.id);
|
|
||||||
console.log(`[GoogleDocs] list q="${q}" → ${files.length} doc(s)`);
|
|
||||||
return { files };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Import a Google Doc as a local .docx and register the link. */
|
/** Import a Google Doc as a local .docx and register the link. */
|
||||||
|
|
|
||||||
|
|
@ -650,18 +650,11 @@ const ipcSchemas = {
|
||||||
missingScopes: z.array(z.string()),
|
missingScopes: z.array(z.string()),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'google-docs:list': {
|
// Live Google OAuth access token for driving the Google Picker in the renderer.
|
||||||
req: z.object({
|
'google-docs:getAccessToken': {
|
||||||
query: z.string().optional(),
|
req: z.null(),
|
||||||
}),
|
|
||||||
res: z.object({
|
res: z.object({
|
||||||
files: z.array(z.object({
|
accessToken: z.string().nullable(),
|
||||||
id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
url: z.string(),
|
|
||||||
modifiedTime: z.string().nullable(),
|
|
||||||
owner: z.string().nullable(),
|
|
||||||
})),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'google-docs:import': {
|
'google-docs:import': {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue