@gamerdexapp/gamerdex-sync 0.1.1 → 0.2.1

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/dist/index.mjs CHANGED
@@ -287,10 +287,11 @@ var decodeBase64 = (base64) => {
287
287
 
288
288
  // src/provider/detail.ts
289
289
  var MAX_DETAIL_LENGTH = 300;
290
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
290
291
  var detailOf = async (response) => {
291
292
  const body = await response.json().catch(() => null);
292
- if (typeof body !== "object" || body === null) return void 0;
293
- const message = body.message;
293
+ if (!isRecord2(body)) return void 0;
294
+ const message = isRecord2(body.error) ? body.error.message : body.message;
294
295
  return typeof message === "string" ? message.slice(0, MAX_DETAIL_LENGTH) : void 0;
295
296
  };
296
297
 
@@ -322,7 +323,7 @@ var ProviderError = class extends Error {
322
323
  var API = "https://api.github.com";
323
324
  var API_VERSION = "2022-11-28";
324
325
  var encodePath = (path) => path.split("/").map(encodeURIComponent).join("/");
325
- var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
326
+ var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
326
327
  var retryAfterOf = (response) => {
327
328
  const header = response.headers.get("retry-after");
328
329
  if (header !== null && Number.isFinite(Number(header))) return Number(header);
@@ -388,10 +389,10 @@ var GitHubProvider = class {
388
389
  const response = await this.request("GET", `${API}/repos/${this.options.repository}`);
389
390
  await this.assertOk(response);
390
391
  const body = await response.json();
391
- if (!isRecord2(body) || typeof body.private !== "boolean") {
392
+ if (!isRecord3(body) || typeof body.private !== "boolean") {
392
393
  throw this.invalid("The repository answer is not understood.");
393
394
  }
394
- const permissions = isRecord2(body.permissions) ? body.permissions : {};
395
+ const permissions = isRecord3(body.permissions) ? body.permissions : {};
395
396
  return {
396
397
  private: body.private,
397
398
  canWrite: permissions.push === true,
@@ -621,10 +622,10 @@ var GITHUB_APP = {
621
622
  };
622
623
 
623
624
  // src/auth/tokens.ts
624
- var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
625
+ var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
625
626
  var isSeconds = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
626
627
  var parseTokenResponse = (body) => {
627
- if (!isRecord3(body)) return null;
628
+ if (!isRecord4(body)) return null;
628
629
  const { accessToken, expiresIn, refreshToken, refreshExpiresIn } = body;
629
630
  if (typeof accessToken !== "string" || accessToken === "") return null;
630
631
  if (typeof refreshToken !== "string" || refreshToken === "") return null;
@@ -714,7 +715,7 @@ var readCallback = (search, expectedState) => {
714
715
  // src/provider/repositories.ts
715
716
  var API2 = "https://api.github.com";
716
717
  var PAGE_SIZE = 100;
717
- var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
718
+ var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
718
719
  var getJson = async (send, token, url) => {
719
720
  let response;
720
721
  try {
@@ -745,7 +746,7 @@ var getJson = async (send, token, url) => {
745
746
  );
746
747
  }
747
748
  const body = await response.json().catch(() => null);
748
- if (!isRecord4(body)) {
749
+ if (!isRecord5(body)) {
749
750
  throw new ProviderError("invalid", "GitHub answer is not understood.", response.status);
750
751
  }
751
752
  return body;
@@ -756,7 +757,7 @@ var listInstalledRepositories = async (token, send = (...args) => globalThis.fet
756
757
  token,
757
758
  `${API2}/user/installations?per_page=${PAGE_SIZE}`
758
759
  );
759
- const ids = (Array.isArray(installations.installations) ? installations.installations : []).filter(isRecord4).map((installation) => installation.id).filter((id) => typeof id === "number");
760
+ const ids = (Array.isArray(installations.installations) ? installations.installations : []).filter(isRecord5).map((installation) => installation.id).filter((id) => typeof id === "number");
760
761
  const repositories = [];
761
762
  for (const id of ids) {
762
763
  const page = await getJson(
@@ -765,17 +766,286 @@ var listInstalledRepositories = async (token, send = (...args) => globalThis.fet
765
766
  `${API2}/user/installations/${id}/repositories?per_page=${PAGE_SIZE}`
766
767
  );
767
768
  for (const item of Array.isArray(page.repositories) ? page.repositories : []) {
768
- if (isRecord4(item) && typeof item.full_name === "string" && typeof item.private === "boolean") {
769
+ if (isRecord5(item) && typeof item.full_name === "string" && typeof item.private === "boolean") {
769
770
  repositories.push({ fullName: item.full_name, private: item.private });
770
771
  }
771
772
  }
772
773
  }
773
774
  return repositories;
774
775
  };
776
+
777
+ // src/provider/drive.ts
778
+ var API3 = "https://www.googleapis.com/drive/v3";
779
+ var UPLOAD = "https://www.googleapis.com/upload/drive/v3";
780
+ var FOLDER_TYPE = "application/vnd.google-apps.folder";
781
+ var RATE_LIMIT_REASONS = ["rateLimitExceeded", "userRateLimitExceeded", "dailyLimitExceeded"];
782
+ var isRecord6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
783
+ var quoted = (value) => `'${value.replace(/[\\']/g, "\\$&")}'`;
784
+ var oldestFirst = (a, b) => a.createdTime.localeCompare(b.createdTime) || a.id.localeCompare(b.id);
785
+ var withOwn = (found, own4) => (found.some((item) => item.id === own4.id) ? found : [...found, own4]).sort(oldestFirst);
786
+ var reasonOf = (body) => {
787
+ if (!isRecord6(body) || !isRecord6(body.error) || !Array.isArray(body.error.errors))
788
+ return void 0;
789
+ const first = body.error.errors[0];
790
+ return isRecord6(first) && typeof first.reason === "string" ? first.reason : void 0;
791
+ };
792
+ var GoogleDriveProvider = class {
793
+ constructor(options) {
794
+ this.options = options;
795
+ this.send = options.fetch ?? ((...args) => globalThis.fetch(...args));
796
+ }
797
+ options;
798
+ send;
799
+ folders = /* @__PURE__ */ new Map();
800
+ async read(path) {
801
+ const file = await this.findFile(path);
802
+ if (!file) return null;
803
+ const head = await this.headOf(file.id);
804
+ if (head === null) return null;
805
+ return { content: await this.revisionContent(file.id, head), version: head };
806
+ }
807
+ async write(path, content, expectedVersion) {
808
+ if (expectedVersion === null) return this.create(path, content);
809
+ const file = await this.findFile(path);
810
+ if (!file || await this.headOf(file.id) !== expectedVersion) {
811
+ throw new VersionConflictError(path);
812
+ }
813
+ const version = await this.upload(file.id, content);
814
+ if (version === expectedVersion) return { version };
815
+ const previous = await this.revisionBefore(file.id, version);
816
+ if (previous === expectedVersion) return { version };
817
+ await this.upload(file.id, await this.revisionContent(file.id, previous));
818
+ throw new VersionConflictError(path);
819
+ }
820
+ async remove(path, expectedVersion) {
821
+ const file = await this.findFile(path);
822
+ if (!file) return;
823
+ if (await this.headOf(file.id) !== expectedVersion) throw new VersionConflictError(path);
824
+ await this.deleteItem(file.id);
825
+ }
826
+ async create(path, content) {
827
+ const { parentId, name } = await this.locate(path, true);
828
+ if ((await this.children(parentId, name)).length > 0) throw new VersionConflictError(path);
829
+ const created = await this.createFile(parentId, name, content);
830
+ const [oldest] = withOwn(await this.children(parentId, name), created);
831
+ if (oldest.id !== created.id) {
832
+ await this.deleteItem(created.id);
833
+ throw new VersionConflictError(path);
834
+ }
835
+ return { version: created.version };
836
+ }
837
+ async findFile(path) {
838
+ const located = await this.locate(path, false);
839
+ if (!located) return null;
840
+ return (await this.children(located.parentId, located.name))[0] ?? null;
841
+ }
842
+ async locate(path, create) {
843
+ const parts = path.split("/");
844
+ const name = parts.pop();
845
+ let parentId = "root";
846
+ let walked = "";
847
+ for (const folder of parts) {
848
+ walked += `/${folder}`;
849
+ const known = this.folders.get(walked);
850
+ if (known) {
851
+ parentId = known;
852
+ continue;
853
+ }
854
+ const id = await this.folderId(parentId, folder, create);
855
+ if (id === null) return null;
856
+ this.folders.set(walked, id);
857
+ parentId = id;
858
+ }
859
+ return { parentId, name };
860
+ }
861
+ async folderId(parentId, name, create) {
862
+ const found = await this.children(parentId, name, true);
863
+ if (found.length > 0) return found[0].id;
864
+ if (!create) return null;
865
+ const response = await this.request("POST", `${API3}/files?fields=id,createdTime`, {
866
+ body: JSON.stringify({ name, mimeType: FOLDER_TYPE, parents: [parentId] }),
867
+ headers: { "Content-Type": "application/json" }
868
+ });
869
+ const created = this.itemOf(await response.json());
870
+ const [oldest] = withOwn(await this.children(parentId, name, true), created);
871
+ if (oldest.id !== created.id) {
872
+ await this.deleteItem(created.id);
873
+ return oldest.id;
874
+ }
875
+ return created.id;
876
+ }
877
+ async children(parentId, name, folder = false) {
878
+ const query = [
879
+ `name = ${quoted(name)}`,
880
+ `${quoted(parentId)} in parents`,
881
+ "trashed = false",
882
+ `mimeType ${folder ? "=" : "!="} '${FOLDER_TYPE}'`
883
+ ].join(" and ");
884
+ const url = `${API3}/files?${new URLSearchParams({
885
+ q: query,
886
+ fields: "files(id,createdTime)",
887
+ pageSize: "100"
888
+ })}`;
889
+ const body = await (await this.request("GET", url)).json();
890
+ const files = isRecord6(body) && Array.isArray(body.files) ? body.files : [];
891
+ return files.filter(
892
+ (item) => isRecord6(item) && typeof item.id === "string" && typeof item.createdTime === "string"
893
+ ).sort(oldestFirst);
894
+ }
895
+ async headOf(id) {
896
+ const response = await this.request(
897
+ "GET",
898
+ `${API3}/files/${encodeURIComponent(id)}?fields=headRevisionId,trashed`,
899
+ { allowMissing: true }
900
+ );
901
+ if (response.status === 404) return null;
902
+ const body = await response.json();
903
+ if (isRecord6(body) && body.trashed === true) return null;
904
+ return this.stringField(body, "headRevisionId");
905
+ }
906
+ async revisionContent(id, revision) {
907
+ const url = `${API3}/files/${encodeURIComponent(id)}/revisions/${encodeURIComponent(revision)}?alt=media`;
908
+ return (await this.request("GET", url)).text();
909
+ }
910
+ async revisionBefore(id, revision) {
911
+ const url = `${API3}/files/${encodeURIComponent(id)}/revisions?${new URLSearchParams({
912
+ fields: "revisions(id)",
913
+ pageSize: "1000"
914
+ })}`;
915
+ const body = await (await this.request("GET", url)).json();
916
+ const revisions = (isRecord6(body) && Array.isArray(body.revisions) ? body.revisions : []).filter(isRecord6).map((item) => item.id);
917
+ const index = revisions.indexOf(revision);
918
+ const previous = revisions[index - 1];
919
+ if (index < 1 || typeof previous !== "string") {
920
+ throw new ProviderError("server", "The new revision is not in the revision list.");
921
+ }
922
+ return previous;
923
+ }
924
+ async upload(id, content) {
925
+ const url = `${UPLOAD}/files/${encodeURIComponent(id)}?uploadType=media&fields=headRevisionId`;
926
+ const response = await this.request("PATCH", url, {
927
+ body: content,
928
+ headers: { "Content-Type": "application/json" }
929
+ });
930
+ return this.stringField(await response.json(), "headRevisionId");
931
+ }
932
+ async createFile(parentId, name, content) {
933
+ const boundary = `gamerdex-${crypto.randomUUID()}`;
934
+ const metadata = JSON.stringify({ name, parents: [parentId], mimeType: "application/json" });
935
+ const body = `--${boundary}\r
936
+ Content-Type: application/json; charset=UTF-8\r
937
+ \r
938
+ ${metadata}\r
939
+ --${boundary}\r
940
+ Content-Type: application/json\r
941
+ \r
942
+ ${content}\r
943
+ --${boundary}--`;
944
+ const response = await this.request(
945
+ "POST",
946
+ `${UPLOAD}/files?uploadType=multipart&fields=id,headRevisionId,createdTime`,
947
+ { body, headers: { "Content-Type": `multipart/related; boundary=${boundary}` } }
948
+ );
949
+ const answer = await response.json();
950
+ return { ...this.itemOf(answer), version: this.stringField(answer, "headRevisionId") };
951
+ }
952
+ async deleteItem(id) {
953
+ await this.request("DELETE", `${API3}/files/${encodeURIComponent(id)}`, { allowMissing: true });
954
+ }
955
+ itemOf(body) {
956
+ return { id: this.stringField(body, "id"), createdTime: this.stringField(body, "createdTime") };
957
+ }
958
+ stringField(body, field) {
959
+ const value = isRecord6(body) ? body[field] : void 0;
960
+ if (typeof value !== "string") {
961
+ throw new ProviderError("invalid", `Google Drive answered without ${field}.`);
962
+ }
963
+ return value;
964
+ }
965
+ async request(method, url, options = {}) {
966
+ const token = await this.options.getToken();
967
+ let response;
968
+ try {
969
+ response = await this.send(url, {
970
+ method,
971
+ headers: { Authorization: `Bearer ${token}`, ...options.headers ?? {} },
972
+ body: options.body
973
+ });
974
+ } catch {
975
+ throw new ProviderError("network", "Google Drive could not be reached.");
976
+ }
977
+ if (response.ok || options.allowMissing && response.status === 404) return response;
978
+ throw await this.failure(response);
979
+ }
980
+ async failure(response) {
981
+ const { status } = response;
982
+ const body = await response.clone().json().catch(() => null);
983
+ const detail = await detailOf(response);
984
+ if (status === 401) {
985
+ return new ProviderError("auth", "The token is not valid.", status, void 0, detail);
986
+ }
987
+ const reason = reasonOf(body);
988
+ if (status === 429 || status === 403 && reason && RATE_LIMIT_REASONS.includes(reason)) {
989
+ const retryAfter = Number(response.headers.get("retry-after"));
990
+ return new ProviderError(
991
+ "rate-limit",
992
+ "Google Drive limits the calls.",
993
+ status,
994
+ Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : void 0,
995
+ detail
996
+ );
997
+ }
998
+ if (status === 403) {
999
+ return new ProviderError(
1000
+ "auth",
1001
+ "The token has no permission for Drive.",
1002
+ status,
1003
+ void 0,
1004
+ detail
1005
+ );
1006
+ }
1007
+ if (status === 404) {
1008
+ return new ProviderError(
1009
+ "not-found",
1010
+ "The file or folder was not found.",
1011
+ status,
1012
+ void 0,
1013
+ detail
1014
+ );
1015
+ }
1016
+ if (status >= 500) {
1017
+ return new ProviderError("server", "Google Drive failed.", status, void 0, detail);
1018
+ }
1019
+ return new ProviderError(
1020
+ "invalid",
1021
+ `Google Drive answered ${status}.`,
1022
+ status,
1023
+ void 0,
1024
+ detail
1025
+ );
1026
+ }
1027
+ };
1028
+
1029
+ // src/auth/google.ts
1030
+ var GOOGLE_DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
1031
+ var googleAuthorizeUrl = (clientId, redirectUri, state) => {
1032
+ const query = new URLSearchParams({
1033
+ client_id: clientId,
1034
+ redirect_uri: redirectUri,
1035
+ response_type: "code",
1036
+ scope: GOOGLE_DRIVE_SCOPE,
1037
+ access_type: "offline",
1038
+ prompt: "consent",
1039
+ state
1040
+ });
1041
+ return `https://accounts.google.com/o/oauth2/v2/auth?${query}`;
1042
+ };
775
1043
  export {
776
1044
  DATA_FILE,
777
1045
  GITHUB_APP,
1046
+ GOOGLE_DRIVE_SCOPE,
778
1047
  GitHubProvider,
1048
+ GoogleDriveProvider,
779
1049
  META_FILE,
780
1050
  META_FORMAT,
781
1051
  MemoryProvider,
@@ -791,6 +1061,7 @@ export {
791
1061
  emptyMeta,
792
1062
  encodeBase64,
793
1063
  equal,
1064
+ googleAuthorizeUrl,
794
1065
  installUrl,
795
1066
  isAppId,
796
1067
  isObject,