@interware/content-generator 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/{App-CTh6owSM.mjs → App-2eX-mcfo.mjs} +2 -2
- package/dist/_chunks/{App-ugsCmlsC.js → App-CvZirjX_.js} +2 -2
- package/dist/_chunks/{ImageStudioTabs-Umf3GODW.mjs → ImageStudioTabs-B6fkCK-o.mjs} +353 -21
- package/dist/_chunks/{ImageStudioTabs-BFEF9ggK.js → ImageStudioTabs-CNktoRax.js} +351 -19
- package/dist/_chunks/{Settings-Sk78JUBS.js → Settings-B-QPodCh.js} +169 -1
- package/dist/_chunks/{Settings-CdGW2O8h.mjs → Settings-CaGruvIm.mjs} +170 -2
- package/dist/_chunks/{StudioApp-DoC0z4eh.js → StudioApp-BDoB31L0.js} +2 -2
- package/dist/_chunks/{StudioApp-CrGn-9Gg.mjs → StudioApp-CF03znuL.mjs} +2 -2
- package/dist/_chunks/{index-D6UAvh53.mjs → index-DlemRDzs.mjs} +3 -3
- package/dist/_chunks/{index-C2s9Jhcc.js → index-DwHGS-BJ.js} +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/LibraryTab.d.ts +5 -0
- package/dist/admin/src/features/library/driveSync.d.ts +41 -0
- package/dist/admin/src/features/templates/export/templateOrigin.d.ts +10 -0
- package/dist/admin/src/lib/use-cases/drive/index.d.ts +3 -0
- package/dist/server/index.js +1411 -49
- package/dist/server/index.mjs +1409 -47
- 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.mjs
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import { promises } from "fs";
|
|
3
3
|
import { HarmCategory, Modality, HarmBlockThreshold, GoogleGenAI, VideoGenerationReferenceType } from "@google/genai";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { readFile, writeFile, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
9
|
const PLUGIN_ID = "strapi-content-generator-gemini";
|
|
5
|
-
const FOLDER_UID = "plugin::upload.folder";
|
|
10
|
+
const FOLDER_UID$1 = "plugin::upload.folder";
|
|
6
11
|
const hasValidPath = (path) => /^(\/\d+)+$/.test(path);
|
|
7
12
|
const repairFolderPaths = async (strapi, name2) => {
|
|
8
|
-
const query = strapi.db.query(FOLDER_UID);
|
|
13
|
+
const query = strapi.db.query(FOLDER_UID$1);
|
|
9
14
|
const folders = await query.findMany({ where: { name: name2 }, populate: ["parent"] });
|
|
10
15
|
const repaired = [];
|
|
11
16
|
for (const f of folders) {
|
|
@@ -17,13 +22,34 @@ const repairFolderPaths = async (strapi, name2) => {
|
|
|
17
22
|
return repaired;
|
|
18
23
|
};
|
|
19
24
|
const ensureFolder = async (strapi, name2) => {
|
|
20
|
-
const folders = await strapi.db.query(FOLDER_UID).findMany({ where: { name: name2 }, populate: ["parent"] });
|
|
25
|
+
const folders = await strapi.db.query(FOLDER_UID$1).findMany({ where: { name: name2 }, populate: ["parent"] });
|
|
21
26
|
const atRoot = folders.find((f) => !f.parent);
|
|
22
27
|
if (atRoot) return atRoot.id;
|
|
23
28
|
const created = await strapi.plugin("upload").service("folder").create({ name: name2, parent: null });
|
|
24
29
|
return created.id;
|
|
25
30
|
};
|
|
31
|
+
const MODELS = { "plugin::upload.file": "file", "plugin::upload.folder": "folder" };
|
|
32
|
+
const subscribeUploadLifecycles = (strapi, onChange) => {
|
|
33
|
+
const notify = (event, id) => {
|
|
34
|
+
const cmsType = MODELS[event.model.uid];
|
|
35
|
+
if (!cmsType || typeof id !== "number") return;
|
|
36
|
+
onChange(cmsType, id).catch((err) => strapi.log.warn(`[drive] no se pudo encolar ${cmsType} ${id}: ${err.message}`));
|
|
37
|
+
};
|
|
38
|
+
strapi.db.lifecycles.subscribe({
|
|
39
|
+
models: Object.keys(MODELS),
|
|
40
|
+
afterCreate: (event) => notify(event, event.result?.id),
|
|
41
|
+
afterUpdate: (event) => notify(event, event.result?.id),
|
|
42
|
+
afterDelete: (event) => notify(event, event.result?.id),
|
|
43
|
+
// `deleteMany` solo trae el `where`: los ids se leen antes de borrar.
|
|
44
|
+
async beforeDeleteMany(event) {
|
|
45
|
+
const rows = await strapi.db.query(event.model.uid).findMany({ where: event.params?.where, select: ["id"] });
|
|
46
|
+
if (event.state) event.state.driveIds = rows.map((r) => r.id);
|
|
47
|
+
},
|
|
48
|
+
afterDeleteMany: (event) => (event.state?.driveIds ?? []).forEach((id) => notify(event, id))
|
|
49
|
+
});
|
|
50
|
+
};
|
|
26
51
|
const SCHEDULE_CRON = "content-generator-publicar-programados";
|
|
52
|
+
const DRIVE_CRON = "content-generator-drive";
|
|
27
53
|
const bootstrap = async ({ strapi }) => {
|
|
28
54
|
strapi.cron.add({
|
|
29
55
|
[SCHEDULE_CRON]: {
|
|
@@ -37,6 +63,19 @@ const bootstrap = async ({ strapi }) => {
|
|
|
37
63
|
options: { rule: "* * * * *" }
|
|
38
64
|
}
|
|
39
65
|
});
|
|
66
|
+
strapi.cron.add({
|
|
67
|
+
[DRIVE_CRON]: {
|
|
68
|
+
task: async () => {
|
|
69
|
+
try {
|
|
70
|
+
await strapi.plugin(PLUGIN_ID).service("drive").runCycle();
|
|
71
|
+
} catch (err) {
|
|
72
|
+
strapi.log.error(`[content-generator] sincronizar con Drive: ${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
options: { rule: "*/15 * * * *" }
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
subscribeUploadLifecycles(strapi, (cmsType, cmsId) => strapi.plugin(PLUGIN_ID).service("drive").track(cmsType, cmsId));
|
|
40
79
|
strapi.admin.services.permission.actionProvider.registerMany([
|
|
41
80
|
{
|
|
42
81
|
section: "plugins",
|
|
@@ -62,7 +101,7 @@ const config$2 = {
|
|
|
62
101
|
validator() {
|
|
63
102
|
}
|
|
64
103
|
};
|
|
65
|
-
const schema = {
|
|
104
|
+
const schema$2 = {
|
|
66
105
|
kind: "collectionType",
|
|
67
106
|
collectionName: "iw_image_templates",
|
|
68
107
|
info: {
|
|
@@ -91,9 +130,67 @@ const schema = {
|
|
|
91
130
|
replacesBase: { type: "string" }
|
|
92
131
|
}
|
|
93
132
|
};
|
|
94
|
-
const template = { schema };
|
|
133
|
+
const template = { schema: schema$2 };
|
|
134
|
+
const schema$1 = {
|
|
135
|
+
kind: "collectionType",
|
|
136
|
+
collectionName: "iw_drive_links",
|
|
137
|
+
info: {
|
|
138
|
+
singularName: "drive-link",
|
|
139
|
+
pluralName: "drive-links",
|
|
140
|
+
displayName: "Vínculo con Drive",
|
|
141
|
+
description: "Sincronización con Google Drive (content-generator)"
|
|
142
|
+
},
|
|
143
|
+
options: { draftAndPublish: false },
|
|
144
|
+
pluginOptions: {
|
|
145
|
+
"content-manager": { visible: false },
|
|
146
|
+
"content-type-builder": { visible: false }
|
|
147
|
+
},
|
|
148
|
+
attributes: {
|
|
149
|
+
cmsType: { type: "enumeration", enum: ["file", "folder"], required: true },
|
|
150
|
+
cmsId: { type: "integer", required: true },
|
|
151
|
+
/** Vacío mientras el archivo espera su primera subida. */
|
|
152
|
+
driveId: { type: "string" },
|
|
153
|
+
/** Lo marcó el usuario; los demás vínculos son contenido o ancestros de algo marcado. */
|
|
154
|
+
root: { type: "boolean", default: false },
|
|
155
|
+
name: { type: "string" },
|
|
156
|
+
parentCmsId: { type: "integer" },
|
|
157
|
+
/** Archivo: `url|size` de la última subida. */
|
|
158
|
+
version: { type: "string" },
|
|
159
|
+
/** `md5Checksum` de Drive de la última versión sincronizada (S7.4). */
|
|
160
|
+
driveRev: { type: "string" },
|
|
161
|
+
status: { type: "enumeration", enum: ["synced", "pending", "error", "skipped"], default: "pending" },
|
|
162
|
+
error: { type: "text" }
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const driveLink = { schema: schema$1 };
|
|
166
|
+
const schema = {
|
|
167
|
+
kind: "collectionType",
|
|
168
|
+
collectionName: "iw_drive_ops",
|
|
169
|
+
info: {
|
|
170
|
+
singularName: "drive-op",
|
|
171
|
+
pluralName: "drive-ops",
|
|
172
|
+
displayName: "Operación de Drive",
|
|
173
|
+
description: "Cola de la sincronización con Google Drive (content-generator)"
|
|
174
|
+
},
|
|
175
|
+
options: { draftAndPublish: false },
|
|
176
|
+
pluginOptions: {
|
|
177
|
+
"content-manager": { visible: false },
|
|
178
|
+
"content-type-builder": { visible: false }
|
|
179
|
+
},
|
|
180
|
+
attributes: {
|
|
181
|
+
cmsType: { type: "enumeration", enum: ["file", "folder"], required: true },
|
|
182
|
+
cmsId: { type: "integer", required: true },
|
|
183
|
+
attempts: { type: "integer", default: 0 },
|
|
184
|
+
/** Epoch en ms: no se procesa antes (espera creciente tras un error). */
|
|
185
|
+
runAfter: { type: "biginteger", default: "0" },
|
|
186
|
+
error: { type: "text" }
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const driveOp = { schema };
|
|
95
190
|
const contentTypes = {
|
|
96
|
-
template
|
|
191
|
+
template,
|
|
192
|
+
"drive-link": driveLink,
|
|
193
|
+
"drive-op": driveOp
|
|
97
194
|
};
|
|
98
195
|
const POST_MAX = 3e3;
|
|
99
196
|
const hashtags = (keywords) => keywords.map((k) => k.trim().replace(/\s+/g, "").replace(/[^\p{L}\p{N}_]/gu, "")).filter(Boolean).map((k) => `#${k}`).join(" ");
|
|
@@ -468,7 +565,7 @@ const parseEditInput = (fields, files) => {
|
|
|
468
565
|
aspectRatio: model.aspectRatios.includes(str(fields.aspectRatio)) ? str(fields.aspectRatio) : void 0
|
|
469
566
|
};
|
|
470
567
|
};
|
|
471
|
-
const fail$
|
|
568
|
+
const fail$4 = (ctx, status, name2, message) => {
|
|
472
569
|
ctx.status = status;
|
|
473
570
|
ctx.body = { data: null, error: { status, name: name2, message } };
|
|
474
571
|
};
|
|
@@ -478,13 +575,13 @@ const imageStudio = ({ strapi }) => {
|
|
|
478
575
|
try {
|
|
479
576
|
ctx.body = await work();
|
|
480
577
|
} catch (err) {
|
|
481
|
-
if (err instanceof InputError) return fail$
|
|
578
|
+
if (err instanceof InputError) return fail$4(ctx, 400, "BadRequestError", err.message);
|
|
482
579
|
if (err instanceof BlockedError) {
|
|
483
580
|
strapi.log.warn(`[image-studio] bloqueo: ${err.detail ?? err.message}`);
|
|
484
|
-
return fail$
|
|
581
|
+
return fail$4(ctx, 422, "UnprocessableEntityError", err.message);
|
|
485
582
|
}
|
|
486
583
|
strapi.log.error(`[image-studio] ${err instanceof Error ? err.message : String(err)}`);
|
|
487
|
-
return fail$
|
|
584
|
+
return fail$4(ctx, 502, "BadGatewayError", "El servicio de imágenes no respondió. Intenta de nuevo en un momento.");
|
|
488
585
|
}
|
|
489
586
|
};
|
|
490
587
|
return {
|
|
@@ -693,11 +790,11 @@ const ARTICLE_SCHEMA = {
|
|
|
693
790
|
const ZONA = "America/Mexico_City";
|
|
694
791
|
const UN_ANIO_MS = 365 * 24 * 60 * 60 * 1e3;
|
|
695
792
|
const fechaEnMexico = (date) => new Intl.DateTimeFormat("en-CA", { timeZone: ZONA, year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
|
|
696
|
-
const parseScheduleAt = (input,
|
|
793
|
+
const parseScheduleAt = (input, now2) => {
|
|
697
794
|
const date = typeof input === "string" ? new Date(input) : /* @__PURE__ */ new Date(NaN);
|
|
698
795
|
if (Number.isNaN(date.getTime())) throw new InputError("La fecha y hora no son válidas.");
|
|
699
|
-
if (date.getTime() <=
|
|
700
|
-
if (date.getTime() -
|
|
796
|
+
if (date.getTime() <= now2.getTime()) throw new InputError("Elige una fecha y hora futuras.");
|
|
797
|
+
if (date.getTime() - now2.getTime() > UN_ANIO_MS) throw new InputError("Programa con menos de un año de anticipación.");
|
|
701
798
|
return date;
|
|
702
799
|
};
|
|
703
800
|
const estadoDe = ({ publicado, programada }) => publicado ? "publicado" : programada ? "programado" : "borrador";
|
|
@@ -743,7 +840,7 @@ const toDetail = (d, publicado) => ({
|
|
|
743
840
|
keywords: (d.keywords ?? []).map((k) => k.name ?? "").filter(Boolean),
|
|
744
841
|
fuentes: Array.isArray(d.fuentes) ? d.fuentes : []
|
|
745
842
|
});
|
|
746
|
-
const createArticles = ({ articulos, categorias, getModels, now = () => /* @__PURE__ */ new Date(), log = silentLog, promptStore = memoryStore() }) => {
|
|
843
|
+
const createArticles = ({ articulos, categorias, getModels, now: now2 = () => /* @__PURE__ */ new Date(), log = silentLog, promptStore = memoryStore() }) => {
|
|
747
844
|
const draftOf = async (documentId, populate = DETAIL_POPULATE) => {
|
|
748
845
|
const d = await articulos.findOne({ documentId, status: "draft", populate });
|
|
749
846
|
if (!d) throw new NotFoundError("El artículo no existe.");
|
|
@@ -847,12 +944,12 @@ const createArticles = ({ articulos, categorias, getModels, now = () => /* @__PU
|
|
|
847
944
|
async publish(documentId) {
|
|
848
945
|
const d = await draftOf(documentId);
|
|
849
946
|
if (!d.image) throw new InputError("Elige una imagen antes de publicar.");
|
|
850
|
-
await articulos.update({ documentId, data: { fecha_publicacion: fechaEnMexico(
|
|
947
|
+
await articulos.update({ documentId, data: { fecha_publicacion: fechaEnMexico(now2()), publicacion_programada: null } });
|
|
851
948
|
await articulos.publish({ documentId });
|
|
852
949
|
return detail(documentId);
|
|
853
950
|
},
|
|
854
951
|
async schedule(documentId, at) {
|
|
855
|
-
const date = parseScheduleAt(at,
|
|
952
|
+
const date = parseScheduleAt(at, now2());
|
|
856
953
|
const d = await draftOf(documentId);
|
|
857
954
|
if (!d.image) throw new InputError("Elige una imagen antes de programar.");
|
|
858
955
|
if (await isPublished(documentId)) throw new InputError("El artículo ya está publicado.");
|
|
@@ -868,7 +965,7 @@ const createArticles = ({ articulos, categorias, getModels, now = () => /* @__PU
|
|
|
868
965
|
async publishDue() {
|
|
869
966
|
const due = await articulos.findMany({
|
|
870
967
|
status: "draft",
|
|
871
|
-
filters: { publicacion_programada: { $notNull: true, $lte:
|
|
968
|
+
filters: { publicacion_programada: { $notNull: true, $lte: now2().toISOString() } },
|
|
872
969
|
fields: ["publicacion_programada"],
|
|
873
970
|
populate: { image: { fields: ["id"] } }
|
|
874
971
|
});
|
|
@@ -910,7 +1007,7 @@ const articles$1 = ({ strapi }) => createArticles({
|
|
|
910
1007
|
};
|
|
911
1008
|
})()
|
|
912
1009
|
});
|
|
913
|
-
const fail$
|
|
1010
|
+
const fail$3 = (ctx, status, name2, message) => {
|
|
914
1011
|
ctx.status = status;
|
|
915
1012
|
ctx.body = { data: null, error: { status, name: name2, message } };
|
|
916
1013
|
};
|
|
@@ -921,11 +1018,11 @@ const articles = ({ strapi }) => {
|
|
|
921
1018
|
try {
|
|
922
1019
|
ctx.body = await work();
|
|
923
1020
|
} catch (err) {
|
|
924
|
-
if (err instanceof InputError) return fail$
|
|
925
|
-
if (err instanceof NotFoundError) return fail$
|
|
926
|
-
if (err instanceof Error && err.name === "ValidationError") return fail$
|
|
1021
|
+
if (err instanceof InputError) return fail$3(ctx, 400, "BadRequestError", err.message);
|
|
1022
|
+
if (err instanceof NotFoundError) return fail$3(ctx, 404, "NotFoundError", err.message);
|
|
1023
|
+
if (err instanceof Error && err.name === "ValidationError") return fail$3(ctx, 400, "ValidationError", err.message);
|
|
927
1024
|
strapi.log.error(`[articles] ${err instanceof Error ? err.message : String(err)}`);
|
|
928
|
-
return fail$
|
|
1025
|
+
return fail$3(ctx, 502, "BadGatewayError", "El servicio de generación no respondió. Intenta de nuevo en un momento.");
|
|
929
1026
|
}
|
|
930
1027
|
};
|
|
931
1028
|
return {
|
|
@@ -1091,7 +1188,7 @@ class TemplateInputError extends Error {
|
|
|
1091
1188
|
}
|
|
1092
1189
|
const LAYER_TYPES = ["text", "list", "quote", "rect", "shape", "badge", "logo", "image", "icon", "avatar", "qr"];
|
|
1093
1190
|
const MAX_LAYERS = 200;
|
|
1094
|
-
const fail$
|
|
1191
|
+
const fail$2 = (message) => {
|
|
1095
1192
|
throw new TemplateInputError(message);
|
|
1096
1193
|
};
|
|
1097
1194
|
const hasDataUrl = (value) => {
|
|
@@ -1102,26 +1199,26 @@ const hasDataUrl = (value) => {
|
|
|
1102
1199
|
};
|
|
1103
1200
|
const name = (v) => {
|
|
1104
1201
|
const n = typeof v === "string" ? v.trim() : "";
|
|
1105
|
-
if (!n || n.length > 120) fail$
|
|
1202
|
+
if (!n || n.length > 120) fail$2("El nombre debe tener entre 1 y 120 caracteres.");
|
|
1106
1203
|
return n;
|
|
1107
1204
|
};
|
|
1108
1205
|
const size = (v) => {
|
|
1109
|
-
if (!Number.isInteger(v) || v < 100 || v > 4e3) fail$
|
|
1206
|
+
if (!Number.isInteger(v) || v < 100 || v > 4e3) fail$2("Las medidas deben ser enteros de 100 a 4000 px.");
|
|
1110
1207
|
return v;
|
|
1111
1208
|
};
|
|
1112
1209
|
const nonNegative = (v, label) => {
|
|
1113
|
-
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) fail$
|
|
1210
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) fail$2(`${label} debe ser un número positivo.`);
|
|
1114
1211
|
return v;
|
|
1115
1212
|
};
|
|
1116
1213
|
const layers = (v) => {
|
|
1117
|
-
if (!Array.isArray(v) || v.length > MAX_LAYERS) fail$
|
|
1214
|
+
if (!Array.isArray(v) || v.length > MAX_LAYERS) fail$2(`Las capas deben ser una lista de hasta ${MAX_LAYERS}.`);
|
|
1118
1215
|
v.forEach((l, i) => {
|
|
1119
1216
|
const layer = l ?? {};
|
|
1120
|
-
if (typeof layer.id !== "string" || !layer.id) fail$
|
|
1121
|
-
if (!LAYER_TYPES.includes(layer.type)) fail$
|
|
1122
|
-
if (typeof layer.x !== "number" || typeof layer.y !== "number") fail$
|
|
1217
|
+
if (typeof layer.id !== "string" || !layer.id) fail$2(`La capa ${i + 1} no tiene id.`);
|
|
1218
|
+
if (!LAYER_TYPES.includes(layer.type)) fail$2(`La capa ${i + 1} tiene un tipo desconocido.`);
|
|
1219
|
+
if (typeof layer.x !== "number" || typeof layer.y !== "number") fail$2(`La capa ${i + 1} no tiene posición.`);
|
|
1123
1220
|
});
|
|
1124
|
-
if (hasDataUrl(v)) fail$
|
|
1221
|
+
if (hasDataUrl(v)) fail$2("Las imágenes deben guardarse en la Media Library, no dentro del template.");
|
|
1125
1222
|
return v;
|
|
1126
1223
|
};
|
|
1127
1224
|
const text = (v, fallback) => typeof v === "string" ? v : fallback;
|
|
@@ -1151,7 +1248,7 @@ const validateTemplatePatch = (input) => {
|
|
|
1151
1248
|
if ("safeMargin" in t) out.safeMargin = nonNegative(t.safeMargin, "El margen seguro");
|
|
1152
1249
|
if ("gridUnit" in t) out.gridUnit = nonNegative(t.gridUnit, "La cuadrícula");
|
|
1153
1250
|
if ("layers" in t) out.layers = layers(t.layers);
|
|
1154
|
-
if (!Object.keys(out).length) fail$
|
|
1251
|
+
if (!Object.keys(out).length) fail$2("Nada que actualizar.");
|
|
1155
1252
|
return out;
|
|
1156
1253
|
};
|
|
1157
1254
|
const TEMPLATE_UID = "plugin::strapi-content-generator-gemini.template";
|
|
@@ -1267,7 +1364,7 @@ const templates$1 = ({ strapi }) => {
|
|
|
1267
1364
|
{ get: (p) => store().get(p), set: (p) => store().set(p) }
|
|
1268
1365
|
);
|
|
1269
1366
|
};
|
|
1270
|
-
const fail = (ctx, status, name2, message) => {
|
|
1367
|
+
const fail$1 = (ctx, status, name2, message) => {
|
|
1271
1368
|
ctx.status = status;
|
|
1272
1369
|
ctx.body = { data: null, error: { status, name: name2, message } };
|
|
1273
1370
|
};
|
|
@@ -1278,10 +1375,10 @@ const templates = ({ strapi }) => {
|
|
|
1278
1375
|
ctx.body = await work();
|
|
1279
1376
|
ctx.status = status;
|
|
1280
1377
|
} catch (err) {
|
|
1281
|
-
if (err instanceof TemplateInputError) return fail(ctx, 400, "BadRequestError", err.message);
|
|
1282
|
-
if (err instanceof TemplateNotFoundError) return fail(ctx, 404, "NotFoundError", err.message);
|
|
1378
|
+
if (err instanceof TemplateInputError) return fail$1(ctx, 400, "BadRequestError", err.message);
|
|
1379
|
+
if (err instanceof TemplateNotFoundError) return fail$1(ctx, 404, "NotFoundError", err.message);
|
|
1283
1380
|
strapi.log.error(`[templates] ${err instanceof Error ? err.message : String(err)}`);
|
|
1284
|
-
return fail(ctx, 500, "InternalServerError", "No se pudo completar la operación con los templates.");
|
|
1381
|
+
return fail$1(ctx, 500, "InternalServerError", "No se pudo completar la operación con los templates.");
|
|
1285
1382
|
}
|
|
1286
1383
|
};
|
|
1287
1384
|
const body = (ctx) => ctx.request.body ?? {};
|
|
@@ -1305,11 +1402,1261 @@ const templates = ({ strapi }) => {
|
|
|
1305
1402
|
deleteFolder: (ctx) => run(ctx, async () => ({ folders: await svc().deleteFolder(body(ctx).path) }))
|
|
1306
1403
|
};
|
|
1307
1404
|
};
|
|
1405
|
+
const DRIVE_SCOPE = "https://www.googleapis.com/auth/drive";
|
|
1406
|
+
const OAUTH_STATE_KEY = "DRIVE_OAUTH_STATE";
|
|
1407
|
+
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
1408
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
1409
|
+
const REVOKE_URL = "https://oauth2.googleapis.com/revoke";
|
|
1410
|
+
const STATE_TTL_MS = 10 * 6e4;
|
|
1411
|
+
class DriveAuthError extends Error {
|
|
1412
|
+
constructor(message) {
|
|
1413
|
+
super(message);
|
|
1414
|
+
this.name = "DriveAuthError";
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
const authUrl = (client, redirectUri, state) => {
|
|
1418
|
+
const params = new URLSearchParams({
|
|
1419
|
+
client_id: client.clientId,
|
|
1420
|
+
redirect_uri: redirectUri,
|
|
1421
|
+
response_type: "code",
|
|
1422
|
+
scope: DRIVE_SCOPE,
|
|
1423
|
+
// Sin `offline` no hay refresh token; sin `consent`, Google no lo repite si la cuenta ya autorizó antes.
|
|
1424
|
+
access_type: "offline",
|
|
1425
|
+
prompt: "consent",
|
|
1426
|
+
include_granted_scopes: "true",
|
|
1427
|
+
state
|
|
1428
|
+
});
|
|
1429
|
+
return `${AUTH_URL}?${params}`;
|
|
1430
|
+
};
|
|
1431
|
+
const newState = async (store, now2 = Date.now()) => {
|
|
1432
|
+
const value = randomBytes(24).toString("hex");
|
|
1433
|
+
await store.set({ key: OAUTH_STATE_KEY, value: { value, expiresAt: now2 + STATE_TTL_MS } });
|
|
1434
|
+
return value;
|
|
1435
|
+
};
|
|
1436
|
+
const checkState = async (store, state, now2 = Date.now()) => {
|
|
1437
|
+
const saved = await store.get({ key: OAUTH_STATE_KEY });
|
|
1438
|
+
await store.delete({ key: OAUTH_STATE_KEY });
|
|
1439
|
+
return typeof state === "string" && !!saved?.value && saved.value === state && (saved.expiresAt ?? 0) >= now2;
|
|
1440
|
+
};
|
|
1441
|
+
const postToken = async (fetchFn, body) => {
|
|
1442
|
+
const res = await fetchFn(TOKEN_URL, {
|
|
1443
|
+
method: "POST",
|
|
1444
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1445
|
+
body: new URLSearchParams(body).toString()
|
|
1446
|
+
});
|
|
1447
|
+
const data = await res.json().catch(() => ({}));
|
|
1448
|
+
if (!res.ok || !data.access_token) throw new DriveAuthError(`Google rechazó la conexión (${data.error ?? `HTTP ${res.status}`}).`);
|
|
1449
|
+
return data;
|
|
1450
|
+
};
|
|
1451
|
+
const exchangeCode = async ({
|
|
1452
|
+
client,
|
|
1453
|
+
code,
|
|
1454
|
+
redirectUri,
|
|
1455
|
+
fetch: fetchFn
|
|
1456
|
+
}) => {
|
|
1457
|
+
const data = await postToken(fetchFn, {
|
|
1458
|
+
code,
|
|
1459
|
+
client_id: client.clientId,
|
|
1460
|
+
client_secret: client.clientSecret,
|
|
1461
|
+
redirect_uri: redirectUri,
|
|
1462
|
+
grant_type: "authorization_code"
|
|
1463
|
+
});
|
|
1464
|
+
if (!data.refresh_token) throw new DriveAuthError("Google no entregó un token de actualización. Intenta conectar de nuevo.");
|
|
1465
|
+
return { accessToken: data.access_token, expiresIn: data.expires_in ?? 3600, refreshToken: data.refresh_token };
|
|
1466
|
+
};
|
|
1467
|
+
const refreshAccessToken = async ({
|
|
1468
|
+
client,
|
|
1469
|
+
refreshToken,
|
|
1470
|
+
fetch: fetchFn
|
|
1471
|
+
}) => {
|
|
1472
|
+
const data = await postToken(fetchFn, {
|
|
1473
|
+
client_id: client.clientId,
|
|
1474
|
+
client_secret: client.clientSecret,
|
|
1475
|
+
refresh_token: refreshToken,
|
|
1476
|
+
grant_type: "refresh_token"
|
|
1477
|
+
});
|
|
1478
|
+
return { accessToken: data.access_token, expiresIn: data.expires_in ?? 3600 };
|
|
1479
|
+
};
|
|
1480
|
+
const revokeToken = async (token, fetchFn) => {
|
|
1481
|
+
await fetchFn(REVOKE_URL, {
|
|
1482
|
+
method: "POST",
|
|
1483
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1484
|
+
body: new URLSearchParams({ token })
|
|
1485
|
+
});
|
|
1486
|
+
};
|
|
1487
|
+
const API = "https://www.googleapis.com/drive/v3";
|
|
1488
|
+
const MAX_RETRIES = 4;
|
|
1489
|
+
const EXPIRY_MARGIN_MS = 6e4;
|
|
1490
|
+
const RATE_REASONS = /* @__PURE__ */ new Set(["rateLimitExceeded", "userRateLimitExceeded"]);
|
|
1491
|
+
class DriveNotConnectedError extends Error {
|
|
1492
|
+
constructor() {
|
|
1493
|
+
super("Google Drive no está conectado.");
|
|
1494
|
+
this.name = "DriveNotConnectedError";
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
class DriveApiError extends Error {
|
|
1498
|
+
constructor(status, message) {
|
|
1499
|
+
super(message);
|
|
1500
|
+
this.status = status;
|
|
1501
|
+
this.name = "DriveApiError";
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
const isRetryable = (status, body) => status === 429 || status >= 500 || status === 403 && !!body.error?.errors?.some((e) => RATE_REASONS.has(e.reason ?? ""));
|
|
1505
|
+
const createDriveClient = ({
|
|
1506
|
+
getCredentials,
|
|
1507
|
+
fetch: fetchFn,
|
|
1508
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
1509
|
+
now: now2 = Date.now
|
|
1510
|
+
}) => {
|
|
1511
|
+
let cached = null;
|
|
1512
|
+
const accessToken = async (force = false) => {
|
|
1513
|
+
if (!force && cached && cached.expiresAt - EXPIRY_MARGIN_MS > now2()) return cached.token;
|
|
1514
|
+
const credentials = await getCredentials();
|
|
1515
|
+
if (!credentials) throw new DriveNotConnectedError();
|
|
1516
|
+
const { accessToken: token, expiresIn } = await refreshAccessToken({ ...credentials, fetch: fetchFn });
|
|
1517
|
+
cached = { token, expiresAt: now2() + expiresIn * 1e3 };
|
|
1518
|
+
return token;
|
|
1519
|
+
};
|
|
1520
|
+
const send = async (path, init = {}) => {
|
|
1521
|
+
const url = path.startsWith("https://") ? path : `${API}${path}`;
|
|
1522
|
+
let renewed = false;
|
|
1523
|
+
for (let attempt = 0; ; attempt++) {
|
|
1524
|
+
const headers = new Headers(init.headers);
|
|
1525
|
+
headers.set("authorization", `Bearer ${await accessToken()}`);
|
|
1526
|
+
const res = await fetchFn(url, { ...init, headers });
|
|
1527
|
+
if (res.ok) return res;
|
|
1528
|
+
const body = await res.json().catch(() => ({}));
|
|
1529
|
+
if (res.status === 401 && !renewed) {
|
|
1530
|
+
renewed = true;
|
|
1531
|
+
await accessToken(true);
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
if (attempt < MAX_RETRIES && isRetryable(res.status, body)) {
|
|
1535
|
+
await sleep(1e3 * 2 ** attempt);
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
throw new DriveApiError(res.status, `Drive respondió ${res.status}: ${body.error?.message ?? res.statusText}`);
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
const request = async (path, init = {}) => {
|
|
1542
|
+
const res = await send(path, init);
|
|
1543
|
+
return res.status === 204 ? null : await res.json();
|
|
1544
|
+
};
|
|
1545
|
+
const download2 = async (path) => new Uint8Array(await (await send(path)).arrayBuffer());
|
|
1546
|
+
const about = async () => {
|
|
1547
|
+
const params = new URLSearchParams({ fields: "user(emailAddress),storageQuota(limit,usage)" });
|
|
1548
|
+
const data = await request(`/about?${params}`);
|
|
1549
|
+
return {
|
|
1550
|
+
email: data.user?.emailAddress ?? "",
|
|
1551
|
+
usage: Number(data.storageQuota?.usage ?? 0),
|
|
1552
|
+
limit: data.storageQuota?.limit ? Number(data.storageQuota.limit) : null
|
|
1553
|
+
};
|
|
1554
|
+
};
|
|
1555
|
+
return { request, download: download2, about, reset: () => void (cached = null) };
|
|
1556
|
+
};
|
|
1557
|
+
const DRIVE_CLIENT_KEY = "DRIVE_CLIENT";
|
|
1558
|
+
const asObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
1559
|
+
const readJson = (value) => {
|
|
1560
|
+
if (typeof value !== "string") return null;
|
|
1561
|
+
try {
|
|
1562
|
+
return asObject(JSON.parse(value));
|
|
1563
|
+
} catch {
|
|
1564
|
+
return null;
|
|
1565
|
+
}
|
|
1566
|
+
};
|
|
1567
|
+
const isText = (v) => typeof v === "string" && v.trim() !== "";
|
|
1568
|
+
const readClient = (data) => {
|
|
1569
|
+
const web = asObject(data.web);
|
|
1570
|
+
if (!web) return { ok: false, message: "El cliente debe ser de tipo Aplicación web." };
|
|
1571
|
+
const uris = Array.isArray(web.redirect_uris) ? web.redirect_uris.filter(isText) : [];
|
|
1572
|
+
const missing = [
|
|
1573
|
+
!isText(web.client_id) && "client_id",
|
|
1574
|
+
!isText(web.client_secret) && "client_secret",
|
|
1575
|
+
uris.length === 0 && "redirect_uris"
|
|
1576
|
+
].filter(Boolean);
|
|
1577
|
+
if (missing.length) return { ok: false, message: `Al JSON del cliente le falta ${missing.join(", ")}.` };
|
|
1578
|
+
return {
|
|
1579
|
+
ok: true,
|
|
1580
|
+
client: { clientId: web.client_id, clientSecret: web.client_secret, redirectUris: uris }
|
|
1581
|
+
};
|
|
1582
|
+
};
|
|
1583
|
+
const parseClientConfig = (value, redirectUri) => {
|
|
1584
|
+
const data = readJson(value);
|
|
1585
|
+
if (!data) return { ok: false, message: "El archivo no es un JSON válido." };
|
|
1586
|
+
const result = readClient(data);
|
|
1587
|
+
if ("client" in result && !result.client.redirectUris.includes(redirectUri)) {
|
|
1588
|
+
return { ok: false, message: `Este cliente no tiene autorizada la URL ${redirectUri}. Agrégala en Google Cloud.` };
|
|
1589
|
+
}
|
|
1590
|
+
return result;
|
|
1591
|
+
};
|
|
1592
|
+
const storedClient = (client) => JSON.stringify({ web: { client_id: client.clientId, client_secret: client.clientSecret, redirect_uris: client.redirectUris } });
|
|
1593
|
+
const loadClient = (stored) => {
|
|
1594
|
+
const data = readJson(stored);
|
|
1595
|
+
if (!data) return null;
|
|
1596
|
+
const result = readClient(data);
|
|
1597
|
+
return "client" in result ? result.client : null;
|
|
1598
|
+
};
|
|
1599
|
+
const publicClient = (stored) => {
|
|
1600
|
+
const client = loadClient(stored);
|
|
1601
|
+
if (!client) return { configured: false, clientId: null, last4: null };
|
|
1602
|
+
return { configured: true, clientId: client.clientId, last4: maskSecret(client.clientSecret).last4 };
|
|
1603
|
+
};
|
|
1604
|
+
const DRIVE_ROOT = "root";
|
|
1605
|
+
const FOLDER_MIME = "application/vnd.google-apps.folder";
|
|
1606
|
+
const UPLOAD = "https://www.googleapis.com/upload/drive/v3/files";
|
|
1607
|
+
const quote = (value) => `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
1608
|
+
const findChild = async (http, parentId, name2) => {
|
|
1609
|
+
const params = new URLSearchParams({
|
|
1610
|
+
q: `${quote(parentId)} in parents and name = ${quote(name2)} and trashed = false`,
|
|
1611
|
+
fields: "files(id,name,mimeType)",
|
|
1612
|
+
pageSize: "10",
|
|
1613
|
+
spaces: "drive"
|
|
1614
|
+
});
|
|
1615
|
+
const { files } = await http.request(`/files?${params}`);
|
|
1616
|
+
return files?.[0] ?? null;
|
|
1617
|
+
};
|
|
1618
|
+
const createFolder = async (http, parentId, name2) => {
|
|
1619
|
+
const { id } = await http.request("/files?fields=id", {
|
|
1620
|
+
method: "POST",
|
|
1621
|
+
headers: { "content-type": "application/json" },
|
|
1622
|
+
body: JSON.stringify({ name: name2, mimeType: FOLDER_MIME, parents: [parentId] })
|
|
1623
|
+
});
|
|
1624
|
+
return id;
|
|
1625
|
+
};
|
|
1626
|
+
const multipart = (metadata, mime, content) => {
|
|
1627
|
+
const boundary = `iw-${randomBytes(12).toString("hex")}`;
|
|
1628
|
+
const head = `--${boundary}\r
|
|
1629
|
+
Content-Type: application/json; charset=UTF-8\r
|
|
1630
|
+
\r
|
|
1631
|
+
${JSON.stringify(metadata)}\r
|
|
1632
|
+
--${boundary}\r
|
|
1633
|
+
Content-Type: ${mime}\r
|
|
1634
|
+
\r
|
|
1635
|
+
`;
|
|
1636
|
+
const body = Buffer.concat([Buffer.from(head, "utf8"), Buffer.from(content), Buffer.from(`\r
|
|
1637
|
+
--${boundary}--`, "utf8")]);
|
|
1638
|
+
return { body, contentType: `multipart/related; boundary=${boundary}` };
|
|
1639
|
+
};
|
|
1640
|
+
const uploadFile = async (http, parentId, file) => {
|
|
1641
|
+
const metadata = { name: file.name, parents: [parentId], ...file.description ? { description: file.description } : {} };
|
|
1642
|
+
const { body, contentType } = multipart(metadata, file.mime, file.content);
|
|
1643
|
+
const { id, md5Checksum } = await http.request(`${UPLOAD}?uploadType=multipart&fields=id,md5Checksum`, {
|
|
1644
|
+
method: "POST",
|
|
1645
|
+
headers: { "content-type": contentType },
|
|
1646
|
+
body
|
|
1647
|
+
});
|
|
1648
|
+
return { id, md5: md5Checksum ?? null };
|
|
1649
|
+
};
|
|
1650
|
+
const updateContent = async (http, driveId, file) => {
|
|
1651
|
+
const metadata = { name: file.name, ...file.description ? { description: file.description } : {} };
|
|
1652
|
+
const { body, contentType } = multipart(metadata, file.mime, file.content);
|
|
1653
|
+
const { md5Checksum } = await http.request(
|
|
1654
|
+
`${UPLOAD}/${encodeURIComponent(driveId)}?uploadType=multipart&fields=id,md5Checksum`,
|
|
1655
|
+
{ method: "PATCH", headers: { "content-type": contentType }, body }
|
|
1656
|
+
);
|
|
1657
|
+
return { id: driveId, md5: md5Checksum ?? null };
|
|
1658
|
+
};
|
|
1659
|
+
const updateMeta = async (http, driveId, { name: name2, from, to }) => {
|
|
1660
|
+
const params = new URLSearchParams({ fields: "id" });
|
|
1661
|
+
if (to && from && to !== from) {
|
|
1662
|
+
params.set("addParents", to);
|
|
1663
|
+
params.set("removeParents", from);
|
|
1664
|
+
}
|
|
1665
|
+
await http.request(`/files/${encodeURIComponent(driveId)}?${params}`, {
|
|
1666
|
+
method: "PATCH",
|
|
1667
|
+
headers: { "content-type": "application/json" },
|
|
1668
|
+
body: JSON.stringify(name2 ? { name: name2 } : {})
|
|
1669
|
+
});
|
|
1670
|
+
};
|
|
1671
|
+
const trash = async (http, driveId) => {
|
|
1672
|
+
try {
|
|
1673
|
+
await http.request(`/files/${encodeURIComponent(driveId)}?fields=id`, {
|
|
1674
|
+
method: "PATCH",
|
|
1675
|
+
headers: { "content-type": "application/json" },
|
|
1676
|
+
body: JSON.stringify({ trashed: true })
|
|
1677
|
+
});
|
|
1678
|
+
} catch (err) {
|
|
1679
|
+
if (err instanceof DriveApiError && err.status === 404) return;
|
|
1680
|
+
throw err;
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
const download = async (http, driveId) => {
|
|
1684
|
+
if (!http.download) throw new Error("El cliente de Drive no puede descargar.");
|
|
1685
|
+
return http.download(`/files/${encodeURIComponent(driveId)}?alt=media`);
|
|
1686
|
+
};
|
|
1687
|
+
const ENTRY_FIELDS = "id,name,mimeType,size,modifiedTime,webViewLink,parents";
|
|
1688
|
+
const toEntry = (f) => ({
|
|
1689
|
+
id: f.id,
|
|
1690
|
+
name: f.name,
|
|
1691
|
+
mimeType: f.mimeType,
|
|
1692
|
+
size: f.size ? Number(f.size) : null,
|
|
1693
|
+
modifiedTime: f.modifiedTime ?? null,
|
|
1694
|
+
webViewLink: f.webViewLink ?? null,
|
|
1695
|
+
parents: f.parents ?? []
|
|
1696
|
+
});
|
|
1697
|
+
const listChildren = async (http, parentId, pageToken) => {
|
|
1698
|
+
const params = new URLSearchParams({
|
|
1699
|
+
q: `${quote(parentId)} in parents and trashed = false`,
|
|
1700
|
+
fields: `nextPageToken,files(${ENTRY_FIELDS})`,
|
|
1701
|
+
orderBy: "folder,name_natural",
|
|
1702
|
+
pageSize: "200",
|
|
1703
|
+
spaces: "drive"
|
|
1704
|
+
});
|
|
1705
|
+
if (pageToken) params.set("pageToken", pageToken);
|
|
1706
|
+
const data = await http.request(`/files?${params}`);
|
|
1707
|
+
return { items: (data.files ?? []).map(toEntry), nextPageToken: data.nextPageToken ?? null };
|
|
1708
|
+
};
|
|
1709
|
+
const getFile = async (http, id) => toEntry(await http.request(`/files/${encodeURIComponent(id)}?fields=${ENTRY_FIELDS}`));
|
|
1710
|
+
const countTree = async (http, folderId, limit = 1e3) => {
|
|
1711
|
+
let files = 0;
|
|
1712
|
+
let folders = 0;
|
|
1713
|
+
const queue = [folderId];
|
|
1714
|
+
while (queue.length && files + folders < limit) {
|
|
1715
|
+
const parent = queue.shift();
|
|
1716
|
+
let token;
|
|
1717
|
+
do {
|
|
1718
|
+
const page = await listChildren(http, parent, token);
|
|
1719
|
+
for (const item of page.items) {
|
|
1720
|
+
if (item.mimeType === FOLDER_MIME) {
|
|
1721
|
+
folders++;
|
|
1722
|
+
queue.push(item.id);
|
|
1723
|
+
} else files++;
|
|
1724
|
+
}
|
|
1725
|
+
token = page.nextPageToken ?? void 0;
|
|
1726
|
+
} while (token && files + folders < limit);
|
|
1727
|
+
}
|
|
1728
|
+
return { files, folders, truncated: files + folders >= limit };
|
|
1729
|
+
};
|
|
1730
|
+
const getMeta = async (http, id) => {
|
|
1731
|
+
try {
|
|
1732
|
+
return await http.request(
|
|
1733
|
+
`/files/${encodeURIComponent(id)}?fields=id,name,mimeType,parents,trashed,md5Checksum,size`
|
|
1734
|
+
);
|
|
1735
|
+
} catch (err) {
|
|
1736
|
+
if (err instanceof DriveApiError && err.status === 404) return null;
|
|
1737
|
+
throw err;
|
|
1738
|
+
}
|
|
1739
|
+
};
|
|
1740
|
+
const rootId = async (http) => (await http.request("/files/root?fields=id")).id;
|
|
1741
|
+
const startPageToken = async (http) => (await http.request("/changes/startPageToken")).startPageToken;
|
|
1742
|
+
const CHANGE_FIELDS = "nextPageToken,newStartPageToken,changes(fileId,removed,file(id,name,mimeType,parents,trashed,md5Checksum,size))";
|
|
1743
|
+
const listChanges = async (http, pageToken) => {
|
|
1744
|
+
const params = new URLSearchParams({ pageToken, fields: CHANGE_FIELDS, includeRemoved: "true", spaces: "drive", pageSize: "1000" });
|
|
1745
|
+
return http.request(`/changes?${params}`);
|
|
1746
|
+
};
|
|
1747
|
+
const driveOps = (http) => ({
|
|
1748
|
+
findChild: (parentId, name2) => findChild(http, parentId, name2),
|
|
1749
|
+
createFolder: (parentId, name2) => createFolder(http, parentId, name2),
|
|
1750
|
+
uploadFile: (parentId, file) => uploadFile(http, parentId, file),
|
|
1751
|
+
updateContent: (driveId, file) => updateContent(http, driveId, file),
|
|
1752
|
+
updateMeta: (driveId, change) => updateMeta(http, driveId, change),
|
|
1753
|
+
trash: (driveId) => trash(http, driveId),
|
|
1754
|
+
download: (driveId) => download(http, driveId),
|
|
1755
|
+
startPageToken: () => startPageToken(http),
|
|
1756
|
+
rootId: () => rootId(http),
|
|
1757
|
+
listChildren: (parentId, pageToken) => listChildren(http, parentId, pageToken),
|
|
1758
|
+
getFile: (id) => getFile(http, id),
|
|
1759
|
+
getMeta: (id) => getMeta(http, id),
|
|
1760
|
+
countTree: (folderId) => countTree(http, folderId),
|
|
1761
|
+
listChanges: (pageToken) => listChanges(http, pageToken)
|
|
1762
|
+
});
|
|
1763
|
+
class DriveSyncError extends Error {
|
|
1764
|
+
constructor(message, status = 400) {
|
|
1765
|
+
super(message);
|
|
1766
|
+
this.status = status;
|
|
1767
|
+
this.name = "DriveSyncError";
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
const collisionMessage = (name2) => `Ya existe «${name2}» en Drive y no se sincronizó desde el CMS. Cambia el nombre para sincronizarla.`;
|
|
1771
|
+
const pathIds = (path) => path.split("/").filter(Boolean).map(Number).filter((n) => Number.isInteger(n));
|
|
1772
|
+
const OUTSIDE = "Solo se sincroniza lo que está dentro de «Generated».";
|
|
1773
|
+
const ROOT_ITSELF = "Marca una carpeta o archivo dentro de «Generated», no la raíz.";
|
|
1774
|
+
const load = async (cms, cmsType, cmsId) => {
|
|
1775
|
+
if (cmsType === "folder") {
|
|
1776
|
+
const folder = await cms.folder(cmsId);
|
|
1777
|
+
if (folder) return { type: "folder", folder };
|
|
1778
|
+
} else {
|
|
1779
|
+
const file = await cms.file(cmsId);
|
|
1780
|
+
if (file) return { type: "file", file };
|
|
1781
|
+
}
|
|
1782
|
+
throw new DriveSyncError("El elemento ya no existe en la Biblioteca.");
|
|
1783
|
+
};
|
|
1784
|
+
const ancestorsOf = async (cms, root, path) => {
|
|
1785
|
+
const ids = pathIds(path);
|
|
1786
|
+
if (ids[0] !== root.pathId) return null;
|
|
1787
|
+
const below = ids.slice(1);
|
|
1788
|
+
if (!below.length) return [];
|
|
1789
|
+
const folders = await cms.foldersByPathIds(below);
|
|
1790
|
+
const byPathId = new Map(folders.map((f) => [f.pathId, f]));
|
|
1791
|
+
return below.map((p) => byPathId.get(p)).filter((f) => !!f);
|
|
1792
|
+
};
|
|
1793
|
+
const containerPath = (item) => item.type === "folder" ? item.folder.path.slice(0, item.folder.path.lastIndexOf("/")) || "/" : item.file.folderPath;
|
|
1794
|
+
const ensureFolderLink = async (deps, folder, parentDriveId, root) => {
|
|
1795
|
+
const existing = await deps.links.find("folder", folder.id);
|
|
1796
|
+
if (existing?.driveId) return root && !existing.root ? deps.links.update(existing.id, { root: true }) : existing;
|
|
1797
|
+
if (await deps.drive.findChild(parentDriveId, folder.name)) throw new DriveSyncError(collisionMessage(folder.name), 409);
|
|
1798
|
+
const driveId = await deps.drive.createFolder(parentDriveId, folder.name);
|
|
1799
|
+
const data = { driveId, name: folder.name, parentCmsId: folder.parentId, status: "synced", error: null };
|
|
1800
|
+
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 });
|
|
1801
|
+
};
|
|
1802
|
+
const mark = async (deps, cmsType, cmsId) => {
|
|
1803
|
+
const root = await deps.cms.generatedRoot();
|
|
1804
|
+
if (!root) throw new DriveSyncError(OUTSIDE);
|
|
1805
|
+
if (cmsType === "folder" && cmsId === root.id) throw new DriveSyncError(ROOT_ITSELF);
|
|
1806
|
+
const item = await load(deps.cms, cmsType, cmsId);
|
|
1807
|
+
const ancestors = await ancestorsOf(deps.cms, root, containerPath(item));
|
|
1808
|
+
if (!ancestors || item.type === "file" && item.file.folderId === null) throw new DriveSyncError(OUTSIDE);
|
|
1809
|
+
let parentDriveId = DRIVE_ROOT;
|
|
1810
|
+
for (const folder of ancestors) parentDriveId = (await ensureFolderLink(deps, folder, parentDriveId, false)).driveId;
|
|
1811
|
+
if (item.type === "folder") {
|
|
1812
|
+
await ensureFolderLink(deps, item.folder, parentDriveId, true);
|
|
1813
|
+
} else {
|
|
1814
|
+
const existing = await deps.links.find("file", item.file.id);
|
|
1815
|
+
if (existing) {
|
|
1816
|
+
if (!existing.root) await deps.links.update(existing.id, { root: true });
|
|
1817
|
+
} else {
|
|
1818
|
+
if (await deps.drive.findChild(parentDriveId, item.file.name)) throw new DriveSyncError(collisionMessage(item.file.name), 409);
|
|
1819
|
+
await deps.links.create({
|
|
1820
|
+
cmsType: "file",
|
|
1821
|
+
cmsId: item.file.id,
|
|
1822
|
+
driveId: null,
|
|
1823
|
+
root: true,
|
|
1824
|
+
name: null,
|
|
1825
|
+
parentCmsId: null,
|
|
1826
|
+
version: null,
|
|
1827
|
+
driveRev: null,
|
|
1828
|
+
status: "pending",
|
|
1829
|
+
error: null
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
await deps.ops.push(cmsType, cmsId);
|
|
1834
|
+
};
|
|
1835
|
+
const neededLinkIds = async (deps) => {
|
|
1836
|
+
const all3 = await deps.links.all();
|
|
1837
|
+
const rootPaths = [];
|
|
1838
|
+
const ancestorPathIds = /* @__PURE__ */ new Set();
|
|
1839
|
+
const keep = /* @__PURE__ */ new Set();
|
|
1840
|
+
for (const link of all3.filter((l) => l.root)) {
|
|
1841
|
+
keep.add(link.id);
|
|
1842
|
+
if (link.cmsType === "folder") {
|
|
1843
|
+
const folder = await deps.cms.folder(link.cmsId);
|
|
1844
|
+
if (!folder) continue;
|
|
1845
|
+
rootPaths.push(folder.path);
|
|
1846
|
+
pathIds(folder.path).slice(0, -1).forEach((p) => ancestorPathIds.add(p));
|
|
1847
|
+
} else {
|
|
1848
|
+
const file = await deps.cms.file(link.cmsId);
|
|
1849
|
+
if (file) pathIds(file.folderPath).forEach((p) => ancestorPathIds.add(p));
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
const under = (path) => rootPaths.some((r) => path === r || path.startsWith(`${r}/`));
|
|
1853
|
+
for (const link of all3) {
|
|
1854
|
+
if (keep.has(link.id)) continue;
|
|
1855
|
+
if (link.cmsType === "folder") {
|
|
1856
|
+
const folder = await deps.cms.folder(link.cmsId);
|
|
1857
|
+
if (folder && (under(folder.path) || ancestorPathIds.has(folder.pathId))) keep.add(link.id);
|
|
1858
|
+
} else {
|
|
1859
|
+
const file = await deps.cms.file(link.cmsId);
|
|
1860
|
+
if (file && under(file.folderPath)) keep.add(link.id);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
return keep;
|
|
1864
|
+
};
|
|
1865
|
+
const unmark = async (deps, cmsType, cmsId) => {
|
|
1866
|
+
const link = await deps.links.find(cmsType, cmsId);
|
|
1867
|
+
if (!link?.root) throw new DriveSyncError("Ese elemento no está marcado para sincronizar.");
|
|
1868
|
+
await deps.links.update(link.id, { root: false });
|
|
1869
|
+
const keep = await neededLinkIds(deps);
|
|
1870
|
+
for (const l of await deps.links.all()) if (!keep.has(l.id)) await deps.links.remove(l.id);
|
|
1871
|
+
};
|
|
1872
|
+
const isSynced = async (deps, cmsType, cmsId) => {
|
|
1873
|
+
const all3 = await deps.links.all();
|
|
1874
|
+
const roots = all3.filter((l) => l.root);
|
|
1875
|
+
if (roots.some((l) => l.cmsType === cmsType && l.cmsId === cmsId)) return true;
|
|
1876
|
+
const path = cmsType === "folder" ? (await deps.cms.folder(cmsId))?.path : (await deps.cms.file(cmsId))?.folderPath;
|
|
1877
|
+
if (!path) return false;
|
|
1878
|
+
for (const l of roots.filter((r) => r.cmsType === "folder")) {
|
|
1879
|
+
const folder = await deps.cms.folder(l.cmsId);
|
|
1880
|
+
if (folder && (path === folder.path ? cmsType === "file" : path.startsWith(`${folder.path}/`))) return true;
|
|
1881
|
+
}
|
|
1882
|
+
return false;
|
|
1883
|
+
};
|
|
1884
|
+
const FOLDER_UID = "plugin::upload.folder";
|
|
1885
|
+
const FILE_UID = "plugin::upload.file";
|
|
1886
|
+
const LINK_UID = "plugin::strapi-content-generator-gemini.drive-link";
|
|
1887
|
+
const OP_UID = "plugin::strapi-content-generator-gemini.drive-op";
|
|
1888
|
+
const toFolder = (r) => ({
|
|
1889
|
+
id: r.id,
|
|
1890
|
+
pathId: Number(r.pathId),
|
|
1891
|
+
name: r.name,
|
|
1892
|
+
path: r.path,
|
|
1893
|
+
parentId: r.parent?.id ?? null
|
|
1894
|
+
});
|
|
1895
|
+
const toFile = (r) => ({
|
|
1896
|
+
id: r.id,
|
|
1897
|
+
name: r.name,
|
|
1898
|
+
folderId: r.folder?.id ?? null,
|
|
1899
|
+
folderPath: r.folderPath ?? "/",
|
|
1900
|
+
url: r.url ?? "",
|
|
1901
|
+
size: Number(r.size ?? 0),
|
|
1902
|
+
mime: r.mime ?? "application/octet-stream"
|
|
1903
|
+
});
|
|
1904
|
+
const strapiCms = (strapi, fetchFn = fetch) => {
|
|
1905
|
+
const folders = () => strapi.db.query(FOLDER_UID);
|
|
1906
|
+
const files = () => strapi.db.query(FILE_UID);
|
|
1907
|
+
const withParent = { populate: ["parent"] };
|
|
1908
|
+
const withFolder = { populate: ["folder"] };
|
|
1909
|
+
return {
|
|
1910
|
+
async generatedRoot() {
|
|
1911
|
+
const rows = await folders().findMany({ where: { name: "Generated" }, ...withParent });
|
|
1912
|
+
const root = rows.find((r) => !r.parent);
|
|
1913
|
+
return root ? toFolder(root) : null;
|
|
1914
|
+
},
|
|
1915
|
+
async folder(id) {
|
|
1916
|
+
const r = await folders().findOne({ where: { id }, ...withParent });
|
|
1917
|
+
return r ? toFolder(r) : null;
|
|
1918
|
+
},
|
|
1919
|
+
async file(id) {
|
|
1920
|
+
const r = await files().findOne({ where: { id }, ...withFolder });
|
|
1921
|
+
return r ? toFile(r) : null;
|
|
1922
|
+
},
|
|
1923
|
+
async foldersByPathIds(pathIds2) {
|
|
1924
|
+
if (!pathIds2.length) return [];
|
|
1925
|
+
return (await folders().findMany({ where: { pathId: { $in: pathIds2 } }, ...withParent })).map(toFolder);
|
|
1926
|
+
},
|
|
1927
|
+
async childFolders(folderId) {
|
|
1928
|
+
return (await folders().findMany({ where: { parent: { id: folderId } }, ...withParent })).map(toFolder);
|
|
1929
|
+
},
|
|
1930
|
+
async childFiles(folderId) {
|
|
1931
|
+
return (await files().findMany({ where: { folder: { id: folderId } }, ...withFolder })).map(toFile);
|
|
1932
|
+
},
|
|
1933
|
+
async readFile(file) {
|
|
1934
|
+
if (/^https?:\/\//.test(file.url)) {
|
|
1935
|
+
const res = await fetchFn(file.url);
|
|
1936
|
+
if (!res.ok) throw new Error(`No se pudo leer ${file.name} (${res.status}).`);
|
|
1937
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
1938
|
+
}
|
|
1939
|
+
return new Uint8Array(await readFile(join(strapi.dirs.static.public, file.url)));
|
|
1940
|
+
}
|
|
1941
|
+
};
|
|
1942
|
+
};
|
|
1943
|
+
const strapiCmsWriter = (strapi) => {
|
|
1944
|
+
const upload = () => strapi.plugin("upload").service("upload");
|
|
1945
|
+
const folderService = () => strapi.plugin("upload").service("folder");
|
|
1946
|
+
const fileById = async (id) => toFile(await strapi.db.query(FILE_UID).findOne({ where: { id }, populate: ["folder"] }));
|
|
1947
|
+
const system = { user: { id: null } };
|
|
1948
|
+
const withTempFile = async (name2, mime, content, work) => {
|
|
1949
|
+
const filepath = join(tmpdir(), `iw-drive-${randomBytes(8).toString("hex")}`);
|
|
1950
|
+
await writeFile(filepath, content);
|
|
1951
|
+
try {
|
|
1952
|
+
return await work({ filepath, originalFilename: name2, mimetype: mime, size: content.length });
|
|
1953
|
+
} finally {
|
|
1954
|
+
await rm(filepath, { force: true });
|
|
1955
|
+
}
|
|
1956
|
+
};
|
|
1957
|
+
return {
|
|
1958
|
+
uploadFile: (folderId, name2, mime, content) => withTempFile(name2, mime, content, async (file) => {
|
|
1959
|
+
const [row] = await upload().upload({ data: { fileInfo: { name: name2, folder: folderId } }, files: file });
|
|
1960
|
+
return fileById(row.id);
|
|
1961
|
+
}),
|
|
1962
|
+
replaceFile: async (id, name2, mime, content) => {
|
|
1963
|
+
const current = await strapi.db.query(FILE_UID).findOne({ where: { id }, populate: ["folder"] });
|
|
1964
|
+
return withTempFile(name2, mime, content, async (file) => {
|
|
1965
|
+
await upload().replace(id, { data: { fileInfo: { name: name2, folder: current?.folder?.id ?? null } }, file });
|
|
1966
|
+
return fileById(id);
|
|
1967
|
+
});
|
|
1968
|
+
},
|
|
1969
|
+
updateFile: async (id, { name: name2, folderId }) => {
|
|
1970
|
+
await upload().updateFileInfo(id, { name: name2, ...folderId !== void 0 ? { folder: folderId } : {} });
|
|
1971
|
+
},
|
|
1972
|
+
deleteFile: async (id) => {
|
|
1973
|
+
const row = await strapi.db.query(FILE_UID).findOne({ where: { id } });
|
|
1974
|
+
if (row) await upload().remove(row);
|
|
1975
|
+
},
|
|
1976
|
+
createFolder: async (name2, parentId) => {
|
|
1977
|
+
const row = await folderService().create({ name: name2, parent: parentId });
|
|
1978
|
+
return toFolder({ ...row, parent: { id: parentId } });
|
|
1979
|
+
},
|
|
1980
|
+
updateFolder: async (id, { name: name2, parentId }) => {
|
|
1981
|
+
const current = await strapi.db.query(FOLDER_UID).findOne({ where: { id }, populate: ["parent"] });
|
|
1982
|
+
if (!current) return;
|
|
1983
|
+
await folderService().update(id, { name: name2 ?? current.name, ...parentId !== void 0 ? { parent: parentId } : {} }, system);
|
|
1984
|
+
},
|
|
1985
|
+
deleteFolder: async (id) => {
|
|
1986
|
+
await folderService().deleteByIds([id]);
|
|
1987
|
+
}
|
|
1988
|
+
};
|
|
1989
|
+
};
|
|
1990
|
+
const toLink = (r) => ({
|
|
1991
|
+
id: r.id,
|
|
1992
|
+
cmsType: r.cmsType,
|
|
1993
|
+
cmsId: Number(r.cmsId),
|
|
1994
|
+
driveId: r.driveId ?? null,
|
|
1995
|
+
root: !!r.root,
|
|
1996
|
+
name: r.name ?? null,
|
|
1997
|
+
parentCmsId: r.parentCmsId ?? null,
|
|
1998
|
+
version: r.version ?? null,
|
|
1999
|
+
driveRev: r.driveRev ?? null,
|
|
2000
|
+
status: r.status ?? "pending",
|
|
2001
|
+
error: r.error ?? null
|
|
2002
|
+
});
|
|
2003
|
+
const strapiLinks = (strapi) => {
|
|
2004
|
+
const q = () => strapi.db.query(LINK_UID);
|
|
2005
|
+
return {
|
|
2006
|
+
all: async () => (await q().findMany({ orderBy: { id: "asc" } })).map(toLink),
|
|
2007
|
+
find: async (cmsType, cmsId) => {
|
|
2008
|
+
const r = await q().findOne({ where: { cmsType, cmsId } });
|
|
2009
|
+
return r ? toLink(r) : null;
|
|
2010
|
+
},
|
|
2011
|
+
findByDriveId: async (driveId) => {
|
|
2012
|
+
const r = await q().findOne({ where: { driveId } });
|
|
2013
|
+
return r ? toLink(r) : null;
|
|
2014
|
+
},
|
|
2015
|
+
create: async (data) => toLink(await q().create({ data })),
|
|
2016
|
+
update: async (id, data) => toLink(await q().update({ where: { id }, data })),
|
|
2017
|
+
remove: async (id) => void await q().delete({ where: { id } })
|
|
2018
|
+
};
|
|
2019
|
+
};
|
|
2020
|
+
const toOp = (r) => ({
|
|
2021
|
+
id: r.id,
|
|
2022
|
+
cmsType: r.cmsType,
|
|
2023
|
+
cmsId: Number(r.cmsId),
|
|
2024
|
+
attempts: Number(r.attempts ?? 0),
|
|
2025
|
+
runAfter: Number(r.runAfter ?? 0),
|
|
2026
|
+
error: r.error ?? null
|
|
2027
|
+
});
|
|
2028
|
+
const strapiOps = (strapi) => {
|
|
2029
|
+
const q = () => strapi.db.query(OP_UID);
|
|
2030
|
+
return {
|
|
2031
|
+
async push(cmsType, cmsId) {
|
|
2032
|
+
if (await q().findOne({ where: { cmsType, cmsId } })) return;
|
|
2033
|
+
await q().create({ data: { cmsType, cmsId, attempts: 0, runAfter: "0" } });
|
|
2034
|
+
},
|
|
2035
|
+
ready: async (limit, now2) => (await q().findMany({ where: { runAfter: { $lte: String(now2) } }, orderBy: { id: "asc" }, limit })).map(toOp),
|
|
2036
|
+
done: async (op) => void await q().delete({ where: { id: op.id } }),
|
|
2037
|
+
retry: async (op, error, runAfter) => void await q().update({ where: { id: op.id }, data: { attempts: op.attempts + 1, error, runAfter: String(runAfter) } }),
|
|
2038
|
+
count: async () => q().count()
|
|
2039
|
+
};
|
|
2040
|
+
};
|
|
2041
|
+
const MAX_FILE_BYTES = 30 * 1024 * 1024;
|
|
2042
|
+
const echo = new AsyncLocalStorage();
|
|
2043
|
+
const withoutEcho = (work) => echo.run(true, work);
|
|
2044
|
+
const isEcho = () => echo.getStore() === true;
|
|
2045
|
+
const versionOf = (file) => `${file.url}|${file.size}`;
|
|
2046
|
+
const PAGE_TOKEN_KEY = "DRIVE_PAGE_TOKEN";
|
|
2047
|
+
const ROOT_ID_KEY = "DRIVE_ROOT_ID";
|
|
2048
|
+
const FULL_CHECK_KEY = "DRIVE_FULL_CHECK";
|
|
2049
|
+
const NATIVE = "application/vnd.google-apps.";
|
|
2050
|
+
const MOVED_AWAY = "Se movió en Drive a una carpeta que no está sincronizada. Regrésalo en Drive o muévelo en el CMS.";
|
|
2051
|
+
const isNative = (f) => f.mimeType.startsWith(NATIVE) && f.mimeType !== FOLDER_MIME;
|
|
2052
|
+
const driveRootId = async (deps) => {
|
|
2053
|
+
const saved = await deps.store.get({ key: ROOT_ID_KEY });
|
|
2054
|
+
if (saved) return saved;
|
|
2055
|
+
const id = await deps.drive.rootId();
|
|
2056
|
+
await deps.store.set({ key: ROOT_ID_KEY, value: id });
|
|
2057
|
+
return id;
|
|
2058
|
+
};
|
|
2059
|
+
const cmsParentOf = async (deps, f, rootId2) => {
|
|
2060
|
+
const parent = f.parents?.[0];
|
|
2061
|
+
if (!parent) return null;
|
|
2062
|
+
if (parent === rootId2 || parent === DRIVE_ROOT) return (await deps.cms.generatedRoot())?.id ?? null;
|
|
2063
|
+
const link = await deps.links.findByDriveId(parent);
|
|
2064
|
+
return link?.cmsType === "folder" ? link.cmsId : null;
|
|
2065
|
+
};
|
|
2066
|
+
const pruneLinks = async (deps) => {
|
|
2067
|
+
const keep = await neededLinkIds(deps);
|
|
2068
|
+
for (const l of await deps.links.all()) if (!keep.has(l.id)) await deps.links.remove(l.id);
|
|
2069
|
+
};
|
|
2070
|
+
const dropDeadLinks = async (deps) => {
|
|
2071
|
+
for (const l of await deps.links.all()) {
|
|
2072
|
+
const exists = l.cmsType === "folder" ? await deps.cms.folder(l.cmsId) : await deps.cms.file(l.cmsId);
|
|
2073
|
+
if (!exists) await deps.links.remove(l.id);
|
|
2074
|
+
}
|
|
2075
|
+
};
|
|
2076
|
+
const removeInCms = async (deps, link) => {
|
|
2077
|
+
if (link.cmsType === "folder") await deps.writer.deleteFolder(link.cmsId);
|
|
2078
|
+
else await deps.writer.deleteFile(link.cmsId);
|
|
2079
|
+
deps.log?.info?.(`[drive] Drive → CMS: borrado «${link.name}»`);
|
|
2080
|
+
await deps.links.remove(link.id);
|
|
2081
|
+
if (link.cmsType === "folder") await dropDeadLinks(deps);
|
|
2082
|
+
};
|
|
2083
|
+
const updateLinked = async (deps, link, f, parentCms) => {
|
|
2084
|
+
let changed = false;
|
|
2085
|
+
if (link.cmsType === "folder") {
|
|
2086
|
+
if (f.name !== link.name || parentCms !== link.parentCmsId) {
|
|
2087
|
+
await deps.writer.updateFolder(link.cmsId, { name: f.name, parentId: parentCms });
|
|
2088
|
+
deps.log?.info?.(`[drive] Drive → CMS: carpeta «${link.name}» → «${f.name}»`);
|
|
2089
|
+
await deps.links.update(link.id, { name: f.name, parentCmsId: parentCms, status: "synced", error: null });
|
|
2090
|
+
return true;
|
|
2091
|
+
}
|
|
2092
|
+
return false;
|
|
2093
|
+
}
|
|
2094
|
+
if (f.md5Checksum && f.md5Checksum !== link.driveRev) {
|
|
2095
|
+
if (Number(f.size ?? 0) > MAX_FILE_BYTES) {
|
|
2096
|
+
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." });
|
|
2097
|
+
return true;
|
|
2098
|
+
}
|
|
2099
|
+
const content = await deps.drive.download(f.id);
|
|
2100
|
+
const cmsFile = await deps.writer.replaceFile(link.cmsId, f.name, f.mimeType, content);
|
|
2101
|
+
deps.log?.info?.(`[drive] Drive → CMS: contenido nuevo de «${f.name}»`);
|
|
2102
|
+
await deps.links.update(link.id, { version: versionOf(cmsFile), driveRev: f.md5Checksum, name: f.name, status: "synced", error: null });
|
|
2103
|
+
link = { ...link, name: f.name };
|
|
2104
|
+
changed = true;
|
|
2105
|
+
}
|
|
2106
|
+
if (f.name !== link.name || parentCms !== link.parentCmsId) {
|
|
2107
|
+
await deps.writer.updateFile(link.cmsId, { name: f.name, folderId: parentCms });
|
|
2108
|
+
deps.log?.info?.(`[drive] Drive → CMS: archivo «${link.name}» → «${f.name}»`);
|
|
2109
|
+
const cmsFile = await deps.cms.file(link.cmsId);
|
|
2110
|
+
await deps.links.update(link.id, { name: f.name, parentCmsId: parentCms, ...cmsFile ? { version: versionOf(cmsFile) } : {}, status: "synced", error: null });
|
|
2111
|
+
changed = true;
|
|
2112
|
+
}
|
|
2113
|
+
return changed;
|
|
2114
|
+
};
|
|
2115
|
+
const createInCms = async (deps, f, parentCms) => {
|
|
2116
|
+
if (f.mimeType === FOLDER_MIME) {
|
|
2117
|
+
const folder = await deps.writer.createFolder(f.name, parentCms);
|
|
2118
|
+
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 });
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
if (Number(f.size ?? 0) > MAX_FILE_BYTES) {
|
|
2122
|
+
deps.log?.warn(`[drive] «${f.name}» pesa más de 30 MB; no se trae al CMS.`);
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
const content = await deps.drive.download(f.id);
|
|
2126
|
+
const file = await deps.writer.uploadFile(parentCms, f.name, f.mimeType, content);
|
|
2127
|
+
await deps.links.create({
|
|
2128
|
+
cmsType: "file",
|
|
2129
|
+
cmsId: file.id,
|
|
2130
|
+
driveId: f.id,
|
|
2131
|
+
root: false,
|
|
2132
|
+
name: f.name,
|
|
2133
|
+
parentCmsId: parentCms,
|
|
2134
|
+
version: versionOf(file),
|
|
2135
|
+
driveRev: f.md5Checksum ?? null,
|
|
2136
|
+
status: "synced",
|
|
2137
|
+
error: null
|
|
2138
|
+
});
|
|
2139
|
+
};
|
|
2140
|
+
const applyChange = async (deps, change, rootId2) => {
|
|
2141
|
+
const link = await deps.links.findByDriveId(change.fileId);
|
|
2142
|
+
const f = change.file;
|
|
2143
|
+
if (change.removed || f?.trashed) {
|
|
2144
|
+
if (!link) return false;
|
|
2145
|
+
await removeInCms(deps, link);
|
|
2146
|
+
return true;
|
|
2147
|
+
}
|
|
2148
|
+
if (!f || isNative(f)) return false;
|
|
2149
|
+
const parentCms = await cmsParentOf(deps, f, rootId2);
|
|
2150
|
+
const parentInside = parentCms !== null && await isSynced(deps, "folder", parentCms);
|
|
2151
|
+
if (link) {
|
|
2152
|
+
const pathOnly = !link.root && !await isSynced(deps, link.cmsType, link.cmsId);
|
|
2153
|
+
if (parentCms === null) {
|
|
2154
|
+
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`);
|
|
2155
|
+
if (pathOnly || link.status === "error" && link.error === MOVED_AWAY) return false;
|
|
2156
|
+
await deps.links.update(link.id, { status: "error", error: MOVED_AWAY });
|
|
2157
|
+
return true;
|
|
2158
|
+
}
|
|
2159
|
+
const changed = await updateLinked(deps, link, f, parentCms);
|
|
2160
|
+
if (!pathOnly && !link.root && !parentInside) {
|
|
2161
|
+
await deps.links.remove(link.id);
|
|
2162
|
+
await pruneLinks(deps);
|
|
2163
|
+
return true;
|
|
2164
|
+
}
|
|
2165
|
+
return changed;
|
|
2166
|
+
}
|
|
2167
|
+
if (!parentInside) return false;
|
|
2168
|
+
await createInCms(deps, f, parentCms);
|
|
2169
|
+
return true;
|
|
2170
|
+
};
|
|
2171
|
+
const fullCheck = async (deps, rootId2) => {
|
|
2172
|
+
let applied = 0;
|
|
2173
|
+
let errors = 0;
|
|
2174
|
+
const all3 = await deps.links.all();
|
|
2175
|
+
deps.log?.info?.(`[drive] revisión completa contra Drive: ${all3.length} vínculos`);
|
|
2176
|
+
for (const link of all3) {
|
|
2177
|
+
if (!link.driveId) continue;
|
|
2178
|
+
if (!await deps.links.find(link.cmsType, link.cmsId)) continue;
|
|
2179
|
+
try {
|
|
2180
|
+
const meta = await deps.drive.getMeta(link.driveId);
|
|
2181
|
+
if (await applyChange(deps, { fileId: link.driveId, removed: meta === null, ...meta ? { file: meta } : {} }, rootId2)) applied++;
|
|
2182
|
+
} catch (err) {
|
|
2183
|
+
errors++;
|
|
2184
|
+
deps.log?.warn(`[drive] revisión de ${link.cmsType} ${link.cmsId}: ${err.message}`);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
return { applied, errors };
|
|
2188
|
+
};
|
|
2189
|
+
const pullChanges = async (deps, { full = false } = {}) => {
|
|
2190
|
+
let token = await deps.store.get({ key: PAGE_TOKEN_KEY });
|
|
2191
|
+
const rootId2 = await driveRootId(deps);
|
|
2192
|
+
let applied = 0;
|
|
2193
|
+
let errors = 0;
|
|
2194
|
+
if (!token) {
|
|
2195
|
+
token = await deps.drive.startPageToken();
|
|
2196
|
+
await deps.store.set({ key: PAGE_TOKEN_KEY, value: token });
|
|
2197
|
+
await deps.store.delete({ key: FULL_CHECK_KEY });
|
|
2198
|
+
}
|
|
2199
|
+
if (full) await deps.store.delete({ key: FULL_CHECK_KEY });
|
|
2200
|
+
if (!await deps.store.get({ key: FULL_CHECK_KEY })) {
|
|
2201
|
+
const checked = await withoutEcho(() => fullCheck(deps, rootId2));
|
|
2202
|
+
await deps.store.set({ key: FULL_CHECK_KEY, value: true });
|
|
2203
|
+
applied += checked.applied;
|
|
2204
|
+
errors += checked.errors;
|
|
2205
|
+
}
|
|
2206
|
+
await withoutEcho(async () => {
|
|
2207
|
+
for (; ; ) {
|
|
2208
|
+
const page = await deps.drive.listChanges(token);
|
|
2209
|
+
for (const change of page.changes ?? []) {
|
|
2210
|
+
try {
|
|
2211
|
+
if (await applyChange(deps, change, rootId2)) applied++;
|
|
2212
|
+
} catch (err) {
|
|
2213
|
+
errors++;
|
|
2214
|
+
deps.log?.warn(`[drive] cambio de Drive ${change.fileId}: ${err.message}`);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
token = page.nextPageToken ?? page.newStartPageToken ?? token;
|
|
2218
|
+
await deps.store.set({ key: PAGE_TOKEN_KEY, value: token });
|
|
2219
|
+
if (!page.nextPageToken) break;
|
|
2220
|
+
}
|
|
2221
|
+
});
|
|
2222
|
+
return { applied, errors };
|
|
2223
|
+
};
|
|
2224
|
+
const LOCK_KEY = "DRIVE_LOCK";
|
|
2225
|
+
const LAST_RUN_KEY = "DRIVE_LAST_RUN";
|
|
2226
|
+
const LOCK_MS = 10 * 6e4;
|
|
2227
|
+
const BATCH = 50;
|
|
2228
|
+
const MAX_ATTEMPTS = 5;
|
|
2229
|
+
const TOO_BIG = "Pesa más de 30 MB; no se sube a Drive.";
|
|
2230
|
+
const bytesOf = (file) => file.size * 1e3;
|
|
2231
|
+
const now = (deps) => (deps.now ?? Date.now)();
|
|
2232
|
+
const driveParent = async (deps, parentCmsId, depth = 0) => {
|
|
2233
|
+
const root = await deps.cms.generatedRoot();
|
|
2234
|
+
if (parentCmsId === null || parentCmsId === root?.id) return DRIVE_ROOT;
|
|
2235
|
+
let link = await deps.links.find("folder", parentCmsId);
|
|
2236
|
+
if (!link?.driveId && depth < 20) {
|
|
2237
|
+
await processItem(deps, "folder", parentCmsId, depth + 1);
|
|
2238
|
+
link = await deps.links.find("folder", parentCmsId);
|
|
2239
|
+
}
|
|
2240
|
+
if (!link?.driveId) throw new Error(`La carpeta ${parentCmsId} todavía no está en Drive.`);
|
|
2241
|
+
return link.driveId;
|
|
2242
|
+
};
|
|
2243
|
+
const saveError = async (deps, link, cmsType, cmsId, error, status = "error") => {
|
|
2244
|
+
if (link) await deps.links.update(link.id, { status, error });
|
|
2245
|
+
else await deps.links.create({ cmsType, cmsId, driveId: null, root: false, name: null, parentCmsId: null, version: null, driveRev: null, status, error });
|
|
2246
|
+
};
|
|
2247
|
+
const syncFolder = async (deps, folder, link, depth) => {
|
|
2248
|
+
const parent = await driveParent(deps, folder.parentId, depth);
|
|
2249
|
+
if (!link?.driveId) {
|
|
2250
|
+
if (await deps.drive.findChild(parent, folder.name)) return saveError(deps, link, "folder", folder.id, collisionMessage(folder.name));
|
|
2251
|
+
const driveId = await deps.drive.createFolder(parent, folder.name);
|
|
2252
|
+
const data = { driveId, name: folder.name, parentCmsId: folder.parentId, status: "synced", error: null };
|
|
2253
|
+
if (link) await deps.links.update(link.id, data);
|
|
2254
|
+
else await deps.links.create({ cmsType: "folder", cmsId: folder.id, root: false, version: null, driveRev: null, ...data });
|
|
2255
|
+
} else if (link.name !== folder.name || link.parentCmsId !== folder.parentId) {
|
|
2256
|
+
const from = link.parentCmsId === folder.parentId ? parent : await driveParent(deps, link.parentCmsId, depth).catch(() => parent);
|
|
2257
|
+
await deps.drive.updateMeta(link.driveId, { name: folder.name, from, to: parent });
|
|
2258
|
+
await deps.links.update(link.id, { name: folder.name, parentCmsId: folder.parentId, status: "synced", error: null });
|
|
2259
|
+
}
|
|
2260
|
+
if (!link?.driveId) {
|
|
2261
|
+
for (const f of await deps.cms.childFolders(folder.id)) await deps.ops.push("folder", f.id);
|
|
2262
|
+
for (const f of await deps.cms.childFiles(folder.id)) await deps.ops.push("file", f.id);
|
|
2263
|
+
}
|
|
2264
|
+
};
|
|
2265
|
+
const syncFile = async (deps, file, link, depth) => {
|
|
2266
|
+
if (bytesOf(file) > MAX_FILE_BYTES) return saveError(deps, link, "file", file.id, TOO_BIG, "skipped");
|
|
2267
|
+
const parent = await driveParent(deps, file.folderId, depth);
|
|
2268
|
+
const version = versionOf(file);
|
|
2269
|
+
const upload = async () => ({ name: file.name, mime: file.mime, content: await deps.cms.readFile(file), description: await deps.describe?.(file) });
|
|
2270
|
+
if (!link?.driveId) {
|
|
2271
|
+
if (await deps.drive.findChild(parent, file.name)) return saveError(deps, link, "file", file.id, collisionMessage(file.name));
|
|
2272
|
+
const { id: driveId, md5 } = await deps.drive.uploadFile(parent, await upload());
|
|
2273
|
+
const data = { driveId, name: file.name, parentCmsId: file.folderId, version, driveRev: md5, status: "synced", error: null };
|
|
2274
|
+
if (link) await deps.links.update(link.id, data);
|
|
2275
|
+
else await deps.links.create({ cmsType: "file", cmsId: file.id, root: false, ...data });
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
let driveRev = link.driveRev;
|
|
2279
|
+
if (link.version !== version) driveRev = (await deps.drive.updateContent(link.driveId, await upload())).md5;
|
|
2280
|
+
if (link.name !== file.name || link.parentCmsId !== file.folderId) {
|
|
2281
|
+
const from = link.parentCmsId === file.folderId ? parent : await driveParent(deps, link.parentCmsId, depth).catch(() => parent);
|
|
2282
|
+
await deps.drive.updateMeta(link.driveId, { name: file.name, from, to: parent });
|
|
2283
|
+
}
|
|
2284
|
+
await deps.links.update(link.id, { name: file.name, parentCmsId: file.folderId, version, driveRev, status: "synced", error: null });
|
|
2285
|
+
};
|
|
2286
|
+
const processItem = async (deps, cmsType, cmsId, depth = 0) => {
|
|
2287
|
+
const link = await deps.links.find(cmsType, cmsId);
|
|
2288
|
+
const item = cmsType === "folder" ? await deps.cms.folder(cmsId) : await deps.cms.file(cmsId);
|
|
2289
|
+
if (!item) {
|
|
2290
|
+
if (link?.driveId) await deps.drive.trash(link.driveId);
|
|
2291
|
+
if (link) await deps.links.remove(link.id);
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
if (!await isSynced(deps, cmsType, cmsId)) {
|
|
2295
|
+
if (link && !(await neededLinkIds(deps)).has(link.id)) await deps.links.remove(link.id);
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
if (cmsType === "folder") await syncFolder(deps, item, link, depth);
|
|
2299
|
+
else await syncFile(deps, item, link, depth);
|
|
2300
|
+
};
|
|
2301
|
+
const reconcile = async (deps) => {
|
|
2302
|
+
const links = await deps.links.all();
|
|
2303
|
+
const linked = new Set(links.map((l) => `${l.cmsType}:${l.cmsId}`));
|
|
2304
|
+
for (const link of links) {
|
|
2305
|
+
if (link.cmsType === "file") {
|
|
2306
|
+
const file = await deps.cms.file(link.cmsId);
|
|
2307
|
+
if (!file || !link.driveId || file.name !== link.name || file.folderId !== link.parentCmsId || versionOf(file) !== link.version) {
|
|
2308
|
+
if (link.status !== "skipped" || !file || bytesOf(file) <= MAX_FILE_BYTES) await deps.ops.push("file", link.cmsId);
|
|
2309
|
+
}
|
|
2310
|
+
continue;
|
|
2311
|
+
}
|
|
2312
|
+
const folder = await deps.cms.folder(link.cmsId);
|
|
2313
|
+
if (!folder || !link.driveId || folder.name !== link.name || folder.parentId !== link.parentCmsId) {
|
|
2314
|
+
await deps.ops.push("folder", link.cmsId);
|
|
2315
|
+
if (!folder) continue;
|
|
2316
|
+
}
|
|
2317
|
+
if (!await isSynced(deps, "folder", link.cmsId)) continue;
|
|
2318
|
+
for (const f of await deps.cms.childFolders(folder.id)) if (!linked.has(`folder:${f.id}`)) await deps.ops.push("folder", f.id);
|
|
2319
|
+
for (const f of await deps.cms.childFiles(folder.id)) if (!linked.has(`file:${f.id}`)) await deps.ops.push("file", f.id);
|
|
2320
|
+
}
|
|
2321
|
+
};
|
|
2322
|
+
const acquire = async (deps) => {
|
|
2323
|
+
const lock = await deps.store.get({ key: LOCK_KEY });
|
|
2324
|
+
if (lock?.until && lock.until > now(deps)) return false;
|
|
2325
|
+
await deps.store.set({ key: LOCK_KEY, value: { until: now(deps) + LOCK_MS } });
|
|
2326
|
+
return true;
|
|
2327
|
+
};
|
|
2328
|
+
const runSync = async (deps, { budgetMs = 4 * 6e4, full = false } = {}) => {
|
|
2329
|
+
if (!await acquire(deps)) return { skipped: true, pulled: 0, processed: 0, errors: 0, pending: await deps.ops.count() };
|
|
2330
|
+
const started = now(deps);
|
|
2331
|
+
let processed = 0;
|
|
2332
|
+
let errors = 0;
|
|
2333
|
+
let pulled = 0;
|
|
2334
|
+
try {
|
|
2335
|
+
if (deps.writer) {
|
|
2336
|
+
const result = await pullChanges({ ...deps, writer: deps.writer }, { full });
|
|
2337
|
+
pulled = result.applied;
|
|
2338
|
+
errors += result.errors;
|
|
2339
|
+
}
|
|
2340
|
+
await reconcile(deps);
|
|
2341
|
+
while (now(deps) - started < budgetMs) {
|
|
2342
|
+
const batch = await deps.ops.ready(BATCH, now(deps));
|
|
2343
|
+
if (!batch.length) break;
|
|
2344
|
+
for (const op of batch) {
|
|
2345
|
+
try {
|
|
2346
|
+
await processItem(deps, op.cmsType, op.cmsId);
|
|
2347
|
+
await deps.ops.done(op);
|
|
2348
|
+
processed++;
|
|
2349
|
+
} catch (err) {
|
|
2350
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2351
|
+
errors++;
|
|
2352
|
+
if (err instanceof DriveSyncError || op.attempts + 1 >= MAX_ATTEMPTS) {
|
|
2353
|
+
await deps.ops.done(op);
|
|
2354
|
+
await saveError(deps, await deps.links.find(op.cmsType, op.cmsId), op.cmsType, op.cmsId, message);
|
|
2355
|
+
} else {
|
|
2356
|
+
await deps.ops.retry(op, message, now(deps) + 3e4 * 2 ** op.attempts);
|
|
2357
|
+
}
|
|
2358
|
+
deps.log?.warn(`[drive] ${op.cmsType} ${op.cmsId}: ${message}`);
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
} finally {
|
|
2363
|
+
await deps.store.set({ key: LAST_RUN_KEY, value: { at: now(deps), pulled, processed, errors } });
|
|
2364
|
+
deps.log?.info?.(`[drive] ciclo terminado: ${pulled} cambios de Drive traídos, ${processed} elementos subidos o revisados, ${errors} errores`);
|
|
2365
|
+
await deps.store.delete({ key: LOCK_KEY });
|
|
2366
|
+
}
|
|
2367
|
+
return { skipped: false, pulled, processed, errors, pending: await deps.ops.count() };
|
|
2368
|
+
};
|
|
2369
|
+
const debounce = (run, delayMs = 5e3, onError = () => {
|
|
2370
|
+
}) => {
|
|
2371
|
+
let timer = null;
|
|
2372
|
+
return () => {
|
|
2373
|
+
if (timer) clearTimeout(timer);
|
|
2374
|
+
timer = setTimeout(() => {
|
|
2375
|
+
timer = null;
|
|
2376
|
+
run().catch(onError);
|
|
2377
|
+
}, delayMs);
|
|
2378
|
+
timer.unref?.();
|
|
2379
|
+
};
|
|
2380
|
+
};
|
|
2381
|
+
const DRIVE_TOKEN_KEY = "DRIVE_TOKEN";
|
|
2382
|
+
const DRIVE_ORIGINS_KEY = "DRIVE_ORIGINS";
|
|
2383
|
+
class DriveInputError extends Error {
|
|
2384
|
+
constructor(message, status = 400) {
|
|
2385
|
+
super(message);
|
|
2386
|
+
this.status = status;
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
const NOT_CONNECTED = { connected: false, email: null, usage: null, limit: null, error: null };
|
|
2390
|
+
const REVOKED = "Desconectada: vuelve a conectar la cuenta.";
|
|
2391
|
+
const createDriveService = ({
|
|
2392
|
+
store,
|
|
2393
|
+
fetch: fetchFn,
|
|
2394
|
+
log,
|
|
2395
|
+
sync
|
|
2396
|
+
}) => {
|
|
2397
|
+
const client = async () => loadClient(await store.get({ key: DRIVE_CLIENT_KEY }));
|
|
2398
|
+
const token = async () => await store.get({ key: DRIVE_TOKEN_KEY });
|
|
2399
|
+
const http = createDriveClient({
|
|
2400
|
+
fetch: fetchFn,
|
|
2401
|
+
getCredentials: async () => {
|
|
2402
|
+
const [c, t] = await Promise.all([client(), token()]);
|
|
2403
|
+
return c && t?.refreshToken ? { client: c, refreshToken: t.refreshToken } : null;
|
|
2404
|
+
}
|
|
2405
|
+
});
|
|
2406
|
+
const clientFor = async (redirectUri) => {
|
|
2407
|
+
const c = await client();
|
|
2408
|
+
if (!c) throw new DriveInputError("Primero carga el JSON del cliente OAuth.");
|
|
2409
|
+
if (typeof redirectUri !== "string" || !c.redirectUris.includes(redirectUri)) {
|
|
2410
|
+
throw new DriveInputError(`Este cliente no tiene autorizada la URL ${String(redirectUri)}. Agrégala en Google Cloud.`);
|
|
2411
|
+
}
|
|
2412
|
+
return c;
|
|
2413
|
+
};
|
|
2414
|
+
const forgetToken = async () => {
|
|
2415
|
+
const t = await token();
|
|
2416
|
+
if (t?.refreshToken) {
|
|
2417
|
+
await revokeToken(t.refreshToken, fetchFn).catch((err) => log.warn(`[drive] no se pudo revocar el token: ${err.message}`));
|
|
2418
|
+
}
|
|
2419
|
+
await store.delete({ key: DRIVE_TOKEN_KEY });
|
|
2420
|
+
http.reset();
|
|
2421
|
+
};
|
|
2422
|
+
const connected = async () => !!await client() && !!(await token())?.refreshToken;
|
|
2423
|
+
const describe = async (file) => {
|
|
2424
|
+
const origins = await store.get({ key: DRIVE_ORIGINS_KEY }) ?? {};
|
|
2425
|
+
const origin2 = origins[String(file.id)];
|
|
2426
|
+
if (!origin2) return void 0;
|
|
2427
|
+
return `Template: ${origin2.name}
|
|
2428
|
+
${sync?.serverUrl ?? ""}/admin/plugins/strapi-content-generator-gemini-studio?tab=templates`;
|
|
2429
|
+
};
|
|
2430
|
+
const syncDeps = () => {
|
|
2431
|
+
if (!sync) throw new Error("La sincronización con Drive no está configurada.");
|
|
2432
|
+
return { drive: driveOps(http), cms: sync.cms, writer: sync.writer, links: sync.links, ops: sync.ops, store, describe, log };
|
|
2433
|
+
};
|
|
2434
|
+
const requireConnection = async () => {
|
|
2435
|
+
if (!await connected()) throw new DriveInputError("Conecta Google Drive en Ajustes antes de sincronizar.");
|
|
2436
|
+
};
|
|
2437
|
+
const asInput = async (work) => {
|
|
2438
|
+
try {
|
|
2439
|
+
return await work();
|
|
2440
|
+
} catch (err) {
|
|
2441
|
+
if (err instanceof DriveSyncError) throw new DriveInputError(err.message, err.status);
|
|
2442
|
+
throw err;
|
|
2443
|
+
}
|
|
2444
|
+
};
|
|
2445
|
+
const runCycle = async ({ full = false } = {}) => await connected() ? runSync(syncDeps(), { full }) : null;
|
|
2446
|
+
const schedule = debounce(() => runCycle(), 5e3, (err) => log.warn(`[drive] el ciclo de sincronización falló: ${err instanceof Error ? err.message : String(err)}`));
|
|
2447
|
+
return {
|
|
2448
|
+
http: () => http,
|
|
2449
|
+
connected,
|
|
2450
|
+
runCycle,
|
|
2451
|
+
/** Marca para sincronizar y programa el ciclo. */
|
|
2452
|
+
async mark(cmsType, cmsId) {
|
|
2453
|
+
await requireConnection();
|
|
2454
|
+
await asInput(() => mark(syncDeps(), cmsType, cmsId));
|
|
2455
|
+
schedule();
|
|
2456
|
+
},
|
|
2457
|
+
async unmark(cmsType, cmsId) {
|
|
2458
|
+
await asInput(() => unmark(syncDeps(), cmsType, cmsId));
|
|
2459
|
+
},
|
|
2460
|
+
/** «Sincronizar ahora»; con `full`, «Revisión completa»: compara todo lo vinculado contra Drive. */
|
|
2461
|
+
async syncNow(full = false) {
|
|
2462
|
+
await requireConnection();
|
|
2463
|
+
return runCycle({ full });
|
|
2464
|
+
},
|
|
2465
|
+
/** Lifecycle de upload: encola si el elemento está (o estaba) en lo sincronizado. */
|
|
2466
|
+
async track(cmsType, cmsId) {
|
|
2467
|
+
if (isEcho() || !sync || !await connected()) return;
|
|
2468
|
+
const deps = syncDeps();
|
|
2469
|
+
if (await deps.links.find(cmsType, cmsId) || await isSynced(deps, cmsType, cmsId)) {
|
|
2470
|
+
await deps.ops.push(cmsType, cmsId);
|
|
2471
|
+
schedule();
|
|
2472
|
+
}
|
|
2473
|
+
},
|
|
2474
|
+
async linkStatuses() {
|
|
2475
|
+
const isConnected = await connected();
|
|
2476
|
+
if (!isConnected || !sync) return { connected: false, items: {}, lastRun: null, pending: 0 };
|
|
2477
|
+
const items = {};
|
|
2478
|
+
const deps = syncDeps();
|
|
2479
|
+
for (const l of await sync.links.all()) {
|
|
2480
|
+
items[`${l.cmsType}:${l.cmsId}`] = { status: l.status, root: l.root, inside: await isSynced(deps, l.cmsType, l.cmsId), error: l.error };
|
|
2481
|
+
}
|
|
2482
|
+
const lastRun = await store.get({ key: LAST_RUN_KEY }) ?? null;
|
|
2483
|
+
return { connected: true, items, lastRun, pending: await sync.ops.count() };
|
|
2484
|
+
},
|
|
2485
|
+
/** Vista de Drive (S7.2): una carpeta en vivo con su ruta y qué hijos están sincronizados. */
|
|
2486
|
+
async browse(folderId, pageToken) {
|
|
2487
|
+
await requireConnection();
|
|
2488
|
+
const ops = driveOps(http);
|
|
2489
|
+
const id = typeof folderId === "string" && folderId !== "" ? folderId : "root";
|
|
2490
|
+
const page = await ops.listChildren(id, typeof pageToken === "string" && pageToken ? pageToken : void 0);
|
|
2491
|
+
const rootId2 = await store.get({ key: "DRIVE_ROOT_ID" }) ?? await ops.rootId();
|
|
2492
|
+
const isRoot = id === "root" || id === rootId2;
|
|
2493
|
+
const path = [];
|
|
2494
|
+
if (!isRoot) {
|
|
2495
|
+
let current = await ops.getFile(id);
|
|
2496
|
+
path.unshift({ id: current.id, name: current.name });
|
|
2497
|
+
for (let depth = 0; depth < 30 && current.parents[0] && current.parents[0] !== rootId2; depth++) {
|
|
2498
|
+
current = await ops.getFile(current.parents[0]);
|
|
2499
|
+
path.unshift({ id: current.id, name: current.name });
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
const linked = new Set((sync ? await sync.links.all() : []).map((l) => l.driveId).filter(Boolean));
|
|
2503
|
+
return {
|
|
2504
|
+
path,
|
|
2505
|
+
items: page.items.map((item) => ({ ...item, synced: linked.has(item.id) })),
|
|
2506
|
+
nextPageToken: page.nextPageToken
|
|
2507
|
+
};
|
|
2508
|
+
},
|
|
2509
|
+
/** Cuántos elementos se irían a la papelera con esta carpeta, y si está sincronizada. */
|
|
2510
|
+
async countItem(id) {
|
|
2511
|
+
await requireConnection();
|
|
2512
|
+
if (typeof id !== "string" || !id) throw new DriveInputError("Elemento de Drive inválido.");
|
|
2513
|
+
const synced = !!(sync && await sync.links.findByDriveId(id));
|
|
2514
|
+
return { ...await driveOps(http).countTree(id), synced };
|
|
2515
|
+
},
|
|
2516
|
+
/** Eliminar desde la vista: a la papelera de Drive. Si estaba sincronizado, el siguiente ciclo lo borra del CMS. */
|
|
2517
|
+
async trashItem(id) {
|
|
2518
|
+
await requireConnection();
|
|
2519
|
+
if (typeof id !== "string" || !id || id === "root") throw new DriveInputError("Elemento de Drive inválido.");
|
|
2520
|
+
await driveOps(http).trash(id);
|
|
2521
|
+
schedule();
|
|
2522
|
+
},
|
|
2523
|
+
/** El panel avisa que un archivo salió de un template (S7.3). */
|
|
2524
|
+
async setOrigin(fileId, documentId, name2) {
|
|
2525
|
+
if (typeof fileId !== "number" || typeof documentId !== "string" || typeof name2 !== "string" || !name2.trim()) {
|
|
2526
|
+
throw new DriveInputError("Origen del template inválido.");
|
|
2527
|
+
}
|
|
2528
|
+
const origins = await store.get({ key: DRIVE_ORIGINS_KEY }) ?? {};
|
|
2529
|
+
await store.set({ key: DRIVE_ORIGINS_KEY, value: { ...origins, [String(fileId)]: { documentId, name: name2.trim() } } });
|
|
2530
|
+
},
|
|
2531
|
+
/** Guarda el cliente del JSON de Google. Un cliente nuevo invalida la conexión anterior. */
|
|
2532
|
+
async saveClient(json, redirectUri) {
|
|
2533
|
+
const parsed = parseClientConfig(json, typeof redirectUri === "string" ? redirectUri : "");
|
|
2534
|
+
if ("message" in parsed) throw new DriveInputError(parsed.message);
|
|
2535
|
+
const previous = await client();
|
|
2536
|
+
if (previous && previous.clientId !== parsed.client.clientId) await forgetToken();
|
|
2537
|
+
await store.set({ key: DRIVE_CLIENT_KEY, value: storedClient(parsed.client) });
|
|
2538
|
+
http.reset();
|
|
2539
|
+
},
|
|
2540
|
+
async removeClient() {
|
|
2541
|
+
await forgetToken();
|
|
2542
|
+
await store.delete({ key: DRIVE_CLIENT_KEY });
|
|
2543
|
+
},
|
|
2544
|
+
async authorizationUrl(redirectUri) {
|
|
2545
|
+
const c = await clientFor(redirectUri);
|
|
2546
|
+
return authUrl(c, redirectUri, await newState(store));
|
|
2547
|
+
},
|
|
2548
|
+
async exchange({ code, state, redirectUri }) {
|
|
2549
|
+
if (!await checkState(store, state) || typeof code !== "string" || code === "") {
|
|
2550
|
+
throw new DriveInputError("La conexión no se pudo verificar. Intenta de nuevo.");
|
|
2551
|
+
}
|
|
2552
|
+
const c = await clientFor(redirectUri);
|
|
2553
|
+
let refreshToken;
|
|
2554
|
+
try {
|
|
2555
|
+
({ refreshToken } = await exchangeCode({ client: c, code, redirectUri, fetch: fetchFn }));
|
|
2556
|
+
} catch (err) {
|
|
2557
|
+
if (err instanceof DriveAuthError) throw new DriveInputError(err.message);
|
|
2558
|
+
throw err;
|
|
2559
|
+
}
|
|
2560
|
+
await store.set({ key: DRIVE_TOKEN_KEY, value: { refreshToken, email: null } });
|
|
2561
|
+
http.reset();
|
|
2562
|
+
const { email } = await http.about();
|
|
2563
|
+
await store.set({ key: DRIVE_TOKEN_KEY, value: { refreshToken, email } });
|
|
2564
|
+
},
|
|
2565
|
+
disconnect: forgetToken,
|
|
2566
|
+
async status() {
|
|
2567
|
+
const pub = publicClient(await store.get({ key: DRIVE_CLIENT_KEY }));
|
|
2568
|
+
const t = await token();
|
|
2569
|
+
if (!pub.configured || !t?.refreshToken) return { client: pub, connection: NOT_CONNECTED, sync: null };
|
|
2570
|
+
const syncState = async () => {
|
|
2571
|
+
if (!sync) return null;
|
|
2572
|
+
const failing = (await sync.links.all()).filter((l) => (l.status === "error" || l.status === "skipped") && l.error);
|
|
2573
|
+
return {
|
|
2574
|
+
lastRun: await store.get({ key: LAST_RUN_KEY }) ?? null,
|
|
2575
|
+
pending: await sync.ops.count(),
|
|
2576
|
+
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 }))
|
|
2577
|
+
};
|
|
2578
|
+
};
|
|
2579
|
+
try {
|
|
2580
|
+
const about = await http.about();
|
|
2581
|
+
return {
|
|
2582
|
+
client: pub,
|
|
2583
|
+
connection: { connected: true, email: about.email, usage: about.usage, limit: about.limit, error: null },
|
|
2584
|
+
sync: await syncState()
|
|
2585
|
+
};
|
|
2586
|
+
} catch (err) {
|
|
2587
|
+
if (err instanceof DriveAuthError || err instanceof DriveNotConnectedError) {
|
|
2588
|
+
return { client: pub, connection: { ...NOT_CONNECTED, email: t.email, error: REVOKED }, sync: null };
|
|
2589
|
+
}
|
|
2590
|
+
return { client: pub, connection: { ...NOT_CONNECTED, connected: true, email: t.email, error: err.message }, sync: await syncState() };
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
};
|
|
2594
|
+
};
|
|
2595
|
+
const drive$1 = ({ strapi }) => createDriveService({
|
|
2596
|
+
store: strapi.store({ type: "plugin", name: "strapi-content-generator-gemini" }),
|
|
2597
|
+
fetch: (...args) => fetch(...args),
|
|
2598
|
+
log: strapi.log,
|
|
2599
|
+
sync: {
|
|
2600
|
+
cms: strapiCms(strapi),
|
|
2601
|
+
writer: strapiCmsWriter(strapi),
|
|
2602
|
+
links: strapiLinks(strapi),
|
|
2603
|
+
ops: strapiOps(strapi),
|
|
2604
|
+
serverUrl: String(strapi.config.get("server.url") ?? "").replace(/\/+$/, "")
|
|
2605
|
+
}
|
|
2606
|
+
});
|
|
2607
|
+
const fail = (ctx, status, name2, message) => {
|
|
2608
|
+
ctx.status = status;
|
|
2609
|
+
ctx.body = { data: null, error: { status, name: name2, message } };
|
|
2610
|
+
};
|
|
2611
|
+
const drive = ({ strapi }) => {
|
|
2612
|
+
const svc = () => strapi.plugin("strapi-content-generator-gemini").service("drive");
|
|
2613
|
+
const run = async (ctx, work) => {
|
|
2614
|
+
try {
|
|
2615
|
+
ctx.body = await work() ?? { ok: true };
|
|
2616
|
+
ctx.status = 200;
|
|
2617
|
+
} catch (err) {
|
|
2618
|
+
if (err instanceof DriveInputError) return fail(ctx, err.status, err.status === 409 ? "ConflictError" : "ValidationError", err.message);
|
|
2619
|
+
strapi.log.error(`[drive] ${err instanceof Error ? err.message : String(err)}`);
|
|
2620
|
+
return fail(ctx, 500, "InternalServerError", "No se pudo completar la operación con Google Drive.");
|
|
2621
|
+
}
|
|
2622
|
+
};
|
|
2623
|
+
const body = (ctx) => ctx.request.body ?? {};
|
|
2624
|
+
const target = (raw) => {
|
|
2625
|
+
const cmsId = Number(raw.cmsId);
|
|
2626
|
+
if (raw.cmsType !== "file" && raw.cmsType !== "folder" || !Number.isInteger(cmsId) || cmsId <= 0) {
|
|
2627
|
+
throw new DriveInputError("Elemento de la Biblioteca inválido.");
|
|
2628
|
+
}
|
|
2629
|
+
return { cmsType: raw.cmsType, cmsId };
|
|
2630
|
+
};
|
|
2631
|
+
return {
|
|
2632
|
+
status: (ctx) => run(ctx, () => svc().status()),
|
|
2633
|
+
saveClient: (ctx) => run(ctx, () => svc().saveClient(body(ctx).json, body(ctx).redirectUri).then(() => void 0)),
|
|
2634
|
+
removeClient: (ctx) => run(ctx, () => svc().removeClient().then(() => void 0)),
|
|
2635
|
+
authorizationUrl: (ctx) => run(ctx, async () => ({ url: await svc().authorizationUrl(ctx.query.redirectUri) })),
|
|
2636
|
+
exchange: (ctx) => run(ctx, () => svc().exchange(body(ctx)).then(() => void 0)),
|
|
2637
|
+
disconnect: (ctx) => run(ctx, () => svc().disconnect().then(() => void 0)),
|
|
2638
|
+
links: (ctx) => run(ctx, () => svc().linkStatuses()),
|
|
2639
|
+
mark: (ctx) => run(ctx, async () => {
|
|
2640
|
+
const { cmsType, cmsId } = target(body(ctx));
|
|
2641
|
+
await svc().mark(cmsType, cmsId);
|
|
2642
|
+
}),
|
|
2643
|
+
unmark: (ctx) => run(ctx, async () => {
|
|
2644
|
+
const { cmsType, cmsId } = target(ctx.params ?? {});
|
|
2645
|
+
await svc().unmark(cmsType, cmsId);
|
|
2646
|
+
}),
|
|
2647
|
+
syncNow: (ctx) => run(ctx, () => svc().syncNow(body(ctx).full === true)),
|
|
2648
|
+
browse: (ctx) => run(ctx, () => svc().browse(ctx.params?.id, ctx.query.pageToken)),
|
|
2649
|
+
count: (ctx) => run(ctx, () => svc().countItem(ctx.params?.id)),
|
|
2650
|
+
trash: (ctx) => run(ctx, () => svc().trashItem(ctx.params?.id).then(() => void 0)),
|
|
2651
|
+
origin: (ctx) => run(ctx, () => svc().setOrigin(body(ctx).fileId, body(ctx).templateDocumentId, body(ctx).templateName).then(() => void 0))
|
|
2652
|
+
};
|
|
2653
|
+
};
|
|
1308
2654
|
const controllers = {
|
|
1309
2655
|
controller,
|
|
1310
2656
|
config: config$1,
|
|
1311
2657
|
"image-studio": imageStudio,
|
|
1312
2658
|
templates,
|
|
2659
|
+
drive,
|
|
1313
2660
|
articles
|
|
1314
2661
|
};
|
|
1315
2662
|
const middlewares = {};
|
|
@@ -1358,7 +2705,21 @@ const admin = [
|
|
|
1358
2705
|
{ method: "POST", path: "/template-folders/delete", handler: "templates.deleteFolder", config },
|
|
1359
2706
|
{ method: "GET", path: "/config", handler: "config.get", config },
|
|
1360
2707
|
{ method: "POST", path: "/config", handler: "config.set", config },
|
|
1361
|
-
{ method: "DELETE", path: "/config/vertex", handler: "config.removeVertex", config }
|
|
2708
|
+
{ method: "DELETE", path: "/config/vertex", handler: "config.removeVertex", config },
|
|
2709
|
+
{ method: "GET", path: "/drive/status", handler: "drive.status", config },
|
|
2710
|
+
{ method: "POST", path: "/drive/client", handler: "drive.saveClient", config },
|
|
2711
|
+
{ method: "DELETE", path: "/drive/client", handler: "drive.removeClient", config },
|
|
2712
|
+
{ method: "GET", path: "/drive/oauth/url", handler: "drive.authorizationUrl", config },
|
|
2713
|
+
{ method: "POST", path: "/drive/oauth/exchange", handler: "drive.exchange", config },
|
|
2714
|
+
{ method: "DELETE", path: "/drive/oauth", handler: "drive.disconnect", config },
|
|
2715
|
+
{ method: "GET", path: "/drive/links", handler: "drive.links", config },
|
|
2716
|
+
{ method: "POST", path: "/drive/links", handler: "drive.mark", config },
|
|
2717
|
+
{ method: "DELETE", path: "/drive/links/:cmsType/:cmsId", handler: "drive.unmark", config },
|
|
2718
|
+
{ method: "POST", path: "/drive/sync", handler: "drive.syncNow", config },
|
|
2719
|
+
{ method: "POST", path: "/drive/origin", handler: "drive.origin", config },
|
|
2720
|
+
{ method: "GET", path: "/drive/folders/:id", handler: "drive.browse", config },
|
|
2721
|
+
{ method: "GET", path: "/drive/items/:id/count", handler: "drive.count", config },
|
|
2722
|
+
{ method: "DELETE", path: "/drive/items/:id", handler: "drive.trash", config }
|
|
1362
2723
|
];
|
|
1363
2724
|
const contentApi = [];
|
|
1364
2725
|
const routes = {
|
|
@@ -2865,13 +4226,13 @@ function speedometer(samplesCount, min) {
|
|
|
2865
4226
|
let firstSampleTS;
|
|
2866
4227
|
min = min !== void 0 ? min : 1e3;
|
|
2867
4228
|
return function push(chunkLength) {
|
|
2868
|
-
const
|
|
4229
|
+
const now2 = Date.now();
|
|
2869
4230
|
const startedAt = timestamps[tail];
|
|
2870
4231
|
if (!firstSampleTS) {
|
|
2871
|
-
firstSampleTS =
|
|
4232
|
+
firstSampleTS = now2;
|
|
2872
4233
|
}
|
|
2873
4234
|
bytes[head] = chunkLength;
|
|
2874
|
-
timestamps[head] =
|
|
4235
|
+
timestamps[head] = now2;
|
|
2875
4236
|
let i = tail;
|
|
2876
4237
|
let bytesCount = 0;
|
|
2877
4238
|
while (i !== head) {
|
|
@@ -2882,10 +4243,10 @@ function speedometer(samplesCount, min) {
|
|
|
2882
4243
|
if (head === tail) {
|
|
2883
4244
|
tail = (tail + 1) % samplesCount;
|
|
2884
4245
|
}
|
|
2885
|
-
if (
|
|
4246
|
+
if (now2 - firstSampleTS < min) {
|
|
2886
4247
|
return;
|
|
2887
4248
|
}
|
|
2888
|
-
const passed = startedAt &&
|
|
4249
|
+
const passed = startedAt && now2 - startedAt;
|
|
2889
4250
|
return passed ? Math.round(bytesCount * 1e3 / passed) : void 0;
|
|
2890
4251
|
};
|
|
2891
4252
|
}
|
|
@@ -2894,8 +4255,8 @@ function throttle(fn, freq) {
|
|
|
2894
4255
|
let threshold = 1e3 / freq;
|
|
2895
4256
|
let lastArgs;
|
|
2896
4257
|
let timer;
|
|
2897
|
-
const invoke = (args,
|
|
2898
|
-
timestamp =
|
|
4258
|
+
const invoke = (args, now2 = Date.now()) => {
|
|
4259
|
+
timestamp = now2;
|
|
2899
4260
|
lastArgs = null;
|
|
2900
4261
|
if (timer) {
|
|
2901
4262
|
clearTimeout(timer);
|
|
@@ -2904,10 +4265,10 @@ function throttle(fn, freq) {
|
|
|
2904
4265
|
fn(...args);
|
|
2905
4266
|
};
|
|
2906
4267
|
const throttled = (...args) => {
|
|
2907
|
-
const
|
|
2908
|
-
const passed =
|
|
4268
|
+
const now2 = Date.now();
|
|
4269
|
+
const passed = now2 - timestamp;
|
|
2909
4270
|
if (passed >= threshold) {
|
|
2910
|
-
invoke(args,
|
|
4271
|
+
invoke(args, now2);
|
|
2911
4272
|
} else {
|
|
2912
4273
|
lastArgs = args;
|
|
2913
4274
|
if (!timer) {
|
|
@@ -4956,6 +6317,7 @@ const services = {
|
|
|
4956
6317
|
service,
|
|
4957
6318
|
"image-studio": imageStudio$1,
|
|
4958
6319
|
templates: templates$1,
|
|
6320
|
+
drive: drive$1,
|
|
4959
6321
|
articles: articles$1
|
|
4960
6322
|
};
|
|
4961
6323
|
const index = {
|