canvas-website/src/lib/google/importers/drive.ts

407 lines
11 KiB
TypeScript

// Google Drive import with folder navigation and progress tracking
// All data is encrypted before storage
import type { EncryptedDriveDocument, ImportProgress, EncryptedData } from '../types';
import { encryptData, deriveServiceKey } from '../encryption';
import { driveStore, syncMetadataStore } from '../database';
import { getAccessToken } from '../oauth';
const DRIVE_API_BASE = 'https://www.googleapis.com/drive/v3';
// Import options
export interface DriveImportOptions {
maxFiles?: number; // Limit total files to import
folderId?: string; // Start from specific folder (null for root)
mimeTypesFilter?: string[]; // Only import these MIME types
includeShared?: boolean; // Include shared files
includeTrashed?: boolean; // Include trashed files
exportFormats?: Record<string, string>; // Google Docs export formats
onProgress?: (progress: ImportProgress) => void;
}
// Drive file list response
interface DriveFileListResponse {
files?: DriveFile[];
nextPageToken?: string;
}
// Drive file metadata
interface DriveFile {
id: string;
name: string;
mimeType: string;
size?: string;
modifiedTime?: string;
createdTime?: string;
parents?: string[];
shared?: boolean;
trashed?: boolean;
webViewLink?: string;
thumbnailLink?: string;
}
// Default export formats for Google Docs
const DEFAULT_EXPORT_FORMATS: Record<string, string> = {
'application/vnd.google-apps.document': 'text/markdown',
'application/vnd.google-apps.spreadsheet': 'text/csv',
'application/vnd.google-apps.presentation': 'application/pdf',
'application/vnd.google-apps.drawing': 'image/png'
};
// Determine content strategy based on file size and type
function getContentStrategy(file: DriveFile): 'inline' | 'reference' | 'chunked' {
const size = parseInt(file.size || '0');
// Google Docs don't have a size, always inline
if (file.mimeType.startsWith('application/vnd.google-apps.')) {
return 'inline';
}
// Small files (< 1MB) inline
if (size < 1024 * 1024) {
return 'inline';
}
// Medium files (1-10MB) chunked
if (size < 10 * 1024 * 1024) {
return 'chunked';
}
// Large files just store reference
return 'reference';
}
// Check if file is a Google Workspace file
function isGoogleWorkspaceFile(mimeType: string): boolean {
return mimeType.startsWith('application/vnd.google-apps.');
}
// Main Drive import class
export class DriveImporter {
private accessToken: string | null = null;
private encryptionKey: CryptoKey | null = null;
private abortController: AbortController | null = null;
constructor(
private masterKey: CryptoKey
) {}
// Initialize importer
async initialize(): Promise<boolean> {
this.accessToken = await getAccessToken(this.masterKey);
if (!this.accessToken) {
console.error('No access token available for Drive');
return false;
}
this.encryptionKey = await deriveServiceKey(this.masterKey, 'drive');
return true;
}
// Abort current import
abort(): void {
this.abortController?.abort();
}
// Import Drive files
async import(options: DriveImportOptions = {}): Promise<ImportProgress> {
const progress: ImportProgress = {
service: 'drive',
total: 0,
imported: 0,
status: 'importing'
};
if (!await this.initialize()) {
progress.status = 'error';
progress.errorMessage = 'Failed to initialize Drive importer';
return progress;
}
this.abortController = new AbortController();
progress.startedAt = Date.now();
const exportFormats = options.exportFormats || DEFAULT_EXPORT_FORMATS;
try {
// Build query
const queryParts: string[] = [];
if (options.folderId) {
queryParts.push(`'${options.folderId}' in parents`);
}
if (options.mimeTypesFilter?.length) {
const mimeQuery = options.mimeTypesFilter
.map(m => `mimeType='${m}'`)
.join(' or ');
queryParts.push(`(${mimeQuery})`);
}
if (!options.includeTrashed) {
queryParts.push('trashed=false');
}
// Get file list
let pageToken: string | undefined;
const batchSize = 100;
const fileBatch: EncryptedDriveDocument[] = [];
do {
if (this.abortController.signal.aborted) {
progress.status = 'paused';
break;
}
const params: Record<string, string> = {
pageSize: String(batchSize),
fields: 'nextPageToken,files(id,name,mimeType,size,modifiedTime,parents,shared,trashed,thumbnailLink)',
q: queryParts.join(' and ') || 'trashed=false'
};
if (pageToken) {
params.pageToken = pageToken;
}
const listResponse = await this.fetchApi('/files', params);
if (!listResponse.files?.length) {
break;
}
// Update total on first page
if (progress.total === 0) {
progress.total = listResponse.files.length;
}
// Process files
for (const file of listResponse.files) {
if (this.abortController.signal.aborted) break;
// Skip shared files if not requested
if (file.shared && !options.includeShared) {
continue;
}
const encrypted = await this.processFile(file, exportFormats);
if (encrypted) {
fileBatch.push(encrypted);
progress.imported++;
// Save batch every 25 files
if (fileBatch.length >= 25) {
await driveStore.putBatch(fileBatch);
fileBatch.length = 0;
}
options.onProgress?.(progress);
}
// Check limit
if (options.maxFiles && progress.imported >= options.maxFiles) {
break;
}
}
pageToken = listResponse.nextPageToken;
// Check limit
if (options.maxFiles && progress.imported >= options.maxFiles) {
break;
}
} while (pageToken);
// Save remaining files
if (fileBatch.length > 0) {
await driveStore.putBatch(fileBatch);
}
progress.status = 'completed';
progress.completedAt = Date.now();
await syncMetadataStore.markComplete('drive', progress.imported);
} catch (error) {
console.error('Drive import error:', error);
progress.status = 'error';
progress.errorMessage = error instanceof Error ? error.message : 'Unknown error';
await syncMetadataStore.markError('drive', progress.errorMessage);
}
options.onProgress?.(progress);
return progress;
}
// Fetch from Drive API
private async fetchApi(
endpoint: string,
params: Record<string, string> = {}
): Promise<DriveFileListResponse> {
const url = new URL(`${DRIVE_API_BASE}${endpoint}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${this.accessToken}`
},
signal: this.abortController?.signal
});
if (!response.ok) {
throw new Error(`Drive API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Process a single file
private async processFile(
file: DriveFile,
exportFormats: Record<string, string>
): Promise<EncryptedDriveDocument | null> {
if (!this.encryptionKey) {
throw new Error('Encryption key not initialized');
}
const strategy = getContentStrategy(file);
let content: string | null = null;
let preview: ArrayBuffer | null = null;
try {
// Get content based on strategy
if (strategy === 'inline' || strategy === 'chunked') {
if (isGoogleWorkspaceFile(file.mimeType)) {
// Export Google Workspace file
const exportFormat = exportFormats[file.mimeType];
if (exportFormat) {
content = await this.exportFile(file.id, exportFormat);
}
} else {
// Download regular file
content = await this.downloadFile(file.id);
}
}
// Get thumbnail if available
if (file.thumbnailLink) {
try {
preview = await this.fetchThumbnail(file.thumbnailLink);
} catch {
// Thumbnail fetch failed, continue without it
}
}
} catch (error) {
console.warn(`Failed to get content for file ${file.name}:`, error);
// Continue with reference-only storage
}
// Helper to encrypt
const encrypt = async (data: string): Promise<EncryptedData> => {
return encryptData(data, this.encryptionKey!);
};
return {
id: file.id,
encryptedName: await encrypt(file.name),
encryptedMimeType: await encrypt(file.mimeType),
encryptedContent: content ? await encrypt(content) : null,
encryptedPreview: preview ? await encryptData(preview, this.encryptionKey) : null,
contentStrategy: strategy,
parentId: file.parents?.[0] || null,
encryptedPath: await encrypt(file.name), // TODO: build full path
isShared: file.shared || false,
modifiedTime: new Date(file.modifiedTime || 0).getTime(),
size: parseInt(file.size || '0'),
syncedAt: Date.now()
};
}
// Export a Google Workspace file
private async exportFile(fileId: string, mimeType: string): Promise<string> {
const response = await fetch(
`${DRIVE_API_BASE}/files/${fileId}/export?mimeType=${encodeURIComponent(mimeType)}`,
{
headers: {
Authorization: `Bearer ${this.accessToken}`
},
signal: this.abortController?.signal
}
);
if (!response.ok) {
throw new Error(`Export failed: ${response.status}`);
}
return response.text();
}
// Download a regular file
private async downloadFile(fileId: string): Promise<string> {
const response = await fetch(
`${DRIVE_API_BASE}/files/${fileId}?alt=media`,
{
headers: {
Authorization: `Bearer ${this.accessToken}`
},
signal: this.abortController?.signal
}
);
if (!response.ok) {
throw new Error(`Download failed: ${response.status}`);
}
return response.text();
}
// Fetch thumbnail
private async fetchThumbnail(thumbnailLink: string): Promise<ArrayBuffer> {
const response = await fetch(thumbnailLink, {
headers: {
Authorization: `Bearer ${this.accessToken}`
},
signal: this.abortController?.signal
});
if (!response.ok) {
throw new Error(`Thumbnail fetch failed: ${response.status}`);
}
return response.arrayBuffer();
}
// List folders for navigation
async listFolders(parentId?: string): Promise<{ id: string; name: string }[]> {
if (!await this.initialize()) {
return [];
}
const query = [
"mimeType='application/vnd.google-apps.folder'",
'trashed=false',
parentId ? `'${parentId}' in parents` : "'root' in parents"
].join(' and ');
try {
const response = await this.fetchApi('/files', {
q: query,
fields: 'files(id,name)',
pageSize: '100'
});
return response.files?.map(f => ({ id: f.id, name: f.name })) || [];
} catch (error) {
console.error('List folders error:', error);
return [];
}
}
}
// Convenience function
export async function importDrive(
masterKey: CryptoKey,
options: DriveImportOptions = {}
): Promise<ImportProgress> {
const importer = new DriveImporter(masterKey);
return importer.import(options);
}