rxdb
Version:
A local-first realtime NoSQL Database for JavaScript applications - https://rxdb.info/
468 lines (412 loc) • 13.6 kB
text/typescript
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';
export async function createFolder(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
parentId: string = 'root',
folderName: string
): Promise<string> {
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<string | undefined> {
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);
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<string> {
const parts = folderPath.split('/').filter(p => p.length > 0);
let parentId = 'root';
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,etag,size,createdTime)' +
'&orderBy=createdTime asc' +
'&q=' + encodeURIComponent(query);
const res = await fetch(url2, {
headers: {
Authorization: 'Bearer ' + googleDriveOptions.authToken,
},
});
const data = await res.json();
const file = ensureNotFalsy(data.files[0]);
return {
status: response.status,
etag: ensureNotFalsy(file.etag),
createdTime: ensureNotFalsy(file.createdTime),
fileId: ensureNotFalsy(file.id),
size: parseInt(file.size, 10)
}
}
export async function fillFileIfEtagMatches<T = any>(
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<T>(
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<void> {
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<void> {
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<T>(
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<DriveFileMetadata[]> {
let parentId = 'root';
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);
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);
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<T>(
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<string, unknown>,
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<DriveFileMetadata[]> {
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",
});
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 ?? [];
}