@brickflow/cli 0.0.6 → 0.0.8
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 +12 -0
- package/index.mjs +31 -80
- package/package.json +13 -1
- package/src/avif/index.js +79 -0
- package/src/filename/index.js +70 -0
- package/src/graph/index.js +78 -0
- package/src/icon/index.js +162 -0
- package/src/icon-check/index.js +928 -0
- package/src/latest/index.js +105 -0
- package/src/size/index.js +64 -0
- package/src/svg/index.js +97 -0
- package/src/translate/ai-context.js +312 -0
- package/src/translate/ai.js +168 -0
- package/src/translate/gemini.js +142 -0
- package/src/translate/index.js +331 -0
- package/src/translate/models.js +12 -0
- package/src/translate/runtime-config.js +118 -0
- package/src/translate/sync.js +238 -0
- package/src/translate/utils.js +409 -0
- package/src/translate-context/index.js +3 -0
- package/src/translate-sync/index.js +25 -0
- package/src/types/index.js +127 -0
- package/src/upgrade/index.js +168 -0
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import { globSync } from 'glob'
|
|
4
|
+
import looksSame from 'looks-same'
|
|
5
|
+
import path from 'path'
|
|
6
|
+
import sharp from 'sharp'
|
|
7
|
+
import { optimize } from 'svgo'
|
|
8
|
+
|
|
9
|
+
const args = process.argv.slice(3)
|
|
10
|
+
|
|
11
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
12
|
+
printHelp()
|
|
13
|
+
process.exit(0)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const options = parseArgs(args)
|
|
17
|
+
|
|
18
|
+
if (!options.path) {
|
|
19
|
+
printHelp()
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const iconsDir = path.resolve(process.cwd(), options.path)
|
|
24
|
+
const scopeName = options.name || path.basename(iconsDir)
|
|
25
|
+
|
|
26
|
+
if (!fs.existsSync(iconsDir)) {
|
|
27
|
+
throw new Error(`Icon directory not found: ${iconsDir}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!fs.statSync(iconsDir).isDirectory()) {
|
|
31
|
+
throw new Error(`Icon path must be a directory: ${iconsDir}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const allowedDuplicateIconPairs = new Set([
|
|
35
|
+
'bounty::affiliate-block-percent::clock-filled',
|
|
36
|
+
'bounty::cs2-icon-mobile::statistics-stat-players_total',
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
const D_HASH_SIZE = 32
|
|
40
|
+
const STRICT_HASH_SIZE = 128
|
|
41
|
+
const PHASH_IMAGE_SIZE = 32
|
|
42
|
+
const PHASH_MATRIX_SIZE = 8
|
|
43
|
+
const ALPHA_THRESHOLD = 8
|
|
44
|
+
const SIMPLE_SIMILARITY_THRESHOLD = 90
|
|
45
|
+
const SIMPLE_IOU_THRESHOLD_PERCENT = 70
|
|
46
|
+
const MAX_FILL_RATIO_DELTA_PERCENT = 10
|
|
47
|
+
const STEP2_DIRECT_SIMILARITY_MIN = 88
|
|
48
|
+
const STEP2_DIRECT_IOU_MIN = 92
|
|
49
|
+
const STEP2_DIRECT_FILL_DELTA_MAX = 8
|
|
50
|
+
const LOOKS_SAME_CONCURRENCY = 8
|
|
51
|
+
const LOOKS_SAME_OPTIONS = {
|
|
52
|
+
ignoreAntialiasing: false,
|
|
53
|
+
tolerance: 65,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function addEdgeByIndexes(graph, entries, firstIndex, secondIndex) {
|
|
57
|
+
addUndirectedEdge(graph, entries[firstIndex].filePath, entries[secondIndex].filePath)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function addUndirectedEdge(graph, firstNode, secondNode) {
|
|
61
|
+
graph.get(firstNode)?.add(secondNode)
|
|
62
|
+
graph.get(secondNode)?.add(firstNode)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildDuplicateGroups(graph, projectDir) {
|
|
66
|
+
const visited = new Set()
|
|
67
|
+
const groups = []
|
|
68
|
+
|
|
69
|
+
for (const filePath of [...graph.keys()].sort((first, second) => first.localeCompare(second))) {
|
|
70
|
+
if (visited.has(filePath)) {
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const neighbors = graph.get(filePath)
|
|
75
|
+
|
|
76
|
+
if (!neighbors || neighbors.size === 0) {
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const queue = [filePath]
|
|
81
|
+
const component = []
|
|
82
|
+
visited.add(filePath)
|
|
83
|
+
|
|
84
|
+
while (queue.length > 0) {
|
|
85
|
+
const currentFilePath = queue.shift()
|
|
86
|
+
|
|
87
|
+
if (!currentFilePath) {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
component.push(currentFilePath)
|
|
92
|
+
|
|
93
|
+
for (const neighborPath of graph.get(currentFilePath) ?? []) {
|
|
94
|
+
if (visited.has(neighborPath)) {
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
visited.add(neighborPath)
|
|
99
|
+
queue.push(neighborPath)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (component.length > 1) {
|
|
104
|
+
const iconNames = component
|
|
105
|
+
.map((iconPath) => path.relative(projectDir, iconPath).replace(/\.svg$/u, ''))
|
|
106
|
+
.sort((first, second) => first.localeCompare(second))
|
|
107
|
+
|
|
108
|
+
groups.push({
|
|
109
|
+
duplicateIcons: iconNames.slice(1),
|
|
110
|
+
iconName: iconNames[0],
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return groups.sort((first, second) => first.iconName.localeCompare(second.iconName))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildExactHashMap(entries) {
|
|
119
|
+
const dHashToIndexes = new Map()
|
|
120
|
+
|
|
121
|
+
entries.forEach((entry, index) => {
|
|
122
|
+
if (!dHashToIndexes.has(entry.dHash)) {
|
|
123
|
+
dHashToIndexes.set(entry.dHash, [])
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
dHashToIndexes.get(entry.dHash).push(index)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const exactGroups = []
|
|
130
|
+
|
|
131
|
+
for (const indexes of dHashToIndexes.values()) {
|
|
132
|
+
if (indexes.length < 2) {
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const strictHashToIndexes = new Map()
|
|
137
|
+
|
|
138
|
+
for (const index of indexes) {
|
|
139
|
+
const strictHash = entries[index].strictHash
|
|
140
|
+
|
|
141
|
+
if (!strictHashToIndexes.has(strictHash)) {
|
|
142
|
+
strictHashToIndexes.set(strictHash, [])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
strictHashToIndexes.get(strictHash).push(index)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const strictIndexes of strictHashToIndexes.values()) {
|
|
149
|
+
if (strictIndexes.length > 1) {
|
|
150
|
+
exactGroups.push(strictIndexes)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return exactGroups
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildSimpleCandidatePairs(entries, exactPairKeys, progressTracker) {
|
|
159
|
+
const totalPairs = (entries.length * (entries.length - 1)) / 2
|
|
160
|
+
|
|
161
|
+
if (totalPairs === 0) {
|
|
162
|
+
return {
|
|
163
|
+
candidatePairs: [],
|
|
164
|
+
directPairs: [],
|
|
165
|
+
totalPairs,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const candidatePairs = []
|
|
170
|
+
const directPairs = []
|
|
171
|
+
|
|
172
|
+
for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) {
|
|
173
|
+
for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) {
|
|
174
|
+
progressTracker?.tick()
|
|
175
|
+
|
|
176
|
+
const pairKey = pairKeyFromIndexes(leftIndex, rightIndex)
|
|
177
|
+
|
|
178
|
+
if (exactPairKeys.has(pairKey)) {
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const leftEntry = entries[leftIndex]
|
|
183
|
+
const rightEntry = entries[rightIndex]
|
|
184
|
+
const hammingDistance = calculateHammingDistance(leftEntry.shapeHash, rightEntry.shapeHash)
|
|
185
|
+
const similarityPercent = Math.round((1 - hammingDistance / leftEntry.shapeHash.length) * 100)
|
|
186
|
+
|
|
187
|
+
if (similarityPercent < SIMPLE_SIMILARITY_THRESHOLD) {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const iouPercent = calculateIoU(leftEntry.normalizedMask, rightEntry.normalizedMask)
|
|
192
|
+
|
|
193
|
+
if (iouPercent < SIMPLE_IOU_THRESHOLD_PERCENT) {
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const fillRatioDeltaPercent = Math.abs(leftEntry.fillRatio - rightEntry.fillRatio) * 100
|
|
198
|
+
|
|
199
|
+
if (fillRatioDeltaPercent > MAX_FILL_RATIO_DELTA_PERCENT) {
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const shouldSendDirectToResult =
|
|
204
|
+
similarityPercent >= STEP2_DIRECT_SIMILARITY_MIN &&
|
|
205
|
+
iouPercent >= STEP2_DIRECT_IOU_MIN &&
|
|
206
|
+
fillRatioDeltaPercent <= STEP2_DIRECT_FILL_DELTA_MAX &&
|
|
207
|
+
leftEntry.componentsCount === rightEntry.componentsCount
|
|
208
|
+
|
|
209
|
+
if (shouldSendDirectToResult) {
|
|
210
|
+
directPairs.push([leftIndex, rightIndex])
|
|
211
|
+
} else {
|
|
212
|
+
candidatePairs.push([leftIndex, rightIndex])
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
candidatePairs,
|
|
219
|
+
directPairs,
|
|
220
|
+
totalPairs,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function calculateHammingDistance(firstBits, secondBits) {
|
|
225
|
+
let distance = 0
|
|
226
|
+
|
|
227
|
+
for (let bitIndex = 0; bitIndex < firstBits.length; bitIndex += 1) {
|
|
228
|
+
if (firstBits[bitIndex] !== secondBits[bitIndex]) {
|
|
229
|
+
distance += 1
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return distance
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function calculateIoU(firstMask, secondMask) {
|
|
237
|
+
let intersection = 0
|
|
238
|
+
let union = 0
|
|
239
|
+
|
|
240
|
+
for (let index = 0; index < firstMask.length; index += 1) {
|
|
241
|
+
const firstFilled = firstMask[index] === 1
|
|
242
|
+
const secondFilled = secondMask[index] === 1
|
|
243
|
+
|
|
244
|
+
if (firstFilled && secondFilled) {
|
|
245
|
+
intersection += 1
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (firstFilled || secondFilled) {
|
|
249
|
+
union += 1
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (union === 0) {
|
|
254
|
+
return 0
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return (intersection / union) * 100
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function countConnectedComponents(mask, size) {
|
|
261
|
+
const totalPixels = size * size
|
|
262
|
+
const visited = new Uint8Array(totalPixels)
|
|
263
|
+
const queue = []
|
|
264
|
+
const directions = [
|
|
265
|
+
[1, 0],
|
|
266
|
+
[-1, 0],
|
|
267
|
+
[0, 1],
|
|
268
|
+
[0, -1],
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
let componentsCount = 0
|
|
272
|
+
|
|
273
|
+
for (let pixelIndex = 0; pixelIndex < totalPixels; pixelIndex += 1) {
|
|
274
|
+
if (visited[pixelIndex] || mask[pixelIndex] !== 1) {
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
componentsCount += 1
|
|
279
|
+
visited[pixelIndex] = 1
|
|
280
|
+
queue.push(pixelIndex)
|
|
281
|
+
|
|
282
|
+
while (queue.length > 0) {
|
|
283
|
+
const currentPixelIndex = queue.pop()
|
|
284
|
+
const x = currentPixelIndex % size
|
|
285
|
+
const y = Math.floor(currentPixelIndex / size)
|
|
286
|
+
|
|
287
|
+
for (const [deltaX, deltaY] of directions) {
|
|
288
|
+
const nextX = x + deltaX
|
|
289
|
+
const nextY = y + deltaY
|
|
290
|
+
|
|
291
|
+
if (nextX < 0 || nextY < 0 || nextX >= size || nextY >= size) {
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const nextPixelIndex = nextY * size + nextX
|
|
296
|
+
|
|
297
|
+
if (visited[nextPixelIndex] || mask[nextPixelIndex] !== 1) {
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
visited[nextPixelIndex] = 1
|
|
302
|
+
queue.push(nextPixelIndex)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return componentsCount
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function createDHashFromRawResult(rawResult) {
|
|
311
|
+
const width = D_HASH_SIZE + 1
|
|
312
|
+
const height = D_HASH_SIZE
|
|
313
|
+
const { data: rgbaPixels, info } = rawResult
|
|
314
|
+
const channels = info.channels
|
|
315
|
+
|
|
316
|
+
let bitString = ''
|
|
317
|
+
|
|
318
|
+
for (let y = 0; y < height; y += 1) {
|
|
319
|
+
const rowStart = y * width
|
|
320
|
+
|
|
321
|
+
for (let x = 0; x < width - 1; x += 1) {
|
|
322
|
+
const leftAlphaIndex = (rowStart + x) * channels + 3
|
|
323
|
+
const rightAlphaIndex = (rowStart + x + 1) * channels + 3
|
|
324
|
+
const left = rgbaPixels[leftAlphaIndex]
|
|
325
|
+
const right = rgbaPixels[rightAlphaIndex]
|
|
326
|
+
bitString += left > right ? '1' : '0'
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return bitString
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function createIconArtifacts(normalizedSvg, svgPath) {
|
|
334
|
+
try {
|
|
335
|
+
const [dHashRaw, strictHashRaw, shapeRaw, looksSamePng] = await Promise.all([
|
|
336
|
+
renderForDHash(normalizedSvg),
|
|
337
|
+
renderForStrictHash(normalizedSvg),
|
|
338
|
+
renderForShape(normalizedSvg),
|
|
339
|
+
renderForLooksSameNormalized(normalizedSvg),
|
|
340
|
+
])
|
|
341
|
+
|
|
342
|
+
const dHash = createDHashFromRawResult(dHashRaw)
|
|
343
|
+
const strictHash = createStrictHashFromRawResult(strictHashRaw)
|
|
344
|
+
const { componentsCount, fillRatio, normalizedMask } = createNormalizedMaskFromRawShape(shapeRaw)
|
|
345
|
+
const shapeHash = createPHash(normalizedMask)
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
componentsCount,
|
|
349
|
+
dHash,
|
|
350
|
+
fillRatio,
|
|
351
|
+
looksSamePng,
|
|
352
|
+
normalizedMask,
|
|
353
|
+
shapeHash,
|
|
354
|
+
strictHash,
|
|
355
|
+
}
|
|
356
|
+
} catch (error) {
|
|
357
|
+
throw new Error(`Failed to process SVG: ${svgPath}\n${error.message}`, { cause: error })
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function createNormalizedMaskFromRawShape(rawResult) {
|
|
362
|
+
const { data: rgbaPixels, info } = rawResult
|
|
363
|
+
const channels = info.channels
|
|
364
|
+
const size = PHASH_IMAGE_SIZE
|
|
365
|
+
const alphaMask = new Uint8Array(size * size)
|
|
366
|
+
|
|
367
|
+
for (let pixelIndex = 0; pixelIndex < alphaMask.length; pixelIndex += 1) {
|
|
368
|
+
alphaMask[pixelIndex] = rgbaPixels[pixelIndex * channels + 3] > ALPHA_THRESHOLD ? 1 : 0
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let minX = size
|
|
372
|
+
let minY = size
|
|
373
|
+
let maxX = -1
|
|
374
|
+
let maxY = -1
|
|
375
|
+
let filledPixels = 0
|
|
376
|
+
|
|
377
|
+
for (let y = 0; y < size; y += 1) {
|
|
378
|
+
for (let x = 0; x < size; x += 1) {
|
|
379
|
+
const value = alphaMask[y * size + x]
|
|
380
|
+
|
|
381
|
+
if (value === 0) {
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
filledPixels += 1
|
|
386
|
+
minX = Math.min(minX, x)
|
|
387
|
+
minY = Math.min(minY, y)
|
|
388
|
+
maxX = Math.max(maxX, x)
|
|
389
|
+
maxY = Math.max(maxY, y)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (maxX === -1 || maxY === -1) {
|
|
394
|
+
return {
|
|
395
|
+
componentsCount: 0,
|
|
396
|
+
fillRatio: 0,
|
|
397
|
+
normalizedMask: alphaMask,
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const boxWidth = maxX - minX + 1
|
|
402
|
+
const boxHeight = maxY - minY + 1
|
|
403
|
+
const normalizedMask = new Uint8Array(size * size)
|
|
404
|
+
|
|
405
|
+
for (let y = 0; y < size; y += 1) {
|
|
406
|
+
const sourceY = minY + Math.min(boxHeight - 1, Math.floor((y / size) * boxHeight))
|
|
407
|
+
|
|
408
|
+
for (let x = 0; x < size; x += 1) {
|
|
409
|
+
const sourceX = minX + Math.min(boxWidth - 1, Math.floor((x / size) * boxWidth))
|
|
410
|
+
normalizedMask[y * size + x] = alphaMask[sourceY * size + sourceX]
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
componentsCount: countConnectedComponents(normalizedMask, size),
|
|
416
|
+
fillRatio: filledPixels / (size * size),
|
|
417
|
+
normalizedMask,
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function createPHash(mask) {
|
|
422
|
+
const matrix = []
|
|
423
|
+
|
|
424
|
+
for (let rowIndex = 0; rowIndex < PHASH_IMAGE_SIZE; rowIndex += 1) {
|
|
425
|
+
const row = new Float64Array(PHASH_IMAGE_SIZE)
|
|
426
|
+
|
|
427
|
+
for (let columnIndex = 0; columnIndex < PHASH_IMAGE_SIZE; columnIndex += 1) {
|
|
428
|
+
row[columnIndex] = mask[rowIndex * PHASH_IMAGE_SIZE + columnIndex]
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
matrix.push(row)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const dct = dct2d(matrix, PHASH_IMAGE_SIZE)
|
|
435
|
+
const lowFrequencyValues = []
|
|
436
|
+
|
|
437
|
+
for (let rowIndex = 0; rowIndex < PHASH_MATRIX_SIZE; rowIndex += 1) {
|
|
438
|
+
for (let columnIndex = 0; columnIndex < PHASH_MATRIX_SIZE; columnIndex += 1) {
|
|
439
|
+
if (rowIndex === 0 && columnIndex === 0) {
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
lowFrequencyValues.push(dct[rowIndex][columnIndex])
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const sortedValues = [...lowFrequencyValues].sort((first, second) => first - second)
|
|
448
|
+
const median = sortedValues[Math.floor(sortedValues.length / 2)]
|
|
449
|
+
const bits = new Uint8Array(PHASH_MATRIX_SIZE * PHASH_MATRIX_SIZE)
|
|
450
|
+
let bitIndex = 0
|
|
451
|
+
|
|
452
|
+
for (let rowIndex = 0; rowIndex < PHASH_MATRIX_SIZE; rowIndex += 1) {
|
|
453
|
+
for (let columnIndex = 0; columnIndex < PHASH_MATRIX_SIZE; columnIndex += 1) {
|
|
454
|
+
if (rowIndex === 0 && columnIndex === 0) {
|
|
455
|
+
bits[bitIndex] = 0
|
|
456
|
+
} else {
|
|
457
|
+
bits[bitIndex] = dct[rowIndex][columnIndex] > median ? 1 : 0
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
bitIndex += 1
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return bits
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function createProgressTracker(label, total) {
|
|
468
|
+
let done = 0
|
|
469
|
+
const safeTotal = Math.max(total, 1)
|
|
470
|
+
let currentTotal = safeTotal
|
|
471
|
+
const barWidth = 18
|
|
472
|
+
let lastRenderedPercent = -1
|
|
473
|
+
|
|
474
|
+
const render = (force = false) => {
|
|
475
|
+
const percent = Math.round((done / currentTotal) * 100)
|
|
476
|
+
|
|
477
|
+
if (!force && percent === lastRenderedPercent) {
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const filledCells = Math.round((percent * barWidth) / 100)
|
|
482
|
+
const bar = `${'#'.repeat(filledCells)}${'.'.repeat(barWidth - filledCells)}`
|
|
483
|
+
|
|
484
|
+
process.stdout.write(`\r ${label}: [${bar}] ${String(percent).padStart(3)}% ${done}/${currentTotal}`)
|
|
485
|
+
|
|
486
|
+
lastRenderedPercent = percent
|
|
487
|
+
|
|
488
|
+
if (done >= currentTotal) {
|
|
489
|
+
process.stdout.write('\n')
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
render(true)
|
|
494
|
+
|
|
495
|
+
const tick = (step = 1) => {
|
|
496
|
+
done = Math.min(done + step, currentTotal)
|
|
497
|
+
render(done >= currentTotal)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
setLabel(nextLabel) {
|
|
502
|
+
label = nextLabel
|
|
503
|
+
render(true)
|
|
504
|
+
},
|
|
505
|
+
setTotal(nextTotal) {
|
|
506
|
+
currentTotal = Math.max(nextTotal, 1)
|
|
507
|
+
done = Math.min(done, currentTotal)
|
|
508
|
+
render(true)
|
|
509
|
+
},
|
|
510
|
+
tick,
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function createStrictHashFromRawResult(rawResult) {
|
|
515
|
+
const { data: rgbaPixels, info } = rawResult
|
|
516
|
+
const channels = info.channels
|
|
517
|
+
const alphaPixels = Buffer.alloc(STRICT_HASH_SIZE * STRICT_HASH_SIZE)
|
|
518
|
+
|
|
519
|
+
for (let pixelIndex = 0; pixelIndex < alphaPixels.length; pixelIndex += 1) {
|
|
520
|
+
alphaPixels[pixelIndex] = rgbaPixels[pixelIndex * channels + 3]
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return crypto.createHash('sha256').update(alphaPixels).digest('hex')
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function dct1d(values) {
|
|
527
|
+
const length = values.length
|
|
528
|
+
const output = new Float64Array(length)
|
|
529
|
+
const factor = Math.PI / (2 * length)
|
|
530
|
+
|
|
531
|
+
for (let frequency = 0; frequency < length; frequency += 1) {
|
|
532
|
+
let sum = 0
|
|
533
|
+
|
|
534
|
+
for (let index = 0; index < length; index += 1) {
|
|
535
|
+
sum += values[index] * Math.cos((2 * index + 1) * frequency * factor)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
output[frequency] = sum * (frequency === 0 ? Math.sqrt(1 / length) : Math.sqrt(2 / length))
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return output
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function dct2d(matrix, size) {
|
|
545
|
+
const rowTransformed = Array.from({ length: size }, () => new Float64Array(size))
|
|
546
|
+
|
|
547
|
+
for (let rowIndex = 0; rowIndex < size; rowIndex += 1) {
|
|
548
|
+
rowTransformed[rowIndex] = dct1d(matrix[rowIndex])
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const output = Array.from({ length: size }, () => new Float64Array(size))
|
|
552
|
+
|
|
553
|
+
for (let columnIndex = 0; columnIndex < size; columnIndex += 1) {
|
|
554
|
+
const column = new Float64Array(size)
|
|
555
|
+
|
|
556
|
+
for (let rowIndex = 0; rowIndex < size; rowIndex += 1) {
|
|
557
|
+
column[rowIndex] = rowTransformed[rowIndex][columnIndex]
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const transformedColumn = dct1d(column)
|
|
561
|
+
|
|
562
|
+
for (let rowIndex = 0; rowIndex < size; rowIndex += 1) {
|
|
563
|
+
output[rowIndex][columnIndex] = transformedColumn[rowIndex]
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return output
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function duplicateIconPairKey(scope, firstIconName, secondIconName) {
|
|
571
|
+
const [leftIconName, rightIconName] = [firstIconName, secondIconName].sort((first, second) =>
|
|
572
|
+
first.localeCompare(second),
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
return `${scope}::${leftIconName}::${rightIconName}`
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function formatDuration(milliseconds) {
|
|
579
|
+
if (milliseconds < 1000) {
|
|
580
|
+
return `${milliseconds}ms`
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return `${(milliseconds / 1000).toFixed(2)}s`
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function isAllowedDuplicateIconPair(scope, firstIconName, secondIconName) {
|
|
587
|
+
return allowedDuplicateIconPairs.has(duplicateIconPairKey(scope, firstIconName, secondIconName))
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function normalizeSvg(svg, svgPath) {
|
|
591
|
+
const optimized = optimize(svg, {
|
|
592
|
+
multipass: true,
|
|
593
|
+
path: svgPath,
|
|
594
|
+
plugins: ['preset-default', 'sortAttrs'],
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
return optimized.data.trim()
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function pairKeyFromIndexes(firstIndex, secondIndex) {
|
|
601
|
+
return firstIndex < secondIndex ? `${firstIndex}:${secondIndex}` : `${secondIndex}:${firstIndex}`
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function parseArgs(rawArgs) {
|
|
605
|
+
const parsedOptions = {
|
|
606
|
+
name: null,
|
|
607
|
+
path: null,
|
|
608
|
+
}
|
|
609
|
+
const positional = []
|
|
610
|
+
|
|
611
|
+
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
612
|
+
const value = rawArgs[index]
|
|
613
|
+
|
|
614
|
+
if (value === '--path') {
|
|
615
|
+
parsedOptions.path = rawArgs[index + 1] ?? null
|
|
616
|
+
index += 1
|
|
617
|
+
continue
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (value === '--name') {
|
|
621
|
+
parsedOptions.name = rawArgs[index + 1] ?? null
|
|
622
|
+
index += 1
|
|
623
|
+
continue
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
positional.push(value)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (!parsedOptions.path) {
|
|
630
|
+
parsedOptions.path = positional[0] ?? null
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (!parsedOptions.name) {
|
|
634
|
+
parsedOptions.name = positional[1] ?? null
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return parsedOptions
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function printHelp() {
|
|
641
|
+
console.log(`brick icon-check <path> [name]
|
|
642
|
+
|
|
643
|
+
Usage:
|
|
644
|
+
brick icon-check ./path/to/svg-icons
|
|
645
|
+
brick icon-check ./path/to/svg-icons bounty
|
|
646
|
+
brick icon-check --path ./path/to/svg-icons --name bounty
|
|
647
|
+
|
|
648
|
+
Notes:
|
|
649
|
+
path: directory with source .svg icons
|
|
650
|
+
name: label for logs and allowed duplicate pairs; default is the directory name`)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function renderForDHash(svgContent) {
|
|
654
|
+
const width = D_HASH_SIZE + 1
|
|
655
|
+
const height = D_HASH_SIZE
|
|
656
|
+
|
|
657
|
+
return sharp(Buffer.from(svgContent, 'utf8'))
|
|
658
|
+
.resize(width, height, {
|
|
659
|
+
background: {
|
|
660
|
+
alpha: 0,
|
|
661
|
+
b: 0,
|
|
662
|
+
g: 0,
|
|
663
|
+
r: 0,
|
|
664
|
+
},
|
|
665
|
+
fit: 'fill',
|
|
666
|
+
})
|
|
667
|
+
.ensureAlpha()
|
|
668
|
+
.raw()
|
|
669
|
+
.toBuffer({ resolveWithObject: true })
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function renderForLooksSameNormalized(svgContent) {
|
|
673
|
+
const input = Buffer.from(svgContent, 'utf8')
|
|
674
|
+
const renderSize = 256
|
|
675
|
+
const outputSize = 128
|
|
676
|
+
|
|
677
|
+
const { data, info } = await sharp(input)
|
|
678
|
+
.resize(renderSize, renderSize, {
|
|
679
|
+
background: { alpha: 0, b: 0, g: 0, r: 0 },
|
|
680
|
+
fit: 'contain',
|
|
681
|
+
})
|
|
682
|
+
.ensureAlpha()
|
|
683
|
+
.raw()
|
|
684
|
+
.toBuffer({ resolveWithObject: true })
|
|
685
|
+
|
|
686
|
+
let minX = info.width
|
|
687
|
+
let minY = info.height
|
|
688
|
+
let maxX = -1
|
|
689
|
+
let maxY = -1
|
|
690
|
+
|
|
691
|
+
for (let y = 0; y < info.height; y += 1) {
|
|
692
|
+
for (let x = 0; x < info.width; x += 1) {
|
|
693
|
+
const alpha = data[(y * info.width + x) * info.channels + 3]
|
|
694
|
+
|
|
695
|
+
if (alpha > ALPHA_THRESHOLD) {
|
|
696
|
+
minX = Math.min(minX, x)
|
|
697
|
+
minY = Math.min(minY, y)
|
|
698
|
+
maxX = Math.max(maxX, x)
|
|
699
|
+
maxY = Math.max(maxY, y)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (maxX === -1 || maxY === -1) {
|
|
705
|
+
return sharp(data, {
|
|
706
|
+
raw: {
|
|
707
|
+
channels: info.channels,
|
|
708
|
+
height: info.height,
|
|
709
|
+
width: info.width,
|
|
710
|
+
},
|
|
711
|
+
})
|
|
712
|
+
.resize(outputSize, outputSize, {
|
|
713
|
+
background: { alpha: 1, b: 255, g: 255, r: 255 },
|
|
714
|
+
fit: 'contain',
|
|
715
|
+
})
|
|
716
|
+
.flatten({ background: { b: 255, g: 255, r: 255 } })
|
|
717
|
+
.png()
|
|
718
|
+
.toBuffer()
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const left = Math.max(0, minX)
|
|
722
|
+
const top = Math.max(0, minY)
|
|
723
|
+
const width = Math.max(1, Math.min(info.width - left, maxX - minX + 1))
|
|
724
|
+
const height = Math.max(1, Math.min(info.height - top, maxY - minY + 1))
|
|
725
|
+
|
|
726
|
+
return sharp(data, {
|
|
727
|
+
raw: {
|
|
728
|
+
channels: info.channels,
|
|
729
|
+
height: info.height,
|
|
730
|
+
width: info.width,
|
|
731
|
+
},
|
|
732
|
+
})
|
|
733
|
+
.extract({
|
|
734
|
+
height,
|
|
735
|
+
left,
|
|
736
|
+
top,
|
|
737
|
+
width,
|
|
738
|
+
})
|
|
739
|
+
.resize(outputSize, outputSize, {
|
|
740
|
+
background: { alpha: 1, b: 255, g: 255, r: 255 },
|
|
741
|
+
fit: 'contain',
|
|
742
|
+
})
|
|
743
|
+
.flatten({ background: { b: 255, g: 255, r: 255 } })
|
|
744
|
+
.png()
|
|
745
|
+
.toBuffer()
|
|
746
|
+
}
|
|
747
|
+
function renderForShape(svgContent) {
|
|
748
|
+
return sharp(Buffer.from(svgContent, 'utf8'))
|
|
749
|
+
.resize(PHASH_IMAGE_SIZE, PHASH_IMAGE_SIZE, {
|
|
750
|
+
background: {
|
|
751
|
+
alpha: 0,
|
|
752
|
+
b: 0,
|
|
753
|
+
g: 0,
|
|
754
|
+
r: 0,
|
|
755
|
+
},
|
|
756
|
+
fit: 'contain',
|
|
757
|
+
})
|
|
758
|
+
.ensureAlpha()
|
|
759
|
+
.raw()
|
|
760
|
+
.toBuffer({ resolveWithObject: true })
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function renderForStrictHash(svgContent) {
|
|
764
|
+
return sharp(Buffer.from(svgContent, 'utf8'))
|
|
765
|
+
.resize(STRICT_HASH_SIZE, STRICT_HASH_SIZE, {
|
|
766
|
+
background: {
|
|
767
|
+
alpha: 0,
|
|
768
|
+
b: 0,
|
|
769
|
+
g: 0,
|
|
770
|
+
r: 0,
|
|
771
|
+
},
|
|
772
|
+
fit: 'contain',
|
|
773
|
+
})
|
|
774
|
+
.ensureAlpha()
|
|
775
|
+
.raw()
|
|
776
|
+
.toBuffer({ resolveWithObject: true })
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function runLooksSameOnCandidates(entries, candidatePairs, progressTracker) {
|
|
780
|
+
const duplicatePairKeys = new Set()
|
|
781
|
+
|
|
782
|
+
if (candidatePairs.length === 0) {
|
|
783
|
+
return duplicatePairKeys
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
let nextPairIndex = 0
|
|
787
|
+
|
|
788
|
+
const worker = async () => {
|
|
789
|
+
while (true) {
|
|
790
|
+
const pairIndex = nextPairIndex
|
|
791
|
+
nextPairIndex += 1
|
|
792
|
+
|
|
793
|
+
if (pairIndex >= candidatePairs.length) {
|
|
794
|
+
break
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const [leftIndex, rightIndex] = candidatePairs[pairIndex]
|
|
798
|
+
const leftImage = entries[leftIndex].looksSamePng
|
|
799
|
+
const rightImage = entries[rightIndex].looksSamePng
|
|
800
|
+
const { equal } = await looksSame(leftImage, rightImage, LOOKS_SAME_OPTIONS)
|
|
801
|
+
|
|
802
|
+
if (equal) {
|
|
803
|
+
duplicatePairKeys.add(pairKeyFromIndexes(leftIndex, rightIndex))
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
progressTracker?.tick()
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const workers = Array.from(
|
|
811
|
+
{ length: Math.min(LOOKS_SAME_CONCURRENCY, Math.max(candidatePairs.length, 1)) },
|
|
812
|
+
() => worker(),
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
await Promise.all(workers)
|
|
816
|
+
|
|
817
|
+
return duplicatePairKeys
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const startAt = Date.now()
|
|
821
|
+
const svgFiles = globSync('**/*.svg', {
|
|
822
|
+
absolute: true,
|
|
823
|
+
cwd: iconsDir,
|
|
824
|
+
nodir: true,
|
|
825
|
+
}).sort()
|
|
826
|
+
|
|
827
|
+
if (svgFiles.length < 2) {
|
|
828
|
+
console.log(`[${scopeName}] skip: ${svgFiles.length} icon(s)`)
|
|
829
|
+
process.exit(0)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
console.log(`Start icon-check for "${scopeName}"`)
|
|
833
|
+
console.log(`[${scopeName}] ${svgFiles.length} icons`)
|
|
834
|
+
|
|
835
|
+
const totalPairsEstimate = (svgFiles.length * (svgFiles.length - 1)) / 2
|
|
836
|
+
const progress = createProgressTracker(
|
|
837
|
+
`${scopeName} pipeline step 1/3 hash & render`,
|
|
838
|
+
svgFiles.length + totalPairsEstimate + 1,
|
|
839
|
+
)
|
|
840
|
+
const entries = []
|
|
841
|
+
|
|
842
|
+
for (const svgPath of svgFiles) {
|
|
843
|
+
const svg = fs.readFileSync(svgPath, 'utf8')
|
|
844
|
+
const normalizedSvg = normalizeSvg(svg, svgPath)
|
|
845
|
+
const artifacts = await createIconArtifacts(normalizedSvg, svgPath)
|
|
846
|
+
|
|
847
|
+
entries.push({
|
|
848
|
+
...artifacts,
|
|
849
|
+
filePath: svgPath,
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
progress.tick()
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const duplicateGraph = new Map(entries.map((entry) => [entry.filePath, new Set()]))
|
|
856
|
+
const exactPairKeys = new Set()
|
|
857
|
+
const exactGroups = buildExactHashMap(entries)
|
|
858
|
+
|
|
859
|
+
for (const indexes of exactGroups) {
|
|
860
|
+
for (let leftIndex = 0; leftIndex < indexes.length; leftIndex += 1) {
|
|
861
|
+
for (let rightIndex = leftIndex + 1; rightIndex < indexes.length; rightIndex += 1) {
|
|
862
|
+
const firstEntryIndex = indexes[leftIndex]
|
|
863
|
+
const secondEntryIndex = indexes[rightIndex]
|
|
864
|
+
exactPairKeys.add(pairKeyFromIndexes(firstEntryIndex, secondEntryIndex))
|
|
865
|
+
addEdgeByIndexes(duplicateGraph, entries, firstEntryIndex, secondEntryIndex)
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
console.log(` ${scopeName} step 1/3 exact hash pairs: ${exactPairKeys.size}`)
|
|
871
|
+
|
|
872
|
+
progress.setLabel(`${scopeName} pipeline step 2/3 simple filter`)
|
|
873
|
+
const { candidatePairs, directPairs, totalPairs } = buildSimpleCandidatePairs(entries, exactPairKeys, progress)
|
|
874
|
+
|
|
875
|
+
for (const [leftIndex, rightIndex] of directPairs) {
|
|
876
|
+
addEdgeByIndexes(duplicateGraph, entries, leftIndex, rightIndex)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
console.log(
|
|
880
|
+
` ${scopeName} step 2/3 direct(semantic 100%)=${directPairs.length}, candidates=${candidatePairs.length} (similarity >= ${SIMPLE_SIMILARITY_THRESHOLD}% and IoU >= ${SIMPLE_IOU_THRESHOLD_PERCENT}%)`,
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
progress.setTotal(svgFiles.length + totalPairs + candidatePairs.length)
|
|
884
|
+
progress.setLabel(`${scopeName} pipeline step 3/3 looks-same`)
|
|
885
|
+
const looksSamePairs = await runLooksSameOnCandidates(entries, candidatePairs, progress)
|
|
886
|
+
|
|
887
|
+
for (const pairKey of looksSamePairs) {
|
|
888
|
+
const [leftIndexRaw, rightIndexRaw] = pairKey.split(':')
|
|
889
|
+
const leftIndex = Number(leftIndexRaw)
|
|
890
|
+
const rightIndex = Number(rightIndexRaw)
|
|
891
|
+
|
|
892
|
+
if (!Number.isInteger(leftIndex) || !Number.isInteger(rightIndex)) {
|
|
893
|
+
continue
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
addEdgeByIndexes(duplicateGraph, entries, leftIndex, rightIndex)
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
console.log(` ${scopeName} step 3/3 looks-same duplicates: ${looksSamePairs.size}`)
|
|
900
|
+
|
|
901
|
+
const duplicateGroups = buildDuplicateGroups(duplicateGraph, iconsDir)
|
|
902
|
+
.map((group) => ({
|
|
903
|
+
...group,
|
|
904
|
+
duplicateIcons: group.duplicateIcons.filter((duplicateIcon) => {
|
|
905
|
+
return !isAllowedDuplicateIconPair(scopeName, group.iconName, duplicateIcon)
|
|
906
|
+
}),
|
|
907
|
+
}))
|
|
908
|
+
.filter((group) => group.duplicateIcons.length > 0)
|
|
909
|
+
|
|
910
|
+
console.log(` ${scopeName} done in ${formatDuration(Date.now() - startAt)}; groups: ${duplicateGroups.length}`)
|
|
911
|
+
console.log(`\nicon-check finished in ${formatDuration(Date.now() - startAt)}`)
|
|
912
|
+
|
|
913
|
+
if (duplicateGroups.length === 0) {
|
|
914
|
+
console.log('✅ No duplicate icons found')
|
|
915
|
+
process.exit(0)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
console.error('❌ Duplicate icons found:')
|
|
919
|
+
console.error(
|
|
920
|
+
`Pipeline: step1 exact-hash -> step2 simple-similarity>=${SIMPLE_SIMILARITY_THRESHOLD}% + IoU>=${SIMPLE_IOU_THRESHOLD_PERCENT}% -> step3 looks-same (strict equal only, tolerance=${LOOKS_SAME_OPTIONS.tolerance}, ignoreAntialiasing=${LOOKS_SAME_OPTIONS.ignoreAntialiasing})`,
|
|
921
|
+
)
|
|
922
|
+
console.error(`\n[${scopeName}]`)
|
|
923
|
+
|
|
924
|
+
for (const group of duplicateGroups) {
|
|
925
|
+
console.error(`${group.iconName} - [${group.duplicateIcons.join(', ')}]`)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
process.exit(1)
|