@lightnet/cli 4.1.1 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -2
- package/package.json +3 -2
- package/src/check-files.js +526 -0
- package/src/check-links.js +456 -0
- package/src/check-translations.js +96 -18
- package/src/index.js +53 -3
- package/src/support/cli-config.js +57 -0
- package/src/support/cli-error.js +1 -0
- package/src/support/content-collections.js +191 -0
- package/src/support/filesystem.js +216 -0
- package/src/support/r2.js +414 -0
- package/src/types.d.ts +0 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @lightnet/cli
|
|
2
2
|
|
|
3
|
+
## 4.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#427](https://github.com/LightNetDev/LightNet/pull/427) [`d34539d`](https://github.com/LightNetDev/LightNet/commit/d34539dc21d8e37d25cf65233ba0837675062fd7) - Add a `lightnet check-links` command that validates media content URLs without downloading linked files.
|
|
8
|
+
|
|
9
|
+
- [#427](https://github.com/LightNetDev/LightNet/pull/427) [`d34539d`](https://github.com/LightNetDev/LightNet/commit/d34539dc21d8e37d25cf65233ba0837675062fd7) - check-translations will provide option to run `pnpm build`
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#427](https://github.com/LightNetDev/LightNet/pull/427) [`d34539d`](https://github.com/LightNetDev/LightNet/commit/d34539dc21d8e37d25cf65233ba0837675062fd7) - Refactor check-files content storage handling.
|
|
14
|
+
|
|
15
|
+
- [#427](https://github.com/LightNetDev/LightNet/pull/427) [`d34539d`](https://github.com/LightNetDev/LightNet/commit/d34539dc21d8e37d25cf65233ba0837675062fd7) - Improve output of check-translations
|
|
16
|
+
|
|
17
|
+
## 4.2.0
|
|
18
|
+
|
|
19
|
+
### Minor Changes
|
|
20
|
+
|
|
21
|
+
- [#423](https://github.com/LightNetDev/LightNet/pull/423) [`30fabef`](https://github.com/LightNetDev/LightNet/commit/30fabef4897506cf4d0194b8883257de513ec452) - Add a new `lightnet check-files` command to validate orphaned and missing content files and thumbnails in LightNet site projects.
|
|
22
|
+
|
|
3
23
|
## 4.1.1
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# LightNet CLI
|
|
2
2
|
|
|
3
|
-
Command-line tools for working with LightNet sites. The CLI currently
|
|
3
|
+
Command-line tools for working with LightNet sites. The CLI currently includes translation validation, file consistency checks, and media link availability checks.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
|
|
8
|
+
pnpm add --D @lightnet/cli
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## LightNet CLI reference
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "CLI for managing LightNet projects",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.3.0",
|
|
7
7
|
"publishConfig": {
|
|
8
8
|
"access": "public"
|
|
9
9
|
},
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"CHANGELOG.md"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"commander": "^15.0.0"
|
|
28
|
+
"commander": "^15.0.0",
|
|
29
|
+
"@clack/prompts": "^1.6.0"
|
|
29
30
|
},
|
|
30
31
|
"engines": {
|
|
31
32
|
"node": ">=22"
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { join, posix, resolve } from "node:path"
|
|
4
|
+
import { cwd as processCwd, stdin, stdout } from "node:process"
|
|
5
|
+
|
|
6
|
+
import { confirm, intro, isCancel, log, outro, text } from "@clack/prompts"
|
|
7
|
+
|
|
8
|
+
import { CliError } from "./support/cli-error.js"
|
|
9
|
+
import { contentCollections } from "./support/content-collections.js"
|
|
10
|
+
import {
|
|
11
|
+
contentFiles,
|
|
12
|
+
files,
|
|
13
|
+
pathExists,
|
|
14
|
+
toLocalContentDisplayPath,
|
|
15
|
+
} from "./support/filesystem.js"
|
|
16
|
+
import { createR2FileStorage } from "./support/r2.js"
|
|
17
|
+
|
|
18
|
+
const supportedScopes = new Set(["content-files", "thumbnails"])
|
|
19
|
+
|
|
20
|
+
const mediaDir = "src/content/media"
|
|
21
|
+
const mediaImagesDir = "src/content/media/images"
|
|
22
|
+
const categoryImagesDir = "src/content/categories/images"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {{
|
|
26
|
+
* scope?: string
|
|
27
|
+
* fix?: boolean
|
|
28
|
+
* yes?: boolean
|
|
29
|
+
* r2?: boolean
|
|
30
|
+
* }} CheckFilesOptions
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** @typedef {import("./support/content-collections.js").MediaItem} MediaItem */
|
|
34
|
+
/** @typedef {import("./support/content-collections.js").Category} Category */
|
|
35
|
+
/** @typedef {import("./support/filesystem.js").FileStorage} FileStorage */
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {{
|
|
39
|
+
* path: string
|
|
40
|
+
* sources: string[]
|
|
41
|
+
* }} MissingReference
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {{
|
|
46
|
+
* displayPath: string
|
|
47
|
+
* sources: Set<string>
|
|
48
|
+
* }} FileReference
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {{
|
|
53
|
+
* cwd?: string
|
|
54
|
+
* isInteractive?: boolean
|
|
55
|
+
* promptText?: (message: string) => Promise<string>
|
|
56
|
+
* promptConfirm?: (message: string) => Promise<boolean>
|
|
57
|
+
* }} CheckFilesRuntime
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {CheckFilesOptions} options
|
|
62
|
+
* @param {CheckFilesRuntime} [runtime]
|
|
63
|
+
*/
|
|
64
|
+
export async function checkFiles(options, runtime = {}) {
|
|
65
|
+
const cwd = runtime.cwd ?? processCwd()
|
|
66
|
+
const interactive =
|
|
67
|
+
runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
|
|
68
|
+
const promptText =
|
|
69
|
+
runtime.promptText ?? (async (message) => defaultPromptText(message))
|
|
70
|
+
const promptConfirm =
|
|
71
|
+
runtime.promptConfirm ?? (async (message) => defaultPromptConfirm(message))
|
|
72
|
+
|
|
73
|
+
const scopes = parseScopes(options.scope)
|
|
74
|
+
const includeContentFiles = scopes.has("content-files")
|
|
75
|
+
const includeThumbnails = scopes.has("thumbnails")
|
|
76
|
+
|
|
77
|
+
intro("check-files")
|
|
78
|
+
|
|
79
|
+
await assertLightNetSiteRoot(cwd)
|
|
80
|
+
|
|
81
|
+
const collections = contentCollections(cwd)
|
|
82
|
+
const mediaItems = await collections.getMediaItems()
|
|
83
|
+
const categories = await collections.getCategories()
|
|
84
|
+
log.message(
|
|
85
|
+
`Checking ${mediaItems.length} media items and ${categories.length} categories.`,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
/** @type {MissingReference[]} */
|
|
89
|
+
let missingContentFiles = []
|
|
90
|
+
/** @type {string[]} */
|
|
91
|
+
let orphanedContentFiles = []
|
|
92
|
+
/** @type {string[]} */
|
|
93
|
+
let orphanedMediaThumbnails = []
|
|
94
|
+
/** @type {string[]} */
|
|
95
|
+
let orphanedCategoryThumbnails = []
|
|
96
|
+
/** @type {string[]} */
|
|
97
|
+
let removedItems = []
|
|
98
|
+
/** @type {string[]} */
|
|
99
|
+
const warnings = []
|
|
100
|
+
|
|
101
|
+
/** @type {FileStorage|undefined} */
|
|
102
|
+
let contentFileStorage = undefined
|
|
103
|
+
const mediaThumbnailStorage = files(cwd, { rootDir: mediaImagesDir })
|
|
104
|
+
const categoryThumbnailStorage = files(cwd, { rootDir: categoryImagesDir })
|
|
105
|
+
|
|
106
|
+
if (includeContentFiles) {
|
|
107
|
+
if (options.r2) {
|
|
108
|
+
contentFileStorage = createR2FileStorage({
|
|
109
|
+
cwd,
|
|
110
|
+
interactive,
|
|
111
|
+
promptConfirm,
|
|
112
|
+
promptText,
|
|
113
|
+
})
|
|
114
|
+
const r2Result = await validateContentFiles({
|
|
115
|
+
mediaItems,
|
|
116
|
+
storage: contentFileStorage,
|
|
117
|
+
})
|
|
118
|
+
missingContentFiles = r2Result.missingReferences
|
|
119
|
+
orphanedContentFiles = r2Result.orphanedFiles
|
|
120
|
+
if (r2Result.referenceCount === 0) {
|
|
121
|
+
warnings.push(
|
|
122
|
+
'No R2-backed content file references found. Use "--scope=thumbnails" or check your "--r2" setup.',
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
contentFileStorage = contentFiles(cwd)
|
|
127
|
+
const localContentResult = await validateContentFiles({
|
|
128
|
+
mediaItems,
|
|
129
|
+
storage: contentFileStorage,
|
|
130
|
+
})
|
|
131
|
+
missingContentFiles = localContentResult.missingReferences
|
|
132
|
+
orphanedContentFiles = localContentResult.orphanedFiles
|
|
133
|
+
if (localContentResult.referenceCount === 0) {
|
|
134
|
+
warnings.push(
|
|
135
|
+
'No local "/files" content file references found. Use "--scope=thumbnails" or run with "--r2".',
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (includeThumbnails) {
|
|
142
|
+
const thumbnailsResult = await validateThumbnails(
|
|
143
|
+
mediaItems,
|
|
144
|
+
categories,
|
|
145
|
+
mediaThumbnailStorage,
|
|
146
|
+
categoryThumbnailStorage,
|
|
147
|
+
)
|
|
148
|
+
orphanedMediaThumbnails = thumbnailsResult.orphanedMediaThumbnails
|
|
149
|
+
orphanedCategoryThumbnails = thumbnailsResult.orphanedCategoryThumbnails
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (options.fix) {
|
|
153
|
+
/** @type {{displayPath:string, target:string}[]} */
|
|
154
|
+
const deletions = []
|
|
155
|
+
/** @type {{displayPath:string, target:string}[]} */
|
|
156
|
+
const contentDeletions = []
|
|
157
|
+
/** @type {{displayPath:string, target:string}[]} */
|
|
158
|
+
const localDeletions = []
|
|
159
|
+
for (const filePath of orphanedContentFiles) {
|
|
160
|
+
if (!contentFileStorage) {
|
|
161
|
+
throw new CliError("Missing file storage for content deletion.")
|
|
162
|
+
}
|
|
163
|
+
const deletion = {
|
|
164
|
+
displayPath: formatContentFilePath(filePath, options),
|
|
165
|
+
target: filePath,
|
|
166
|
+
}
|
|
167
|
+
contentDeletions.push(deletion)
|
|
168
|
+
deletions.push(deletion)
|
|
169
|
+
}
|
|
170
|
+
for (const filePath of orphanedMediaThumbnails) {
|
|
171
|
+
const deletion = {
|
|
172
|
+
displayPath: filePath,
|
|
173
|
+
target: filePath,
|
|
174
|
+
}
|
|
175
|
+
localDeletions.push(deletion)
|
|
176
|
+
deletions.push(deletion)
|
|
177
|
+
}
|
|
178
|
+
for (const filePath of orphanedCategoryThumbnails) {
|
|
179
|
+
const deletion = {
|
|
180
|
+
displayPath: filePath,
|
|
181
|
+
target: filePath,
|
|
182
|
+
}
|
|
183
|
+
localDeletions.push(deletion)
|
|
184
|
+
deletions.push(deletion)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (deletions.length > 0) {
|
|
188
|
+
if (!options.yes && !interactive) {
|
|
189
|
+
throw new CliError(
|
|
190
|
+
'Deletion requires confirmation. Re-run with "--fix --yes" in non-interactive environments.',
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
const shouldDelete =
|
|
194
|
+
options.yes ||
|
|
195
|
+
(await confirmDeletion({
|
|
196
|
+
deletions,
|
|
197
|
+
promptConfirm,
|
|
198
|
+
}))
|
|
199
|
+
|
|
200
|
+
if (shouldDelete) {
|
|
201
|
+
const deletedContentTargets = contentFileStorage
|
|
202
|
+
? await contentFileStorage.delete(
|
|
203
|
+
contentDeletions.map((deletion) => deletion.target),
|
|
204
|
+
)
|
|
205
|
+
: []
|
|
206
|
+
const deletedLocalTargets = [
|
|
207
|
+
...(await mediaThumbnailStorage.delete(
|
|
208
|
+
localDeletions
|
|
209
|
+
.filter((deletion) => deletion.target.startsWith(mediaImagesDir))
|
|
210
|
+
.map((deletion) => deletion.target),
|
|
211
|
+
)),
|
|
212
|
+
...(await categoryThumbnailStorage.delete(
|
|
213
|
+
localDeletions
|
|
214
|
+
.filter((deletion) =>
|
|
215
|
+
deletion.target.startsWith(categoryImagesDir),
|
|
216
|
+
)
|
|
217
|
+
.map((deletion) => deletion.target),
|
|
218
|
+
)),
|
|
219
|
+
]
|
|
220
|
+
const deletedTargets = new Set([
|
|
221
|
+
...deletedContentTargets,
|
|
222
|
+
...deletedLocalTargets,
|
|
223
|
+
])
|
|
224
|
+
for (const deletion of [...contentDeletions, ...localDeletions]) {
|
|
225
|
+
if (deletedTargets.has(deletion.target)) {
|
|
226
|
+
removedItems.push(deletion.displayPath)
|
|
227
|
+
} else {
|
|
228
|
+
log.error(`Failed to delete "${deletion.displayPath}".`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
orphanedContentFiles = orphanedContentFiles.filter(
|
|
232
|
+
(item) => !deletedTargets.has(item),
|
|
233
|
+
)
|
|
234
|
+
orphanedMediaThumbnails = orphanedMediaThumbnails.filter(
|
|
235
|
+
(item) => !deletedTargets.has(item),
|
|
236
|
+
)
|
|
237
|
+
orphanedCategoryThumbnails = orphanedCategoryThumbnails.filter(
|
|
238
|
+
(item) => !deletedTargets.has(item),
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const hasIssues =
|
|
245
|
+
warnings.length > 0 ||
|
|
246
|
+
missingContentFiles.length > 0 ||
|
|
247
|
+
orphanedContentFiles.length > 0 ||
|
|
248
|
+
orphanedMediaThumbnails.length > 0 ||
|
|
249
|
+
orphanedCategoryThumbnails.length > 0
|
|
250
|
+
|
|
251
|
+
if (!hasIssues && removedItems.length === 0) {
|
|
252
|
+
outro("No issues found. 🎉")
|
|
253
|
+
return true
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const warning of warnings) {
|
|
257
|
+
log.warn(warning)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
printMissingReferenceSection(
|
|
261
|
+
"Missing referenced content files",
|
|
262
|
+
missingContentFiles,
|
|
263
|
+
)
|
|
264
|
+
printSection(
|
|
265
|
+
options.r2 ? "Orphaned R2 objects" : "Orphaned local content files",
|
|
266
|
+
orphanedContentFiles.map((filePath) =>
|
|
267
|
+
formatContentFilePath(filePath, options),
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
printSection("Orphaned media thumbnails", orphanedMediaThumbnails)
|
|
271
|
+
printSection("Orphaned category thumbnails", orphanedCategoryThumbnails)
|
|
272
|
+
printSection("Removed items", removedItems)
|
|
273
|
+
|
|
274
|
+
outro(hasIssues ? "Issues found. 🚧" : "Cleanup complete. 🧹")
|
|
275
|
+
|
|
276
|
+
return !hasIssues
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* @param {string} title
|
|
281
|
+
* @param {string[]} items
|
|
282
|
+
*/
|
|
283
|
+
function printSection(title, items) {
|
|
284
|
+
if (items.length === 0) {
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
log.warn(`${title} (${items.length})`)
|
|
288
|
+
for (const item of items) {
|
|
289
|
+
log.message(`• ${item}`)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* @param {string} title
|
|
295
|
+
* @param {MissingReference[]} items
|
|
296
|
+
*/
|
|
297
|
+
function printMissingReferenceSection(title, items) {
|
|
298
|
+
if (items.length === 0) {
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
log.error(`${title} (${items.length})`)
|
|
302
|
+
for (const item of items) {
|
|
303
|
+
log.message(
|
|
304
|
+
`• ${item.path} (referenced by ${item.sources.toSorted().join(", ")})`,
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* @param {string|undefined} rawScope
|
|
311
|
+
*/
|
|
312
|
+
function parseScopes(rawScope) {
|
|
313
|
+
const values = (rawScope ?? "content-files,thumbnails")
|
|
314
|
+
.split(",")
|
|
315
|
+
.map((value) => value.trim())
|
|
316
|
+
.filter(Boolean)
|
|
317
|
+
|
|
318
|
+
if (values.length === 0) {
|
|
319
|
+
throw new CliError(
|
|
320
|
+
// intentionally kept as a CLI-facing validation error
|
|
321
|
+
'Expected "--scope" to include at least one of "content-files" or "thumbnails".',
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const scopes = new Set(values)
|
|
326
|
+
for (const scope of scopes) {
|
|
327
|
+
if (!supportedScopes.has(scope)) {
|
|
328
|
+
throw new CliError(
|
|
329
|
+
`Unsupported scope "${scope}". Allowed values: content-files, thumbnails.`,
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return scopes
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* @param {string} cwd
|
|
339
|
+
*/
|
|
340
|
+
async function assertLightNetSiteRoot(cwd) {
|
|
341
|
+
if (!(await pathExists(resolve(cwd, mediaDir)))) {
|
|
342
|
+
throw new CliError(
|
|
343
|
+
'Expected a LightNet site root with "src/content/media". Run this command inside a LightNet site project.',
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @param {MediaItem[]} mediaItems
|
|
350
|
+
* @param {Category[]} categories
|
|
351
|
+
* @param {FileStorage} mediaThumbnailStorage
|
|
352
|
+
* @param {FileStorage} categoryThumbnailStorage
|
|
353
|
+
*/
|
|
354
|
+
async function validateThumbnails(
|
|
355
|
+
mediaItems,
|
|
356
|
+
categories,
|
|
357
|
+
mediaThumbnailStorage,
|
|
358
|
+
categoryThumbnailStorage,
|
|
359
|
+
) {
|
|
360
|
+
const mediaReferences = new Set(
|
|
361
|
+
mediaItems
|
|
362
|
+
.map((item) => toThumbnailPath("media", item.image))
|
|
363
|
+
.filter((value) => value !== undefined),
|
|
364
|
+
)
|
|
365
|
+
const categoryReferences = new Set(
|
|
366
|
+
categories
|
|
367
|
+
.map((item) => toThumbnailPath("categories", item.image))
|
|
368
|
+
.filter((value) => value !== undefined),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
const initializedMediaStorage = await mediaThumbnailStorage.init()
|
|
372
|
+
const initializedCategoryStorage = await categoryThumbnailStorage.init()
|
|
373
|
+
const mediaFiles = await initializedMediaStorage.list()
|
|
374
|
+
const categoryFiles = await initializedCategoryStorage.list()
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
orphanedMediaThumbnails: mediaFiles.filter(
|
|
378
|
+
(file) => !mediaReferences.has(file),
|
|
379
|
+
),
|
|
380
|
+
orphanedCategoryThumbnails: categoryFiles.filter(
|
|
381
|
+
(file) => !categoryReferences.has(file),
|
|
382
|
+
),
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* @param {{
|
|
388
|
+
* mediaItems: MediaItem[]
|
|
389
|
+
* storage: FileStorage
|
|
390
|
+
* }} args
|
|
391
|
+
*/
|
|
392
|
+
async function validateContentFiles({ mediaItems, storage }) {
|
|
393
|
+
const initializedStorage = await storage.init()
|
|
394
|
+
const references = collectContentReferences(mediaItems, initializedStorage)
|
|
395
|
+
if (references.size === 0) {
|
|
396
|
+
return {
|
|
397
|
+
missingReferences: /** @type {MissingReference[]} */ ([]),
|
|
398
|
+
orphanedFiles: /** @type {string[]} */ ([]),
|
|
399
|
+
referenceCount: 0,
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const files = await initializedStorage.list()
|
|
404
|
+
const fileSet = new Set(files)
|
|
405
|
+
|
|
406
|
+
const missingReferences = [...references.entries()]
|
|
407
|
+
.filter(([path]) => !fileSet.has(path))
|
|
408
|
+
.map(([, reference]) => ({
|
|
409
|
+
path: reference.displayPath,
|
|
410
|
+
sources: [...reference.sources].toSorted(),
|
|
411
|
+
}))
|
|
412
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
413
|
+
|
|
414
|
+
const orphanedFiles = files
|
|
415
|
+
.filter((filePath) => !references.has(filePath))
|
|
416
|
+
.sort()
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
missingReferences,
|
|
420
|
+
orphanedFiles,
|
|
421
|
+
referenceCount: references.size,
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* @param {MediaItem[]} mediaItems
|
|
427
|
+
* @param {FileStorage} storage
|
|
428
|
+
*/
|
|
429
|
+
function collectContentReferences(mediaItems, storage) {
|
|
430
|
+
const references = new Map()
|
|
431
|
+
for (const item of mediaItems) {
|
|
432
|
+
const sourceFileName = posix.basename(toPosixPath(item.path))
|
|
433
|
+
for (const contentItem of item.content) {
|
|
434
|
+
if (contentItem.type !== "upload") {
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
const filePath = storage.toPath(contentItem.url)
|
|
438
|
+
if (!filePath) {
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
const current = references.get(filePath)
|
|
442
|
+
if (current) {
|
|
443
|
+
current.sources.add(sourceFileName)
|
|
444
|
+
} else {
|
|
445
|
+
references.set(filePath, {
|
|
446
|
+
displayPath: contentItem.url,
|
|
447
|
+
sources: new Set([sourceFileName]),
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return references
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* @param {"media"|"categories"} kind
|
|
457
|
+
* @param {string|undefined} image
|
|
458
|
+
*/
|
|
459
|
+
function toThumbnailPath(kind, image) {
|
|
460
|
+
if (typeof image !== "string") {
|
|
461
|
+
return undefined
|
|
462
|
+
}
|
|
463
|
+
const relativePath = image.startsWith("./images/")
|
|
464
|
+
? image.slice("./images/".length)
|
|
465
|
+
: image.startsWith("images/")
|
|
466
|
+
? image.slice("images/".length)
|
|
467
|
+
: undefined
|
|
468
|
+
if (
|
|
469
|
+
!relativePath ||
|
|
470
|
+
relativePath.startsWith("/") ||
|
|
471
|
+
relativePath.includes("../")
|
|
472
|
+
) {
|
|
473
|
+
return undefined
|
|
474
|
+
}
|
|
475
|
+
return kind === "media"
|
|
476
|
+
? toPosixPath(join(mediaImagesDir, relativePath))
|
|
477
|
+
: toPosixPath(join(categoryImagesDir, relativePath))
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* @param {string} path
|
|
482
|
+
* @param {CheckFilesOptions} options
|
|
483
|
+
*/
|
|
484
|
+
function formatContentFilePath(path, options) {
|
|
485
|
+
return options.r2 ? path : toLocalContentDisplayPath(path)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* @param {{
|
|
490
|
+
* deletions: {displayPath:string, target:string}[]
|
|
491
|
+
* promptConfirm: (message:string)=>Promise<boolean>
|
|
492
|
+
* }} args
|
|
493
|
+
*/
|
|
494
|
+
async function confirmDeletion({ deletions, promptConfirm }) {
|
|
495
|
+
log.warn(`Files to remove (${deletions.length})`)
|
|
496
|
+
for (const deletion of deletions) {
|
|
497
|
+
log.message(`• ${deletion.displayPath}`)
|
|
498
|
+
}
|
|
499
|
+
return promptConfirm("Delete these files? [y/N] ")
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* @param {string} filePath
|
|
504
|
+
*/
|
|
505
|
+
function toPosixPath(filePath) {
|
|
506
|
+
return filePath.split("\\").join("/")
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* @param {string} message
|
|
511
|
+
*/
|
|
512
|
+
async function defaultPromptText(message) {
|
|
513
|
+
const answer = await text({ message })
|
|
514
|
+
return isCancel(answer) ? "" : String(answer)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* @param {string} message
|
|
519
|
+
*/
|
|
520
|
+
async function defaultPromptConfirm(message) {
|
|
521
|
+
const answer = await confirm({
|
|
522
|
+
message,
|
|
523
|
+
initialValue: false,
|
|
524
|
+
})
|
|
525
|
+
return isCancel(answer) ? false : answer
|
|
526
|
+
}
|