@interware/content-generator 0.7.1 → 0.8.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/dist/_chunks/{App-h4_txTsI.mjs → App-DD1MzaWm.mjs} +2 -2
- package/dist/_chunks/{App-Cwr5ra5m.js → App-DspaMfyW.js} +2 -2
- package/dist/_chunks/{ImageStudioTabs-B-mOwDfY.mjs → ImageStudioTabs-DwaLxRjl.mjs} +367 -61
- package/dist/_chunks/{ImageStudioTabs-CG4KTn1S.js → ImageStudioTabs-uXSBkFeu.js} +365 -59
- package/dist/_chunks/{Settings-BT-EqvXs.mjs → Settings-C6TbUrgU.mjs} +170 -2
- package/dist/_chunks/{Settings-D4NGBTk1.js → Settings-ps0o7-fC.js} +169 -1
- package/dist/_chunks/{StudioApp-xaEy-uIk.mjs → StudioApp-B5I1OfGw.mjs} +2 -2
- package/dist/_chunks/{StudioApp-_ViSAwCm.js → StudioApp-B7kGpqqo.js} +2 -2
- package/dist/_chunks/{index-NA9OuMGs.js → index-BudahdfX.js} +3 -3
- package/dist/_chunks/{index-D67xWUPz.mjs → index-zenHkX-p.mjs} +3 -3
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/admin/src/components/settings/DriveSettings.d.ts +77 -0
- package/dist/admin/src/features/drive/DriveTab.d.ts +42 -0
- package/dist/admin/src/features/image-studio/ImageStudioTabs.d.ts +3 -0
- package/dist/admin/src/features/library/FileCard.d.ts +5 -2
- package/dist/admin/src/features/library/driveSync.d.ts +41 -0
- package/dist/admin/src/features/templates/editor/ColorField.d.ts +4 -2
- package/dist/admin/src/features/templates/export/templateOrigin.d.ts +10 -0
- package/dist/admin/src/features/templates/model/palette.d.ts +2 -2
- package/dist/admin/src/lib/imageLimits.d.ts +9 -0
- package/dist/admin/src/lib/use-cases/drive/index.d.ts +3 -0
- package/dist/server/index.js +1414 -51
- package/dist/server/index.mjs +1412 -49
- package/dist/server/src/bootstrap.d.ts +2 -0
- package/dist/server/src/content-types/drive-link/index.d.ts +62 -0
- package/dist/server/src/content-types/drive-link/schema.d.ts +68 -0
- package/dist/server/src/content-types/drive-op/index.d.ts +46 -0
- package/dist/server/src/content-types/drive-op/schema.d.ts +49 -0
- package/dist/server/src/content-types/index.d.ts +106 -0
- package/dist/server/src/controllers/drive.d.ts +30 -0
- package/dist/server/src/controllers/index.d.ts +130 -0
- package/dist/server/src/drive/client.d.ts +32 -0
- package/dist/server/src/drive/clientConfig.d.ts +27 -0
- package/dist/server/src/drive/common.d.ts +6 -0
- package/dist/server/src/drive/fakes.d.ts +104 -0
- package/dist/server/src/drive/files.d.ts +136 -0
- package/dist/server/src/drive/lifecycles.d.ts +7 -0
- package/dist/server/src/drive/links.d.ts +23 -0
- package/dist/server/src/drive/model.d.ts +99 -0
- package/dist/server/src/drive/oauth.d.ts +47 -0
- package/dist/server/src/drive/pull.d.ts +23 -0
- package/dist/server/src/drive/strapiPorts.d.ts +10 -0
- package/dist/server/src/drive/sync.d.ts +40 -0
- package/dist/server/src/index.d.ts +294 -0
- package/dist/server/src/services/drive.d.ts +141 -0
- package/dist/server/src/services/index.d.ts +55 -0
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const fs = require("fs");
|
|
3
3
|
const genai = require("@google/genai");
|
|
4
|
+
const node_crypto = require("node:crypto");
|
|
5
|
+
const promises = require("node:fs/promises");
|
|
6
|
+
const node_os = require("node:os");
|
|
7
|
+
const node_path = require("node:path");
|
|
8
|
+
const node_async_hooks = require("node:async_hooks");
|
|
4
9
|
function _interopNamespace(e) {
|
|
5
10
|
if (e && e.__esModule) return e;
|
|
6
11
|
const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
|
|
@@ -20,10 +25,10 @@ function _interopNamespace(e) {
|
|
|
20
25
|
}
|
|
21
26
|
const fs__namespace = /* @__PURE__ */ _interopNamespace(fs);
|
|
22
27
|
const PLUGIN_ID = "strapi-content-generator-gemini";
|
|
23
|
-
const FOLDER_UID = "plugin::upload.folder";
|
|
28
|
+
const FOLDER_UID$1 = "plugin::upload.folder";
|
|
24
29
|
const hasValidPath = (path) => /^(\/\d+)+$/.test(path);
|
|
25
30
|
const repairFolderPaths = async (strapi, name2) => {
|
|
26
|
-
const query = strapi.db.query(FOLDER_UID);
|
|
31
|
+
const query = strapi.db.query(FOLDER_UID$1);
|
|
27
32
|
const folders = await query.findMany({ where: { name: name2 }, populate: ["parent"] });
|
|
28
33
|
const repaired = [];
|
|
29
34
|
for (const f of folders) {
|
|
@@ -35,13 +40,34 @@ const repairFolderPaths = async (strapi, name2) => {
|
|
|
35
40
|
return repaired;
|
|
36
41
|
};
|
|
37
42
|
const ensureFolder = async (strapi, name2) => {
|
|
38
|
-
const folders = await strapi.db.query(FOLDER_UID).findMany({ where: { name: name2 }, populate: ["parent"] });
|
|
43
|
+
const folders = await strapi.db.query(FOLDER_UID$1).findMany({ where: { name: name2 }, populate: ["parent"] });
|
|
39
44
|
const atRoot = folders.find((f) => !f.parent);
|
|
40
45
|
if (atRoot) return atRoot.id;
|
|
41
46
|
const created = await strapi.plugin("upload").service("folder").create({ name: name2, parent: null });
|
|
42
47
|
return created.id;
|
|
43
48
|
};
|
|
49
|
+
const MODELS = { "plugin::upload.file": "file", "plugin::upload.folder": "folder" };
|
|
50
|
+
const subscribeUploadLifecycles = (strapi, onChange) => {
|
|
51
|
+
const notify = (event, id) => {
|
|
52
|
+
const cmsType = MODELS[event.model.uid];
|
|
53
|
+
if (!cmsType || typeof id !== "number") return;
|
|
54
|
+
onChange(cmsType, id).catch((err) => strapi.log.warn(`[drive] no se pudo encolar ${cmsType} ${id}: ${err.message}`));
|
|
55
|
+
};
|
|
56
|
+
strapi.db.lifecycles.subscribe({
|
|
57
|
+
models: Object.keys(MODELS),
|
|
58
|
+
afterCreate: (event) => notify(event, event.result?.id),
|
|
59
|
+
afterUpdate: (event) => notify(event, event.result?.id),
|
|
60
|
+
afterDelete: (event) => notify(event, event.result?.id),
|
|
61
|
+
// `deleteMany` solo trae el `where`: los ids se leen antes de borrar.
|
|
62
|
+
async beforeDeleteMany(event) {
|
|
63
|
+
const rows = await strapi.db.query(event.model.uid).findMany({ where: event.params?.where, select: ["id"] });
|
|
64
|
+
if (event.state) event.state.driveIds = rows.map((r) => r.id);
|
|
65
|
+
},
|
|
66
|
+
afterDeleteMany: (event) => (event.state?.driveIds ?? []).forEach((id) => notify(event, id))
|
|
67
|
+
});
|
|
68
|
+
};
|
|
44
69
|
const SCHEDULE_CRON = "content-generator-publicar-programados";
|
|
70
|
+
const DRIVE_CRON = "content-generator-drive";
|
|
45
71
|
const bootstrap = async ({ strapi }) => {
|
|
46
72
|
strapi.cron.add({
|
|
47
73
|
[SCHEDULE_CRON]: {
|
|
@@ -55,6 +81,19 @@ const bootstrap = async ({ strapi }) => {
|
|
|
55
81
|
options: { rule: "* * * * *" }
|
|
56
82
|
}
|
|
57
83
|
});
|
|
84
|
+
strapi.cron.add({
|
|
85
|
+
[DRIVE_CRON]: {
|
|
86
|
+
task: async () => {
|
|
87
|
+
try {
|
|
88
|
+
await strapi.plugin(PLUGIN_ID).service("drive").runCycle();
|
|
89
|
+
} catch (err) {
|
|
90
|
+
strapi.log.error(`[content-generator] sincronizar con Drive: ${err.message}`);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
options: { rule: "*/15 * * * *" }
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
subscribeUploadLifecycles(strapi, (cmsType, cmsId) => strapi.plugin(PLUGIN_ID).service("drive").track(cmsType, cmsId));
|
|
58
97
|
strapi.admin.services.permission.actionProvider.registerMany([
|
|
59
98
|
{
|
|
60
99
|
section: "plugins",
|
|
@@ -80,7 +119,7 @@ const config$2 = {
|
|
|
80
119
|
validator() {
|
|
81
120
|
}
|
|
82
121
|
};
|
|
83
|
-
const schema = {
|
|
122
|
+
const schema$2 = {
|
|
84
123
|
kind: "collectionType",
|
|
85
124
|
collectionName: "iw_image_templates",
|
|
86
125
|
info: {
|
|
@@ -109,9 +148,67 @@ const schema = {
|
|
|
109
148
|
replacesBase: { type: "string" }
|
|
110
149
|
}
|
|
111
150
|
};
|
|
112
|
-
const template = { schema };
|
|
151
|
+
const template = { schema: schema$2 };
|
|
152
|
+
const schema$1 = {
|
|
153
|
+
kind: "collectionType",
|
|
154
|
+
collectionName: "iw_drive_links",
|
|
155
|
+
info: {
|
|
156
|
+
singularName: "drive-link",
|
|
157
|
+
pluralName: "drive-links",
|
|
158
|
+
displayName: "Vínculo con Drive",
|
|
159
|
+
description: "Sincronización con Google Drive (content-generator)"
|
|
160
|
+
},
|
|
161
|
+
options: { draftAndPublish: false },
|
|
162
|
+
pluginOptions: {
|
|
163
|
+
"content-manager": { visible: false },
|
|
164
|
+
"content-type-builder": { visible: false }
|
|
165
|
+
},
|
|
166
|
+
attributes: {
|
|
167
|
+
cmsType: { type: "enumeration", enum: ["file", "folder"], required: true },
|
|
168
|
+
cmsId: { type: "integer", required: true },
|
|
169
|
+
/** Vacío mientras el archivo espera su primera subida. */
|
|
170
|
+
driveId: { type: "string" },
|
|
171
|
+
/** Lo marcó el usuario; los demás vínculos son contenido o ancestros de algo marcado. */
|
|
172
|
+
root: { type: "boolean", default: false },
|
|
173
|
+
name: { type: "string" },
|
|
174
|
+
parentCmsId: { type: "integer" },
|
|
175
|
+
/** Archivo: `url|size` de la última subida. */
|
|
176
|
+
version: { type: "string" },
|
|
177
|
+
/** `md5Checksum` de Drive de la última versión sincronizada (S7.4). */
|
|
178
|
+
driveRev: { type: "string" },
|
|
179
|
+
status: { type: "enumeration", enum: ["synced", "pending", "error", "skipped"], default: "pending" },
|
|
180
|
+
error: { type: "text" }
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const driveLink = { schema: schema$1 };
|
|
184
|
+
const schema = {
|
|
185
|
+
kind: "collectionType",
|
|
186
|
+
collectionName: "iw_drive_ops",
|
|
187
|
+
info: {
|
|
188
|
+
singularName: "drive-op",
|
|
189
|
+
pluralName: "drive-ops",
|
|
190
|
+
displayName: "Operación de Drive",
|
|
191
|
+
description: "Cola de la sincronización con Google Drive (content-generator)"
|
|
192
|
+
},
|
|
193
|
+
options: { draftAndPublish: false },
|
|
194
|
+
pluginOptions: {
|
|
195
|
+
"content-manager": { visible: false },
|
|
196
|
+
"content-type-builder": { visible: false }
|
|
197
|
+
},
|
|
198
|
+
attributes: {
|
|
199
|
+
cmsType: { type: "enumeration", enum: ["file", "folder"], required: true },
|
|
200
|
+
cmsId: { type: "integer", required: true },
|
|
201
|
+
attempts: { type: "integer", default: 0 },
|
|
202
|
+
/** Epoch en ms: no se procesa antes (espera creciente tras un error). */
|
|
203
|
+
runAfter: { type: "biginteger", default: "0" },
|
|
204
|
+
error: { type: "text" }
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const driveOp = { schema };
|
|
113
208
|
const contentTypes = {
|
|
114
|
-
template
|
|
209
|
+
template,
|
|
210
|
+
"drive-link": driveLink,
|
|
211
|
+
"drive-op": driveOp
|
|
115
212
|
};
|
|
116
213
|
const POST_MAX = 3e3;
|
|
117
214
|
const hashtags = (keywords) => keywords.map((k) => k.trim().replace(/\s+/g, "").replace(/[^\p{L}\p{N}_]/gu, "")).filter(Boolean).map((k) => `#${k}`).join(" ");
|
|
@@ -428,10 +525,11 @@ const imageStudio$1 = ({ strapi }) => createImageStudio(async () => {
|
|
|
428
525
|
const { ai, backend } = await strapi.plugin("strapi-content-generator-gemini").service("service").geminiClient();
|
|
429
526
|
return { models: ai.models, backend };
|
|
430
527
|
});
|
|
528
|
+
const IMAGE_MAX_MB = 30;
|
|
529
|
+
const IMAGE_MAX_BYTES = IMAGE_MAX_MB * 1024 * 1024;
|
|
431
530
|
class InputError extends Error {
|
|
432
531
|
}
|
|
433
532
|
const ALLOWED = ["image/png", "image/jpeg", "image/webp"];
|
|
434
|
-
const MAX_BYTES = 10 * 1024 * 1024;
|
|
435
533
|
const str = (v) => typeof v === "string" ? v : "";
|
|
436
534
|
const bool = (v) => v === true || v === "true";
|
|
437
535
|
const list = (v) => v ? Array.isArray(v) ? v : [v] : [];
|
|
@@ -442,7 +540,7 @@ const clean = (text2, labels) => {
|
|
|
442
540
|
};
|
|
443
541
|
const checkImage = (f) => {
|
|
444
542
|
if (!ALLOWED.includes(f.mimetype ?? "")) throw new InputError("Las imágenes deben ser PNG, JPG o WEBP.");
|
|
445
|
-
if (f.size >
|
|
543
|
+
if (f.size > IMAGE_MAX_BYTES) throw new InputError(`Cada imagen puede pesar hasta ${IMAGE_MAX_MB} MB.`);
|
|
446
544
|
return f;
|
|
447
545
|
};
|
|
448
546
|
const parseGenerateInput = (fields, files) => {
|
|
@@ -485,7 +583,7 @@ const parseEditInput = (fields, files) => {
|
|
|
485
583
|
aspectRatio: model.aspectRatios.includes(str(fields.aspectRatio)) ? str(fields.aspectRatio) : void 0
|
|
486
584
|
};
|
|
487
585
|
};
|
|
488
|
-
const fail$
|
|
586
|
+
const fail$4 = (ctx, status, name2, message) => {
|
|
489
587
|
ctx.status = status;
|
|
490
588
|
ctx.body = { data: null, error: { status, name: name2, message } };
|
|
491
589
|
};
|
|
@@ -495,13 +593,13 @@ const imageStudio = ({ strapi }) => {
|
|
|
495
593
|
try {
|
|
496
594
|
ctx.body = await work();
|
|
497
595
|
} catch (err) {
|
|
498
|
-
if (err instanceof InputError) return fail$
|
|
596
|
+
if (err instanceof InputError) return fail$4(ctx, 400, "BadRequestError", err.message);
|
|
499
597
|
if (err instanceof BlockedError) {
|
|
500
598
|
strapi.log.warn(`[image-studio] bloqueo: ${err.detail ?? err.message}`);
|
|
501
|
-
return fail$
|
|
599
|
+
return fail$4(ctx, 422, "UnprocessableEntityError", err.message);
|
|
502
600
|
}
|
|
503
601
|
strapi.log.error(`[image-studio] ${err instanceof Error ? err.message : String(err)}`);
|
|
504
|
-
return fail$
|
|
602
|
+
return fail$4(ctx, 502, "BadGatewayError", "El servicio de imágenes no respondió. Intenta de nuevo en un momento.");
|
|
505
603
|
}
|
|
506
604
|
};
|
|
507
605
|
return {
|
|
@@ -710,11 +808,11 @@ const ARTICLE_SCHEMA = {
|
|
|
710
808
|
const ZONA = "America/Mexico_City";
|
|
711
809
|
const UN_ANIO_MS = 365 * 24 * 60 * 60 * 1e3;
|
|
712
810
|
const fechaEnMexico = (date) => new Intl.DateTimeFormat("en-CA", { timeZone: ZONA, year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
|
|
713
|
-
const parseScheduleAt = (input,
|
|
811
|
+
const parseScheduleAt = (input, now2) => {
|
|
714
812
|
const date = typeof input === "string" ? new Date(input) : /* @__PURE__ */ new Date(NaN);
|
|
715
813
|
if (Number.isNaN(date.getTime())) throw new InputError("La fecha y hora no son válidas.");
|
|
716
|
-
if (date.getTime() <=
|
|
717
|
-
if (date.getTime() -
|
|
814
|
+
if (date.getTime() <= now2.getTime()) throw new InputError("Elige una fecha y hora futuras.");
|
|
815
|
+
if (date.getTime() - now2.getTime() > UN_ANIO_MS) throw new InputError("Programa con menos de un año de anticipación.");
|
|
718
816
|
return date;
|
|
719
817
|
};
|
|
720
818
|
const estadoDe = ({ publicado, programada }) => publicado ? "publicado" : programada ? "programado" : "borrador";
|
|
@@ -760,7 +858,7 @@ const toDetail = (d, publicado) => ({
|
|
|
760
858
|
keywords: (d.keywords ?? []).map((k) => k.name ?? "").filter(Boolean),
|
|
761
859
|
fuentes: Array.isArray(d.fuentes) ? d.fuentes : []
|
|
762
860
|
});
|
|
763
|
-
const createArticles = ({ articulos, categorias, getModels, now = () => /* @__PURE__ */ new Date(), log = silentLog, promptStore = memoryStore() }) => {
|
|
861
|
+
const createArticles = ({ articulos, categorias, getModels, now: now2 = () => /* @__PURE__ */ new Date(), log = silentLog, promptStore = memoryStore() }) => {
|
|
764
862
|
const draftOf = async (documentId, populate = DETAIL_POPULATE) => {
|
|
765
863
|
const d = await articulos.findOne({ documentId, status: "draft", populate });
|
|
766
864
|
if (!d) throw new NotFoundError("El artículo no existe.");
|
|
@@ -864,12 +962,12 @@ const createArticles = ({ articulos, categorias, getModels, now = () => /* @__PU
|
|
|
864
962
|
async publish(documentId) {
|
|
865
963
|
const d = await draftOf(documentId);
|
|
866
964
|
if (!d.image) throw new InputError("Elige una imagen antes de publicar.");
|
|
867
|
-
await articulos.update({ documentId, data: { fecha_publicacion: fechaEnMexico(
|
|
965
|
+
await articulos.update({ documentId, data: { fecha_publicacion: fechaEnMexico(now2()), publicacion_programada: null } });
|
|
868
966
|
await articulos.publish({ documentId });
|
|
869
967
|
return detail(documentId);
|
|
870
968
|
},
|
|
871
969
|
async schedule(documentId, at) {
|
|
872
|
-
const date = parseScheduleAt(at,
|
|
970
|
+
const date = parseScheduleAt(at, now2());
|
|
873
971
|
const d = await draftOf(documentId);
|
|
874
972
|
if (!d.image) throw new InputError("Elige una imagen antes de programar.");
|
|
875
973
|
if (await isPublished(documentId)) throw new InputError("El artículo ya está publicado.");
|
|
@@ -885,7 +983,7 @@ const createArticles = ({ articulos, categorias, getModels, now = () => /* @__PU
|
|
|
885
983
|
async publishDue() {
|
|
886
984
|
const due = await articulos.findMany({
|
|
887
985
|
status: "draft",
|
|
888
|
-
filters: { publicacion_programada: { $notNull: true, $lte:
|
|
986
|
+
filters: { publicacion_programada: { $notNull: true, $lte: now2().toISOString() } },
|
|
889
987
|
fields: ["publicacion_programada"],
|
|
890
988
|
populate: { image: { fields: ["id"] } }
|
|
891
989
|
});
|
|
@@ -927,7 +1025,7 @@ const articles$1 = ({ strapi }) => createArticles({
|
|
|
927
1025
|
};
|
|
928
1026
|
})()
|
|
929
1027
|
});
|
|
930
|
-
const fail$
|
|
1028
|
+
const fail$3 = (ctx, status, name2, message) => {
|
|
931
1029
|
ctx.status = status;
|
|
932
1030
|
ctx.body = { data: null, error: { status, name: name2, message } };
|
|
933
1031
|
};
|
|
@@ -938,11 +1036,11 @@ const articles = ({ strapi }) => {
|
|
|
938
1036
|
try {
|
|
939
1037
|
ctx.body = await work();
|
|
940
1038
|
} catch (err) {
|
|
941
|
-
if (err instanceof InputError) return fail$
|
|
942
|
-
if (err instanceof NotFoundError) return fail$
|
|
943
|
-
if (err instanceof Error && err.name === "ValidationError") return fail$
|
|
1039
|
+
if (err instanceof InputError) return fail$3(ctx, 400, "BadRequestError", err.message);
|
|
1040
|
+
if (err instanceof NotFoundError) return fail$3(ctx, 404, "NotFoundError", err.message);
|
|
1041
|
+
if (err instanceof Error && err.name === "ValidationError") return fail$3(ctx, 400, "ValidationError", err.message);
|
|
944
1042
|
strapi.log.error(`[articles] ${err instanceof Error ? err.message : String(err)}`);
|
|
945
|
-
return fail$
|
|
1043
|
+
return fail$3(ctx, 502, "BadGatewayError", "El servicio de generación no respondió. Intenta de nuevo en un momento.");
|
|
946
1044
|
}
|
|
947
1045
|
};
|
|
948
1046
|
return {
|
|
@@ -1108,7 +1206,7 @@ class TemplateInputError extends Error {
|
|
|
1108
1206
|
}
|
|
1109
1207
|
const LAYER_TYPES = ["text", "list", "quote", "rect", "shape", "badge", "logo", "image", "icon", "avatar", "qr"];
|
|
1110
1208
|
const MAX_LAYERS = 200;
|
|
1111
|
-
const fail$
|
|
1209
|
+
const fail$2 = (message) => {
|
|
1112
1210
|
throw new TemplateInputError(message);
|
|
1113
1211
|
};
|
|
1114
1212
|
const hasDataUrl = (value) => {
|
|
@@ -1119,26 +1217,26 @@ const hasDataUrl = (value) => {
|
|
|
1119
1217
|
};
|
|
1120
1218
|
const name = (v) => {
|
|
1121
1219
|
const n = typeof v === "string" ? v.trim() : "";
|
|
1122
|
-
if (!n || n.length > 120) fail$
|
|
1220
|
+
if (!n || n.length > 120) fail$2("El nombre debe tener entre 1 y 120 caracteres.");
|
|
1123
1221
|
return n;
|
|
1124
1222
|
};
|
|
1125
1223
|
const size = (v) => {
|
|
1126
|
-
if (!Number.isInteger(v) || v < 100 || v > 4e3) fail$
|
|
1224
|
+
if (!Number.isInteger(v) || v < 100 || v > 4e3) fail$2("Las medidas deben ser enteros de 100 a 4000 px.");
|
|
1127
1225
|
return v;
|
|
1128
1226
|
};
|
|
1129
1227
|
const nonNegative = (v, label) => {
|
|
1130
|
-
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) fail$
|
|
1228
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) fail$2(`${label} debe ser un número positivo.`);
|
|
1131
1229
|
return v;
|
|
1132
1230
|
};
|
|
1133
1231
|
const layers = (v) => {
|
|
1134
|
-
if (!Array.isArray(v) || v.length > MAX_LAYERS) fail$
|
|
1232
|
+
if (!Array.isArray(v) || v.length > MAX_LAYERS) fail$2(`Las capas deben ser una lista de hasta ${MAX_LAYERS}.`);
|
|
1135
1233
|
v.forEach((l, i) => {
|
|
1136
1234
|
const layer = l ?? {};
|
|
1137
|
-
if (typeof layer.id !== "string" || !layer.id) fail$
|
|
1138
|
-
if (!LAYER_TYPES.includes(layer.type)) fail$
|
|
1139
|
-
if (typeof layer.x !== "number" || typeof layer.y !== "number") fail$
|
|
1235
|
+
if (typeof layer.id !== "string" || !layer.id) fail$2(`La capa ${i + 1} no tiene id.`);
|
|
1236
|
+
if (!LAYER_TYPES.includes(layer.type)) fail$2(`La capa ${i + 1} tiene un tipo desconocido.`);
|
|
1237
|
+
if (typeof layer.x !== "number" || typeof layer.y !== "number") fail$2(`La capa ${i + 1} no tiene posición.`);
|
|
1140
1238
|
});
|
|
1141
|
-
if (hasDataUrl(v)) fail$
|
|
1239
|
+
if (hasDataUrl(v)) fail$2("Las imágenes deben guardarse en la Media Library, no dentro del template.");
|
|
1142
1240
|
return v;
|
|
1143
1241
|
};
|
|
1144
1242
|
const text = (v, fallback) => typeof v === "string" ? v : fallback;
|
|
@@ -1168,7 +1266,7 @@ const validateTemplatePatch = (input) => {
|
|
|
1168
1266
|
if ("safeMargin" in t) out.safeMargin = nonNegative(t.safeMargin, "El margen seguro");
|
|
1169
1267
|
if ("gridUnit" in t) out.gridUnit = nonNegative(t.gridUnit, "La cuadrícula");
|
|
1170
1268
|
if ("layers" in t) out.layers = layers(t.layers);
|
|
1171
|
-
if (!Object.keys(out).length) fail$
|
|
1269
|
+
if (!Object.keys(out).length) fail$2("Nada que actualizar.");
|
|
1172
1270
|
return out;
|
|
1173
1271
|
};
|
|
1174
1272
|
const TEMPLATE_UID = "plugin::strapi-content-generator-gemini.template";
|
|
@@ -1284,7 +1382,7 @@ const templates$1 = ({ strapi }) => {
|
|
|
1284
1382
|
{ get: (p) => store().get(p), set: (p) => store().set(p) }
|
|
1285
1383
|
);
|
|
1286
1384
|
};
|
|
1287
|
-
const fail = (ctx, status, name2, message) => {
|
|
1385
|
+
const fail$1 = (ctx, status, name2, message) => {
|
|
1288
1386
|
ctx.status = status;
|
|
1289
1387
|
ctx.body = { data: null, error: { status, name: name2, message } };
|
|
1290
1388
|
};
|
|
@@ -1295,10 +1393,10 @@ const templates = ({ strapi }) => {
|
|
|
1295
1393
|
ctx.body = await work();
|
|
1296
1394
|
ctx.status = status;
|
|
1297
1395
|
} catch (err) {
|
|
1298
|
-
if (err instanceof TemplateInputError) return fail(ctx, 400, "BadRequestError", err.message);
|
|
1299
|
-
if (err instanceof TemplateNotFoundError) return fail(ctx, 404, "NotFoundError", err.message);
|
|
1396
|
+
if (err instanceof TemplateInputError) return fail$1(ctx, 400, "BadRequestError", err.message);
|
|
1397
|
+
if (err instanceof TemplateNotFoundError) return fail$1(ctx, 404, "NotFoundError", err.message);
|
|
1300
1398
|
strapi.log.error(`[templates] ${err instanceof Error ? err.message : String(err)}`);
|
|
1301
|
-
return fail(ctx, 500, "InternalServerError", "No se pudo completar la operación con los templates.");
|
|
1399
|
+
return fail$1(ctx, 500, "InternalServerError", "No se pudo completar la operación con los templates.");
|
|
1302
1400
|
}
|
|
1303
1401
|
};
|
|
1304
1402
|
const body = (ctx) => ctx.request.body ?? {};
|
|
@@ -1322,11 +1420,1261 @@ const templates = ({ strapi }) => {
|
|
|
1322
1420
|
deleteFolder: (ctx) => run(ctx, async () => ({ folders: await svc().deleteFolder(body(ctx).path) }))
|
|
1323
1421
|
};
|
|
1324
1422
|
};
|
|
1423
|
+
const DRIVE_SCOPE = "https://www.googleapis.com/auth/drive";
|
|
1424
|
+
const OAUTH_STATE_KEY = "DRIVE_OAUTH_STATE";
|
|
1425
|
+
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
1426
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
1427
|
+
const REVOKE_URL = "https://oauth2.googleapis.com/revoke";
|
|
1428
|
+
const STATE_TTL_MS = 10 * 6e4;
|
|
1429
|
+
class DriveAuthError extends Error {
|
|
1430
|
+
constructor(message) {
|
|
1431
|
+
super(message);
|
|
1432
|
+
this.name = "DriveAuthError";
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
const authUrl = (client, redirectUri, state) => {
|
|
1436
|
+
const params = new URLSearchParams({
|
|
1437
|
+
client_id: client.clientId,
|
|
1438
|
+
redirect_uri: redirectUri,
|
|
1439
|
+
response_type: "code",
|
|
1440
|
+
scope: DRIVE_SCOPE,
|
|
1441
|
+
// Sin `offline` no hay refresh token; sin `consent`, Google no lo repite si la cuenta ya autorizó antes.
|
|
1442
|
+
access_type: "offline",
|
|
1443
|
+
prompt: "consent",
|
|
1444
|
+
include_granted_scopes: "true",
|
|
1445
|
+
state
|
|
1446
|
+
});
|
|
1447
|
+
return `${AUTH_URL}?${params}`;
|
|
1448
|
+
};
|
|
1449
|
+
const newState = async (store, now2 = Date.now()) => {
|
|
1450
|
+
const value = node_crypto.randomBytes(24).toString("hex");
|
|
1451
|
+
await store.set({ key: OAUTH_STATE_KEY, value: { value, expiresAt: now2 + STATE_TTL_MS } });
|
|
1452
|
+
return value;
|
|
1453
|
+
};
|
|
1454
|
+
const checkState = async (store, state, now2 = Date.now()) => {
|
|
1455
|
+
const saved = await store.get({ key: OAUTH_STATE_KEY });
|
|
1456
|
+
await store.delete({ key: OAUTH_STATE_KEY });
|
|
1457
|
+
return typeof state === "string" && !!saved?.value && saved.value === state && (saved.expiresAt ?? 0) >= now2;
|
|
1458
|
+
};
|
|
1459
|
+
const postToken = async (fetchFn, body) => {
|
|
1460
|
+
const res = await fetchFn(TOKEN_URL, {
|
|
1461
|
+
method: "POST",
|
|
1462
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1463
|
+
body: new URLSearchParams(body).toString()
|
|
1464
|
+
});
|
|
1465
|
+
const data = await res.json().catch(() => ({}));
|
|
1466
|
+
if (!res.ok || !data.access_token) throw new DriveAuthError(`Google rechazó la conexión (${data.error ?? `HTTP ${res.status}`}).`);
|
|
1467
|
+
return data;
|
|
1468
|
+
};
|
|
1469
|
+
const exchangeCode = async ({
|
|
1470
|
+
client,
|
|
1471
|
+
code,
|
|
1472
|
+
redirectUri,
|
|
1473
|
+
fetch: fetchFn
|
|
1474
|
+
}) => {
|
|
1475
|
+
const data = await postToken(fetchFn, {
|
|
1476
|
+
code,
|
|
1477
|
+
client_id: client.clientId,
|
|
1478
|
+
client_secret: client.clientSecret,
|
|
1479
|
+
redirect_uri: redirectUri,
|
|
1480
|
+
grant_type: "authorization_code"
|
|
1481
|
+
});
|
|
1482
|
+
if (!data.refresh_token) throw new DriveAuthError("Google no entregó un token de actualización. Intenta conectar de nuevo.");
|
|
1483
|
+
return { accessToken: data.access_token, expiresIn: data.expires_in ?? 3600, refreshToken: data.refresh_token };
|
|
1484
|
+
};
|
|
1485
|
+
const refreshAccessToken = async ({
|
|
1486
|
+
client,
|
|
1487
|
+
refreshToken,
|
|
1488
|
+
fetch: fetchFn
|
|
1489
|
+
}) => {
|
|
1490
|
+
const data = await postToken(fetchFn, {
|
|
1491
|
+
client_id: client.clientId,
|
|
1492
|
+
client_secret: client.clientSecret,
|
|
1493
|
+
refresh_token: refreshToken,
|
|
1494
|
+
grant_type: "refresh_token"
|
|
1495
|
+
});
|
|
1496
|
+
return { accessToken: data.access_token, expiresIn: data.expires_in ?? 3600 };
|
|
1497
|
+
};
|
|
1498
|
+
const revokeToken = async (token, fetchFn) => {
|
|
1499
|
+
await fetchFn(REVOKE_URL, {
|
|
1500
|
+
method: "POST",
|
|
1501
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1502
|
+
body: new URLSearchParams({ token })
|
|
1503
|
+
});
|
|
1504
|
+
};
|
|
1505
|
+
const API = "https://www.googleapis.com/drive/v3";
|
|
1506
|
+
const MAX_RETRIES = 4;
|
|
1507
|
+
const EXPIRY_MARGIN_MS = 6e4;
|
|
1508
|
+
const RATE_REASONS = /* @__PURE__ */ new Set(["rateLimitExceeded", "userRateLimitExceeded"]);
|
|
1509
|
+
class DriveNotConnectedError extends Error {
|
|
1510
|
+
constructor() {
|
|
1511
|
+
super("Google Drive no está conectado.");
|
|
1512
|
+
this.name = "DriveNotConnectedError";
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
class DriveApiError extends Error {
|
|
1516
|
+
constructor(status, message) {
|
|
1517
|
+
super(message);
|
|
1518
|
+
this.status = status;
|
|
1519
|
+
this.name = "DriveApiError";
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
const isRetryable = (status, body) => status === 429 || status >= 500 || status === 403 && !!body.error?.errors?.some((e) => RATE_REASONS.has(e.reason ?? ""));
|
|
1523
|
+
const createDriveClient = ({
|
|
1524
|
+
getCredentials,
|
|
1525
|
+
fetch: fetchFn,
|
|
1526
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
1527
|
+
now: now2 = Date.now
|
|
1528
|
+
}) => {
|
|
1529
|
+
let cached = null;
|
|
1530
|
+
const accessToken = async (force = false) => {
|
|
1531
|
+
if (!force && cached && cached.expiresAt - EXPIRY_MARGIN_MS > now2()) return cached.token;
|
|
1532
|
+
const credentials = await getCredentials();
|
|
1533
|
+
if (!credentials) throw new DriveNotConnectedError();
|
|
1534
|
+
const { accessToken: token, expiresIn } = await refreshAccessToken({ ...credentials, fetch: fetchFn });
|
|
1535
|
+
cached = { token, expiresAt: now2() + expiresIn * 1e3 };
|
|
1536
|
+
return token;
|
|
1537
|
+
};
|
|
1538
|
+
const send = async (path, init = {}) => {
|
|
1539
|
+
const url = path.startsWith("https://") ? path : `${API}${path}`;
|
|
1540
|
+
let renewed = false;
|
|
1541
|
+
for (let attempt = 0; ; attempt++) {
|
|
1542
|
+
const headers = new Headers(init.headers);
|
|
1543
|
+
headers.set("authorization", `Bearer ${await accessToken()}`);
|
|
1544
|
+
const res = await fetchFn(url, { ...init, headers });
|
|
1545
|
+
if (res.ok) return res;
|
|
1546
|
+
const body = await res.json().catch(() => ({}));
|
|
1547
|
+
if (res.status === 401 && !renewed) {
|
|
1548
|
+
renewed = true;
|
|
1549
|
+
await accessToken(true);
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
if (attempt < MAX_RETRIES && isRetryable(res.status, body)) {
|
|
1553
|
+
await sleep(1e3 * 2 ** attempt);
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
throw new DriveApiError(res.status, `Drive respondió ${res.status}: ${body.error?.message ?? res.statusText}`);
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1559
|
+
const request = async (path, init = {}) => {
|
|
1560
|
+
const res = await send(path, init);
|
|
1561
|
+
return res.status === 204 ? null : await res.json();
|
|
1562
|
+
};
|
|
1563
|
+
const download2 = async (path) => new Uint8Array(await (await send(path)).arrayBuffer());
|
|
1564
|
+
const about = async () => {
|
|
1565
|
+
const params = new URLSearchParams({ fields: "user(emailAddress),storageQuota(limit,usage)" });
|
|
1566
|
+
const data = await request(`/about?${params}`);
|
|
1567
|
+
return {
|
|
1568
|
+
email: data.user?.emailAddress ?? "",
|
|
1569
|
+
usage: Number(data.storageQuota?.usage ?? 0),
|
|
1570
|
+
limit: data.storageQuota?.limit ? Number(data.storageQuota.limit) : null
|
|
1571
|
+
};
|
|
1572
|
+
};
|
|
1573
|
+
return { request, download: download2, about, reset: () => void (cached = null) };
|
|
1574
|
+
};
|
|
1575
|
+
const DRIVE_CLIENT_KEY = "DRIVE_CLIENT";
|
|
1576
|
+
const asObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
1577
|
+
const readJson = (value) => {
|
|
1578
|
+
if (typeof value !== "string") return null;
|
|
1579
|
+
try {
|
|
1580
|
+
return asObject(JSON.parse(value));
|
|
1581
|
+
} catch {
|
|
1582
|
+
return null;
|
|
1583
|
+
}
|
|
1584
|
+
};
|
|
1585
|
+
const isText = (v) => typeof v === "string" && v.trim() !== "";
|
|
1586
|
+
const readClient = (data) => {
|
|
1587
|
+
const web = asObject(data.web);
|
|
1588
|
+
if (!web) return { ok: false, message: "El cliente debe ser de tipo Aplicación web." };
|
|
1589
|
+
const uris = Array.isArray(web.redirect_uris) ? web.redirect_uris.filter(isText) : [];
|
|
1590
|
+
const missing = [
|
|
1591
|
+
!isText(web.client_id) && "client_id",
|
|
1592
|
+
!isText(web.client_secret) && "client_secret",
|
|
1593
|
+
uris.length === 0 && "redirect_uris"
|
|
1594
|
+
].filter(Boolean);
|
|
1595
|
+
if (missing.length) return { ok: false, message: `Al JSON del cliente le falta ${missing.join(", ")}.` };
|
|
1596
|
+
return {
|
|
1597
|
+
ok: true,
|
|
1598
|
+
client: { clientId: web.client_id, clientSecret: web.client_secret, redirectUris: uris }
|
|
1599
|
+
};
|
|
1600
|
+
};
|
|
1601
|
+
const parseClientConfig = (value, redirectUri) => {
|
|
1602
|
+
const data = readJson(value);
|
|
1603
|
+
if (!data) return { ok: false, message: "El archivo no es un JSON válido." };
|
|
1604
|
+
const result = readClient(data);
|
|
1605
|
+
if ("client" in result && !result.client.redirectUris.includes(redirectUri)) {
|
|
1606
|
+
return { ok: false, message: `Este cliente no tiene autorizada la URL ${redirectUri}. Agrégala en Google Cloud.` };
|
|
1607
|
+
}
|
|
1608
|
+
return result;
|
|
1609
|
+
};
|
|
1610
|
+
const storedClient = (client) => JSON.stringify({ web: { client_id: client.clientId, client_secret: client.clientSecret, redirect_uris: client.redirectUris } });
|
|
1611
|
+
const loadClient = (stored) => {
|
|
1612
|
+
const data = readJson(stored);
|
|
1613
|
+
if (!data) return null;
|
|
1614
|
+
const result = readClient(data);
|
|
1615
|
+
return "client" in result ? result.client : null;
|
|
1616
|
+
};
|
|
1617
|
+
const publicClient = (stored) => {
|
|
1618
|
+
const client = loadClient(stored);
|
|
1619
|
+
if (!client) return { configured: false, clientId: null, last4: null };
|
|
1620
|
+
return { configured: true, clientId: client.clientId, last4: maskSecret(client.clientSecret).last4 };
|
|
1621
|
+
};
|
|
1622
|
+
const DRIVE_ROOT = "root";
|
|
1623
|
+
const FOLDER_MIME = "application/vnd.google-apps.folder";
|
|
1624
|
+
const UPLOAD = "https://www.googleapis.com/upload/drive/v3/files";
|
|
1625
|
+
const quote = (value) => `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
1626
|
+
const findChild = async (http, parentId, name2) => {
|
|
1627
|
+
const params = new URLSearchParams({
|
|
1628
|
+
q: `${quote(parentId)} in parents and name = ${quote(name2)} and trashed = false`,
|
|
1629
|
+
fields: "files(id,name,mimeType)",
|
|
1630
|
+
pageSize: "10",
|
|
1631
|
+
spaces: "drive"
|
|
1632
|
+
});
|
|
1633
|
+
const { files } = await http.request(`/files?${params}`);
|
|
1634
|
+
return files?.[0] ?? null;
|
|
1635
|
+
};
|
|
1636
|
+
const createFolder = async (http, parentId, name2) => {
|
|
1637
|
+
const { id } = await http.request("/files?fields=id", {
|
|
1638
|
+
method: "POST",
|
|
1639
|
+
headers: { "content-type": "application/json" },
|
|
1640
|
+
body: JSON.stringify({ name: name2, mimeType: FOLDER_MIME, parents: [parentId] })
|
|
1641
|
+
});
|
|
1642
|
+
return id;
|
|
1643
|
+
};
|
|
1644
|
+
const multipart = (metadata, mime, content) => {
|
|
1645
|
+
const boundary = `iw-${node_crypto.randomBytes(12).toString("hex")}`;
|
|
1646
|
+
const head = `--${boundary}\r
|
|
1647
|
+
Content-Type: application/json; charset=UTF-8\r
|
|
1648
|
+
\r
|
|
1649
|
+
${JSON.stringify(metadata)}\r
|
|
1650
|
+
--${boundary}\r
|
|
1651
|
+
Content-Type: ${mime}\r
|
|
1652
|
+
\r
|
|
1653
|
+
`;
|
|
1654
|
+
const body = Buffer.concat([Buffer.from(head, "utf8"), Buffer.from(content), Buffer.from(`\r
|
|
1655
|
+
--${boundary}--`, "utf8")]);
|
|
1656
|
+
return { body, contentType: `multipart/related; boundary=${boundary}` };
|
|
1657
|
+
};
|
|
1658
|
+
const uploadFile = async (http, parentId, file) => {
|
|
1659
|
+
const metadata = { name: file.name, parents: [parentId], ...file.description ? { description: file.description } : {} };
|
|
1660
|
+
const { body, contentType } = multipart(metadata, file.mime, file.content);
|
|
1661
|
+
const { id, md5Checksum } = await http.request(`${UPLOAD}?uploadType=multipart&fields=id,md5Checksum`, {
|
|
1662
|
+
method: "POST",
|
|
1663
|
+
headers: { "content-type": contentType },
|
|
1664
|
+
body
|
|
1665
|
+
});
|
|
1666
|
+
return { id, md5: md5Checksum ?? null };
|
|
1667
|
+
};
|
|
1668
|
+
const updateContent = async (http, driveId, file) => {
|
|
1669
|
+
const metadata = { name: file.name, ...file.description ? { description: file.description } : {} };
|
|
1670
|
+
const { body, contentType } = multipart(metadata, file.mime, file.content);
|
|
1671
|
+
const { md5Checksum } = await http.request(
|
|
1672
|
+
`${UPLOAD}/${encodeURIComponent(driveId)}?uploadType=multipart&fields=id,md5Checksum`,
|
|
1673
|
+
{ method: "PATCH", headers: { "content-type": contentType }, body }
|
|
1674
|
+
);
|
|
1675
|
+
return { id: driveId, md5: md5Checksum ?? null };
|
|
1676
|
+
};
|
|
1677
|
+
const updateMeta = async (http, driveId, { name: name2, from, to }) => {
|
|
1678
|
+
const params = new URLSearchParams({ fields: "id" });
|
|
1679
|
+
if (to && from && to !== from) {
|
|
1680
|
+
params.set("addParents", to);
|
|
1681
|
+
params.set("removeParents", from);
|
|
1682
|
+
}
|
|
1683
|
+
await http.request(`/files/${encodeURIComponent(driveId)}?${params}`, {
|
|
1684
|
+
method: "PATCH",
|
|
1685
|
+
headers: { "content-type": "application/json" },
|
|
1686
|
+
body: JSON.stringify(name2 ? { name: name2 } : {})
|
|
1687
|
+
});
|
|
1688
|
+
};
|
|
1689
|
+
const trash = async (http, driveId) => {
|
|
1690
|
+
try {
|
|
1691
|
+
await http.request(`/files/${encodeURIComponent(driveId)}?fields=id`, {
|
|
1692
|
+
method: "PATCH",
|
|
1693
|
+
headers: { "content-type": "application/json" },
|
|
1694
|
+
body: JSON.stringify({ trashed: true })
|
|
1695
|
+
});
|
|
1696
|
+
} catch (err) {
|
|
1697
|
+
if (err instanceof DriveApiError && err.status === 404) return;
|
|
1698
|
+
throw err;
|
|
1699
|
+
}
|
|
1700
|
+
};
|
|
1701
|
+
const download = async (http, driveId) => {
|
|
1702
|
+
if (!http.download) throw new Error("El cliente de Drive no puede descargar.");
|
|
1703
|
+
return http.download(`/files/${encodeURIComponent(driveId)}?alt=media`);
|
|
1704
|
+
};
|
|
1705
|
+
const ENTRY_FIELDS = "id,name,mimeType,size,modifiedTime,webViewLink,parents";
|
|
1706
|
+
const toEntry = (f) => ({
|
|
1707
|
+
id: f.id,
|
|
1708
|
+
name: f.name,
|
|
1709
|
+
mimeType: f.mimeType,
|
|
1710
|
+
size: f.size ? Number(f.size) : null,
|
|
1711
|
+
modifiedTime: f.modifiedTime ?? null,
|
|
1712
|
+
webViewLink: f.webViewLink ?? null,
|
|
1713
|
+
parents: f.parents ?? []
|
|
1714
|
+
});
|
|
1715
|
+
const listChildren = async (http, parentId, pageToken) => {
|
|
1716
|
+
const params = new URLSearchParams({
|
|
1717
|
+
q: `${quote(parentId)} in parents and trashed = false`,
|
|
1718
|
+
fields: `nextPageToken,files(${ENTRY_FIELDS})`,
|
|
1719
|
+
orderBy: "folder,name_natural",
|
|
1720
|
+
pageSize: "200",
|
|
1721
|
+
spaces: "drive"
|
|
1722
|
+
});
|
|
1723
|
+
if (pageToken) params.set("pageToken", pageToken);
|
|
1724
|
+
const data = await http.request(`/files?${params}`);
|
|
1725
|
+
return { items: (data.files ?? []).map(toEntry), nextPageToken: data.nextPageToken ?? null };
|
|
1726
|
+
};
|
|
1727
|
+
const getFile = async (http, id) => toEntry(await http.request(`/files/${encodeURIComponent(id)}?fields=${ENTRY_FIELDS}`));
|
|
1728
|
+
const countTree = async (http, folderId, limit = 1e3) => {
|
|
1729
|
+
let files = 0;
|
|
1730
|
+
let folders = 0;
|
|
1731
|
+
const queue = [folderId];
|
|
1732
|
+
while (queue.length && files + folders < limit) {
|
|
1733
|
+
const parent = queue.shift();
|
|
1734
|
+
let token;
|
|
1735
|
+
do {
|
|
1736
|
+
const page = await listChildren(http, parent, token);
|
|
1737
|
+
for (const item of page.items) {
|
|
1738
|
+
if (item.mimeType === FOLDER_MIME) {
|
|
1739
|
+
folders++;
|
|
1740
|
+
queue.push(item.id);
|
|
1741
|
+
} else files++;
|
|
1742
|
+
}
|
|
1743
|
+
token = page.nextPageToken ?? void 0;
|
|
1744
|
+
} while (token && files + folders < limit);
|
|
1745
|
+
}
|
|
1746
|
+
return { files, folders, truncated: files + folders >= limit };
|
|
1747
|
+
};
|
|
1748
|
+
const getMeta = async (http, id) => {
|
|
1749
|
+
try {
|
|
1750
|
+
return await http.request(
|
|
1751
|
+
`/files/${encodeURIComponent(id)}?fields=id,name,mimeType,parents,trashed,md5Checksum,size`
|
|
1752
|
+
);
|
|
1753
|
+
} catch (err) {
|
|
1754
|
+
if (err instanceof DriveApiError && err.status === 404) return null;
|
|
1755
|
+
throw err;
|
|
1756
|
+
}
|
|
1757
|
+
};
|
|
1758
|
+
const rootId = async (http) => (await http.request("/files/root?fields=id")).id;
|
|
1759
|
+
const startPageToken = async (http) => (await http.request("/changes/startPageToken")).startPageToken;
|
|
1760
|
+
const CHANGE_FIELDS = "nextPageToken,newStartPageToken,changes(fileId,removed,file(id,name,mimeType,parents,trashed,md5Checksum,size))";
|
|
1761
|
+
const listChanges = async (http, pageToken) => {
|
|
1762
|
+
const params = new URLSearchParams({ pageToken, fields: CHANGE_FIELDS, includeRemoved: "true", spaces: "drive", pageSize: "1000" });
|
|
1763
|
+
return http.request(`/changes?${params}`);
|
|
1764
|
+
};
|
|
1765
|
+
const driveOps = (http) => ({
|
|
1766
|
+
findChild: (parentId, name2) => findChild(http, parentId, name2),
|
|
1767
|
+
createFolder: (parentId, name2) => createFolder(http, parentId, name2),
|
|
1768
|
+
uploadFile: (parentId, file) => uploadFile(http, parentId, file),
|
|
1769
|
+
updateContent: (driveId, file) => updateContent(http, driveId, file),
|
|
1770
|
+
updateMeta: (driveId, change) => updateMeta(http, driveId, change),
|
|
1771
|
+
trash: (driveId) => trash(http, driveId),
|
|
1772
|
+
download: (driveId) => download(http, driveId),
|
|
1773
|
+
startPageToken: () => startPageToken(http),
|
|
1774
|
+
rootId: () => rootId(http),
|
|
1775
|
+
listChildren: (parentId, pageToken) => listChildren(http, parentId, pageToken),
|
|
1776
|
+
getFile: (id) => getFile(http, id),
|
|
1777
|
+
getMeta: (id) => getMeta(http, id),
|
|
1778
|
+
countTree: (folderId) => countTree(http, folderId),
|
|
1779
|
+
listChanges: (pageToken) => listChanges(http, pageToken)
|
|
1780
|
+
});
|
|
1781
|
+
class DriveSyncError extends Error {
|
|
1782
|
+
constructor(message, status = 400) {
|
|
1783
|
+
super(message);
|
|
1784
|
+
this.status = status;
|
|
1785
|
+
this.name = "DriveSyncError";
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
const collisionMessage = (name2) => `Ya existe «${name2}» en Drive y no se sincronizó desde el CMS. Cambia el nombre para sincronizarla.`;
|
|
1789
|
+
const pathIds = (path) => path.split("/").filter(Boolean).map(Number).filter((n) => Number.isInteger(n));
|
|
1790
|
+
const OUTSIDE = "Solo se sincroniza lo que está dentro de «Generated».";
|
|
1791
|
+
const ROOT_ITSELF = "Marca una carpeta o archivo dentro de «Generated», no la raíz.";
|
|
1792
|
+
const load = async (cms, cmsType, cmsId) => {
|
|
1793
|
+
if (cmsType === "folder") {
|
|
1794
|
+
const folder = await cms.folder(cmsId);
|
|
1795
|
+
if (folder) return { type: "folder", folder };
|
|
1796
|
+
} else {
|
|
1797
|
+
const file = await cms.file(cmsId);
|
|
1798
|
+
if (file) return { type: "file", file };
|
|
1799
|
+
}
|
|
1800
|
+
throw new DriveSyncError("El elemento ya no existe en la Biblioteca.");
|
|
1801
|
+
};
|
|
1802
|
+
const ancestorsOf = async (cms, root, path) => {
|
|
1803
|
+
const ids = pathIds(path);
|
|
1804
|
+
if (ids[0] !== root.pathId) return null;
|
|
1805
|
+
const below = ids.slice(1);
|
|
1806
|
+
if (!below.length) return [];
|
|
1807
|
+
const folders = await cms.foldersByPathIds(below);
|
|
1808
|
+
const byPathId = new Map(folders.map((f) => [f.pathId, f]));
|
|
1809
|
+
return below.map((p) => byPathId.get(p)).filter((f) => !!f);
|
|
1810
|
+
};
|
|
1811
|
+
const containerPath = (item) => item.type === "folder" ? item.folder.path.slice(0, item.folder.path.lastIndexOf("/")) || "/" : item.file.folderPath;
|
|
1812
|
+
const ensureFolderLink = async (deps, folder, parentDriveId, root) => {
|
|
1813
|
+
const existing = await deps.links.find("folder", folder.id);
|
|
1814
|
+
if (existing?.driveId) return root && !existing.root ? deps.links.update(existing.id, { root: true }) : existing;
|
|
1815
|
+
if (await deps.drive.findChild(parentDriveId, folder.name)) throw new DriveSyncError(collisionMessage(folder.name), 409);
|
|
1816
|
+
const driveId = await deps.drive.createFolder(parentDriveId, folder.name);
|
|
1817
|
+
const data = { driveId, name: folder.name, parentCmsId: folder.parentId, status: "synced", error: null };
|
|
1818
|
+
return existing ? deps.links.update(existing.id, { ...data, root: root || existing.root }) : deps.links.create({ cmsType: "folder", cmsId: folder.id, root, version: null, driveRev: null, ...data });
|
|
1819
|
+
};
|
|
1820
|
+
const mark = async (deps, cmsType, cmsId) => {
|
|
1821
|
+
const root = await deps.cms.generatedRoot();
|
|
1822
|
+
if (!root) throw new DriveSyncError(OUTSIDE);
|
|
1823
|
+
if (cmsType === "folder" && cmsId === root.id) throw new DriveSyncError(ROOT_ITSELF);
|
|
1824
|
+
const item = await load(deps.cms, cmsType, cmsId);
|
|
1825
|
+
const ancestors = await ancestorsOf(deps.cms, root, containerPath(item));
|
|
1826
|
+
if (!ancestors || item.type === "file" && item.file.folderId === null) throw new DriveSyncError(OUTSIDE);
|
|
1827
|
+
let parentDriveId = DRIVE_ROOT;
|
|
1828
|
+
for (const folder of ancestors) parentDriveId = (await ensureFolderLink(deps, folder, parentDriveId, false)).driveId;
|
|
1829
|
+
if (item.type === "folder") {
|
|
1830
|
+
await ensureFolderLink(deps, item.folder, parentDriveId, true);
|
|
1831
|
+
} else {
|
|
1832
|
+
const existing = await deps.links.find("file", item.file.id);
|
|
1833
|
+
if (existing) {
|
|
1834
|
+
if (!existing.root) await deps.links.update(existing.id, { root: true });
|
|
1835
|
+
} else {
|
|
1836
|
+
if (await deps.drive.findChild(parentDriveId, item.file.name)) throw new DriveSyncError(collisionMessage(item.file.name), 409);
|
|
1837
|
+
await deps.links.create({
|
|
1838
|
+
cmsType: "file",
|
|
1839
|
+
cmsId: item.file.id,
|
|
1840
|
+
driveId: null,
|
|
1841
|
+
root: true,
|
|
1842
|
+
name: null,
|
|
1843
|
+
parentCmsId: null,
|
|
1844
|
+
version: null,
|
|
1845
|
+
driveRev: null,
|
|
1846
|
+
status: "pending",
|
|
1847
|
+
error: null
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
await deps.ops.push(cmsType, cmsId);
|
|
1852
|
+
};
|
|
1853
|
+
const neededLinkIds = async (deps) => {
|
|
1854
|
+
const all3 = await deps.links.all();
|
|
1855
|
+
const rootPaths = [];
|
|
1856
|
+
const ancestorPathIds = /* @__PURE__ */ new Set();
|
|
1857
|
+
const keep = /* @__PURE__ */ new Set();
|
|
1858
|
+
for (const link of all3.filter((l) => l.root)) {
|
|
1859
|
+
keep.add(link.id);
|
|
1860
|
+
if (link.cmsType === "folder") {
|
|
1861
|
+
const folder = await deps.cms.folder(link.cmsId);
|
|
1862
|
+
if (!folder) continue;
|
|
1863
|
+
rootPaths.push(folder.path);
|
|
1864
|
+
pathIds(folder.path).slice(0, -1).forEach((p) => ancestorPathIds.add(p));
|
|
1865
|
+
} else {
|
|
1866
|
+
const file = await deps.cms.file(link.cmsId);
|
|
1867
|
+
if (file) pathIds(file.folderPath).forEach((p) => ancestorPathIds.add(p));
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
const under = (path) => rootPaths.some((r) => path === r || path.startsWith(`${r}/`));
|
|
1871
|
+
for (const link of all3) {
|
|
1872
|
+
if (keep.has(link.id)) continue;
|
|
1873
|
+
if (link.cmsType === "folder") {
|
|
1874
|
+
const folder = await deps.cms.folder(link.cmsId);
|
|
1875
|
+
if (folder && (under(folder.path) || ancestorPathIds.has(folder.pathId))) keep.add(link.id);
|
|
1876
|
+
} else {
|
|
1877
|
+
const file = await deps.cms.file(link.cmsId);
|
|
1878
|
+
if (file && under(file.folderPath)) keep.add(link.id);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
return keep;
|
|
1882
|
+
};
|
|
1883
|
+
const unmark = async (deps, cmsType, cmsId) => {
|
|
1884
|
+
const link = await deps.links.find(cmsType, cmsId);
|
|
1885
|
+
if (!link?.root) throw new DriveSyncError("Ese elemento no está marcado para sincronizar.");
|
|
1886
|
+
await deps.links.update(link.id, { root: false });
|
|
1887
|
+
const keep = await neededLinkIds(deps);
|
|
1888
|
+
for (const l of await deps.links.all()) if (!keep.has(l.id)) await deps.links.remove(l.id);
|
|
1889
|
+
};
|
|
1890
|
+
const isSynced = async (deps, cmsType, cmsId) => {
|
|
1891
|
+
const all3 = await deps.links.all();
|
|
1892
|
+
const roots = all3.filter((l) => l.root);
|
|
1893
|
+
if (roots.some((l) => l.cmsType === cmsType && l.cmsId === cmsId)) return true;
|
|
1894
|
+
const path = cmsType === "folder" ? (await deps.cms.folder(cmsId))?.path : (await deps.cms.file(cmsId))?.folderPath;
|
|
1895
|
+
if (!path) return false;
|
|
1896
|
+
for (const l of roots.filter((r) => r.cmsType === "folder")) {
|
|
1897
|
+
const folder = await deps.cms.folder(l.cmsId);
|
|
1898
|
+
if (folder && (path === folder.path ? cmsType === "file" : path.startsWith(`${folder.path}/`))) return true;
|
|
1899
|
+
}
|
|
1900
|
+
return false;
|
|
1901
|
+
};
|
|
1902
|
+
const FOLDER_UID = "plugin::upload.folder";
|
|
1903
|
+
const FILE_UID = "plugin::upload.file";
|
|
1904
|
+
const LINK_UID = "plugin::strapi-content-generator-gemini.drive-link";
|
|
1905
|
+
const OP_UID = "plugin::strapi-content-generator-gemini.drive-op";
|
|
1906
|
+
const toFolder = (r) => ({
|
|
1907
|
+
id: r.id,
|
|
1908
|
+
pathId: Number(r.pathId),
|
|
1909
|
+
name: r.name,
|
|
1910
|
+
path: r.path,
|
|
1911
|
+
parentId: r.parent?.id ?? null
|
|
1912
|
+
});
|
|
1913
|
+
const toFile = (r) => ({
|
|
1914
|
+
id: r.id,
|
|
1915
|
+
name: r.name,
|
|
1916
|
+
folderId: r.folder?.id ?? null,
|
|
1917
|
+
folderPath: r.folderPath ?? "/",
|
|
1918
|
+
url: r.url ?? "",
|
|
1919
|
+
size: Number(r.size ?? 0),
|
|
1920
|
+
mime: r.mime ?? "application/octet-stream"
|
|
1921
|
+
});
|
|
1922
|
+
const strapiCms = (strapi, fetchFn = fetch) => {
|
|
1923
|
+
const folders = () => strapi.db.query(FOLDER_UID);
|
|
1924
|
+
const files = () => strapi.db.query(FILE_UID);
|
|
1925
|
+
const withParent = { populate: ["parent"] };
|
|
1926
|
+
const withFolder = { populate: ["folder"] };
|
|
1927
|
+
return {
|
|
1928
|
+
async generatedRoot() {
|
|
1929
|
+
const rows = await folders().findMany({ where: { name: "Generated" }, ...withParent });
|
|
1930
|
+
const root = rows.find((r) => !r.parent);
|
|
1931
|
+
return root ? toFolder(root) : null;
|
|
1932
|
+
},
|
|
1933
|
+
async folder(id) {
|
|
1934
|
+
const r = await folders().findOne({ where: { id }, ...withParent });
|
|
1935
|
+
return r ? toFolder(r) : null;
|
|
1936
|
+
},
|
|
1937
|
+
async file(id) {
|
|
1938
|
+
const r = await files().findOne({ where: { id }, ...withFolder });
|
|
1939
|
+
return r ? toFile(r) : null;
|
|
1940
|
+
},
|
|
1941
|
+
async foldersByPathIds(pathIds2) {
|
|
1942
|
+
if (!pathIds2.length) return [];
|
|
1943
|
+
return (await folders().findMany({ where: { pathId: { $in: pathIds2 } }, ...withParent })).map(toFolder);
|
|
1944
|
+
},
|
|
1945
|
+
async childFolders(folderId) {
|
|
1946
|
+
return (await folders().findMany({ where: { parent: { id: folderId } }, ...withParent })).map(toFolder);
|
|
1947
|
+
},
|
|
1948
|
+
async childFiles(folderId) {
|
|
1949
|
+
return (await files().findMany({ where: { folder: { id: folderId } }, ...withFolder })).map(toFile);
|
|
1950
|
+
},
|
|
1951
|
+
async readFile(file) {
|
|
1952
|
+
if (/^https?:\/\//.test(file.url)) {
|
|
1953
|
+
const res = await fetchFn(file.url);
|
|
1954
|
+
if (!res.ok) throw new Error(`No se pudo leer ${file.name} (${res.status}).`);
|
|
1955
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
1956
|
+
}
|
|
1957
|
+
return new Uint8Array(await promises.readFile(node_path.join(strapi.dirs.static.public, file.url)));
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1960
|
+
};
|
|
1961
|
+
const strapiCmsWriter = (strapi) => {
|
|
1962
|
+
const upload = () => strapi.plugin("upload").service("upload");
|
|
1963
|
+
const folderService = () => strapi.plugin("upload").service("folder");
|
|
1964
|
+
const fileById = async (id) => toFile(await strapi.db.query(FILE_UID).findOne({ where: { id }, populate: ["folder"] }));
|
|
1965
|
+
const system = { user: { id: null } };
|
|
1966
|
+
const withTempFile = async (name2, mime, content, work) => {
|
|
1967
|
+
const filepath = node_path.join(node_os.tmpdir(), `iw-drive-${node_crypto.randomBytes(8).toString("hex")}`);
|
|
1968
|
+
await promises.writeFile(filepath, content);
|
|
1969
|
+
try {
|
|
1970
|
+
return await work({ filepath, originalFilename: name2, mimetype: mime, size: content.length });
|
|
1971
|
+
} finally {
|
|
1972
|
+
await promises.rm(filepath, { force: true });
|
|
1973
|
+
}
|
|
1974
|
+
};
|
|
1975
|
+
return {
|
|
1976
|
+
uploadFile: (folderId, name2, mime, content) => withTempFile(name2, mime, content, async (file) => {
|
|
1977
|
+
const [row] = await upload().upload({ data: { fileInfo: { name: name2, folder: folderId } }, files: file });
|
|
1978
|
+
return fileById(row.id);
|
|
1979
|
+
}),
|
|
1980
|
+
replaceFile: async (id, name2, mime, content) => {
|
|
1981
|
+
const current = await strapi.db.query(FILE_UID).findOne({ where: { id }, populate: ["folder"] });
|
|
1982
|
+
return withTempFile(name2, mime, content, async (file) => {
|
|
1983
|
+
await upload().replace(id, { data: { fileInfo: { name: name2, folder: current?.folder?.id ?? null } }, file });
|
|
1984
|
+
return fileById(id);
|
|
1985
|
+
});
|
|
1986
|
+
},
|
|
1987
|
+
updateFile: async (id, { name: name2, folderId }) => {
|
|
1988
|
+
await upload().updateFileInfo(id, { name: name2, ...folderId !== void 0 ? { folder: folderId } : {} });
|
|
1989
|
+
},
|
|
1990
|
+
deleteFile: async (id) => {
|
|
1991
|
+
const row = await strapi.db.query(FILE_UID).findOne({ where: { id } });
|
|
1992
|
+
if (row) await upload().remove(row);
|
|
1993
|
+
},
|
|
1994
|
+
createFolder: async (name2, parentId) => {
|
|
1995
|
+
const row = await folderService().create({ name: name2, parent: parentId });
|
|
1996
|
+
return toFolder({ ...row, parent: { id: parentId } });
|
|
1997
|
+
},
|
|
1998
|
+
updateFolder: async (id, { name: name2, parentId }) => {
|
|
1999
|
+
const current = await strapi.db.query(FOLDER_UID).findOne({ where: { id }, populate: ["parent"] });
|
|
2000
|
+
if (!current) return;
|
|
2001
|
+
await folderService().update(id, { name: name2 ?? current.name, ...parentId !== void 0 ? { parent: parentId } : {} }, system);
|
|
2002
|
+
},
|
|
2003
|
+
deleteFolder: async (id) => {
|
|
2004
|
+
await folderService().deleteByIds([id]);
|
|
2005
|
+
}
|
|
2006
|
+
};
|
|
2007
|
+
};
|
|
2008
|
+
const toLink = (r) => ({
|
|
2009
|
+
id: r.id,
|
|
2010
|
+
cmsType: r.cmsType,
|
|
2011
|
+
cmsId: Number(r.cmsId),
|
|
2012
|
+
driveId: r.driveId ?? null,
|
|
2013
|
+
root: !!r.root,
|
|
2014
|
+
name: r.name ?? null,
|
|
2015
|
+
parentCmsId: r.parentCmsId ?? null,
|
|
2016
|
+
version: r.version ?? null,
|
|
2017
|
+
driveRev: r.driveRev ?? null,
|
|
2018
|
+
status: r.status ?? "pending",
|
|
2019
|
+
error: r.error ?? null
|
|
2020
|
+
});
|
|
2021
|
+
const strapiLinks = (strapi) => {
|
|
2022
|
+
const q = () => strapi.db.query(LINK_UID);
|
|
2023
|
+
return {
|
|
2024
|
+
all: async () => (await q().findMany({ orderBy: { id: "asc" } })).map(toLink),
|
|
2025
|
+
find: async (cmsType, cmsId) => {
|
|
2026
|
+
const r = await q().findOne({ where: { cmsType, cmsId } });
|
|
2027
|
+
return r ? toLink(r) : null;
|
|
2028
|
+
},
|
|
2029
|
+
findByDriveId: async (driveId) => {
|
|
2030
|
+
const r = await q().findOne({ where: { driveId } });
|
|
2031
|
+
return r ? toLink(r) : null;
|
|
2032
|
+
},
|
|
2033
|
+
create: async (data) => toLink(await q().create({ data })),
|
|
2034
|
+
update: async (id, data) => toLink(await q().update({ where: { id }, data })),
|
|
2035
|
+
remove: async (id) => void await q().delete({ where: { id } })
|
|
2036
|
+
};
|
|
2037
|
+
};
|
|
2038
|
+
const toOp = (r) => ({
|
|
2039
|
+
id: r.id,
|
|
2040
|
+
cmsType: r.cmsType,
|
|
2041
|
+
cmsId: Number(r.cmsId),
|
|
2042
|
+
attempts: Number(r.attempts ?? 0),
|
|
2043
|
+
runAfter: Number(r.runAfter ?? 0),
|
|
2044
|
+
error: r.error ?? null
|
|
2045
|
+
});
|
|
2046
|
+
const strapiOps = (strapi) => {
|
|
2047
|
+
const q = () => strapi.db.query(OP_UID);
|
|
2048
|
+
return {
|
|
2049
|
+
async push(cmsType, cmsId) {
|
|
2050
|
+
if (await q().findOne({ where: { cmsType, cmsId } })) return;
|
|
2051
|
+
await q().create({ data: { cmsType, cmsId, attempts: 0, runAfter: "0" } });
|
|
2052
|
+
},
|
|
2053
|
+
ready: async (limit, now2) => (await q().findMany({ where: { runAfter: { $lte: String(now2) } }, orderBy: { id: "asc" }, limit })).map(toOp),
|
|
2054
|
+
done: async (op) => void await q().delete({ where: { id: op.id } }),
|
|
2055
|
+
retry: async (op, error, runAfter) => void await q().update({ where: { id: op.id }, data: { attempts: op.attempts + 1, error, runAfter: String(runAfter) } }),
|
|
2056
|
+
count: async () => q().count()
|
|
2057
|
+
};
|
|
2058
|
+
};
|
|
2059
|
+
const MAX_FILE_BYTES = 30 * 1024 * 1024;
|
|
2060
|
+
const echo = new node_async_hooks.AsyncLocalStorage();
|
|
2061
|
+
const withoutEcho = (work) => echo.run(true, work);
|
|
2062
|
+
const isEcho = () => echo.getStore() === true;
|
|
2063
|
+
const versionOf = (file) => `${file.url}|${file.size}`;
|
|
2064
|
+
const PAGE_TOKEN_KEY = "DRIVE_PAGE_TOKEN";
|
|
2065
|
+
const ROOT_ID_KEY = "DRIVE_ROOT_ID";
|
|
2066
|
+
const FULL_CHECK_KEY = "DRIVE_FULL_CHECK";
|
|
2067
|
+
const NATIVE = "application/vnd.google-apps.";
|
|
2068
|
+
const MOVED_AWAY = "Se movió en Drive a una carpeta que no está sincronizada. Regrésalo en Drive o muévelo en el CMS.";
|
|
2069
|
+
const isNative = (f) => f.mimeType.startsWith(NATIVE) && f.mimeType !== FOLDER_MIME;
|
|
2070
|
+
const driveRootId = async (deps) => {
|
|
2071
|
+
const saved = await deps.store.get({ key: ROOT_ID_KEY });
|
|
2072
|
+
if (saved) return saved;
|
|
2073
|
+
const id = await deps.drive.rootId();
|
|
2074
|
+
await deps.store.set({ key: ROOT_ID_KEY, value: id });
|
|
2075
|
+
return id;
|
|
2076
|
+
};
|
|
2077
|
+
const cmsParentOf = async (deps, f, rootId2) => {
|
|
2078
|
+
const parent = f.parents?.[0];
|
|
2079
|
+
if (!parent) return null;
|
|
2080
|
+
if (parent === rootId2 || parent === DRIVE_ROOT) return (await deps.cms.generatedRoot())?.id ?? null;
|
|
2081
|
+
const link = await deps.links.findByDriveId(parent);
|
|
2082
|
+
return link?.cmsType === "folder" ? link.cmsId : null;
|
|
2083
|
+
};
|
|
2084
|
+
const pruneLinks = async (deps) => {
|
|
2085
|
+
const keep = await neededLinkIds(deps);
|
|
2086
|
+
for (const l of await deps.links.all()) if (!keep.has(l.id)) await deps.links.remove(l.id);
|
|
2087
|
+
};
|
|
2088
|
+
const dropDeadLinks = async (deps) => {
|
|
2089
|
+
for (const l of await deps.links.all()) {
|
|
2090
|
+
const exists = l.cmsType === "folder" ? await deps.cms.folder(l.cmsId) : await deps.cms.file(l.cmsId);
|
|
2091
|
+
if (!exists) await deps.links.remove(l.id);
|
|
2092
|
+
}
|
|
2093
|
+
};
|
|
2094
|
+
const removeInCms = async (deps, link) => {
|
|
2095
|
+
if (link.cmsType === "folder") await deps.writer.deleteFolder(link.cmsId);
|
|
2096
|
+
else await deps.writer.deleteFile(link.cmsId);
|
|
2097
|
+
deps.log?.info?.(`[drive] Drive → CMS: borrado «${link.name}»`);
|
|
2098
|
+
await deps.links.remove(link.id);
|
|
2099
|
+
if (link.cmsType === "folder") await dropDeadLinks(deps);
|
|
2100
|
+
};
|
|
2101
|
+
const updateLinked = async (deps, link, f, parentCms) => {
|
|
2102
|
+
let changed = false;
|
|
2103
|
+
if (link.cmsType === "folder") {
|
|
2104
|
+
if (f.name !== link.name || parentCms !== link.parentCmsId) {
|
|
2105
|
+
await deps.writer.updateFolder(link.cmsId, { name: f.name, parentId: parentCms });
|
|
2106
|
+
deps.log?.info?.(`[drive] Drive → CMS: carpeta «${link.name}» → «${f.name}»`);
|
|
2107
|
+
await deps.links.update(link.id, { name: f.name, parentCmsId: parentCms, status: "synced", error: null });
|
|
2108
|
+
return true;
|
|
2109
|
+
}
|
|
2110
|
+
return false;
|
|
2111
|
+
}
|
|
2112
|
+
if (f.md5Checksum && f.md5Checksum !== link.driveRev) {
|
|
2113
|
+
if (Number(f.size ?? 0) > MAX_FILE_BYTES) {
|
|
2114
|
+
await deps.links.update(link.id, { status: "skipped", error: "La versión de Drive pesa más de 30 MB; no se trae al CMS." });
|
|
2115
|
+
return true;
|
|
2116
|
+
}
|
|
2117
|
+
const content = await deps.drive.download(f.id);
|
|
2118
|
+
const cmsFile = await deps.writer.replaceFile(link.cmsId, f.name, f.mimeType, content);
|
|
2119
|
+
deps.log?.info?.(`[drive] Drive → CMS: contenido nuevo de «${f.name}»`);
|
|
2120
|
+
await deps.links.update(link.id, { version: versionOf(cmsFile), driveRev: f.md5Checksum, name: f.name, status: "synced", error: null });
|
|
2121
|
+
link = { ...link, name: f.name };
|
|
2122
|
+
changed = true;
|
|
2123
|
+
}
|
|
2124
|
+
if (f.name !== link.name || parentCms !== link.parentCmsId) {
|
|
2125
|
+
await deps.writer.updateFile(link.cmsId, { name: f.name, folderId: parentCms });
|
|
2126
|
+
deps.log?.info?.(`[drive] Drive → CMS: archivo «${link.name}» → «${f.name}»`);
|
|
2127
|
+
const cmsFile = await deps.cms.file(link.cmsId);
|
|
2128
|
+
await deps.links.update(link.id, { name: f.name, parentCmsId: parentCms, ...cmsFile ? { version: versionOf(cmsFile) } : {}, status: "synced", error: null });
|
|
2129
|
+
changed = true;
|
|
2130
|
+
}
|
|
2131
|
+
return changed;
|
|
2132
|
+
};
|
|
2133
|
+
const createInCms = async (deps, f, parentCms) => {
|
|
2134
|
+
if (f.mimeType === FOLDER_MIME) {
|
|
2135
|
+
const folder = await deps.writer.createFolder(f.name, parentCms);
|
|
2136
|
+
await deps.links.create({ cmsType: "folder", cmsId: folder.id, driveId: f.id, root: false, name: f.name, parentCmsId: parentCms, version: null, driveRev: null, status: "synced", error: null });
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
if (Number(f.size ?? 0) > MAX_FILE_BYTES) {
|
|
2140
|
+
deps.log?.warn(`[drive] «${f.name}» pesa más de 30 MB; no se trae al CMS.`);
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
const content = await deps.drive.download(f.id);
|
|
2144
|
+
const file = await deps.writer.uploadFile(parentCms, f.name, f.mimeType, content);
|
|
2145
|
+
await deps.links.create({
|
|
2146
|
+
cmsType: "file",
|
|
2147
|
+
cmsId: file.id,
|
|
2148
|
+
driveId: f.id,
|
|
2149
|
+
root: false,
|
|
2150
|
+
name: f.name,
|
|
2151
|
+
parentCmsId: parentCms,
|
|
2152
|
+
version: versionOf(file),
|
|
2153
|
+
driveRev: f.md5Checksum ?? null,
|
|
2154
|
+
status: "synced",
|
|
2155
|
+
error: null
|
|
2156
|
+
});
|
|
2157
|
+
};
|
|
2158
|
+
const applyChange = async (deps, change, rootId2) => {
|
|
2159
|
+
const link = await deps.links.findByDriveId(change.fileId);
|
|
2160
|
+
const f = change.file;
|
|
2161
|
+
if (change.removed || f?.trashed) {
|
|
2162
|
+
if (!link) return false;
|
|
2163
|
+
await removeInCms(deps, link);
|
|
2164
|
+
return true;
|
|
2165
|
+
}
|
|
2166
|
+
if (!f || isNative(f)) return false;
|
|
2167
|
+
const parentCms = await cmsParentOf(deps, f, rootId2);
|
|
2168
|
+
const parentInside = parentCms !== null && await isSynced(deps, "folder", parentCms);
|
|
2169
|
+
if (link) {
|
|
2170
|
+
const pathOnly = !link.root && !await isSynced(deps, link.cmsType, link.cmsId);
|
|
2171
|
+
if (parentCms === null) {
|
|
2172
|
+
if (f.name !== link.name) deps.log?.info?.(`[drive] «${f.name}» no se aplica: su carpeta en Drive (${f.parents?.[0] ?? "ninguna"}) no está vinculada`);
|
|
2173
|
+
if (pathOnly || link.status === "error" && link.error === MOVED_AWAY) return false;
|
|
2174
|
+
await deps.links.update(link.id, { status: "error", error: MOVED_AWAY });
|
|
2175
|
+
return true;
|
|
2176
|
+
}
|
|
2177
|
+
const changed = await updateLinked(deps, link, f, parentCms);
|
|
2178
|
+
if (!pathOnly && !link.root && !parentInside) {
|
|
2179
|
+
await deps.links.remove(link.id);
|
|
2180
|
+
await pruneLinks(deps);
|
|
2181
|
+
return true;
|
|
2182
|
+
}
|
|
2183
|
+
return changed;
|
|
2184
|
+
}
|
|
2185
|
+
if (!parentInside) return false;
|
|
2186
|
+
await createInCms(deps, f, parentCms);
|
|
2187
|
+
return true;
|
|
2188
|
+
};
|
|
2189
|
+
const fullCheck = async (deps, rootId2) => {
|
|
2190
|
+
let applied = 0;
|
|
2191
|
+
let errors = 0;
|
|
2192
|
+
const all3 = await deps.links.all();
|
|
2193
|
+
deps.log?.info?.(`[drive] revisión completa contra Drive: ${all3.length} vínculos`);
|
|
2194
|
+
for (const link of all3) {
|
|
2195
|
+
if (!link.driveId) continue;
|
|
2196
|
+
if (!await deps.links.find(link.cmsType, link.cmsId)) continue;
|
|
2197
|
+
try {
|
|
2198
|
+
const meta = await deps.drive.getMeta(link.driveId);
|
|
2199
|
+
if (await applyChange(deps, { fileId: link.driveId, removed: meta === null, ...meta ? { file: meta } : {} }, rootId2)) applied++;
|
|
2200
|
+
} catch (err) {
|
|
2201
|
+
errors++;
|
|
2202
|
+
deps.log?.warn(`[drive] revisión de ${link.cmsType} ${link.cmsId}: ${err.message}`);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
return { applied, errors };
|
|
2206
|
+
};
|
|
2207
|
+
const pullChanges = async (deps, { full = false } = {}) => {
|
|
2208
|
+
let token = await deps.store.get({ key: PAGE_TOKEN_KEY });
|
|
2209
|
+
const rootId2 = await driveRootId(deps);
|
|
2210
|
+
let applied = 0;
|
|
2211
|
+
let errors = 0;
|
|
2212
|
+
if (!token) {
|
|
2213
|
+
token = await deps.drive.startPageToken();
|
|
2214
|
+
await deps.store.set({ key: PAGE_TOKEN_KEY, value: token });
|
|
2215
|
+
await deps.store.delete({ key: FULL_CHECK_KEY });
|
|
2216
|
+
}
|
|
2217
|
+
if (full) await deps.store.delete({ key: FULL_CHECK_KEY });
|
|
2218
|
+
if (!await deps.store.get({ key: FULL_CHECK_KEY })) {
|
|
2219
|
+
const checked = await withoutEcho(() => fullCheck(deps, rootId2));
|
|
2220
|
+
await deps.store.set({ key: FULL_CHECK_KEY, value: true });
|
|
2221
|
+
applied += checked.applied;
|
|
2222
|
+
errors += checked.errors;
|
|
2223
|
+
}
|
|
2224
|
+
await withoutEcho(async () => {
|
|
2225
|
+
for (; ; ) {
|
|
2226
|
+
const page = await deps.drive.listChanges(token);
|
|
2227
|
+
for (const change of page.changes ?? []) {
|
|
2228
|
+
try {
|
|
2229
|
+
if (await applyChange(deps, change, rootId2)) applied++;
|
|
2230
|
+
} catch (err) {
|
|
2231
|
+
errors++;
|
|
2232
|
+
deps.log?.warn(`[drive] cambio de Drive ${change.fileId}: ${err.message}`);
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
token = page.nextPageToken ?? page.newStartPageToken ?? token;
|
|
2236
|
+
await deps.store.set({ key: PAGE_TOKEN_KEY, value: token });
|
|
2237
|
+
if (!page.nextPageToken) break;
|
|
2238
|
+
}
|
|
2239
|
+
});
|
|
2240
|
+
return { applied, errors };
|
|
2241
|
+
};
|
|
2242
|
+
const LOCK_KEY = "DRIVE_LOCK";
|
|
2243
|
+
const LAST_RUN_KEY = "DRIVE_LAST_RUN";
|
|
2244
|
+
const LOCK_MS = 10 * 6e4;
|
|
2245
|
+
const BATCH = 50;
|
|
2246
|
+
const MAX_ATTEMPTS = 5;
|
|
2247
|
+
const TOO_BIG = "Pesa más de 30 MB; no se sube a Drive.";
|
|
2248
|
+
const bytesOf = (file) => file.size * 1e3;
|
|
2249
|
+
const now = (deps) => (deps.now ?? Date.now)();
|
|
2250
|
+
const driveParent = async (deps, parentCmsId, depth = 0) => {
|
|
2251
|
+
const root = await deps.cms.generatedRoot();
|
|
2252
|
+
if (parentCmsId === null || parentCmsId === root?.id) return DRIVE_ROOT;
|
|
2253
|
+
let link = await deps.links.find("folder", parentCmsId);
|
|
2254
|
+
if (!link?.driveId && depth < 20) {
|
|
2255
|
+
await processItem(deps, "folder", parentCmsId, depth + 1);
|
|
2256
|
+
link = await deps.links.find("folder", parentCmsId);
|
|
2257
|
+
}
|
|
2258
|
+
if (!link?.driveId) throw new Error(`La carpeta ${parentCmsId} todavía no está en Drive.`);
|
|
2259
|
+
return link.driveId;
|
|
2260
|
+
};
|
|
2261
|
+
const saveError = async (deps, link, cmsType, cmsId, error, status = "error") => {
|
|
2262
|
+
if (link) await deps.links.update(link.id, { status, error });
|
|
2263
|
+
else await deps.links.create({ cmsType, cmsId, driveId: null, root: false, name: null, parentCmsId: null, version: null, driveRev: null, status, error });
|
|
2264
|
+
};
|
|
2265
|
+
const syncFolder = async (deps, folder, link, depth) => {
|
|
2266
|
+
const parent = await driveParent(deps, folder.parentId, depth);
|
|
2267
|
+
if (!link?.driveId) {
|
|
2268
|
+
if (await deps.drive.findChild(parent, folder.name)) return saveError(deps, link, "folder", folder.id, collisionMessage(folder.name));
|
|
2269
|
+
const driveId = await deps.drive.createFolder(parent, folder.name);
|
|
2270
|
+
const data = { driveId, name: folder.name, parentCmsId: folder.parentId, status: "synced", error: null };
|
|
2271
|
+
if (link) await deps.links.update(link.id, data);
|
|
2272
|
+
else await deps.links.create({ cmsType: "folder", cmsId: folder.id, root: false, version: null, driveRev: null, ...data });
|
|
2273
|
+
} else if (link.name !== folder.name || link.parentCmsId !== folder.parentId) {
|
|
2274
|
+
const from = link.parentCmsId === folder.parentId ? parent : await driveParent(deps, link.parentCmsId, depth).catch(() => parent);
|
|
2275
|
+
await deps.drive.updateMeta(link.driveId, { name: folder.name, from, to: parent });
|
|
2276
|
+
await deps.links.update(link.id, { name: folder.name, parentCmsId: folder.parentId, status: "synced", error: null });
|
|
2277
|
+
}
|
|
2278
|
+
if (!link?.driveId) {
|
|
2279
|
+
for (const f of await deps.cms.childFolders(folder.id)) await deps.ops.push("folder", f.id);
|
|
2280
|
+
for (const f of await deps.cms.childFiles(folder.id)) await deps.ops.push("file", f.id);
|
|
2281
|
+
}
|
|
2282
|
+
};
|
|
2283
|
+
const syncFile = async (deps, file, link, depth) => {
|
|
2284
|
+
if (bytesOf(file) > MAX_FILE_BYTES) return saveError(deps, link, "file", file.id, TOO_BIG, "skipped");
|
|
2285
|
+
const parent = await driveParent(deps, file.folderId, depth);
|
|
2286
|
+
const version = versionOf(file);
|
|
2287
|
+
const upload = async () => ({ name: file.name, mime: file.mime, content: await deps.cms.readFile(file), description: await deps.describe?.(file) });
|
|
2288
|
+
if (!link?.driveId) {
|
|
2289
|
+
if (await deps.drive.findChild(parent, file.name)) return saveError(deps, link, "file", file.id, collisionMessage(file.name));
|
|
2290
|
+
const { id: driveId, md5 } = await deps.drive.uploadFile(parent, await upload());
|
|
2291
|
+
const data = { driveId, name: file.name, parentCmsId: file.folderId, version, driveRev: md5, status: "synced", error: null };
|
|
2292
|
+
if (link) await deps.links.update(link.id, data);
|
|
2293
|
+
else await deps.links.create({ cmsType: "file", cmsId: file.id, root: false, ...data });
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
let driveRev = link.driveRev;
|
|
2297
|
+
if (link.version !== version) driveRev = (await deps.drive.updateContent(link.driveId, await upload())).md5;
|
|
2298
|
+
if (link.name !== file.name || link.parentCmsId !== file.folderId) {
|
|
2299
|
+
const from = link.parentCmsId === file.folderId ? parent : await driveParent(deps, link.parentCmsId, depth).catch(() => parent);
|
|
2300
|
+
await deps.drive.updateMeta(link.driveId, { name: file.name, from, to: parent });
|
|
2301
|
+
}
|
|
2302
|
+
await deps.links.update(link.id, { name: file.name, parentCmsId: file.folderId, version, driveRev, status: "synced", error: null });
|
|
2303
|
+
};
|
|
2304
|
+
const processItem = async (deps, cmsType, cmsId, depth = 0) => {
|
|
2305
|
+
const link = await deps.links.find(cmsType, cmsId);
|
|
2306
|
+
const item = cmsType === "folder" ? await deps.cms.folder(cmsId) : await deps.cms.file(cmsId);
|
|
2307
|
+
if (!item) {
|
|
2308
|
+
if (link?.driveId) await deps.drive.trash(link.driveId);
|
|
2309
|
+
if (link) await deps.links.remove(link.id);
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
if (!await isSynced(deps, cmsType, cmsId)) {
|
|
2313
|
+
if (link && !(await neededLinkIds(deps)).has(link.id)) await deps.links.remove(link.id);
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
if (cmsType === "folder") await syncFolder(deps, item, link, depth);
|
|
2317
|
+
else await syncFile(deps, item, link, depth);
|
|
2318
|
+
};
|
|
2319
|
+
const reconcile = async (deps) => {
|
|
2320
|
+
const links = await deps.links.all();
|
|
2321
|
+
const linked = new Set(links.map((l) => `${l.cmsType}:${l.cmsId}`));
|
|
2322
|
+
for (const link of links) {
|
|
2323
|
+
if (link.cmsType === "file") {
|
|
2324
|
+
const file = await deps.cms.file(link.cmsId);
|
|
2325
|
+
if (!file || !link.driveId || file.name !== link.name || file.folderId !== link.parentCmsId || versionOf(file) !== link.version) {
|
|
2326
|
+
if (link.status !== "skipped" || !file || bytesOf(file) <= MAX_FILE_BYTES) await deps.ops.push("file", link.cmsId);
|
|
2327
|
+
}
|
|
2328
|
+
continue;
|
|
2329
|
+
}
|
|
2330
|
+
const folder = await deps.cms.folder(link.cmsId);
|
|
2331
|
+
if (!folder || !link.driveId || folder.name !== link.name || folder.parentId !== link.parentCmsId) {
|
|
2332
|
+
await deps.ops.push("folder", link.cmsId);
|
|
2333
|
+
if (!folder) continue;
|
|
2334
|
+
}
|
|
2335
|
+
if (!await isSynced(deps, "folder", link.cmsId)) continue;
|
|
2336
|
+
for (const f of await deps.cms.childFolders(folder.id)) if (!linked.has(`folder:${f.id}`)) await deps.ops.push("folder", f.id);
|
|
2337
|
+
for (const f of await deps.cms.childFiles(folder.id)) if (!linked.has(`file:${f.id}`)) await deps.ops.push("file", f.id);
|
|
2338
|
+
}
|
|
2339
|
+
};
|
|
2340
|
+
const acquire = async (deps) => {
|
|
2341
|
+
const lock = await deps.store.get({ key: LOCK_KEY });
|
|
2342
|
+
if (lock?.until && lock.until > now(deps)) return false;
|
|
2343
|
+
await deps.store.set({ key: LOCK_KEY, value: { until: now(deps) + LOCK_MS } });
|
|
2344
|
+
return true;
|
|
2345
|
+
};
|
|
2346
|
+
const runSync = async (deps, { budgetMs = 4 * 6e4, full = false } = {}) => {
|
|
2347
|
+
if (!await acquire(deps)) return { skipped: true, pulled: 0, processed: 0, errors: 0, pending: await deps.ops.count() };
|
|
2348
|
+
const started = now(deps);
|
|
2349
|
+
let processed = 0;
|
|
2350
|
+
let errors = 0;
|
|
2351
|
+
let pulled = 0;
|
|
2352
|
+
try {
|
|
2353
|
+
if (deps.writer) {
|
|
2354
|
+
const result = await pullChanges({ ...deps, writer: deps.writer }, { full });
|
|
2355
|
+
pulled = result.applied;
|
|
2356
|
+
errors += result.errors;
|
|
2357
|
+
}
|
|
2358
|
+
await reconcile(deps);
|
|
2359
|
+
while (now(deps) - started < budgetMs) {
|
|
2360
|
+
const batch = await deps.ops.ready(BATCH, now(deps));
|
|
2361
|
+
if (!batch.length) break;
|
|
2362
|
+
for (const op of batch) {
|
|
2363
|
+
try {
|
|
2364
|
+
await processItem(deps, op.cmsType, op.cmsId);
|
|
2365
|
+
await deps.ops.done(op);
|
|
2366
|
+
processed++;
|
|
2367
|
+
} catch (err) {
|
|
2368
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2369
|
+
errors++;
|
|
2370
|
+
if (err instanceof DriveSyncError || op.attempts + 1 >= MAX_ATTEMPTS) {
|
|
2371
|
+
await deps.ops.done(op);
|
|
2372
|
+
await saveError(deps, await deps.links.find(op.cmsType, op.cmsId), op.cmsType, op.cmsId, message);
|
|
2373
|
+
} else {
|
|
2374
|
+
await deps.ops.retry(op, message, now(deps) + 3e4 * 2 ** op.attempts);
|
|
2375
|
+
}
|
|
2376
|
+
deps.log?.warn(`[drive] ${op.cmsType} ${op.cmsId}: ${message}`);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
} finally {
|
|
2381
|
+
await deps.store.set({ key: LAST_RUN_KEY, value: { at: now(deps), pulled, processed, errors } });
|
|
2382
|
+
deps.log?.info?.(`[drive] ciclo terminado: ${pulled} cambios de Drive traídos, ${processed} elementos subidos o revisados, ${errors} errores`);
|
|
2383
|
+
await deps.store.delete({ key: LOCK_KEY });
|
|
2384
|
+
}
|
|
2385
|
+
return { skipped: false, pulled, processed, errors, pending: await deps.ops.count() };
|
|
2386
|
+
};
|
|
2387
|
+
const debounce = (run, delayMs = 5e3, onError = () => {
|
|
2388
|
+
}) => {
|
|
2389
|
+
let timer = null;
|
|
2390
|
+
return () => {
|
|
2391
|
+
if (timer) clearTimeout(timer);
|
|
2392
|
+
timer = setTimeout(() => {
|
|
2393
|
+
timer = null;
|
|
2394
|
+
run().catch(onError);
|
|
2395
|
+
}, delayMs);
|
|
2396
|
+
timer.unref?.();
|
|
2397
|
+
};
|
|
2398
|
+
};
|
|
2399
|
+
const DRIVE_TOKEN_KEY = "DRIVE_TOKEN";
|
|
2400
|
+
const DRIVE_ORIGINS_KEY = "DRIVE_ORIGINS";
|
|
2401
|
+
class DriveInputError extends Error {
|
|
2402
|
+
constructor(message, status = 400) {
|
|
2403
|
+
super(message);
|
|
2404
|
+
this.status = status;
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
const NOT_CONNECTED = { connected: false, email: null, usage: null, limit: null, error: null };
|
|
2408
|
+
const REVOKED = "Desconectada: vuelve a conectar la cuenta.";
|
|
2409
|
+
const createDriveService = ({
|
|
2410
|
+
store,
|
|
2411
|
+
fetch: fetchFn,
|
|
2412
|
+
log,
|
|
2413
|
+
sync
|
|
2414
|
+
}) => {
|
|
2415
|
+
const client = async () => loadClient(await store.get({ key: DRIVE_CLIENT_KEY }));
|
|
2416
|
+
const token = async () => await store.get({ key: DRIVE_TOKEN_KEY });
|
|
2417
|
+
const http = createDriveClient({
|
|
2418
|
+
fetch: fetchFn,
|
|
2419
|
+
getCredentials: async () => {
|
|
2420
|
+
const [c, t] = await Promise.all([client(), token()]);
|
|
2421
|
+
return c && t?.refreshToken ? { client: c, refreshToken: t.refreshToken } : null;
|
|
2422
|
+
}
|
|
2423
|
+
});
|
|
2424
|
+
const clientFor = async (redirectUri) => {
|
|
2425
|
+
const c = await client();
|
|
2426
|
+
if (!c) throw new DriveInputError("Primero carga el JSON del cliente OAuth.");
|
|
2427
|
+
if (typeof redirectUri !== "string" || !c.redirectUris.includes(redirectUri)) {
|
|
2428
|
+
throw new DriveInputError(`Este cliente no tiene autorizada la URL ${String(redirectUri)}. Agrégala en Google Cloud.`);
|
|
2429
|
+
}
|
|
2430
|
+
return c;
|
|
2431
|
+
};
|
|
2432
|
+
const forgetToken = async () => {
|
|
2433
|
+
const t = await token();
|
|
2434
|
+
if (t?.refreshToken) {
|
|
2435
|
+
await revokeToken(t.refreshToken, fetchFn).catch((err) => log.warn(`[drive] no se pudo revocar el token: ${err.message}`));
|
|
2436
|
+
}
|
|
2437
|
+
await store.delete({ key: DRIVE_TOKEN_KEY });
|
|
2438
|
+
http.reset();
|
|
2439
|
+
};
|
|
2440
|
+
const connected = async () => !!await client() && !!(await token())?.refreshToken;
|
|
2441
|
+
const describe = async (file) => {
|
|
2442
|
+
const origins = await store.get({ key: DRIVE_ORIGINS_KEY }) ?? {};
|
|
2443
|
+
const origin2 = origins[String(file.id)];
|
|
2444
|
+
if (!origin2) return void 0;
|
|
2445
|
+
return `Template: ${origin2.name}
|
|
2446
|
+
${sync?.serverUrl ?? ""}/admin/plugins/strapi-content-generator-gemini-studio?tab=templates`;
|
|
2447
|
+
};
|
|
2448
|
+
const syncDeps = () => {
|
|
2449
|
+
if (!sync) throw new Error("La sincronización con Drive no está configurada.");
|
|
2450
|
+
return { drive: driveOps(http), cms: sync.cms, writer: sync.writer, links: sync.links, ops: sync.ops, store, describe, log };
|
|
2451
|
+
};
|
|
2452
|
+
const requireConnection = async () => {
|
|
2453
|
+
if (!await connected()) throw new DriveInputError("Conecta Google Drive en Ajustes antes de sincronizar.");
|
|
2454
|
+
};
|
|
2455
|
+
const asInput = async (work) => {
|
|
2456
|
+
try {
|
|
2457
|
+
return await work();
|
|
2458
|
+
} catch (err) {
|
|
2459
|
+
if (err instanceof DriveSyncError) throw new DriveInputError(err.message, err.status);
|
|
2460
|
+
throw err;
|
|
2461
|
+
}
|
|
2462
|
+
};
|
|
2463
|
+
const runCycle = async ({ full = false } = {}) => await connected() ? runSync(syncDeps(), { full }) : null;
|
|
2464
|
+
const schedule = debounce(() => runCycle(), 5e3, (err) => log.warn(`[drive] el ciclo de sincronización falló: ${err instanceof Error ? err.message : String(err)}`));
|
|
2465
|
+
return {
|
|
2466
|
+
http: () => http,
|
|
2467
|
+
connected,
|
|
2468
|
+
runCycle,
|
|
2469
|
+
/** Marca para sincronizar y programa el ciclo. */
|
|
2470
|
+
async mark(cmsType, cmsId) {
|
|
2471
|
+
await requireConnection();
|
|
2472
|
+
await asInput(() => mark(syncDeps(), cmsType, cmsId));
|
|
2473
|
+
schedule();
|
|
2474
|
+
},
|
|
2475
|
+
async unmark(cmsType, cmsId) {
|
|
2476
|
+
await asInput(() => unmark(syncDeps(), cmsType, cmsId));
|
|
2477
|
+
},
|
|
2478
|
+
/** «Sincronizar ahora»; con `full`, «Revisión completa»: compara todo lo vinculado contra Drive. */
|
|
2479
|
+
async syncNow(full = false) {
|
|
2480
|
+
await requireConnection();
|
|
2481
|
+
return runCycle({ full });
|
|
2482
|
+
},
|
|
2483
|
+
/** Lifecycle de upload: encola si el elemento está (o estaba) en lo sincronizado. */
|
|
2484
|
+
async track(cmsType, cmsId) {
|
|
2485
|
+
if (isEcho() || !sync || !await connected()) return;
|
|
2486
|
+
const deps = syncDeps();
|
|
2487
|
+
if (await deps.links.find(cmsType, cmsId) || await isSynced(deps, cmsType, cmsId)) {
|
|
2488
|
+
await deps.ops.push(cmsType, cmsId);
|
|
2489
|
+
schedule();
|
|
2490
|
+
}
|
|
2491
|
+
},
|
|
2492
|
+
async linkStatuses() {
|
|
2493
|
+
const isConnected = await connected();
|
|
2494
|
+
if (!isConnected || !sync) return { connected: false, items: {}, lastRun: null, pending: 0 };
|
|
2495
|
+
const items = {};
|
|
2496
|
+
const deps = syncDeps();
|
|
2497
|
+
for (const l of await sync.links.all()) {
|
|
2498
|
+
items[`${l.cmsType}:${l.cmsId}`] = { status: l.status, root: l.root, inside: await isSynced(deps, l.cmsType, l.cmsId), error: l.error };
|
|
2499
|
+
}
|
|
2500
|
+
const lastRun = await store.get({ key: LAST_RUN_KEY }) ?? null;
|
|
2501
|
+
return { connected: true, items, lastRun, pending: await sync.ops.count() };
|
|
2502
|
+
},
|
|
2503
|
+
/** Vista de Drive (S7.2): una carpeta en vivo con su ruta y qué hijos están sincronizados. */
|
|
2504
|
+
async browse(folderId, pageToken) {
|
|
2505
|
+
await requireConnection();
|
|
2506
|
+
const ops = driveOps(http);
|
|
2507
|
+
const id = typeof folderId === "string" && folderId !== "" ? folderId : "root";
|
|
2508
|
+
const page = await ops.listChildren(id, typeof pageToken === "string" && pageToken ? pageToken : void 0);
|
|
2509
|
+
const rootId2 = await store.get({ key: "DRIVE_ROOT_ID" }) ?? await ops.rootId();
|
|
2510
|
+
const isRoot = id === "root" || id === rootId2;
|
|
2511
|
+
const path = [];
|
|
2512
|
+
if (!isRoot) {
|
|
2513
|
+
let current = await ops.getFile(id);
|
|
2514
|
+
path.unshift({ id: current.id, name: current.name });
|
|
2515
|
+
for (let depth = 0; depth < 30 && current.parents[0] && current.parents[0] !== rootId2; depth++) {
|
|
2516
|
+
current = await ops.getFile(current.parents[0]);
|
|
2517
|
+
path.unshift({ id: current.id, name: current.name });
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
const linked = new Set((sync ? await sync.links.all() : []).map((l) => l.driveId).filter(Boolean));
|
|
2521
|
+
return {
|
|
2522
|
+
path,
|
|
2523
|
+
items: page.items.map((item) => ({ ...item, synced: linked.has(item.id) })),
|
|
2524
|
+
nextPageToken: page.nextPageToken
|
|
2525
|
+
};
|
|
2526
|
+
},
|
|
2527
|
+
/** Cuántos elementos se irían a la papelera con esta carpeta, y si está sincronizada. */
|
|
2528
|
+
async countItem(id) {
|
|
2529
|
+
await requireConnection();
|
|
2530
|
+
if (typeof id !== "string" || !id) throw new DriveInputError("Elemento de Drive inválido.");
|
|
2531
|
+
const synced = !!(sync && await sync.links.findByDriveId(id));
|
|
2532
|
+
return { ...await driveOps(http).countTree(id), synced };
|
|
2533
|
+
},
|
|
2534
|
+
/** Eliminar desde la vista: a la papelera de Drive. Si estaba sincronizado, el siguiente ciclo lo borra del CMS. */
|
|
2535
|
+
async trashItem(id) {
|
|
2536
|
+
await requireConnection();
|
|
2537
|
+
if (typeof id !== "string" || !id || id === "root") throw new DriveInputError("Elemento de Drive inválido.");
|
|
2538
|
+
await driveOps(http).trash(id);
|
|
2539
|
+
schedule();
|
|
2540
|
+
},
|
|
2541
|
+
/** El panel avisa que un archivo salió de un template (S7.3). */
|
|
2542
|
+
async setOrigin(fileId, documentId, name2) {
|
|
2543
|
+
if (typeof fileId !== "number" || typeof documentId !== "string" || typeof name2 !== "string" || !name2.trim()) {
|
|
2544
|
+
throw new DriveInputError("Origen del template inválido.");
|
|
2545
|
+
}
|
|
2546
|
+
const origins = await store.get({ key: DRIVE_ORIGINS_KEY }) ?? {};
|
|
2547
|
+
await store.set({ key: DRIVE_ORIGINS_KEY, value: { ...origins, [String(fileId)]: { documentId, name: name2.trim() } } });
|
|
2548
|
+
},
|
|
2549
|
+
/** Guarda el cliente del JSON de Google. Un cliente nuevo invalida la conexión anterior. */
|
|
2550
|
+
async saveClient(json, redirectUri) {
|
|
2551
|
+
const parsed = parseClientConfig(json, typeof redirectUri === "string" ? redirectUri : "");
|
|
2552
|
+
if ("message" in parsed) throw new DriveInputError(parsed.message);
|
|
2553
|
+
const previous = await client();
|
|
2554
|
+
if (previous && previous.clientId !== parsed.client.clientId) await forgetToken();
|
|
2555
|
+
await store.set({ key: DRIVE_CLIENT_KEY, value: storedClient(parsed.client) });
|
|
2556
|
+
http.reset();
|
|
2557
|
+
},
|
|
2558
|
+
async removeClient() {
|
|
2559
|
+
await forgetToken();
|
|
2560
|
+
await store.delete({ key: DRIVE_CLIENT_KEY });
|
|
2561
|
+
},
|
|
2562
|
+
async authorizationUrl(redirectUri) {
|
|
2563
|
+
const c = await clientFor(redirectUri);
|
|
2564
|
+
return authUrl(c, redirectUri, await newState(store));
|
|
2565
|
+
},
|
|
2566
|
+
async exchange({ code, state, redirectUri }) {
|
|
2567
|
+
if (!await checkState(store, state) || typeof code !== "string" || code === "") {
|
|
2568
|
+
throw new DriveInputError("La conexión no se pudo verificar. Intenta de nuevo.");
|
|
2569
|
+
}
|
|
2570
|
+
const c = await clientFor(redirectUri);
|
|
2571
|
+
let refreshToken;
|
|
2572
|
+
try {
|
|
2573
|
+
({ refreshToken } = await exchangeCode({ client: c, code, redirectUri, fetch: fetchFn }));
|
|
2574
|
+
} catch (err) {
|
|
2575
|
+
if (err instanceof DriveAuthError) throw new DriveInputError(err.message);
|
|
2576
|
+
throw err;
|
|
2577
|
+
}
|
|
2578
|
+
await store.set({ key: DRIVE_TOKEN_KEY, value: { refreshToken, email: null } });
|
|
2579
|
+
http.reset();
|
|
2580
|
+
const { email } = await http.about();
|
|
2581
|
+
await store.set({ key: DRIVE_TOKEN_KEY, value: { refreshToken, email } });
|
|
2582
|
+
},
|
|
2583
|
+
disconnect: forgetToken,
|
|
2584
|
+
async status() {
|
|
2585
|
+
const pub = publicClient(await store.get({ key: DRIVE_CLIENT_KEY }));
|
|
2586
|
+
const t = await token();
|
|
2587
|
+
if (!pub.configured || !t?.refreshToken) return { client: pub, connection: NOT_CONNECTED, sync: null };
|
|
2588
|
+
const syncState = async () => {
|
|
2589
|
+
if (!sync) return null;
|
|
2590
|
+
const failing = (await sync.links.all()).filter((l) => (l.status === "error" || l.status === "skipped") && l.error);
|
|
2591
|
+
return {
|
|
2592
|
+
lastRun: await store.get({ key: LAST_RUN_KEY }) ?? null,
|
|
2593
|
+
pending: await sync.ops.count(),
|
|
2594
|
+
errors: failing.slice(0, 20).map((l) => ({ key: `${l.cmsType}:${l.cmsId}`, name: l.name ?? `${l.cmsType === "file" ? "Archivo" : "Carpeta"} ${l.cmsId}`, error: l.error }))
|
|
2595
|
+
};
|
|
2596
|
+
};
|
|
2597
|
+
try {
|
|
2598
|
+
const about = await http.about();
|
|
2599
|
+
return {
|
|
2600
|
+
client: pub,
|
|
2601
|
+
connection: { connected: true, email: about.email, usage: about.usage, limit: about.limit, error: null },
|
|
2602
|
+
sync: await syncState()
|
|
2603
|
+
};
|
|
2604
|
+
} catch (err) {
|
|
2605
|
+
if (err instanceof DriveAuthError || err instanceof DriveNotConnectedError) {
|
|
2606
|
+
return { client: pub, connection: { ...NOT_CONNECTED, email: t.email, error: REVOKED }, sync: null };
|
|
2607
|
+
}
|
|
2608
|
+
return { client: pub, connection: { ...NOT_CONNECTED, connected: true, email: t.email, error: err.message }, sync: await syncState() };
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
};
|
|
2612
|
+
};
|
|
2613
|
+
const drive$1 = ({ strapi }) => createDriveService({
|
|
2614
|
+
store: strapi.store({ type: "plugin", name: "strapi-content-generator-gemini" }),
|
|
2615
|
+
fetch: (...args) => fetch(...args),
|
|
2616
|
+
log: strapi.log,
|
|
2617
|
+
sync: {
|
|
2618
|
+
cms: strapiCms(strapi),
|
|
2619
|
+
writer: strapiCmsWriter(strapi),
|
|
2620
|
+
links: strapiLinks(strapi),
|
|
2621
|
+
ops: strapiOps(strapi),
|
|
2622
|
+
serverUrl: String(strapi.config.get("server.url") ?? "").replace(/\/+$/, "")
|
|
2623
|
+
}
|
|
2624
|
+
});
|
|
2625
|
+
const fail = (ctx, status, name2, message) => {
|
|
2626
|
+
ctx.status = status;
|
|
2627
|
+
ctx.body = { data: null, error: { status, name: name2, message } };
|
|
2628
|
+
};
|
|
2629
|
+
const drive = ({ strapi }) => {
|
|
2630
|
+
const svc = () => strapi.plugin("strapi-content-generator-gemini").service("drive");
|
|
2631
|
+
const run = async (ctx, work) => {
|
|
2632
|
+
try {
|
|
2633
|
+
ctx.body = await work() ?? { ok: true };
|
|
2634
|
+
ctx.status = 200;
|
|
2635
|
+
} catch (err) {
|
|
2636
|
+
if (err instanceof DriveInputError) return fail(ctx, err.status, err.status === 409 ? "ConflictError" : "ValidationError", err.message);
|
|
2637
|
+
strapi.log.error(`[drive] ${err instanceof Error ? err.message : String(err)}`);
|
|
2638
|
+
return fail(ctx, 500, "InternalServerError", "No se pudo completar la operación con Google Drive.");
|
|
2639
|
+
}
|
|
2640
|
+
};
|
|
2641
|
+
const body = (ctx) => ctx.request.body ?? {};
|
|
2642
|
+
const target = (raw) => {
|
|
2643
|
+
const cmsId = Number(raw.cmsId);
|
|
2644
|
+
if (raw.cmsType !== "file" && raw.cmsType !== "folder" || !Number.isInteger(cmsId) || cmsId <= 0) {
|
|
2645
|
+
throw new DriveInputError("Elemento de la Biblioteca inválido.");
|
|
2646
|
+
}
|
|
2647
|
+
return { cmsType: raw.cmsType, cmsId };
|
|
2648
|
+
};
|
|
2649
|
+
return {
|
|
2650
|
+
status: (ctx) => run(ctx, () => svc().status()),
|
|
2651
|
+
saveClient: (ctx) => run(ctx, () => svc().saveClient(body(ctx).json, body(ctx).redirectUri).then(() => void 0)),
|
|
2652
|
+
removeClient: (ctx) => run(ctx, () => svc().removeClient().then(() => void 0)),
|
|
2653
|
+
authorizationUrl: (ctx) => run(ctx, async () => ({ url: await svc().authorizationUrl(ctx.query.redirectUri) })),
|
|
2654
|
+
exchange: (ctx) => run(ctx, () => svc().exchange(body(ctx)).then(() => void 0)),
|
|
2655
|
+
disconnect: (ctx) => run(ctx, () => svc().disconnect().then(() => void 0)),
|
|
2656
|
+
links: (ctx) => run(ctx, () => svc().linkStatuses()),
|
|
2657
|
+
mark: (ctx) => run(ctx, async () => {
|
|
2658
|
+
const { cmsType, cmsId } = target(body(ctx));
|
|
2659
|
+
await svc().mark(cmsType, cmsId);
|
|
2660
|
+
}),
|
|
2661
|
+
unmark: (ctx) => run(ctx, async () => {
|
|
2662
|
+
const { cmsType, cmsId } = target(ctx.params ?? {});
|
|
2663
|
+
await svc().unmark(cmsType, cmsId);
|
|
2664
|
+
}),
|
|
2665
|
+
syncNow: (ctx) => run(ctx, () => svc().syncNow(body(ctx).full === true)),
|
|
2666
|
+
browse: (ctx) => run(ctx, () => svc().browse(ctx.params?.id, ctx.query.pageToken)),
|
|
2667
|
+
count: (ctx) => run(ctx, () => svc().countItem(ctx.params?.id)),
|
|
2668
|
+
trash: (ctx) => run(ctx, () => svc().trashItem(ctx.params?.id).then(() => void 0)),
|
|
2669
|
+
origin: (ctx) => run(ctx, () => svc().setOrigin(body(ctx).fileId, body(ctx).templateDocumentId, body(ctx).templateName).then(() => void 0))
|
|
2670
|
+
};
|
|
2671
|
+
};
|
|
1325
2672
|
const controllers = {
|
|
1326
2673
|
controller,
|
|
1327
2674
|
config: config$1,
|
|
1328
2675
|
"image-studio": imageStudio,
|
|
1329
2676
|
templates,
|
|
2677
|
+
drive,
|
|
1330
2678
|
articles
|
|
1331
2679
|
};
|
|
1332
2680
|
const middlewares = {};
|
|
@@ -1375,7 +2723,21 @@ const admin = [
|
|
|
1375
2723
|
{ method: "POST", path: "/template-folders/delete", handler: "templates.deleteFolder", config },
|
|
1376
2724
|
{ method: "GET", path: "/config", handler: "config.get", config },
|
|
1377
2725
|
{ method: "POST", path: "/config", handler: "config.set", config },
|
|
1378
|
-
{ method: "DELETE", path: "/config/vertex", handler: "config.removeVertex", config }
|
|
2726
|
+
{ method: "DELETE", path: "/config/vertex", handler: "config.removeVertex", config },
|
|
2727
|
+
{ method: "GET", path: "/drive/status", handler: "drive.status", config },
|
|
2728
|
+
{ method: "POST", path: "/drive/client", handler: "drive.saveClient", config },
|
|
2729
|
+
{ method: "DELETE", path: "/drive/client", handler: "drive.removeClient", config },
|
|
2730
|
+
{ method: "GET", path: "/drive/oauth/url", handler: "drive.authorizationUrl", config },
|
|
2731
|
+
{ method: "POST", path: "/drive/oauth/exchange", handler: "drive.exchange", config },
|
|
2732
|
+
{ method: "DELETE", path: "/drive/oauth", handler: "drive.disconnect", config },
|
|
2733
|
+
{ method: "GET", path: "/drive/links", handler: "drive.links", config },
|
|
2734
|
+
{ method: "POST", path: "/drive/links", handler: "drive.mark", config },
|
|
2735
|
+
{ method: "DELETE", path: "/drive/links/:cmsType/:cmsId", handler: "drive.unmark", config },
|
|
2736
|
+
{ method: "POST", path: "/drive/sync", handler: "drive.syncNow", config },
|
|
2737
|
+
{ method: "POST", path: "/drive/origin", handler: "drive.origin", config },
|
|
2738
|
+
{ method: "GET", path: "/drive/folders/:id", handler: "drive.browse", config },
|
|
2739
|
+
{ method: "GET", path: "/drive/items/:id/count", handler: "drive.count", config },
|
|
2740
|
+
{ method: "DELETE", path: "/drive/items/:id", handler: "drive.trash", config }
|
|
1379
2741
|
];
|
|
1380
2742
|
const contentApi = [];
|
|
1381
2743
|
const routes = {
|
|
@@ -2882,13 +4244,13 @@ function speedometer(samplesCount, min) {
|
|
|
2882
4244
|
let firstSampleTS;
|
|
2883
4245
|
min = min !== void 0 ? min : 1e3;
|
|
2884
4246
|
return function push(chunkLength) {
|
|
2885
|
-
const
|
|
4247
|
+
const now2 = Date.now();
|
|
2886
4248
|
const startedAt = timestamps[tail];
|
|
2887
4249
|
if (!firstSampleTS) {
|
|
2888
|
-
firstSampleTS =
|
|
4250
|
+
firstSampleTS = now2;
|
|
2889
4251
|
}
|
|
2890
4252
|
bytes[head] = chunkLength;
|
|
2891
|
-
timestamps[head] =
|
|
4253
|
+
timestamps[head] = now2;
|
|
2892
4254
|
let i = tail;
|
|
2893
4255
|
let bytesCount = 0;
|
|
2894
4256
|
while (i !== head) {
|
|
@@ -2899,10 +4261,10 @@ function speedometer(samplesCount, min) {
|
|
|
2899
4261
|
if (head === tail) {
|
|
2900
4262
|
tail = (tail + 1) % samplesCount;
|
|
2901
4263
|
}
|
|
2902
|
-
if (
|
|
4264
|
+
if (now2 - firstSampleTS < min) {
|
|
2903
4265
|
return;
|
|
2904
4266
|
}
|
|
2905
|
-
const passed = startedAt &&
|
|
4267
|
+
const passed = startedAt && now2 - startedAt;
|
|
2906
4268
|
return passed ? Math.round(bytesCount * 1e3 / passed) : void 0;
|
|
2907
4269
|
};
|
|
2908
4270
|
}
|
|
@@ -2911,8 +4273,8 @@ function throttle(fn, freq) {
|
|
|
2911
4273
|
let threshold = 1e3 / freq;
|
|
2912
4274
|
let lastArgs;
|
|
2913
4275
|
let timer;
|
|
2914
|
-
const invoke = (args,
|
|
2915
|
-
timestamp =
|
|
4276
|
+
const invoke = (args, now2 = Date.now()) => {
|
|
4277
|
+
timestamp = now2;
|
|
2916
4278
|
lastArgs = null;
|
|
2917
4279
|
if (timer) {
|
|
2918
4280
|
clearTimeout(timer);
|
|
@@ -2921,10 +4283,10 @@ function throttle(fn, freq) {
|
|
|
2921
4283
|
fn(...args);
|
|
2922
4284
|
};
|
|
2923
4285
|
const throttled = (...args) => {
|
|
2924
|
-
const
|
|
2925
|
-
const passed =
|
|
4286
|
+
const now2 = Date.now();
|
|
4287
|
+
const passed = now2 - timestamp;
|
|
2926
4288
|
if (passed >= threshold) {
|
|
2927
|
-
invoke(args,
|
|
4289
|
+
invoke(args, now2);
|
|
2928
4290
|
} else {
|
|
2929
4291
|
lastArgs = args;
|
|
2930
4292
|
if (!timer) {
|
|
@@ -4584,8 +5946,8 @@ axios.VERSION = VERSION$1;
|
|
|
4584
5946
|
axios.toFormData = toFormData$1;
|
|
4585
5947
|
axios.AxiosError = AxiosError$1;
|
|
4586
5948
|
axios.Cancel = axios.CanceledError;
|
|
4587
|
-
axios.all = function all(
|
|
4588
|
-
return Promise.all(
|
|
5949
|
+
axios.all = function all(promises2) {
|
|
5950
|
+
return Promise.all(promises2);
|
|
4589
5951
|
};
|
|
4590
5952
|
axios.spread = spread$1;
|
|
4591
5953
|
axios.isAxiosError = isAxiosError$1;
|
|
@@ -4973,6 +6335,7 @@ const services = {
|
|
|
4973
6335
|
service,
|
|
4974
6336
|
"image-studio": imageStudio$1,
|
|
4975
6337
|
templates: templates$1,
|
|
6338
|
+
drive: drive$1,
|
|
4976
6339
|
articles: articles$1
|
|
4977
6340
|
};
|
|
4978
6341
|
const index = {
|