@blinkk/root-cms 2.4.4 → 2.4.7
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/app.js +8 -2
- package/dist/cli.js +630 -40
- package/dist/{client-DYO2WWlf.d.ts → client-wkm3JBRI.d.ts} +8 -3
- package/dist/client.d.ts +1 -1
- package/dist/core.d.ts +3 -3
- package/dist/core.js +18 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.js +11 -5
- package/dist/project.d.ts +1 -1
- package/dist/{schema-BxPmMeRc.d.ts → schema-Bux4PrV2.d.ts} +5 -0
- package/dist/ui/ui.css +1 -1
- package/dist/ui/ui.js +152 -152
- package/package.json +9 -3
package/dist/cli.js
CHANGED
|
@@ -2,23 +2,363 @@
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { bgGreen, black } from "kleur/colors";
|
|
4
4
|
|
|
5
|
-
// cli/
|
|
6
|
-
import
|
|
5
|
+
// cli/export.ts
|
|
6
|
+
import fs from "fs";
|
|
7
7
|
import path from "path";
|
|
8
|
+
import { loadRootConfig } from "@blinkk/root/node";
|
|
9
|
+
|
|
10
|
+
// core/client.ts
|
|
11
|
+
import crypto from "crypto";
|
|
12
|
+
import {
|
|
13
|
+
FieldValue as FieldValue2,
|
|
14
|
+
Timestamp as Timestamp2
|
|
15
|
+
} from "firebase-admin/firestore";
|
|
16
|
+
|
|
17
|
+
// core/translations-manager.ts
|
|
18
|
+
import {
|
|
19
|
+
FieldValue,
|
|
20
|
+
Timestamp
|
|
21
|
+
} from "firebase-admin/firestore";
|
|
22
|
+
|
|
23
|
+
// shared/strings.ts
|
|
24
|
+
import fnv from "fnv-plus";
|
|
25
|
+
|
|
26
|
+
// core/translations-manager.ts
|
|
27
|
+
var TRANSLATIONS_DB_PATH_FORMAT = "/Projects/{project}/TranslationsManager/{mode}/Translations";
|
|
28
|
+
var TRANSLATIONS_LOCALE_DOC_DB_PATH_FORMAT = `${TRANSLATIONS_DB_PATH_FORMAT}/{id}:{locale}`;
|
|
29
|
+
|
|
30
|
+
// core/client.ts
|
|
31
|
+
function getCmsPlugin(rootConfig) {
|
|
32
|
+
const plugins = rootConfig.plugins || [];
|
|
33
|
+
const plugin = plugins.find((plugin2) => plugin2.name === "root-cms");
|
|
34
|
+
if (!plugin) {
|
|
35
|
+
throw new Error("could not find root-cms plugin config in root.config.ts");
|
|
36
|
+
}
|
|
37
|
+
return plugin;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// cli/utils.ts
|
|
41
|
+
function parseFilters(filterStr) {
|
|
42
|
+
if (!filterStr) {
|
|
43
|
+
return { includes: [], excludes: [] };
|
|
44
|
+
}
|
|
45
|
+
const parts = filterStr.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
46
|
+
const includes = parts.filter((p) => !p.startsWith("!"));
|
|
47
|
+
const excludes = parts.filter((p) => p.startsWith("!")).map((p) => p.slice(1));
|
|
48
|
+
return { includes, excludes };
|
|
49
|
+
}
|
|
50
|
+
function getPathStatus(path4, includes, excludes) {
|
|
51
|
+
for (const pattern of excludes) {
|
|
52
|
+
if (globMatch(path4, pattern)) {
|
|
53
|
+
return "EXCLUDE";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (includes.length === 0) {
|
|
57
|
+
return "INCLUDE";
|
|
58
|
+
}
|
|
59
|
+
let isIncluded = false;
|
|
60
|
+
let shouldTraverse = false;
|
|
61
|
+
for (const pattern of includes) {
|
|
62
|
+
const match = checkMatch(path4, pattern);
|
|
63
|
+
if (match === "FULL") {
|
|
64
|
+
isIncluded = true;
|
|
65
|
+
} else if (match === "PARTIAL") {
|
|
66
|
+
shouldTraverse = true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (isIncluded) {
|
|
70
|
+
return "INCLUDE";
|
|
71
|
+
}
|
|
72
|
+
if (shouldTraverse) {
|
|
73
|
+
return "TRAVERSE";
|
|
74
|
+
}
|
|
75
|
+
return "SKIP";
|
|
76
|
+
}
|
|
77
|
+
function globMatch(path4, pattern) {
|
|
78
|
+
return checkMatch(path4, pattern) === "FULL";
|
|
79
|
+
}
|
|
80
|
+
function checkMatch(path4, pattern) {
|
|
81
|
+
const pathParts = path4.split("/");
|
|
82
|
+
const patternParts = pattern.split("/");
|
|
83
|
+
if (matchSegments(pathParts, patternParts)) {
|
|
84
|
+
return "FULL";
|
|
85
|
+
}
|
|
86
|
+
if (pathParts.length < patternParts.length) {
|
|
87
|
+
const patternPrefix = patternParts.slice(0, pathParts.length);
|
|
88
|
+
if (matchSegments(pathParts, patternPrefix)) {
|
|
89
|
+
return "PARTIAL";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return "NONE";
|
|
93
|
+
}
|
|
94
|
+
function matchSegments(pathParts, patternParts) {
|
|
95
|
+
if (patternParts[patternParts.length - 1] === "**") {
|
|
96
|
+
const basePattern = patternParts.slice(0, -1);
|
|
97
|
+
if (pathParts.length < basePattern.length) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
return matchSegments(pathParts.slice(0, basePattern.length), basePattern);
|
|
101
|
+
}
|
|
102
|
+
if (pathParts.length !== patternParts.length) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
for (let i = 0; i < pathParts.length; i++) {
|
|
106
|
+
const pathPart = pathParts[i];
|
|
107
|
+
const patternPart = patternParts[i];
|
|
108
|
+
if (patternPart === "*") {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (pathPart !== patternPart) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
function pLimit(concurrency) {
|
|
118
|
+
const queue = [];
|
|
119
|
+
let activeCount = 0;
|
|
120
|
+
const next = () => {
|
|
121
|
+
activeCount--;
|
|
122
|
+
if (queue.length > 0) {
|
|
123
|
+
const job = queue.shift();
|
|
124
|
+
if (job) job();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
return async (fn) => {
|
|
128
|
+
if (activeCount >= concurrency) {
|
|
129
|
+
await new Promise((resolve) => {
|
|
130
|
+
queue.push(resolve);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
activeCount++;
|
|
134
|
+
try {
|
|
135
|
+
return await fn();
|
|
136
|
+
} finally {
|
|
137
|
+
next();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// cli/export.ts
|
|
143
|
+
async function exportData(options) {
|
|
144
|
+
const rootDir = process.cwd();
|
|
145
|
+
const rootConfig = await loadRootConfig(rootDir, { command: "root-cms" });
|
|
146
|
+
const cmsPlugin = getCmsPlugin(rootConfig);
|
|
147
|
+
const cmsPluginOptions = cmsPlugin.getConfig();
|
|
148
|
+
const siteId = options.site || cmsPluginOptions.id || "default";
|
|
149
|
+
const databaseId = options.database || "(default)";
|
|
150
|
+
const db = cmsPlugin.getFirestore({ databaseId });
|
|
151
|
+
const projectPath = `Projects/${siteId}`;
|
|
152
|
+
const projectRef = db.doc(projectPath);
|
|
153
|
+
const collections = await projectRef.listCollections();
|
|
154
|
+
const allCollectionIds = collections.map((c) => c.id);
|
|
155
|
+
const { includes, excludes } = parseFilters(options.filter);
|
|
156
|
+
const collectionsToExport = allCollectionIds.filter((id) => {
|
|
157
|
+
const status = getPathStatus(id, includes, excludes);
|
|
158
|
+
return status !== "SKIP" && status !== "EXCLUDE";
|
|
159
|
+
});
|
|
160
|
+
const now = /* @__PURE__ */ new Date();
|
|
161
|
+
const timestamp = formatTimestamp(now);
|
|
162
|
+
const gcpProjectId = options.project || cmsPluginOptions.firebaseConfig?.projectId || "default";
|
|
163
|
+
const exportDir = `export_${gcpProjectId}_${databaseId}_${siteId}_${timestamp}`.replace(/\(/g, "").replace(/\)/g, "");
|
|
164
|
+
console.log("");
|
|
165
|
+
console.table({
|
|
166
|
+
Directory: exportDir,
|
|
167
|
+
"GCP Project": gcpProjectId,
|
|
168
|
+
Database: `${databaseId}/Projects/${siteId}`,
|
|
169
|
+
Site: siteId,
|
|
170
|
+
Collections: collectionsToExport.join(", "),
|
|
171
|
+
Filter: options.filter || "(none)"
|
|
172
|
+
});
|
|
173
|
+
console.log("");
|
|
174
|
+
if (!fs.existsSync(exportDir)) {
|
|
175
|
+
fs.mkdirSync(exportDir, { recursive: true });
|
|
176
|
+
}
|
|
177
|
+
if (!options.filter) {
|
|
178
|
+
const projectDoc = await projectRef.get();
|
|
179
|
+
if (projectDoc.exists) {
|
|
180
|
+
const projectData = projectDoc.data();
|
|
181
|
+
const projectFilePath = path.join(exportDir, "__data.json");
|
|
182
|
+
fs.writeFileSync(
|
|
183
|
+
projectFilePath,
|
|
184
|
+
JSON.stringify(convertForExport(projectData), null, 2)
|
|
185
|
+
);
|
|
186
|
+
console.log("Exported project document to __data.json");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const limit = pLimit(10);
|
|
190
|
+
for (const collectionType of collectionsToExport) {
|
|
191
|
+
console.log(`Exporting ${collectionType}...`);
|
|
192
|
+
const collectionPath = `Projects/${siteId}/${collectionType}`;
|
|
193
|
+
const collectionDir = path.join(exportDir, collectionType);
|
|
194
|
+
await exportCollection(
|
|
195
|
+
db,
|
|
196
|
+
collectionPath,
|
|
197
|
+
collectionDir,
|
|
198
|
+
collectionType,
|
|
199
|
+
collectionType,
|
|
200
|
+
includes,
|
|
201
|
+
excludes,
|
|
202
|
+
limit
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
console.log(`
|
|
206
|
+
\u2705 Export complete! Data saved to: ${exportDir}`);
|
|
207
|
+
}
|
|
208
|
+
var Spinner = class {
|
|
209
|
+
constructor(text) {
|
|
210
|
+
this.timer = null;
|
|
211
|
+
this.frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
212
|
+
this.currentFrame = 0;
|
|
213
|
+
this.text = text;
|
|
214
|
+
}
|
|
215
|
+
start() {
|
|
216
|
+
this.render();
|
|
217
|
+
this.timer = setInterval(() => {
|
|
218
|
+
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
|
|
219
|
+
this.render();
|
|
220
|
+
}, 80);
|
|
221
|
+
}
|
|
222
|
+
update(text) {
|
|
223
|
+
this.text = text;
|
|
224
|
+
this.render();
|
|
225
|
+
}
|
|
226
|
+
stop() {
|
|
227
|
+
if (this.timer) {
|
|
228
|
+
clearInterval(this.timer);
|
|
229
|
+
this.timer = null;
|
|
230
|
+
}
|
|
231
|
+
process.stdout.write("\r\x1B[K");
|
|
232
|
+
}
|
|
233
|
+
render() {
|
|
234
|
+
process.stdout.write(`\r${this.frames[this.currentFrame]} ${this.text}`);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
async function exportCollection(db, collectionPath, outputDir, displayName, relativePath, includes, excludes, limit) {
|
|
238
|
+
const stats = { count: 0 };
|
|
239
|
+
const spinner = new Spinner(`Exporting ${displayName}...`);
|
|
240
|
+
spinner.start();
|
|
241
|
+
await exportCollectionRecursive(
|
|
242
|
+
db,
|
|
243
|
+
collectionPath,
|
|
244
|
+
outputDir,
|
|
245
|
+
stats,
|
|
246
|
+
spinner,
|
|
247
|
+
relativePath,
|
|
248
|
+
includes,
|
|
249
|
+
excludes,
|
|
250
|
+
limit
|
|
251
|
+
);
|
|
252
|
+
spinner.stop();
|
|
253
|
+
console.log(` - ${displayName}: ${stats.count} documents`);
|
|
254
|
+
}
|
|
255
|
+
async function exportCollectionRecursive(db, collectionPath, outputDir, stats, spinner, relativePath, includes, excludes, limit) {
|
|
256
|
+
if (!fs.existsSync(outputDir)) {
|
|
257
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
258
|
+
}
|
|
259
|
+
const collection = db.collection(collectionPath);
|
|
260
|
+
const refs = await limit(() => collection.listDocuments());
|
|
261
|
+
if (refs.length === 0) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const snapshot = await limit(() => collection.get());
|
|
265
|
+
const docMap = /* @__PURE__ */ new Map();
|
|
266
|
+
snapshot.docs.forEach((doc) => {
|
|
267
|
+
docMap.set(doc.id, doc);
|
|
268
|
+
});
|
|
269
|
+
await Promise.all(
|
|
270
|
+
refs.map(async (ref) => {
|
|
271
|
+
const doc = docMap.get(ref.id);
|
|
272
|
+
const subcollections = await limit(() => ref.listCollections());
|
|
273
|
+
const hasSubcollections = subcollections.length > 0;
|
|
274
|
+
const docRelativePath = `${relativePath}/${ref.id}`;
|
|
275
|
+
const docStatus = getPathStatus(docRelativePath, includes, excludes);
|
|
276
|
+
if (doc && docStatus !== "SKIP" && docStatus !== "EXCLUDE") {
|
|
277
|
+
const docData = doc.data();
|
|
278
|
+
let filePath;
|
|
279
|
+
if (hasSubcollections) {
|
|
280
|
+
const docDir = path.join(outputDir, doc.id);
|
|
281
|
+
if (!fs.existsSync(docDir)) {
|
|
282
|
+
fs.mkdirSync(docDir, { recursive: true });
|
|
283
|
+
}
|
|
284
|
+
filePath = path.join(docDir, "__data.json");
|
|
285
|
+
} else {
|
|
286
|
+
filePath = path.join(outputDir, `${doc.id}.json`);
|
|
287
|
+
}
|
|
288
|
+
fs.writeFileSync(
|
|
289
|
+
filePath,
|
|
290
|
+
JSON.stringify(convertForExport(docData), null, 2)
|
|
291
|
+
);
|
|
292
|
+
stats.count++;
|
|
293
|
+
spinner.update(`Exporting ${stats.count} documents...`);
|
|
294
|
+
}
|
|
295
|
+
for (const subcollection of subcollections) {
|
|
296
|
+
const subRelativePath = `${docRelativePath}/${subcollection.id}`;
|
|
297
|
+
const status = getPathStatus(subRelativePath, includes, excludes);
|
|
298
|
+
if (status === "SKIP" || status === "EXCLUDE") {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const subcollectionDir = path.join(outputDir, ref.id, subcollection.id);
|
|
302
|
+
await exportCollectionRecursive(
|
|
303
|
+
db,
|
|
304
|
+
subcollection.path,
|
|
305
|
+
subcollectionDir,
|
|
306
|
+
stats,
|
|
307
|
+
spinner,
|
|
308
|
+
subRelativePath,
|
|
309
|
+
includes,
|
|
310
|
+
excludes,
|
|
311
|
+
limit
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
function formatTimestamp(date) {
|
|
318
|
+
const year = date.getFullYear();
|
|
319
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
320
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
321
|
+
const hour = String(date.getHours()).padStart(2, "0");
|
|
322
|
+
const minute = String(date.getMinutes()).padStart(2, "0");
|
|
323
|
+
return `${year}${month}${day}t${hour}${minute}`;
|
|
324
|
+
}
|
|
325
|
+
function convertForExport(obj) {
|
|
326
|
+
if (obj === null || obj === void 0) {
|
|
327
|
+
return obj;
|
|
328
|
+
}
|
|
329
|
+
if (typeof obj === "object" && typeof obj.path === "string" && obj.constructor.name === "DocumentReference") {
|
|
330
|
+
return { _referencePath: obj.path };
|
|
331
|
+
}
|
|
332
|
+
if (Array.isArray(obj)) {
|
|
333
|
+
return obj.map((item) => convertForExport(item));
|
|
334
|
+
}
|
|
335
|
+
if (typeof obj === "object") {
|
|
336
|
+
const converted = {};
|
|
337
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
338
|
+
converted[key] = convertForExport(value);
|
|
339
|
+
}
|
|
340
|
+
return converted;
|
|
341
|
+
}
|
|
342
|
+
return obj;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// cli/generate-types.ts
|
|
346
|
+
import { promises as fs2 } from "fs";
|
|
347
|
+
import path2 from "path";
|
|
8
348
|
import { fileURLToPath } from "url";
|
|
9
|
-
import { loadRootConfig, viteSsrLoadModule } from "@blinkk/root/node";
|
|
349
|
+
import { loadRootConfig as loadRootConfig2, viteSsrLoadModule } from "@blinkk/root/node";
|
|
10
350
|
import * as dom from "dts-dom";
|
|
11
|
-
var __dirname =
|
|
351
|
+
var __dirname = path2.dirname(fileURLToPath(import.meta.url));
|
|
12
352
|
async function generateTypes() {
|
|
13
353
|
const rootDir = process.cwd();
|
|
14
|
-
const rootConfig = await
|
|
15
|
-
const modulePath =
|
|
354
|
+
const rootConfig = await loadRootConfig2(rootDir, { command: "root-cms" });
|
|
355
|
+
const modulePath = path2.resolve(__dirname, "./project.js");
|
|
16
356
|
const project = await viteSsrLoadModule(
|
|
17
357
|
rootConfig,
|
|
18
358
|
modulePath
|
|
19
359
|
);
|
|
20
360
|
const schemas = await project.getProjectSchemas();
|
|
21
|
-
const outputPath =
|
|
361
|
+
const outputPath = path2.resolve(rootDir, "root-cms.d.ts");
|
|
22
362
|
await generateSchemaDts(outputPath, schemas);
|
|
23
363
|
console.log("saved root-cms.d.ts!");
|
|
24
364
|
}
|
|
@@ -95,7 +435,7 @@ var DtsFormatter = class {
|
|
|
95
435
|
/** Adds the types for a `.schema.ts` file to the `.d.ts` file. */
|
|
96
436
|
addSchemaFile(fileId, schema) {
|
|
97
437
|
const jsdoc = `Generated from \`${fileId}\`.`;
|
|
98
|
-
const typeId = alphanumeric(
|
|
438
|
+
const typeId = alphanumeric(path2.parse(fileId).name.split(".")[0]);
|
|
99
439
|
const oneOfTypes = {};
|
|
100
440
|
const fieldsTypeId = `${typeId}Fields`;
|
|
101
441
|
const fieldsType = dom.create.interface(
|
|
@@ -178,7 +518,7 @@ async function generateSchemaDts(outputPath, schemas) {
|
|
|
178
518
|
dtsFormatter.addSchemaFile(fileId, schema);
|
|
179
519
|
}
|
|
180
520
|
const output = dtsFormatter.toString();
|
|
181
|
-
await
|
|
521
|
+
await fs2.writeFile(outputPath, output, "utf-8");
|
|
182
522
|
}
|
|
183
523
|
function fieldProperty(field, options) {
|
|
184
524
|
const prop = dom.create.property(
|
|
@@ -300,39 +640,269 @@ function alphanumeric(input) {
|
|
|
300
640
|
return input.replace(/[^a-zA-Z0-9]/g, "");
|
|
301
641
|
}
|
|
302
642
|
|
|
303
|
-
// cli/
|
|
304
|
-
import
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
import
|
|
308
|
-
import
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
FieldValue,
|
|
316
|
-
Timestamp
|
|
317
|
-
} from "firebase-admin/firestore";
|
|
318
|
-
|
|
319
|
-
// shared/strings.ts
|
|
320
|
-
import fnv from "fnv-plus";
|
|
321
|
-
|
|
322
|
-
// core/translations-manager.ts
|
|
323
|
-
var TRANSLATIONS_DB_PATH_FORMAT = "/Projects/{project}/TranslationsManager/{mode}/Translations";
|
|
324
|
-
var TRANSLATIONS_LOCALE_DOC_DB_PATH_FORMAT = `${TRANSLATIONS_DB_PATH_FORMAT}/{id}:{locale}`;
|
|
325
|
-
|
|
326
|
-
// core/client.ts
|
|
327
|
-
function getCmsPlugin(rootConfig) {
|
|
328
|
-
const plugins = rootConfig.plugins || [];
|
|
329
|
-
const plugin = plugins.find((plugin2) => plugin2.name === "root-cms");
|
|
330
|
-
if (!plugin) {
|
|
331
|
-
throw new Error("could not find root-cms plugin config in root.config.ts");
|
|
643
|
+
// cli/import.ts
|
|
644
|
+
import fs3 from "fs";
|
|
645
|
+
import path3 from "path";
|
|
646
|
+
import * as readline from "readline";
|
|
647
|
+
import { loadRootConfig as loadRootConfig3 } from "@blinkk/root/node";
|
|
648
|
+
import cliProgress from "cli-progress";
|
|
649
|
+
import { Timestamp as Timestamp3, GeoPoint } from "firebase-admin/firestore";
|
|
650
|
+
async function importData(options) {
|
|
651
|
+
if (!options.dir) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
"Error: --dir flag is required. Usage: root-cms import --dir=export_siteId_timestamp"
|
|
654
|
+
);
|
|
332
655
|
}
|
|
333
|
-
|
|
656
|
+
const importDir = options.dir;
|
|
657
|
+
if (!fs3.existsSync(importDir)) {
|
|
658
|
+
throw new Error(`Error: Directory not found: ${importDir}`);
|
|
659
|
+
}
|
|
660
|
+
const rootDir = process.cwd();
|
|
661
|
+
const rootConfig = await loadRootConfig3(rootDir, { command: "root-cms" });
|
|
662
|
+
const cmsPlugin = getCmsPlugin(rootConfig);
|
|
663
|
+
const cmsPluginOptions = cmsPlugin.getConfig();
|
|
664
|
+
const siteId = options.site || cmsPluginOptions.id || "default";
|
|
665
|
+
const databaseId = options.database || "(default)";
|
|
666
|
+
const gcpProjectId = options.project || cmsPluginOptions.firebaseConfig?.projectId || "unknown";
|
|
667
|
+
const db = cmsPlugin.getFirestore({ databaseId });
|
|
668
|
+
const entries = fs3.readdirSync(importDir, { withFileTypes: true });
|
|
669
|
+
const availableCollections = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
670
|
+
const { includes, excludes } = parseFilters(options.filter);
|
|
671
|
+
const collectionsToImport = availableCollections.filter((id) => {
|
|
672
|
+
const status = getPathStatus(id, includes, excludes);
|
|
673
|
+
return status !== "SKIP" && status !== "EXCLUDE";
|
|
674
|
+
});
|
|
675
|
+
if (collectionsToImport.length === 0) {
|
|
676
|
+
throw new Error(
|
|
677
|
+
`No valid collection types found matching filter. Available types: ${availableCollections.join(
|
|
678
|
+
", "
|
|
679
|
+
)}`
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
const summary = [];
|
|
683
|
+
for (const collectionType of collectionsToImport) {
|
|
684
|
+
const collectionDir = path3.join(importDir, collectionType);
|
|
685
|
+
if (fs3.existsSync(collectionDir)) {
|
|
686
|
+
const count = countJsonFilesRecursive(
|
|
687
|
+
collectionDir,
|
|
688
|
+
collectionType,
|
|
689
|
+
includes,
|
|
690
|
+
excludes
|
|
691
|
+
);
|
|
692
|
+
if (count > 0) {
|
|
693
|
+
summary.push({
|
|
694
|
+
collectionType,
|
|
695
|
+
count
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (summary.length === 0) {
|
|
701
|
+
console.log("No data found to import.");
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const tableData = {};
|
|
705
|
+
tableData["Import Directory"] = importDir;
|
|
706
|
+
tableData["GCP Project"] = gcpProjectId;
|
|
707
|
+
tableData["Database"] = `${databaseId}/Projects/${siteId}`;
|
|
708
|
+
tableData["Site"] = siteId;
|
|
709
|
+
for (const item of summary) {
|
|
710
|
+
tableData[item.collectionType] = `${item.count} documents`;
|
|
711
|
+
}
|
|
712
|
+
console.log("");
|
|
713
|
+
console.table(tableData);
|
|
714
|
+
console.log("");
|
|
715
|
+
const proceed = await promptYesNo("Proceed with import? (yes/no): ");
|
|
716
|
+
if (!proceed) {
|
|
717
|
+
console.log("Import cancelled.");
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
console.log("\nStarting import...\n");
|
|
721
|
+
const limit = pLimit(10);
|
|
722
|
+
for (const item of summary) {
|
|
723
|
+
const collectionPath = `Projects/${siteId}/${item.collectionType}`;
|
|
724
|
+
const collectionDir = path3.join(importDir, item.collectionType);
|
|
725
|
+
await importCollection(
|
|
726
|
+
db,
|
|
727
|
+
collectionPath,
|
|
728
|
+
collectionDir,
|
|
729
|
+
item.collectionType,
|
|
730
|
+
item.count,
|
|
731
|
+
item.collectionType,
|
|
732
|
+
includes,
|
|
733
|
+
excludes,
|
|
734
|
+
limit
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
const projectFilePath = path3.join(importDir, "__data.json");
|
|
738
|
+
if (fs3.existsSync(projectFilePath)) {
|
|
739
|
+
console.log("Importing project document...");
|
|
740
|
+
const rawData = JSON.parse(fs3.readFileSync(projectFilePath, "utf-8"));
|
|
741
|
+
const projectData = convertFirestoreTypes(rawData, db);
|
|
742
|
+
const projectPath = `Projects/${siteId}`;
|
|
743
|
+
await db.doc(projectPath).set(projectData, { merge: true });
|
|
744
|
+
console.log(" - Project document updated");
|
|
745
|
+
}
|
|
746
|
+
console.log("\n\u2705 Import complete!");
|
|
747
|
+
}
|
|
748
|
+
function countJsonFilesRecursive(dir, relativePath, includes, excludes) {
|
|
749
|
+
let count = 0;
|
|
750
|
+
const entries = fs3.readdirSync(dir, { withFileTypes: true });
|
|
751
|
+
for (const entry of entries) {
|
|
752
|
+
if (entry.isDirectory()) {
|
|
753
|
+
const subRelativePath = `${relativePath}/${entry.name}`;
|
|
754
|
+
const status = getPathStatus(subRelativePath, includes, excludes);
|
|
755
|
+
if (status !== "SKIP" && status !== "EXCLUDE") {
|
|
756
|
+
count += countJsonFilesRecursive(
|
|
757
|
+
path3.join(dir, entry.name),
|
|
758
|
+
subRelativePath,
|
|
759
|
+
includes,
|
|
760
|
+
excludes
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
} else if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
764
|
+
if (entry.name === "__data.json") {
|
|
765
|
+
const status2 = getPathStatus(relativePath, includes, excludes);
|
|
766
|
+
if (status2 === "INCLUDE") {
|
|
767
|
+
count++;
|
|
768
|
+
}
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
const docId = path3.basename(entry.name, ".json");
|
|
772
|
+
const docRelativePath = `${relativePath}/${docId}`;
|
|
773
|
+
const status = getPathStatus(docRelativePath, includes, excludes);
|
|
774
|
+
if (status !== "SKIP" && status !== "EXCLUDE") {
|
|
775
|
+
count++;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return count;
|
|
780
|
+
}
|
|
781
|
+
async function importCollection(db, collectionPath, inputDir, displayName, totalDocs, relativePath, includes, excludes, limit) {
|
|
782
|
+
console.log(`Importing ${displayName}...`);
|
|
783
|
+
const progressBar = new cliProgress.SingleBar({
|
|
784
|
+
format: `Importing ${displayName} [{bar}] {percentage}% | {value}/{total}`,
|
|
785
|
+
barCompleteChar: "\u2588",
|
|
786
|
+
barIncompleteChar: "\u2591",
|
|
787
|
+
hideCursor: true
|
|
788
|
+
});
|
|
789
|
+
progressBar.start(totalDocs, 0);
|
|
790
|
+
await importCollectionRecursive(
|
|
791
|
+
db,
|
|
792
|
+
collectionPath,
|
|
793
|
+
inputDir,
|
|
794
|
+
progressBar,
|
|
795
|
+
relativePath,
|
|
796
|
+
includes,
|
|
797
|
+
excludes,
|
|
798
|
+
limit
|
|
799
|
+
);
|
|
800
|
+
progressBar.stop();
|
|
801
|
+
console.log(` - ${displayName}: ${totalDocs} documents`);
|
|
802
|
+
}
|
|
803
|
+
function convertFirestoreTypes(obj, db) {
|
|
804
|
+
if (obj === null || obj === void 0) {
|
|
805
|
+
return obj;
|
|
806
|
+
}
|
|
807
|
+
if (typeof obj === "object" && "_seconds" in obj && "_nanoseconds" in obj && Object.keys(obj).length === 2) {
|
|
808
|
+
return new Timestamp3(obj._seconds, obj._nanoseconds);
|
|
809
|
+
}
|
|
810
|
+
if (typeof obj === "object" && "_latitude" in obj && "_longitude" in obj && Object.keys(obj).length === 2) {
|
|
811
|
+
return new GeoPoint(obj._latitude, obj._longitude);
|
|
812
|
+
}
|
|
813
|
+
if (typeof obj === "object" && "_referencePath" in obj && Object.keys(obj).length === 1) {
|
|
814
|
+
return db.doc(obj._referencePath);
|
|
815
|
+
}
|
|
816
|
+
if (Array.isArray(obj)) {
|
|
817
|
+
return obj.map((item) => convertFirestoreTypes(item, db));
|
|
818
|
+
}
|
|
819
|
+
if (typeof obj === "object") {
|
|
820
|
+
const converted = {};
|
|
821
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
822
|
+
converted[key] = convertFirestoreTypes(value, db);
|
|
823
|
+
}
|
|
824
|
+
return converted;
|
|
825
|
+
}
|
|
826
|
+
return obj;
|
|
827
|
+
}
|
|
828
|
+
async function importCollectionRecursive(db, collectionPath, inputDir, progressBar, relativePath, includes, excludes, limit) {
|
|
829
|
+
if (!fs3.existsSync(inputDir)) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const entries = fs3.readdirSync(inputDir, { withFileTypes: true });
|
|
833
|
+
await Promise.all(
|
|
834
|
+
entries.map(async (entry) => {
|
|
835
|
+
if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
836
|
+
const rawData = JSON.parse(
|
|
837
|
+
fs3.readFileSync(path3.join(inputDir, entry.name), "utf-8")
|
|
838
|
+
);
|
|
839
|
+
const docData = convertFirestoreTypes(rawData, db);
|
|
840
|
+
if (entry.name === "__data.json") {
|
|
841
|
+
const status = includes && excludes ? getPathStatus(relativePath || "", includes, excludes) : "INCLUDE";
|
|
842
|
+
if (status === "INCLUDE") {
|
|
843
|
+
if (limit) {
|
|
844
|
+
await limit(
|
|
845
|
+
() => db.doc(collectionPath).set(docData, { merge: true })
|
|
846
|
+
);
|
|
847
|
+
} else {
|
|
848
|
+
await db.doc(collectionPath).set(docData, { merge: true });
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
} else {
|
|
852
|
+
const docId = path3.basename(entry.name, ".json");
|
|
853
|
+
const docRelativePath = relativePath ? `${relativePath}/${docId}` : docId;
|
|
854
|
+
const status = includes && excludes ? getPathStatus(docRelativePath, includes, excludes) : "INCLUDE";
|
|
855
|
+
if (status !== "SKIP" && status !== "EXCLUDE") {
|
|
856
|
+
if (limit) {
|
|
857
|
+
await limit(
|
|
858
|
+
() => db.doc(`${collectionPath}/${docId}`).set(docData)
|
|
859
|
+
);
|
|
860
|
+
} else {
|
|
861
|
+
await db.doc(`${collectionPath}/${docId}`).set(docData);
|
|
862
|
+
}
|
|
863
|
+
if (progressBar) {
|
|
864
|
+
progressBar.increment();
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
} else if (entry.isDirectory()) {
|
|
869
|
+
const subCollectionPath = `${collectionPath}/${entry.name}`;
|
|
870
|
+
const subCollectionDir = path3.join(inputDir, entry.name);
|
|
871
|
+
const subRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
872
|
+
const status = includes && excludes ? getPathStatus(subRelativePath, includes, excludes) : "INCLUDE";
|
|
873
|
+
if (status !== "SKIP" && status !== "EXCLUDE") {
|
|
874
|
+
await importCollectionRecursive(
|
|
875
|
+
db,
|
|
876
|
+
subCollectionPath,
|
|
877
|
+
subCollectionDir,
|
|
878
|
+
progressBar,
|
|
879
|
+
subRelativePath,
|
|
880
|
+
includes,
|
|
881
|
+
excludes,
|
|
882
|
+
limit
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
})
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
async function promptYesNo(question) {
|
|
890
|
+
const rl = readline.createInterface({
|
|
891
|
+
input: process.stdin,
|
|
892
|
+
output: process.stdout
|
|
893
|
+
});
|
|
894
|
+
return new Promise((resolve) => {
|
|
895
|
+
rl.question(question, (answer) => {
|
|
896
|
+
rl.close();
|
|
897
|
+
const normalized = answer.trim().toLowerCase();
|
|
898
|
+
resolve(normalized === "yes" || normalized === "y");
|
|
899
|
+
});
|
|
900
|
+
});
|
|
334
901
|
}
|
|
335
902
|
|
|
903
|
+
// cli/init-firebase.ts
|
|
904
|
+
import { loadRootConfig as loadRootConfig4 } from "@blinkk/root/node";
|
|
905
|
+
|
|
336
906
|
// core/security.ts
|
|
337
907
|
import { initializeApp } from "firebase-admin/app";
|
|
338
908
|
import { getSecurityRules } from "firebase-admin/security-rules";
|
|
@@ -397,7 +967,7 @@ async function applySecurityRules(projectId) {
|
|
|
397
967
|
// cli/init-firebase.ts
|
|
398
968
|
async function initFirebase(options) {
|
|
399
969
|
const rootDir = process.cwd();
|
|
400
|
-
const rootConfig = await
|
|
970
|
+
const rootConfig = await loadRootConfig4(rootDir, { command: "root-cms" });
|
|
401
971
|
const cmsPlugin = getCmsPlugin(rootConfig);
|
|
402
972
|
const cmsPluginOptions = cmsPlugin.getConfig();
|
|
403
973
|
const gcpProjectId = options.project || cmsPluginOptions.firebaseConfig.projectId;
|
|
@@ -453,11 +1023,31 @@ var CliRunner = class {
|
|
|
453
1023
|
program.command("generate-types").alias("types").description(
|
|
454
1024
|
"generates root-cms.d.ts from *.schema.ts files in the project"
|
|
455
1025
|
).action(generateTypes);
|
|
1026
|
+
program.command("export").description(
|
|
1027
|
+
'exports firestore data to a local directory\n\nBy default, all content in the Firestore database associated with the CMS will be exported.\n\nA unique new directory will be created for each export.\n\nFile naming conventions:\n- Documents that act as containers for subcollections are exported as directories containing a `__data.json` file for the document\'s own data.\n- Standalone documents are exported as JSON files named after their document ID (e.g. `page.json`).\n- For collections like ActionLogs and Translations, the document ID (often a hash) is used as the filename.\n\nUsage examples:\n $ root-cms export\n $ root-cms export --filter "Collections/Pages/**"\n $ root-cms export --filter "Collections/Pages/**,!Collections/Pages/Draft/**"\n\nExample output:\n <output>/Collections/Pages/Draft/...\n <output>/Collections/Pages/Published/...\n <output>/ActionLogs/...'
|
|
1028
|
+
).option(
|
|
1029
|
+
"--filter <pattern>",
|
|
1030
|
+
"comma-separated list of glob patterns to filter content (e.g. Collections/Pages/**, !ActionLogs/**)"
|
|
1031
|
+
).option("--site <siteId>", "site id to export (overrides root config)").option(
|
|
1032
|
+
"--database <databaseId>",
|
|
1033
|
+
'firestore database id (overrides root config, default: "(default)")'
|
|
1034
|
+
).option("--project <projectId>", "gcp project id (overrides root config)").action(exportData);
|
|
1035
|
+
program.command("import").description(
|
|
1036
|
+
'imports firestore data from a local directory\n\nUsage examples:\n $ root-cms import --dir export_project_site_20251209t1305\n $ root-cms import --dir export_project_site_20251209t1305 --filter "Collections/Pages/**"'
|
|
1037
|
+
).option("--dir <directory>", "directory to import from (required)").option(
|
|
1038
|
+
"--filter <pattern>",
|
|
1039
|
+
"comma-separated list of glob patterns to filter content (e.g. Collections/Pages/**, !ActionLogs/**)"
|
|
1040
|
+
).option("--site <siteId>", "site id to import to (overrides root config)").option(
|
|
1041
|
+
"--database <databaseId>",
|
|
1042
|
+
'firestore database id (overrides root config, default: "(default)")'
|
|
1043
|
+
).option("--project <projectId>", "gcp project id (overrides root config)").action(importData);
|
|
456
1044
|
await program.parseAsync(argv);
|
|
457
1045
|
}
|
|
458
1046
|
};
|
|
459
1047
|
export {
|
|
460
1048
|
CliRunner,
|
|
1049
|
+
exportData,
|
|
461
1050
|
generateTypes,
|
|
1051
|
+
importData,
|
|
462
1052
|
initFirebase
|
|
463
1053
|
};
|