@cyfgoogle/drive-folder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cyfgoogle/drive-folder",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Google Drive folder client — path resolution, CRUD, and multipart upload for appDataFolder or drive space.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Cyftec",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"main": "./src/index.ts",
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"import": "./src/index.ts",
|
|
15
|
+
"default": "./src/index.ts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"typecheck": "bun x tsc --noEmit && bun x tsc --noEmit -p tsconfig.tests.json",
|
|
23
|
+
"test:runtime": "bun test",
|
|
24
|
+
"tests": "bun run test:runtime && bun run typecheck"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@cyfgoogle/oauth": "0.1.0"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { DriveAmbiguousPathError } from "./errors/drive-ambiguous-path-error.ts";
|
|
2
|
+
import { DriveApiError } from "./errors/drive-api-error.ts";
|
|
3
|
+
import { DriveScopeError } from "./errors/drive-scope-error.ts";
|
|
4
|
+
import type { GoogleOAuth } from "@cyfgoogle/oauth";
|
|
5
|
+
|
|
6
|
+
export type DriveSpace = "appDataFolder" | "drive";
|
|
7
|
+
|
|
8
|
+
export interface GoogleDriveFolderConfig {
|
|
9
|
+
oauth: GoogleOAuth;
|
|
10
|
+
space: DriveSpace;
|
|
11
|
+
rootFolderPath: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type DriveFileEntry = {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
createdTime: string;
|
|
18
|
+
mimeType: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type DriveContext = Pick<GoogleDriveFolderConfig, "oauth" | "space">;
|
|
22
|
+
|
|
23
|
+
const METADATA_OPERATIONS_ENDPOINT = "https://www.googleapis.com/drive/v3";
|
|
24
|
+
const UPLOAD_OPERATION_ENDPOINT = "https://www.googleapis.com/upload/drive/v3";
|
|
25
|
+
const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder";
|
|
26
|
+
|
|
27
|
+
const APPDATA_SCOPE = "https://www.googleapis.com/auth/drive.appdata";
|
|
28
|
+
const DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
29
|
+
|
|
30
|
+
const SPACE_SCOPES: Record<DriveSpace, readonly string[]> = {
|
|
31
|
+
appDataFolder: [APPDATA_SCOPE],
|
|
32
|
+
drive: [DRIVE_FILE_SCOPE],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class GoogleDriveFolder {
|
|
36
|
+
private readonly ctx: DriveContext;
|
|
37
|
+
private rootFolderId!: string;
|
|
38
|
+
private pathToFolderIdMap!: Map<string, string>;
|
|
39
|
+
|
|
40
|
+
private constructor(config: GoogleDriveFolderConfig) {
|
|
41
|
+
this.ctx = { oauth: config.oauth, space: config.space };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public static async getFolderHandle(
|
|
45
|
+
config: GoogleDriveFolderConfig,
|
|
46
|
+
): Promise<GoogleDriveFolder> {
|
|
47
|
+
const folder = new GoogleDriveFolder(config);
|
|
48
|
+
folder.assertSpaceScope();
|
|
49
|
+
await folder.resolveRootFolder(config.rootFolderPath);
|
|
50
|
+
return folder;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public async files(subpath = ""): Promise<DriveFileEntry[]> {
|
|
54
|
+
const parentFolderId = await this.folderIdForPath(subpath, false);
|
|
55
|
+
const query = `'${parentFolderId}' in parents and trashed=false`;
|
|
56
|
+
const entries = await this.queryFiles(query);
|
|
57
|
+
return entries.filter((entry) => entry.mimeType !== FOLDER_MIME_TYPE);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public async read(relativePath: string): Promise<Blob> {
|
|
61
|
+
const { parentFolderId, name } = await this.splitPath(relativePath, false);
|
|
62
|
+
const file = await this.findFileInParent(parentFolderId, name);
|
|
63
|
+
return (
|
|
64
|
+
await this.driveRequest(
|
|
65
|
+
METADATA_OPERATIONS_ENDPOINT,
|
|
66
|
+
`/files/${file.id}?alt=media`,
|
|
67
|
+
)
|
|
68
|
+
).blob();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public async write(
|
|
72
|
+
relativePath: string,
|
|
73
|
+
fileBlob: Blob,
|
|
74
|
+
mimeType: string,
|
|
75
|
+
): Promise<DriveFileEntry> {
|
|
76
|
+
const { parentFolderId, name } = await this.splitPath(relativePath, true);
|
|
77
|
+
const body = this.encodeMultipart(name, parentFolderId, mimeType, fileBlob);
|
|
78
|
+
const response = await this.driveRequest(
|
|
79
|
+
UPLOAD_OPERATION_ENDPOINT,
|
|
80
|
+
"/files?uploadType=multipart&fields=id,name,createdTime,mimeType",
|
|
81
|
+
{ method: "POST", body },
|
|
82
|
+
);
|
|
83
|
+
return (await response.json()) as DriveFileEntry;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public async exists(relativePath: string): Promise<boolean> {
|
|
87
|
+
const segments = this.normalizePath(relativePath);
|
|
88
|
+
if (segments.length === 0) return true;
|
|
89
|
+
|
|
90
|
+
const fileName = segments.at(-1)!;
|
|
91
|
+
const parentPath = segments.slice(0, -1).join("/");
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const parentFolderId = await this.folderIdForPath(parentPath, false);
|
|
95
|
+
const escapedName = fileName.replace(/'/g, "\\'");
|
|
96
|
+
const query = `name='${escapedName}' and '${parentFolderId}' in parents and trashed=false`;
|
|
97
|
+
const matches = await this.queryFiles(query);
|
|
98
|
+
return matches.length > 0;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public async mkdir(relativePath: string): Promise<void> {
|
|
105
|
+
await this.folderIdForPath(relativePath, true);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public async deleteById(fileId: string): Promise<void> {
|
|
109
|
+
await this.driveRequest(
|
|
110
|
+
METADATA_OPERATIONS_ENDPOINT,
|
|
111
|
+
`/files/${fileId}`,
|
|
112
|
+
{ method: "DELETE" },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public async deleteByPath(relativePath: string): Promise<void> {
|
|
117
|
+
const { parentFolderId, name } = await this.splitPath(relativePath, false);
|
|
118
|
+
const file = await this.findFileInParent(parentFolderId, name);
|
|
119
|
+
await this.deleteById(file.id);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async folderIdForPath(
|
|
123
|
+
subpath: string,
|
|
124
|
+
createMissing: boolean,
|
|
125
|
+
): Promise<string> {
|
|
126
|
+
const segments = this.normalizePath(subpath);
|
|
127
|
+
if (segments.length === 0) return this.rootFolderId;
|
|
128
|
+
|
|
129
|
+
const folderPath = segments.join("/");
|
|
130
|
+
const cachedFolderId = this.pathToFolderIdMap.get(folderPath);
|
|
131
|
+
if (cachedFolderId) return cachedFolderId;
|
|
132
|
+
|
|
133
|
+
return this.walkFolderPath(this.rootFolderId, segments, createMissing);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async splitPath(
|
|
137
|
+
relativePath: string,
|
|
138
|
+
createParents: boolean,
|
|
139
|
+
): Promise<{
|
|
140
|
+
parentFolderId: string;
|
|
141
|
+
name: string;
|
|
142
|
+
}> {
|
|
143
|
+
const segments = this.normalizePath(relativePath);
|
|
144
|
+
if (segments.length === 0) {
|
|
145
|
+
throw new Error("File path must include a file name");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const name = segments.at(-1)!;
|
|
149
|
+
const parentPath = segments.slice(0, -1).join("/");
|
|
150
|
+
return {
|
|
151
|
+
parentFolderId: await this.folderIdForPath(parentPath, createParents),
|
|
152
|
+
name,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async findFileInParent(
|
|
157
|
+
parentFolderId: string,
|
|
158
|
+
fileName: string,
|
|
159
|
+
): Promise<DriveFileEntry> {
|
|
160
|
+
const escapedName = fileName.replace(/'/g, "\\'");
|
|
161
|
+
const query = `name='${escapedName}' and '${parentFolderId}' in parents and trashed=false`;
|
|
162
|
+
const matches = await this.queryFiles(query);
|
|
163
|
+
const file = matches.find((entry) => entry.mimeType !== FOLDER_MIME_TYPE);
|
|
164
|
+
if (!file) {
|
|
165
|
+
throw new DriveApiError(`File not found: ${fileName}`, 404, "notFound");
|
|
166
|
+
}
|
|
167
|
+
return file;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private encodeMultipart(
|
|
171
|
+
fileName: string,
|
|
172
|
+
parentFolderId: string,
|
|
173
|
+
mimeType: string,
|
|
174
|
+
fileBlob: Blob,
|
|
175
|
+
): Blob {
|
|
176
|
+
const boundary = `drive_socket_${crypto.randomUUID()}`;
|
|
177
|
+
const filePart = {
|
|
178
|
+
name: fileName,
|
|
179
|
+
parents: [parentFolderId],
|
|
180
|
+
mimeType,
|
|
181
|
+
};
|
|
182
|
+
const metaPart = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(filePart)}\r\n`;
|
|
183
|
+
const filePartHeader = `--${boundary}\r\nContent-Type: ${mimeType}\r\n\r\n`;
|
|
184
|
+
const closing = `\r\n--${boundary}--`;
|
|
185
|
+
|
|
186
|
+
return new Blob([metaPart, filePartHeader, fileBlob, closing], {
|
|
187
|
+
type: `multipart/related; boundary=${boundary}`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private normalizePath(path: string): string[] {
|
|
192
|
+
return path
|
|
193
|
+
.split("/")
|
|
194
|
+
.map((segment) => segment.trim())
|
|
195
|
+
.filter((segment) => segment.length > 0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private assertSpaceScope(): void {
|
|
199
|
+
const configuredScopes = new Set(
|
|
200
|
+
this.ctx.oauth.getConfiguredScopes().split(/\s+/).filter(Boolean),
|
|
201
|
+
);
|
|
202
|
+
const requiredScopes = SPACE_SCOPES[this.ctx.space];
|
|
203
|
+
const hasScope = requiredScopes.some((scope) => configuredScopes.has(scope));
|
|
204
|
+
if (!hasScope) {
|
|
205
|
+
throw new DriveScopeError(this.ctx.space, requiredScopes);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async parseDriveError(response: Response): Promise<DriveApiError> {
|
|
210
|
+
let message = `Drive API error: ${response.status}`;
|
|
211
|
+
let reason = "unknown";
|
|
212
|
+
try {
|
|
213
|
+
const body = (await response.json()) as {
|
|
214
|
+
error?: { message?: string; errors?: Array<{ reason?: string }> };
|
|
215
|
+
};
|
|
216
|
+
message = body.error?.message ?? message;
|
|
217
|
+
reason = body.error?.errors?.[0]?.reason ?? reason;
|
|
218
|
+
} catch {
|
|
219
|
+
// keep defaults
|
|
220
|
+
}
|
|
221
|
+
return new DriveApiError(message, response.status, reason);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async driveRequest(
|
|
225
|
+
driveOperationEndpoint: string,
|
|
226
|
+
driveOperationSubpath: string,
|
|
227
|
+
init?: RequestInit,
|
|
228
|
+
): Promise<Response> {
|
|
229
|
+
const response = await this.ctx.oauth.authorizedFetch(
|
|
230
|
+
`${driveOperationEndpoint}${driveOperationSubpath}`,
|
|
231
|
+
init,
|
|
232
|
+
);
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
throw await this.parseDriveError(response);
|
|
235
|
+
}
|
|
236
|
+
return response;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async queryFiles(query: string): Promise<DriveFileEntry[]> {
|
|
240
|
+
const files: DriveFileEntry[] = [];
|
|
241
|
+
let pageToken: string | undefined;
|
|
242
|
+
|
|
243
|
+
do {
|
|
244
|
+
const params = new URLSearchParams({
|
|
245
|
+
spaces: this.ctx.space,
|
|
246
|
+
q: query,
|
|
247
|
+
fields: "nextPageToken,files(id,name,createdTime,mimeType)",
|
|
248
|
+
pageSize: "100",
|
|
249
|
+
});
|
|
250
|
+
if (pageToken) params.set("pageToken", pageToken);
|
|
251
|
+
|
|
252
|
+
const response = await this.driveRequest(
|
|
253
|
+
METADATA_OPERATIONS_ENDPOINT,
|
|
254
|
+
`/files?${params.toString()}`,
|
|
255
|
+
);
|
|
256
|
+
const result = (await response.json()) as {
|
|
257
|
+
files?: DriveFileEntry[];
|
|
258
|
+
nextPageToken?: string;
|
|
259
|
+
};
|
|
260
|
+
for (const file of result.files ?? []) files.push(file);
|
|
261
|
+
pageToken = result.nextPageToken;
|
|
262
|
+
} while (pageToken);
|
|
263
|
+
|
|
264
|
+
return files;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private findFolderMatches(
|
|
268
|
+
folderId: string,
|
|
269
|
+
folderName: string,
|
|
270
|
+
): Promise<DriveFileEntry[]> {
|
|
271
|
+
const escapedName = folderName.replace(/'/g, "\\'");
|
|
272
|
+
const query = `name='${escapedName}' and '${folderId}' in parents and mimeType='${FOLDER_MIME_TYPE}' and trashed=false`;
|
|
273
|
+
return this.queryFiles(query);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async folderSegmentId(
|
|
277
|
+
parentId: string,
|
|
278
|
+
folderName: string,
|
|
279
|
+
createMissing: boolean,
|
|
280
|
+
): Promise<string> {
|
|
281
|
+
const matches = await this.findFolderMatches(parentId, folderName);
|
|
282
|
+
if (matches.length > 1) {
|
|
283
|
+
throw new DriveAmbiguousPathError(parentId, folderName);
|
|
284
|
+
}
|
|
285
|
+
if (matches.length === 1) return matches[0]!.id;
|
|
286
|
+
if (!createMissing) {
|
|
287
|
+
throw new DriveApiError(`Folder not found: ${folderName}`, 404, "notFound");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const response = await this.driveRequest(
|
|
291
|
+
METADATA_OPERATIONS_ENDPOINT,
|
|
292
|
+
"/files?fields=id",
|
|
293
|
+
{
|
|
294
|
+
method: "POST",
|
|
295
|
+
headers: { "Content-Type": "application/json" },
|
|
296
|
+
body: JSON.stringify({
|
|
297
|
+
name: folderName,
|
|
298
|
+
mimeType: FOLDER_MIME_TYPE,
|
|
299
|
+
parents: [parentId],
|
|
300
|
+
}),
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
const created = (await response.json()) as { id: string };
|
|
304
|
+
return created.id;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private async walkFolderPath(
|
|
308
|
+
startFolderId: string,
|
|
309
|
+
segments: string[],
|
|
310
|
+
createMissing: boolean,
|
|
311
|
+
): Promise<string> {
|
|
312
|
+
let folderId = startFolderId;
|
|
313
|
+
let currentPath = "";
|
|
314
|
+
|
|
315
|
+
for (const segment of segments) {
|
|
316
|
+
folderId = await this.folderSegmentId(folderId, segment, createMissing);
|
|
317
|
+
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
318
|
+
this.pathToFolderIdMap.set(currentPath, folderId);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return folderId;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private async resolveRootFolder(rootFolderPath: string): Promise<void> {
|
|
325
|
+
const segments = this.normalizePath(rootFolderPath);
|
|
326
|
+
this.pathToFolderIdMap = new Map<string, string>();
|
|
327
|
+
const spaceRootParentId =
|
|
328
|
+
this.ctx.space === "appDataFolder" ? "appDataFolder" : "root";
|
|
329
|
+
|
|
330
|
+
this.rootFolderId =
|
|
331
|
+
segments.length === 0
|
|
332
|
+
? spaceRootParentId
|
|
333
|
+
: await this.walkFolderPath(spaceRootParentId, segments, true);
|
|
334
|
+
|
|
335
|
+
this.pathToFolderIdMap.set("", this.rootFolderId);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export class DriveAmbiguousPathError extends Error {
|
|
2
|
+
readonly parentFolderId: string;
|
|
3
|
+
readonly folderName: string;
|
|
4
|
+
|
|
5
|
+
constructor(parentFolderId: string, folderName: string) {
|
|
6
|
+
super(
|
|
7
|
+
`Multiple folders named "${folderName}" found under parent "${parentFolderId}"`,
|
|
8
|
+
);
|
|
9
|
+
this.name = "DriveAmbiguousPathError";
|
|
10
|
+
this.parentFolderId = parentFolderId;
|
|
11
|
+
this.folderName = folderName;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export class DriveApiError extends Error {
|
|
2
|
+
readonly status: number;
|
|
3
|
+
readonly reason: string;
|
|
4
|
+
|
|
5
|
+
constructor(message: string, status: number, reason: string) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'DriveApiError';
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.reason = reason;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export class DriveScopeError extends Error {
|
|
2
|
+
readonly space: string;
|
|
3
|
+
readonly requiredScopes: readonly string[];
|
|
4
|
+
|
|
5
|
+
constructor(space: string, requiredScopes: readonly string[]) {
|
|
6
|
+
super(
|
|
7
|
+
`OAuth scopes insufficient for Drive space "${space}". Required one of: ${requiredScopes.join(", ")}`,
|
|
8
|
+
);
|
|
9
|
+
this.name = "DriveScopeError";
|
|
10
|
+
this.space = space;
|
|
11
|
+
this.requiredScopes = requiredScopes;
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export {
|
|
2
|
+
GoogleDriveFolder,
|
|
3
|
+
type DriveSpace,
|
|
4
|
+
type DriveFileEntry,
|
|
5
|
+
type GoogleDriveFolderConfig,
|
|
6
|
+
} from "./drive-folder.ts";
|
|
7
|
+
export { DriveApiError } from "./errors/drive-api-error.ts";
|
|
8
|
+
export { DriveScopeError } from "./errors/drive-scope-error.ts";
|
|
9
|
+
export { DriveAmbiguousPathError } from "./errors/drive-ambiguous-path-error.ts";
|