@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.
Files changed (46) hide show
  1. package/dist/_chunks/{App-CTh6owSM.mjs → App-2eX-mcfo.mjs} +2 -2
  2. package/dist/_chunks/{App-ugsCmlsC.js → App-CvZirjX_.js} +2 -2
  3. package/dist/_chunks/{ImageStudioTabs-Umf3GODW.mjs → ImageStudioTabs-B6fkCK-o.mjs} +353 -21
  4. package/dist/_chunks/{ImageStudioTabs-BFEF9ggK.js → ImageStudioTabs-CNktoRax.js} +351 -19
  5. package/dist/_chunks/{Settings-Sk78JUBS.js → Settings-B-QPodCh.js} +169 -1
  6. package/dist/_chunks/{Settings-CdGW2O8h.mjs → Settings-CaGruvIm.mjs} +170 -2
  7. package/dist/_chunks/{StudioApp-DoC0z4eh.js → StudioApp-BDoB31L0.js} +2 -2
  8. package/dist/_chunks/{StudioApp-CrGn-9Gg.mjs → StudioApp-CF03znuL.mjs} +2 -2
  9. package/dist/_chunks/{index-D6UAvh53.mjs → index-DlemRDzs.mjs} +3 -3
  10. package/dist/_chunks/{index-C2s9Jhcc.js → index-DwHGS-BJ.js} +3 -3
  11. package/dist/admin/index.js +1 -1
  12. package/dist/admin/index.mjs +1 -1
  13. package/dist/admin/src/components/settings/DriveSettings.d.ts +77 -0
  14. package/dist/admin/src/features/drive/DriveTab.d.ts +42 -0
  15. package/dist/admin/src/features/image-studio/ImageStudioTabs.d.ts +3 -0
  16. package/dist/admin/src/features/library/FileCard.d.ts +5 -2
  17. package/dist/admin/src/features/library/LibraryTab.d.ts +5 -0
  18. package/dist/admin/src/features/library/driveSync.d.ts +41 -0
  19. package/dist/admin/src/features/templates/export/templateOrigin.d.ts +10 -0
  20. package/dist/admin/src/lib/use-cases/drive/index.d.ts +3 -0
  21. package/dist/server/index.js +1411 -49
  22. package/dist/server/index.mjs +1409 -47
  23. package/dist/server/src/bootstrap.d.ts +2 -0
  24. package/dist/server/src/content-types/drive-link/index.d.ts +62 -0
  25. package/dist/server/src/content-types/drive-link/schema.d.ts +68 -0
  26. package/dist/server/src/content-types/drive-op/index.d.ts +46 -0
  27. package/dist/server/src/content-types/drive-op/schema.d.ts +49 -0
  28. package/dist/server/src/content-types/index.d.ts +106 -0
  29. package/dist/server/src/controllers/drive.d.ts +30 -0
  30. package/dist/server/src/controllers/index.d.ts +130 -0
  31. package/dist/server/src/drive/client.d.ts +32 -0
  32. package/dist/server/src/drive/clientConfig.d.ts +27 -0
  33. package/dist/server/src/drive/common.d.ts +6 -0
  34. package/dist/server/src/drive/fakes.d.ts +104 -0
  35. package/dist/server/src/drive/files.d.ts +136 -0
  36. package/dist/server/src/drive/lifecycles.d.ts +7 -0
  37. package/dist/server/src/drive/links.d.ts +23 -0
  38. package/dist/server/src/drive/model.d.ts +99 -0
  39. package/dist/server/src/drive/oauth.d.ts +47 -0
  40. package/dist/server/src/drive/pull.d.ts +23 -0
  41. package/dist/server/src/drive/strapiPorts.d.ts +10 -0
  42. package/dist/server/src/drive/sync.d.ts +40 -0
  43. package/dist/server/src/index.d.ts +294 -0
  44. package/dist/server/src/services/drive.d.ts +141 -0
  45. package/dist/server/src/services/index.d.ts +55 -0
  46. package/package.json +1 -1
@@ -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$3 = (ctx, status, name2, message) => {
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$3(ctx, 400, "BadRequestError", err.message);
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$3(ctx, 422, "UnprocessableEntityError", err.message);
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$3(ctx, 502, "BadGatewayError", "El servicio de imágenes no respondió. Intenta de nuevo en un momento.");
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, now) => {
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() <= now.getTime()) throw new InputError("Elige una fecha y hora futuras.");
700
- if (date.getTime() - now.getTime() > UN_ANIO_MS) throw new InputError("Programa con menos de un año de anticipación.");
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(now()), publicacion_programada: null } });
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, now());
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: now().toISOString() } },
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$2 = (ctx, status, name2, message) => {
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$2(ctx, 400, "BadRequestError", err.message);
925
- if (err instanceof NotFoundError) return fail$2(ctx, 404, "NotFoundError", err.message);
926
- if (err instanceof Error && err.name === "ValidationError") return fail$2(ctx, 400, "ValidationError", err.message);
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$2(ctx, 502, "BadGatewayError", "El servicio de generación no respondió. Intenta de nuevo en un momento.");
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$1 = (message) => {
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$1("El nombre debe tener entre 1 y 120 caracteres.");
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$1("Las medidas deben ser enteros de 100 a 4000 px.");
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$1(`${label} debe ser un número positivo.`);
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$1(`Las capas deben ser una lista de hasta ${MAX_LAYERS}.`);
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$1(`La capa ${i + 1} no tiene id.`);
1121
- if (!LAYER_TYPES.includes(layer.type)) fail$1(`La capa ${i + 1} tiene un tipo desconocido.`);
1122
- if (typeof layer.x !== "number" || typeof layer.y !== "number") fail$1(`La capa ${i + 1} no tiene posición.`);
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$1("Las imágenes deben guardarse en la Media Library, no dentro del template.");
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$1("Nada que actualizar.");
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 now = Date.now();
4229
+ const now2 = Date.now();
2869
4230
  const startedAt = timestamps[tail];
2870
4231
  if (!firstSampleTS) {
2871
- firstSampleTS = now;
4232
+ firstSampleTS = now2;
2872
4233
  }
2873
4234
  bytes[head] = chunkLength;
2874
- timestamps[head] = now;
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 (now - firstSampleTS < min) {
4246
+ if (now2 - firstSampleTS < min) {
2886
4247
  return;
2887
4248
  }
2888
- const passed = startedAt && now - 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, now = Date.now()) => {
2898
- timestamp = now;
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 now = Date.now();
2908
- const passed = now - timestamp;
4268
+ const now2 = Date.now();
4269
+ const passed = now2 - timestamp;
2909
4270
  if (passed >= threshold) {
2910
- invoke(args, now);
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 = {