@gamerdexapp/gamerdex-sync 0.1.1 → 0.2.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/CHANGELOG.md +11 -0
- package/dist/index.d.mts +50 -1
- package/dist/index.d.ts +50 -1
- package/dist/index.js +284 -11
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +281 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-10-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `GoogleDriveProvider` keeps the files in the Google Drive of the player, in a visible `Gamerdex`
|
|
15
|
+
folder, with the narrow `drive.file` scope. Drive cannot refuse a write made from an old version,
|
|
16
|
+
so the provider checks the head revision before the upload and the revision list after it. When
|
|
17
|
+
another device wrote in between, its content is put back and the sync merges both sides.
|
|
18
|
+
- `googleAuthorizeUrl()` and `GOOGLE_DRIVE_SCOPE` for the Google sign in page.
|
|
19
|
+
- `ProviderError.detail` also reads the message of a failed Google request.
|
|
20
|
+
|
|
10
21
|
## [0.1.1] - 2026-10-17
|
|
11
22
|
|
|
12
23
|
### Added
|
package/dist/index.d.mts
CHANGED
|
@@ -393,4 +393,53 @@ interface InstalledRepository {
|
|
|
393
393
|
*/
|
|
394
394
|
declare const listInstalledRepositories: (token: string, send?: typeof fetch) => Promise<InstalledRepository[]>;
|
|
395
395
|
|
|
396
|
-
|
|
396
|
+
interface GoogleDriveOptions {
|
|
397
|
+
/** Returns the token. It may refresh the token first, so it is called for every request. */
|
|
398
|
+
getToken: () => Promise<string>;
|
|
399
|
+
fetch?: typeof fetch;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Keeps the files of an app in the Google Drive of the player, in a visible `Gamerdex` folder. It
|
|
403
|
+
* needs only the `drive.file` scope, which sees the files that this app created.
|
|
404
|
+
*
|
|
405
|
+
* Drive cannot refuse a write made from an old version. Writes are checked in two steps instead:
|
|
406
|
+
* the head revision must equal the expected version before the upload, and the revision list must
|
|
407
|
+
* show the new revision directly on top of the expected one after it. When another device wrote in
|
|
408
|
+
* between, its content is written back as the head and `VersionConflictError` is thrown, so the
|
|
409
|
+
* next sync merges both sides.
|
|
410
|
+
*/
|
|
411
|
+
declare class GoogleDriveProvider implements SyncProvider {
|
|
412
|
+
private readonly options;
|
|
413
|
+
private readonly send;
|
|
414
|
+
private readonly folders;
|
|
415
|
+
constructor(options: GoogleDriveOptions);
|
|
416
|
+
read(path: string): Promise<RemoteFile | null>;
|
|
417
|
+
write(path: string, content: string, expectedVersion: string | null): Promise<{
|
|
418
|
+
version: string;
|
|
419
|
+
}>;
|
|
420
|
+
remove(path: string, expectedVersion: string): Promise<void>;
|
|
421
|
+
private create;
|
|
422
|
+
private findFile;
|
|
423
|
+
private locate;
|
|
424
|
+
private folderId;
|
|
425
|
+
private children;
|
|
426
|
+
private headOf;
|
|
427
|
+
private revisionContent;
|
|
428
|
+
private revisionBefore;
|
|
429
|
+
private upload;
|
|
430
|
+
private createFile;
|
|
431
|
+
private deleteItem;
|
|
432
|
+
private stringField;
|
|
433
|
+
private request;
|
|
434
|
+
private failure;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The only Drive scope that sync asks for. It sees the files that the app created itself. */
|
|
438
|
+
declare const GOOGLE_DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
439
|
+
/**
|
|
440
|
+
* The address of the Google sign in page. `access_type=offline` and `prompt=consent` make Google
|
|
441
|
+
* send a refresh token, which it otherwise sends only the first time the player agrees.
|
|
442
|
+
*/
|
|
443
|
+
declare const googleAuthorizeUrl: (clientId: string, redirectUri: string, state: string) => string;
|
|
444
|
+
|
|
445
|
+
export { type CallbackResult, type Conflict, type ConflictKind, DATA_FILE, type DeviceRecord, type FirstSyncChoice, GITHUB_APP, GOOGLE_DRIVE_SCOPE, type GitHubOptions, GitHubProvider, type GoogleDriveOptions, GoogleDriveProvider, type InstalledRepository, META_FILE, META_FORMAT, MemoryProvider, type MergeInput, type MergeResult, type ParsedMeta, type Path, type PathStep, ProviderError, type ProviderErrorKind, ROOT_FOLDER, RemoteDataError, type RemoteFile, type RepositoryCheck, type Schema, type Side, type StoredTokens, SyncBusyError, type SyncMeta, type SyncOptions, type SyncProvider, type SyncResult, type SyncState, type TokenResponse, type TokenSourceOptions, VersionConflictError, authorizeUrl, createTokenSource, dataPath, decodeBase64, emptyMeta, encodeBase64, equal, googleAuthorizeUrl, installUrl, isAppId, isObject, listInstalledRepositories, merge, metaPath, newState, parseMeta, parseTokenResponse, readCallback, resolveConflict, restoreCloudValues, sync, toStoredTokens, withChiefRemoved, withChiefRevision, withDevice, withSettingsRevision };
|
package/dist/index.d.ts
CHANGED
|
@@ -393,4 +393,53 @@ interface InstalledRepository {
|
|
|
393
393
|
*/
|
|
394
394
|
declare const listInstalledRepositories: (token: string, send?: typeof fetch) => Promise<InstalledRepository[]>;
|
|
395
395
|
|
|
396
|
-
|
|
396
|
+
interface GoogleDriveOptions {
|
|
397
|
+
/** Returns the token. It may refresh the token first, so it is called for every request. */
|
|
398
|
+
getToken: () => Promise<string>;
|
|
399
|
+
fetch?: typeof fetch;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Keeps the files of an app in the Google Drive of the player, in a visible `Gamerdex` folder. It
|
|
403
|
+
* needs only the `drive.file` scope, which sees the files that this app created.
|
|
404
|
+
*
|
|
405
|
+
* Drive cannot refuse a write made from an old version. Writes are checked in two steps instead:
|
|
406
|
+
* the head revision must equal the expected version before the upload, and the revision list must
|
|
407
|
+
* show the new revision directly on top of the expected one after it. When another device wrote in
|
|
408
|
+
* between, its content is written back as the head and `VersionConflictError` is thrown, so the
|
|
409
|
+
* next sync merges both sides.
|
|
410
|
+
*/
|
|
411
|
+
declare class GoogleDriveProvider implements SyncProvider {
|
|
412
|
+
private readonly options;
|
|
413
|
+
private readonly send;
|
|
414
|
+
private readonly folders;
|
|
415
|
+
constructor(options: GoogleDriveOptions);
|
|
416
|
+
read(path: string): Promise<RemoteFile | null>;
|
|
417
|
+
write(path: string, content: string, expectedVersion: string | null): Promise<{
|
|
418
|
+
version: string;
|
|
419
|
+
}>;
|
|
420
|
+
remove(path: string, expectedVersion: string): Promise<void>;
|
|
421
|
+
private create;
|
|
422
|
+
private findFile;
|
|
423
|
+
private locate;
|
|
424
|
+
private folderId;
|
|
425
|
+
private children;
|
|
426
|
+
private headOf;
|
|
427
|
+
private revisionContent;
|
|
428
|
+
private revisionBefore;
|
|
429
|
+
private upload;
|
|
430
|
+
private createFile;
|
|
431
|
+
private deleteItem;
|
|
432
|
+
private stringField;
|
|
433
|
+
private request;
|
|
434
|
+
private failure;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The only Drive scope that sync asks for. It sees the files that the app created itself. */
|
|
438
|
+
declare const GOOGLE_DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
439
|
+
/**
|
|
440
|
+
* The address of the Google sign in page. `access_type=offline` and `prompt=consent` make Google
|
|
441
|
+
* send a refresh token, which it otherwise sends only the first time the player agrees.
|
|
442
|
+
*/
|
|
443
|
+
declare const googleAuthorizeUrl: (clientId: string, redirectUri: string, state: string) => string;
|
|
444
|
+
|
|
445
|
+
export { type CallbackResult, type Conflict, type ConflictKind, DATA_FILE, type DeviceRecord, type FirstSyncChoice, GITHUB_APP, GOOGLE_DRIVE_SCOPE, type GitHubOptions, GitHubProvider, type GoogleDriveOptions, GoogleDriveProvider, type InstalledRepository, META_FILE, META_FORMAT, MemoryProvider, type MergeInput, type MergeResult, type ParsedMeta, type Path, type PathStep, ProviderError, type ProviderErrorKind, ROOT_FOLDER, RemoteDataError, type RemoteFile, type RepositoryCheck, type Schema, type Side, type StoredTokens, SyncBusyError, type SyncMeta, type SyncOptions, type SyncProvider, type SyncResult, type SyncState, type TokenResponse, type TokenSourceOptions, VersionConflictError, authorizeUrl, createTokenSource, dataPath, decodeBase64, emptyMeta, encodeBase64, equal, googleAuthorizeUrl, installUrl, isAppId, isObject, listInstalledRepositories, merge, metaPath, newState, parseMeta, parseTokenResponse, readCallback, resolveConflict, restoreCloudValues, sync, toStoredTokens, withChiefRemoved, withChiefRevision, withDevice, withSettingsRevision };
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,9 @@ var index_exports = {};
|
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
DATA_FILE: () => DATA_FILE,
|
|
24
24
|
GITHUB_APP: () => GITHUB_APP,
|
|
25
|
+
GOOGLE_DRIVE_SCOPE: () => GOOGLE_DRIVE_SCOPE,
|
|
25
26
|
GitHubProvider: () => GitHubProvider,
|
|
27
|
+
GoogleDriveProvider: () => GoogleDriveProvider,
|
|
26
28
|
META_FILE: () => META_FILE,
|
|
27
29
|
META_FORMAT: () => META_FORMAT,
|
|
28
30
|
MemoryProvider: () => MemoryProvider,
|
|
@@ -38,6 +40,7 @@ __export(index_exports, {
|
|
|
38
40
|
emptyMeta: () => emptyMeta,
|
|
39
41
|
encodeBase64: () => encodeBase64,
|
|
40
42
|
equal: () => equal,
|
|
43
|
+
googleAuthorizeUrl: () => googleAuthorizeUrl,
|
|
41
44
|
installUrl: () => installUrl,
|
|
42
45
|
isAppId: () => isAppId,
|
|
43
46
|
isObject: () => isObject,
|
|
@@ -348,10 +351,11 @@ var decodeBase64 = (base64) => {
|
|
|
348
351
|
|
|
349
352
|
// src/provider/detail.ts
|
|
350
353
|
var MAX_DETAIL_LENGTH = 300;
|
|
354
|
+
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
351
355
|
var detailOf = async (response) => {
|
|
352
356
|
const body = await response.json().catch(() => null);
|
|
353
|
-
if (
|
|
354
|
-
const message = body.message;
|
|
357
|
+
if (!isRecord2(body)) return void 0;
|
|
358
|
+
const message = isRecord2(body.error) ? body.error.message : body.message;
|
|
355
359
|
return typeof message === "string" ? message.slice(0, MAX_DETAIL_LENGTH) : void 0;
|
|
356
360
|
};
|
|
357
361
|
|
|
@@ -383,7 +387,7 @@ var ProviderError = class extends Error {
|
|
|
383
387
|
var API = "https://api.github.com";
|
|
384
388
|
var API_VERSION = "2022-11-28";
|
|
385
389
|
var encodePath = (path) => path.split("/").map(encodeURIComponent).join("/");
|
|
386
|
-
var
|
|
390
|
+
var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
387
391
|
var retryAfterOf = (response) => {
|
|
388
392
|
const header = response.headers.get("retry-after");
|
|
389
393
|
if (header !== null && Number.isFinite(Number(header))) return Number(header);
|
|
@@ -449,10 +453,10 @@ var GitHubProvider = class {
|
|
|
449
453
|
const response = await this.request("GET", `${API}/repos/${this.options.repository}`);
|
|
450
454
|
await this.assertOk(response);
|
|
451
455
|
const body = await response.json();
|
|
452
|
-
if (!
|
|
456
|
+
if (!isRecord3(body) || typeof body.private !== "boolean") {
|
|
453
457
|
throw this.invalid("The repository answer is not understood.");
|
|
454
458
|
}
|
|
455
|
-
const permissions =
|
|
459
|
+
const permissions = isRecord3(body.permissions) ? body.permissions : {};
|
|
456
460
|
return {
|
|
457
461
|
private: body.private,
|
|
458
462
|
canWrite: permissions.push === true,
|
|
@@ -682,10 +686,10 @@ var GITHUB_APP = {
|
|
|
682
686
|
};
|
|
683
687
|
|
|
684
688
|
// src/auth/tokens.ts
|
|
685
|
-
var
|
|
689
|
+
var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
686
690
|
var isSeconds = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
687
691
|
var parseTokenResponse = (body) => {
|
|
688
|
-
if (!
|
|
692
|
+
if (!isRecord4(body)) return null;
|
|
689
693
|
const { accessToken, expiresIn, refreshToken, refreshExpiresIn } = body;
|
|
690
694
|
if (typeof accessToken !== "string" || accessToken === "") return null;
|
|
691
695
|
if (typeof refreshToken !== "string" || refreshToken === "") return null;
|
|
@@ -775,7 +779,7 @@ var readCallback = (search, expectedState) => {
|
|
|
775
779
|
// src/provider/repositories.ts
|
|
776
780
|
var API2 = "https://api.github.com";
|
|
777
781
|
var PAGE_SIZE = 100;
|
|
778
|
-
var
|
|
782
|
+
var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
779
783
|
var getJson = async (send, token, url) => {
|
|
780
784
|
let response;
|
|
781
785
|
try {
|
|
@@ -806,7 +810,7 @@ var getJson = async (send, token, url) => {
|
|
|
806
810
|
);
|
|
807
811
|
}
|
|
808
812
|
const body = await response.json().catch(() => null);
|
|
809
|
-
if (!
|
|
813
|
+
if (!isRecord5(body)) {
|
|
810
814
|
throw new ProviderError("invalid", "GitHub answer is not understood.", response.status);
|
|
811
815
|
}
|
|
812
816
|
return body;
|
|
@@ -817,7 +821,7 @@ var listInstalledRepositories = async (token, send = (...args) => globalThis.fet
|
|
|
817
821
|
token,
|
|
818
822
|
`${API2}/user/installations?per_page=${PAGE_SIZE}`
|
|
819
823
|
);
|
|
820
|
-
const ids = (Array.isArray(installations.installations) ? installations.installations : []).filter(
|
|
824
|
+
const ids = (Array.isArray(installations.installations) ? installations.installations : []).filter(isRecord5).map((installation) => installation.id).filter((id) => typeof id === "number");
|
|
821
825
|
const repositories = [];
|
|
822
826
|
for (const id of ids) {
|
|
823
827
|
const page = await getJson(
|
|
@@ -826,18 +830,286 @@ var listInstalledRepositories = async (token, send = (...args) => globalThis.fet
|
|
|
826
830
|
`${API2}/user/installations/${id}/repositories?per_page=${PAGE_SIZE}`
|
|
827
831
|
);
|
|
828
832
|
for (const item of Array.isArray(page.repositories) ? page.repositories : []) {
|
|
829
|
-
if (
|
|
833
|
+
if (isRecord5(item) && typeof item.full_name === "string" && typeof item.private === "boolean") {
|
|
830
834
|
repositories.push({ fullName: item.full_name, private: item.private });
|
|
831
835
|
}
|
|
832
836
|
}
|
|
833
837
|
}
|
|
834
838
|
return repositories;
|
|
835
839
|
};
|
|
840
|
+
|
|
841
|
+
// src/provider/drive.ts
|
|
842
|
+
var API3 = "https://www.googleapis.com/drive/v3";
|
|
843
|
+
var UPLOAD = "https://www.googleapis.com/upload/drive/v3";
|
|
844
|
+
var FOLDER_TYPE = "application/vnd.google-apps.folder";
|
|
845
|
+
var RATE_LIMIT_REASONS = ["rateLimitExceeded", "userRateLimitExceeded", "dailyLimitExceeded"];
|
|
846
|
+
var isRecord6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
847
|
+
var quoted = (value) => `'${value.replace(/[\\']/g, "\\$&")}'`;
|
|
848
|
+
var oldestFirst = (a, b) => a.createdTime.localeCompare(b.createdTime) || a.id.localeCompare(b.id);
|
|
849
|
+
var reasonOf = (body) => {
|
|
850
|
+
if (!isRecord6(body) || !isRecord6(body.error) || !Array.isArray(body.error.errors))
|
|
851
|
+
return void 0;
|
|
852
|
+
const first = body.error.errors[0];
|
|
853
|
+
return isRecord6(first) && typeof first.reason === "string" ? first.reason : void 0;
|
|
854
|
+
};
|
|
855
|
+
var GoogleDriveProvider = class {
|
|
856
|
+
constructor(options) {
|
|
857
|
+
this.options = options;
|
|
858
|
+
this.send = options.fetch ?? ((...args) => globalThis.fetch(...args));
|
|
859
|
+
}
|
|
860
|
+
options;
|
|
861
|
+
send;
|
|
862
|
+
folders = /* @__PURE__ */ new Map();
|
|
863
|
+
async read(path) {
|
|
864
|
+
const file = await this.findFile(path);
|
|
865
|
+
if (!file) return null;
|
|
866
|
+
const head = await this.headOf(file.id);
|
|
867
|
+
if (head === null) return null;
|
|
868
|
+
return { content: await this.revisionContent(file.id, head), version: head };
|
|
869
|
+
}
|
|
870
|
+
async write(path, content, expectedVersion) {
|
|
871
|
+
if (expectedVersion === null) return this.create(path, content);
|
|
872
|
+
const file = await this.findFile(path);
|
|
873
|
+
if (!file || await this.headOf(file.id) !== expectedVersion) {
|
|
874
|
+
throw new VersionConflictError(path);
|
|
875
|
+
}
|
|
876
|
+
const version = await this.upload(file.id, content);
|
|
877
|
+
if (version === expectedVersion) return { version };
|
|
878
|
+
const previous = await this.revisionBefore(file.id, version);
|
|
879
|
+
if (previous === expectedVersion) return { version };
|
|
880
|
+
await this.upload(file.id, await this.revisionContent(file.id, previous));
|
|
881
|
+
throw new VersionConflictError(path);
|
|
882
|
+
}
|
|
883
|
+
async remove(path, expectedVersion) {
|
|
884
|
+
const file = await this.findFile(path);
|
|
885
|
+
if (!file) return;
|
|
886
|
+
if (await this.headOf(file.id) !== expectedVersion) throw new VersionConflictError(path);
|
|
887
|
+
await this.deleteItem(file.id);
|
|
888
|
+
}
|
|
889
|
+
async create(path, content) {
|
|
890
|
+
const { parentId, name } = await this.locate(path, true);
|
|
891
|
+
if ((await this.children(parentId, name)).length > 0) throw new VersionConflictError(path);
|
|
892
|
+
const created = await this.createFile(parentId, name, content);
|
|
893
|
+
const [oldest] = await this.children(parentId, name);
|
|
894
|
+
if (oldest.id !== created.id) {
|
|
895
|
+
await this.deleteItem(created.id);
|
|
896
|
+
throw new VersionConflictError(path);
|
|
897
|
+
}
|
|
898
|
+
return { version: created.version };
|
|
899
|
+
}
|
|
900
|
+
async findFile(path) {
|
|
901
|
+
const located = await this.locate(path, false);
|
|
902
|
+
if (!located) return null;
|
|
903
|
+
return (await this.children(located.parentId, located.name))[0] ?? null;
|
|
904
|
+
}
|
|
905
|
+
async locate(path, create) {
|
|
906
|
+
const parts = path.split("/");
|
|
907
|
+
const name = parts.pop();
|
|
908
|
+
let parentId = "root";
|
|
909
|
+
let walked = "";
|
|
910
|
+
for (const folder of parts) {
|
|
911
|
+
walked += `/${folder}`;
|
|
912
|
+
const known = this.folders.get(walked);
|
|
913
|
+
if (known) {
|
|
914
|
+
parentId = known;
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
const id = await this.folderId(parentId, folder, create);
|
|
918
|
+
if (id === null) return null;
|
|
919
|
+
this.folders.set(walked, id);
|
|
920
|
+
parentId = id;
|
|
921
|
+
}
|
|
922
|
+
return { parentId, name };
|
|
923
|
+
}
|
|
924
|
+
async folderId(parentId, name, create) {
|
|
925
|
+
const found = await this.children(parentId, name, true);
|
|
926
|
+
if (found.length > 0) return found[0].id;
|
|
927
|
+
if (!create) return null;
|
|
928
|
+
const response = await this.request("POST", `${API3}/files?fields=id`, {
|
|
929
|
+
body: JSON.stringify({ name, mimeType: FOLDER_TYPE, parents: [parentId] }),
|
|
930
|
+
headers: { "Content-Type": "application/json" }
|
|
931
|
+
});
|
|
932
|
+
const id = this.stringField(await response.json(), "id");
|
|
933
|
+
const [oldest] = await this.children(parentId, name, true);
|
|
934
|
+
if (oldest.id !== id) {
|
|
935
|
+
await this.deleteItem(id);
|
|
936
|
+
return oldest.id;
|
|
937
|
+
}
|
|
938
|
+
return id;
|
|
939
|
+
}
|
|
940
|
+
async children(parentId, name, folder = false) {
|
|
941
|
+
const query = [
|
|
942
|
+
`name = ${quoted(name)}`,
|
|
943
|
+
`${quoted(parentId)} in parents`,
|
|
944
|
+
"trashed = false",
|
|
945
|
+
`mimeType ${folder ? "=" : "!="} '${FOLDER_TYPE}'`
|
|
946
|
+
].join(" and ");
|
|
947
|
+
const url = `${API3}/files?${new URLSearchParams({
|
|
948
|
+
q: query,
|
|
949
|
+
fields: "files(id,createdTime)",
|
|
950
|
+
pageSize: "100"
|
|
951
|
+
})}`;
|
|
952
|
+
const body = await (await this.request("GET", url)).json();
|
|
953
|
+
const files = isRecord6(body) && Array.isArray(body.files) ? body.files : [];
|
|
954
|
+
return files.filter(
|
|
955
|
+
(item) => isRecord6(item) && typeof item.id === "string" && typeof item.createdTime === "string"
|
|
956
|
+
).sort(oldestFirst);
|
|
957
|
+
}
|
|
958
|
+
async headOf(id) {
|
|
959
|
+
const response = await this.request(
|
|
960
|
+
"GET",
|
|
961
|
+
`${API3}/files/${encodeURIComponent(id)}?fields=headRevisionId,trashed`,
|
|
962
|
+
{ allowMissing: true }
|
|
963
|
+
);
|
|
964
|
+
if (response.status === 404) return null;
|
|
965
|
+
const body = await response.json();
|
|
966
|
+
if (isRecord6(body) && body.trashed === true) return null;
|
|
967
|
+
return this.stringField(body, "headRevisionId");
|
|
968
|
+
}
|
|
969
|
+
async revisionContent(id, revision) {
|
|
970
|
+
const url = `${API3}/files/${encodeURIComponent(id)}/revisions/${encodeURIComponent(revision)}?alt=media`;
|
|
971
|
+
return (await this.request("GET", url)).text();
|
|
972
|
+
}
|
|
973
|
+
async revisionBefore(id, revision) {
|
|
974
|
+
const url = `${API3}/files/${encodeURIComponent(id)}/revisions?${new URLSearchParams({
|
|
975
|
+
fields: "revisions(id)",
|
|
976
|
+
pageSize: "1000"
|
|
977
|
+
})}`;
|
|
978
|
+
const body = await (await this.request("GET", url)).json();
|
|
979
|
+
const revisions = (isRecord6(body) && Array.isArray(body.revisions) ? body.revisions : []).filter(isRecord6).map((item) => item.id);
|
|
980
|
+
const index = revisions.indexOf(revision);
|
|
981
|
+
const previous = revisions[index - 1];
|
|
982
|
+
if (index < 1 || typeof previous !== "string") {
|
|
983
|
+
throw new ProviderError("server", "The new revision is not in the revision list.");
|
|
984
|
+
}
|
|
985
|
+
return previous;
|
|
986
|
+
}
|
|
987
|
+
async upload(id, content) {
|
|
988
|
+
const url = `${UPLOAD}/files/${encodeURIComponent(id)}?uploadType=media&fields=headRevisionId`;
|
|
989
|
+
const response = await this.request("PATCH", url, {
|
|
990
|
+
body: content,
|
|
991
|
+
headers: { "Content-Type": "application/json" }
|
|
992
|
+
});
|
|
993
|
+
return this.stringField(await response.json(), "headRevisionId");
|
|
994
|
+
}
|
|
995
|
+
async createFile(parentId, name, content) {
|
|
996
|
+
const boundary = `gamerdex-${crypto.randomUUID()}`;
|
|
997
|
+
const metadata = JSON.stringify({ name, parents: [parentId], mimeType: "application/json" });
|
|
998
|
+
const body = `--${boundary}\r
|
|
999
|
+
Content-Type: application/json; charset=UTF-8\r
|
|
1000
|
+
\r
|
|
1001
|
+
${metadata}\r
|
|
1002
|
+
--${boundary}\r
|
|
1003
|
+
Content-Type: application/json\r
|
|
1004
|
+
\r
|
|
1005
|
+
${content}\r
|
|
1006
|
+
--${boundary}--`;
|
|
1007
|
+
const response = await this.request(
|
|
1008
|
+
"POST",
|
|
1009
|
+
`${UPLOAD}/files?uploadType=multipart&fields=id,headRevisionId`,
|
|
1010
|
+
{ body, headers: { "Content-Type": `multipart/related; boundary=${boundary}` } }
|
|
1011
|
+
);
|
|
1012
|
+
const answer = await response.json();
|
|
1013
|
+
return {
|
|
1014
|
+
id: this.stringField(answer, "id"),
|
|
1015
|
+
version: this.stringField(answer, "headRevisionId")
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
async deleteItem(id) {
|
|
1019
|
+
await this.request("DELETE", `${API3}/files/${encodeURIComponent(id)}`, { allowMissing: true });
|
|
1020
|
+
}
|
|
1021
|
+
stringField(body, field) {
|
|
1022
|
+
const value = isRecord6(body) ? body[field] : void 0;
|
|
1023
|
+
if (typeof value !== "string") {
|
|
1024
|
+
throw new ProviderError("invalid", `Google Drive answered without ${field}.`);
|
|
1025
|
+
}
|
|
1026
|
+
return value;
|
|
1027
|
+
}
|
|
1028
|
+
async request(method, url, options = {}) {
|
|
1029
|
+
const token = await this.options.getToken();
|
|
1030
|
+
let response;
|
|
1031
|
+
try {
|
|
1032
|
+
response = await this.send(url, {
|
|
1033
|
+
method,
|
|
1034
|
+
headers: { Authorization: `Bearer ${token}`, ...options.headers ?? {} },
|
|
1035
|
+
body: options.body
|
|
1036
|
+
});
|
|
1037
|
+
} catch {
|
|
1038
|
+
throw new ProviderError("network", "Google Drive could not be reached.");
|
|
1039
|
+
}
|
|
1040
|
+
if (response.ok || options.allowMissing && response.status === 404) return response;
|
|
1041
|
+
throw await this.failure(response);
|
|
1042
|
+
}
|
|
1043
|
+
async failure(response) {
|
|
1044
|
+
const { status } = response;
|
|
1045
|
+
const body = await response.clone().json().catch(() => null);
|
|
1046
|
+
const detail = await detailOf(response);
|
|
1047
|
+
if (status === 401) {
|
|
1048
|
+
return new ProviderError("auth", "The token is not valid.", status, void 0, detail);
|
|
1049
|
+
}
|
|
1050
|
+
const reason = reasonOf(body);
|
|
1051
|
+
if (status === 429 || status === 403 && reason && RATE_LIMIT_REASONS.includes(reason)) {
|
|
1052
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
1053
|
+
return new ProviderError(
|
|
1054
|
+
"rate-limit",
|
|
1055
|
+
"Google Drive limits the calls.",
|
|
1056
|
+
status,
|
|
1057
|
+
Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : void 0,
|
|
1058
|
+
detail
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
if (status === 403) {
|
|
1062
|
+
return new ProviderError(
|
|
1063
|
+
"auth",
|
|
1064
|
+
"The token has no permission for Drive.",
|
|
1065
|
+
status,
|
|
1066
|
+
void 0,
|
|
1067
|
+
detail
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
if (status === 404) {
|
|
1071
|
+
return new ProviderError(
|
|
1072
|
+
"not-found",
|
|
1073
|
+
"The file or folder was not found.",
|
|
1074
|
+
status,
|
|
1075
|
+
void 0,
|
|
1076
|
+
detail
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
if (status >= 500) {
|
|
1080
|
+
return new ProviderError("server", "Google Drive failed.", status, void 0, detail);
|
|
1081
|
+
}
|
|
1082
|
+
return new ProviderError(
|
|
1083
|
+
"invalid",
|
|
1084
|
+
`Google Drive answered ${status}.`,
|
|
1085
|
+
status,
|
|
1086
|
+
void 0,
|
|
1087
|
+
detail
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
// src/auth/google.ts
|
|
1093
|
+
var GOOGLE_DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
1094
|
+
var googleAuthorizeUrl = (clientId, redirectUri, state) => {
|
|
1095
|
+
const query = new URLSearchParams({
|
|
1096
|
+
client_id: clientId,
|
|
1097
|
+
redirect_uri: redirectUri,
|
|
1098
|
+
response_type: "code",
|
|
1099
|
+
scope: GOOGLE_DRIVE_SCOPE,
|
|
1100
|
+
access_type: "offline",
|
|
1101
|
+
prompt: "consent",
|
|
1102
|
+
state
|
|
1103
|
+
});
|
|
1104
|
+
return `https://accounts.google.com/o/oauth2/v2/auth?${query}`;
|
|
1105
|
+
};
|
|
836
1106
|
// Annotate the CommonJS export names for ESM import in node:
|
|
837
1107
|
0 && (module.exports = {
|
|
838
1108
|
DATA_FILE,
|
|
839
1109
|
GITHUB_APP,
|
|
1110
|
+
GOOGLE_DRIVE_SCOPE,
|
|
840
1111
|
GitHubProvider,
|
|
1112
|
+
GoogleDriveProvider,
|
|
841
1113
|
META_FILE,
|
|
842
1114
|
META_FORMAT,
|
|
843
1115
|
MemoryProvider,
|
|
@@ -853,6 +1125,7 @@ var listInstalledRepositories = async (token, send = (...args) => globalThis.fet
|
|
|
853
1125
|
emptyMeta,
|
|
854
1126
|
encodeBase64,
|
|
855
1127
|
equal,
|
|
1128
|
+
googleAuthorizeUrl,
|
|
856
1129
|
installUrl,
|
|
857
1130
|
isAppId,
|
|
858
1131
|
isObject,
|