mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +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 { 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, 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 { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||
import { API_URL } from '@x/core/dist/config/env.js';
|
||||
|
|
@ -815,8 +815,8 @@ export function setupIpcHandlers() {
|
|||
'google-docs:getStatus': async () => {
|
||||
return getGoogleDocsConnectionStatus();
|
||||
},
|
||||
'google-docs:list': async (_event, args) => {
|
||||
return listGoogleDocs(args.query);
|
||||
'google-docs:getAccessToken': async () => {
|
||||
return { accessToken: await getGoogleAccessToken() };
|
||||
},
|
||||
'google-docs:import': async (_event, args) => {
|
||||
return importGoogleDoc(args.fileId, args.targetFolder);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { FileText, Loader2, RefreshCw, Search } from 'lucide-react'
|
||||
import { FileText, Loader2, RefreshCw } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -12,17 +12,9 @@ import { Button } from '@/components/ui/button'
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { GoogleClientIdModal } from '@/components/google-client-id-modal'
|
||||
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'
|
||||
|
||||
type GoogleDocListItem = {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
modifiedTime: string | null
|
||||
owner: string | null
|
||||
}
|
||||
|
||||
type GoogleDocsStatus = {
|
||||
connected: boolean
|
||||
hasRequiredScopes: boolean
|
||||
|
|
@ -36,11 +28,6 @@ type GoogleDocPickerDialogProps = {
|
|||
onImported: (path: string) => void
|
||||
}
|
||||
|
||||
function formatModified(modifiedTime: string | null): string {
|
||||
if (!modifiedTime) return ''
|
||||
return formatRelativeTime(modifiedTime)
|
||||
}
|
||||
|
||||
export function GoogleDocPickerDialog({
|
||||
open,
|
||||
targetFolder,
|
||||
|
|
@ -48,15 +35,13 @@ export function GoogleDocPickerDialog({
|
|||
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 [opening, setOpening] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
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 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(() => {
|
||||
if (!open) return
|
||||
setQuery('')
|
||||
setDocs([])
|
||||
setError(null)
|
||||
setApiKey(getStoredPickerApiKey())
|
||||
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)
|
||||
|
|
@ -117,9 +79,9 @@ export function GoogleDocPickerDialog({
|
|||
}
|
||||
}, [])
|
||||
|
||||
// BYOK: connect Google with the user's own OAuth client. Unlike the managed
|
||||
// (rowboat) sign-in, this local flow requests the Drive + Docs scopes, so a
|
||||
// signed-in user can actually grant Docs access without a backend change.
|
||||
// BYOK: connect Google with the user's own OAuth client, which requests the
|
||||
// drive.file scope locally (managed sign-in can't grant it without a backend
|
||||
// change).
|
||||
const handleByokSubmit = useCallback((clientId: string, clientSecret: string) => {
|
||||
setGoogleCredentials(clientId, clientSecret)
|
||||
setByokOpen(false)
|
||||
|
|
@ -144,38 +106,68 @@ export function GoogleDocPickerDialog({
|
|||
return cleanup
|
||||
}, [open, loadStatus])
|
||||
|
||||
const handleImport = useCallback(async (doc: GoogleDocListItem) => {
|
||||
setImportingId(doc.id)
|
||||
const handleChoose = useCallback(async () => {
|
||||
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)
|
||||
const key = apiKey.trim()
|
||||
if (!key) {
|
||||
setError('Enter your Google Picker API key first.')
|
||||
return
|
||||
}
|
||||
}, [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 (
|
||||
<>
|
||||
<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">
|
||||
<DialogTitle>Add Google Doc</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a Google Doc to link into {targetLabel}.
|
||||
Link a Google Doc or Word file from Drive into {targetLabel}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{!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>
|
||||
<Button variant="outline" onClick={() => void loadStatus()}>
|
||||
<RefreshCw className="size-4" />
|
||||
|
|
@ -183,14 +175,14 @@ export function GoogleDocPickerDialog({
|
|||
</Button>
|
||||
</div>
|
||||
) : !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" />
|
||||
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">
|
||||
) : !canPick ? (
|
||||
<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">
|
||||
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>
|
||||
{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">
|
||||
|
|
@ -207,65 +199,42 @@ export function GoogleDocPickerDialog({
|
|||
</Button>
|
||||
</div>
|
||||
<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,
|
||||
connect a Google OAuth client (Desktop app) with the Drive API and Docs API enabled.
|
||||
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 Picker API enabled.
|
||||
</p>
|
||||
</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 className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
|
||||
<div className="max-w-sm text-sm text-muted-foreground">
|
||||
Pick a Google Doc or Word file from your Drive. It imports as an editable
|
||||
<code> .docx</code> and stays linked for two-way sync.
|
||||
</div>
|
||||
<div className="flex w-full max-w-sm flex-col gap-1.5 text-left">
|
||||
<label htmlFor="picker-api-key" className="text-xs font-medium text-muted-foreground">
|
||||
Google Picker API key
|
||||
</label>
|
||||
<Input
|
||||
id="picker-api-key"
|
||||
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>
|
||||
|
||||
{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}
|
||||
</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>
|
||||
</>
|
||||
<Button onClick={() => void handleChoose()} disabled={opening}>
|
||||
{opening ? <Loader2 className="size-4 animate-spin" /> : <FileText className="size-4" />}
|
||||
Choose from Google Drive
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
@ -274,7 +243,7 @@ export function GoogleDocPickerDialog({
|
|||
open={byokOpen}
|
||||
onOpenChange={setByokOpen}
|
||||
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: [
|
||||
'https://www.googleapis.com/auth/gmail.modify',
|
||||
'https://www.googleapis.com/auth/calendar.events.readonly',
|
||||
// Full Drive access: read/export Google Docs to .docx AND write the edited
|
||||
// .docx back into the original doc (files.update needs write, which
|
||||
// drive.readonly does not grant). Covers list/get/export/update.
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
// Per-file Drive access (non-restricted): the user grants read/write to a
|
||||
// specific doc by choosing it in the Google Picker. Enough to export/
|
||||
// download and write back, without the restricted full-drive scope.
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
],
|
||||
},
|
||||
'fireflies-ai': {
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import { google, drive_v3 as drive } from 'googleapis';
|
|||
import { resolveWorkspacePath } from '../workspace/workspace.js';
|
||||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
|
||||
// Full Drive scope: export Google Docs to .docx (read) and write the edited
|
||||
// .docx back via files.update (write). drive.readonly can't do the write half.
|
||||
// Per-file Drive scope (non-restricted). The user picks a doc via the Google
|
||||
// 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 = [
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
] as const;
|
||||
|
||||
export type GoogleDocListItem = {
|
||||
|
|
@ -58,10 +59,6 @@ function sanitizeFilename(name: string): string {
|
|||
return cleaned || 'Google Doc';
|
||||
}
|
||||
|
||||
function escapeDriveQueryValue(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
function normalizeRel(relPath: string): string {
|
||||
return relPath.replace(/\\/g, '/');
|
||||
}
|
||||
|
|
@ -198,34 +195,13 @@ export async function getGoogleDocsConnectionStatus(): Promise<{
|
|||
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 access. Reconnect Google.');
|
||||
|
||||
const driveClient = await getDriveClient();
|
||||
// Native Google Docs (exportable) and uploaded Word files (downloadable).
|
||||
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 };
|
||||
/**
|
||||
* The live Google OAuth access token, for the renderer to drive the Google
|
||||
* Picker (file selection happens client-side; the app never lists Drive).
|
||||
*/
|
||||
export async function getGoogleAccessToken(): Promise<string | null> {
|
||||
const auth = await GoogleClientFactory.getClient();
|
||||
return auth?.credentials?.access_token ?? null;
|
||||
}
|
||||
|
||||
/** Import a Google Doc as a local .docx and register the link. */
|
||||
|
|
|
|||
|
|
@ -650,18 +650,11 @@ const ipcSchemas = {
|
|||
missingScopes: z.array(z.string()),
|
||||
}),
|
||||
},
|
||||
'google-docs:list': {
|
||||
req: z.object({
|
||||
query: z.string().optional(),
|
||||
}),
|
||||
// Live Google OAuth access token for driving the Google Picker in the renderer.
|
||||
'google-docs:getAccessToken': {
|
||||
req: z.null(),
|
||||
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(),
|
||||
})),
|
||||
accessToken: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
'google-docs:import': {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue