import { newRxError, newRxFetchError } from '../../rx-error.ts'; import { ensureNotFalsy } from '../utils/index.ts'; import type { GoogleDriveOptionsWithDefaults, DriveFileMetadata } from './google-drive-types.ts'; import { DriveStructure } from './init.ts'; export const DRIVE_API_VERSION = 'v3'; export const DRIVE_MAX_PAGE_SIZE = 1000; export const DRIVE_MAX_BULK_SIZE = DRIVE_MAX_PAGE_SIZE / 4; export const FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder'; /** * The id of the top level parent for the configured space. * For 'appDataFolder' this is the literal 'appDataFolder' which Google Drive * accepts as a parent id and in queries. For 'drive' it is the regular 'root'. */ export function driveRootParentId( googleDriveOptions: GoogleDriveOptionsWithDefaults ): string { return googleDriveOptions.space === 'appDataFolder' ? 'appDataFolder' : 'root'; } /** * files.list only returns appDataFolder contents when spaces=appDataFolder * is set, even when querying by an explicit parent id. */ export function applyDriveSpace( googleDriveOptions: GoogleDriveOptionsWithDefaults, params: URLSearchParams ): void { if (googleDriveOptions.space === 'appDataFolder') { params.set('spaces', 'appDataFolder'); } } /** * Same as applyDriveSpace() but for URLs that are built via string concatenation. * Returns an empty string for the default 'drive' space. */ export function driveSpaceQuerySuffix( googleDriveOptions: GoogleDriveOptionsWithDefaults ): string { return googleDriveOptions.space === 'appDataFolder' ? '&spaces=appDataFolder' : ''; } export async function createFolder( googleDriveOptions: GoogleDriveOptionsWithDefaults, parentId: string = 'root', folderName: string ): Promise { const url = googleDriveOptions.apiEndpoint + '/drive/v3/files?fields=id,name,mimeType,trashed'; const body = { name: folderName, mimeType: FOLDER_MIME_TYPE, parents: [parentId] }; const response = await fetch(url, { method: 'POST', headers: { Authorization: 'Bearer ' + googleDriveOptions.authToken, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!response.ok) { const errorText = await response.text(); if (response.status == 409) { // someone else created the same folder, return that one instead. const found = await findFolder(googleDriveOptions, parentId, folderName); return ensureNotFalsy(found); } throw await newRxFetchError(response, { folderName, parentId }); } await response.json(); /** * To make the function idempotent, we do not use the id from the creation-response. * Instead after creating the folder, we search for it again so that in case * some other instance created the same folder, we use the oldest one always. */ const foundFolder = await findFolder( googleDriveOptions, parentId, folderName ); return ensureNotFalsy(foundFolder); } export async function findFolder( googleDriveOptions: GoogleDriveOptionsWithDefaults, parentId: string = 'root', folderName: string ): Promise { const query = "name = '" + folderName + "' and '" + parentId + "' in parents and trashed = false and mimeType = '" + FOLDER_MIME_TYPE + "'"; /** * We sort by createdTime ASC * so in case the same folder was created multiple times, we always pick the same * one which is the oldest one. */ const searchUrl = googleDriveOptions.apiEndpoint + '/drive/v3/files?fields=files(id,mimeType)&orderBy=createdTime asc&q=' + encodeURIComponent(query) + driveSpaceQuerySuffix(googleDriveOptions); const searchResponse = await fetch(searchUrl, { method: 'GET', headers: { Authorization: 'Bearer ' + googleDriveOptions.authToken } }); const searchData = await searchResponse.json(); if (searchData.files && searchData.files.length > 0) { const file = searchData.files[0]; if (file.mimeType !== FOLDER_MIME_TYPE) { throw newRxError('GDR3', { folderName, args: { file, FOLDER_MIME_TYPE } }); } return file.id; } else { return undefined; } } export async function ensureFolderExists( googleDriveOptions: GoogleDriveOptionsWithDefaults, folderPath: string ): Promise { const parts = folderPath.split('/').filter(p => p.length > 0); let parentId = driveRootParentId(googleDriveOptions); for (const part of parts) { const newParentId = await findFolder(googleDriveOptions, parentId, part); if (newParentId) { parentId = newParentId } else { parentId = await createFolder(googleDriveOptions, parentId, part); } } return parentId; } export async function createEmptyFile( googleDriveOptions: GoogleDriveOptionsWithDefaults, parentId: string, fileName: string ) { const url = googleDriveOptions.apiEndpoint + '/drive/v3/files?fields=id'; const body = { name: fileName, parents: [parentId], mimeType: 'application/json' }; const response = await fetch(url, { method: 'POST', headers: { Authorization: 'Bearer ' + googleDriveOptions.authToken, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); /** * Do not throw on duplicates, * if the file is there already, find its id * and return that one. */ if (!response.ok && response.status !== 409) { throw await newRxFetchError(response, { folderName: fileName }); } /** * For idempotent runs, fetch the file again * after creating it. */ const query = [ `name = '${fileName}'`, `'${parentId}' in parents`, `trashed = false`, ].join(' and '); const url2 = googleDriveOptions.apiEndpoint + '/drive/v3/files' + '?fields=files(id)' + '&orderBy=createdTime asc' + '&q=' + encodeURIComponent(query) + driveSpaceQuerySuffix(googleDriveOptions); const res = await fetch(url2, { headers: { Authorization: 'Bearer ' + googleDriveOptions.authToken, }, }); const data = await res.json(); const fileId = ensureNotFalsy(data.files[0]?.id); /** * Fetch the file metadata via v2 to get the etag (from the ETag response header) * and the file size atomically in a single request. * This avoids a race condition where the etag could change between * the file listing and a separate etag fetch. */ const meta = await getFileMetadataV2(googleDriveOptions, fileId); return { status: response.status, etag: meta.etag, createdTime: meta.createdTime, fileId, size: meta.size }; } /** * Fetches file metadata from the Google Drive v2 API. * Returns the etag (from the ETag response header), size, and createdTime * in a single atomic request. * * Note: the v2 API uses "createdDate" and "fileSize" as field names; * this function maps them to "createdTime" and "size" for consistency * with the rest of the codebase. */ async function getFileMetadataV2( googleDriveOptions: GoogleDriveOptionsWithDefaults, fileId: string ): Promise<{ etag: string; size: number; createdTime: string }> { const url = googleDriveOptions.apiEndpoint + `/drive/v2/files/${encodeURIComponent(fileId)}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${googleDriveOptions.authToken}`, }, }); if (!res.ok) { throw await newRxFetchError(res, { args: { fileId } }); } const meta = await res.json(); return { etag: ensureNotFalsy(res.headers.get('ETag'), 'ETag missing'), // v2 uses "fileSize" and "createdDate" instead of the v3 "size" and "createdTime" size: parseInt(meta.fileSize ?? '0', 10), createdTime: ensureNotFalsy(meta.createdDate) }; } export async function getFileEtag( googleDriveOptions: GoogleDriveOptionsWithDefaults, fileId: string ): Promise { const meta = await getFileMetadataV2(googleDriveOptions, fileId); return meta.etag; } export async function fillFileIfEtagMatches( googleDriveOptions: GoogleDriveOptionsWithDefaults, fileId: string, etag: string, jsonContent?: any ): Promise<{ status: number; etag: string; content: T | undefined; serverTime: number; }> { const url = `${googleDriveOptions.apiEndpoint}` + `/upload/drive/v2/files/${encodeURIComponent(fileId)}` + `?uploadType=media`; const writeContent = typeof jsonContent !== 'undefined' ? JSON.stringify(jsonContent) : ''; const res = await fetch(url, { method: "PUT", headers: { Authorization: `Bearer ${googleDriveOptions.authToken}`, "Content-Type": "application/json; charset=utf-8", "If-Match": etag, }, body: writeContent, }); if (res.status !== 412 && res.status !== 200) { throw await newRxFetchError(res); } return readJsonFileContent( googleDriveOptions, fileId ).then(r => { return { content: r.content, etag: r.etag, status: res.status, serverTime: r.serverTime }; }); } export async function deleteIfEtagMatches( googleDriveOptions: GoogleDriveOptionsWithDefaults, fileId: string, etag: string ): Promise { const url = `${googleDriveOptions.apiEndpoint}` + `/drive/v2/files/${encodeURIComponent(fileId)}`; const res = await fetch(url, { method: "DELETE", headers: { Authorization: `Bearer ${googleDriveOptions.authToken}`, "If-Match": etag, }, }); if (!res.ok) { throw await newRxFetchError(res, { args: { etag, fileId } }); } if (res.ok) { // Drive v2 returns 204 No Content on successful delete return; } } export async function deleteFile( googleDriveOptions: GoogleDriveOptionsWithDefaults, fileId: string ): Promise { const url = `${googleDriveOptions.apiEndpoint}` + `/drive/v2/files/${encodeURIComponent(fileId)}`; const res = await fetch(url, { method: "DELETE", headers: { Authorization: `Bearer ${googleDriveOptions.authToken}`, }, }); if (!res.ok) { throw await newRxFetchError(res, { args: { fileId } }); } if (res.ok) { // Drive v2 returns 204 No Content on successful delete return; } } export async function readJsonFileContent( googleDriveOptions: GoogleDriveOptionsWithDefaults, fileId: string ): Promise<{ etag: string; content: T | undefined; serverTime: number; }> { const url = `${googleDriveOptions.apiEndpoint}` + `/drive/v2/files/${encodeURIComponent(fileId)}?alt=media`; const res = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${googleDriveOptions.authToken}`, Accept: "application/json", }, }); if (!res.ok) { throw await newRxFetchError(res, { args: { fileId } }); } const dateHeader = res.headers.get('date'); const unixMs = Date.parse(ensureNotFalsy(dateHeader)); const contentType = res.headers.get("content-type") || ""; if (!contentType.includes("application/json")) { const err = new Error("NOT_A_JSON_FILE but " + contentType); (err as any).code = "NOT_A_JSON_FILE"; (err as any).contentType = contentType; throw err; } const contentText = await res.text(); const content = contentText.length > 0 ? JSON.parse(contentText) : undefined; const etag = ensureNotFalsy(res.headers.get('etag')); return { etag, content: content as T, serverTime: unixMs }; } export async function readFolder( googleDriveOptions: GoogleDriveOptionsWithDefaults, folderPath: string ): Promise { let parentId = driveRootParentId(googleDriveOptions); const parts = folderPath.split('/').filter(p => p.length > 0); // Resolve folder path for (const part of parts) { const query = "name = '" + part + "' and '" + parentId + "' in parents and trashed = false and mimeType = '" + FOLDER_MIME_TYPE + "'"; const searchUrl = googleDriveOptions.apiEndpoint + '/drive/v3/files?fields=files(id)&q=' + encodeURIComponent(query) + driveSpaceQuerySuffix(googleDriveOptions); const searchResponse = await fetch(searchUrl, { method: 'GET', headers: { Authorization: 'Bearer ' + googleDriveOptions.authToken } }); const searchData = await searchResponse.json(); if (searchData.files && searchData.files.length > 0) { parentId = searchData.files[0].id; } else { throw newRxError('SNH', { folderPath }); } } // List children const query = "'" + parentId + "' in parents and trashed = false"; const listUrl = googleDriveOptions.apiEndpoint + '/drive/v3/files?fields=files(id,name,mimeType,trashed,parents)&q=' + encodeURIComponent(query) + driveSpaceQuerySuffix(googleDriveOptions); const listResponse = await fetch(listUrl, { method: 'GET', headers: { Authorization: 'Bearer ' + googleDriveOptions.authToken } }); if (!listResponse.ok) { throw await newRxFetchError(listResponse, { folderName: folderPath }); } const listData = await listResponse.json(); return listData.files || []; } export async function insertMultipartFile( googleDriveOptions: GoogleDriveOptionsWithDefaults, folderId: string, filename: string, jsonData: T ) { const content = JSON.stringify(jsonData); const metadata = { name: filename, mimeType: 'application/json', parents: [folderId], }; const postData = createMultipartBody( metadata, content ); const res = await fetch(googleDriveOptions.apiEndpoint + "/upload/drive/v3/files?uploadType=multipart", { method: 'POST', headers: { Authorization: `Bearer ${googleDriveOptions.authToken}`, 'Content-Type': 'multipart/related; boundary="' + postData.boundary + '"' }, body: postData.body }); if (!res.ok) { throw await newRxFetchError(res); } } export function createMultipartBody( metadata: Record, content: string ) { const multipartBoundary = '-------1337-use-RxDB-7355608-' + Math.random().toString(16).slice(2); const delimiter = '\r\n--' + multipartBoundary + '\r\n'; const closeDelim = '\r\n--' + multipartBoundary + '--'; const body = delimiter + 'Content-Type: application/json\r\n\r\n' + JSON.stringify(metadata) + delimiter + 'Content-Type: application/json\r\n\r\n' + content + closeDelim; return { body, boundary: multipartBoundary }; }; export async function listFilesInFolder( googleDriveOptions: GoogleDriveOptionsWithDefaults, folderId: string ): Promise { const q = `'${folderId}' in parents and trashed = false`; const params = new URLSearchParams({ q, pageSize: "1000", // max allowed fields: "files(id,name,mimeType,parents,modifiedTime,size)", supportsAllDrives: "true", includeItemsFromAllDrives: "true", }); applyDriveSpace(googleDriveOptions, params); const url = googleDriveOptions.apiEndpoint + "/drive/v3/files?" + params.toString(); const res = await fetch(url, { headers: { Authorization: `Bearer ${googleDriveOptions.authToken}`, }, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`files.list failed: ${res.status} ${text}`); } const data = await res.json(); return data.files ?? []; }