@liiift-studio/sanity-font-manager 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/UploadModal-ADNRGQUI.mjs +6 -0
- package/dist/UploadModal-WPK2CXLR.js +6 -0
- package/dist/chunk-JCDZ7SWZ.js +7711 -0
- package/dist/chunk-TMDE4A54.mjs +7711 -0
- package/dist/index.js +666 -1647
- package/dist/index.mjs +319 -1209
- package/package.json +5 -5
- package/src/components/BatchUploadFonts.jsx +57 -44
- package/src/components/BulkActions.jsx +99 -0
- package/src/components/ExistingDocumentResolver.jsx +152 -0
- package/src/components/FontReviewCard.jsx +455 -0
- package/src/components/SingleUploaderTool.jsx +3 -4
- package/src/components/UploadModal.jsx +304 -0
- package/src/components/UploadScriptsComponent.jsx +23 -21
- package/src/components/UploadStep1Settings.jsx +272 -0
- package/src/components/UploadStep2Review.jsx +474 -0
- package/src/components/UploadStep3Execute.jsx +234 -0
- package/src/components/UploadStep3bInstances.jsx +396 -0
- package/src/components/UploadSummary.jsx +196 -0
- package/src/index.js +46 -0
- package/src/utils/buildUploadPlan.js +326 -0
- package/src/utils/executeUploadPlan.js +430 -0
- package/src/utils/executionReducer.js +56 -0
- package/src/utils/fontHelpers.js +267 -0
- package/src/utils/generateCssFile.js +79 -77
- package/src/utils/generateFontData.js +47 -94
- package/src/utils/getEmptyFontKit.js +19 -17
- package/src/utils/parseFont.js +55 -0
- package/src/utils/parseVariableFontInstances.js +237 -147
- package/src/utils/planReducer.js +517 -0
- package/src/utils/planTypes.js +183 -0
- package/src/utils/processFontFiles.js +121 -78
- package/src/utils/regenerateFontData.js +2 -2
- package/src/utils/resolveExistingFont.js +87 -0
- package/src/utils/updateTypefaceDocument.js +15 -2
- package/src/utils/uploadFontFiles.js +405 -405
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
// Plan reducer — manages UploadPlan state for the upload modal review UI
|
|
2
|
+
|
|
3
|
+
import { PLAN_PHASE, FONT_STATUS, PROCESSING_OWNED_FIELDS } from './planTypes';
|
|
4
|
+
import { sanitizeForSanityId } from './sanitizeForSanityId';
|
|
5
|
+
|
|
6
|
+
/** Valid phase transitions — any phase can transition to 'idle' (reset) */
|
|
7
|
+
const VALID_TRANSITIONS = {
|
|
8
|
+
[PLAN_PHASE.IDLE]: [PLAN_PHASE.PROCESSING],
|
|
9
|
+
[PLAN_PHASE.PROCESSING]: [PLAN_PHASE.REVIEWING],
|
|
10
|
+
[PLAN_PHASE.REVIEWING]: [PLAN_PHASE.READY, PLAN_PHASE.EXECUTING],
|
|
11
|
+
[PLAN_PHASE.READY]: [PLAN_PHASE.EXECUTING],
|
|
12
|
+
[PLAN_PHASE.EXECUTING]: [PLAN_PHASE.COMPLETE, PLAN_PHASE.ERROR],
|
|
13
|
+
[PLAN_PHASE.COMPLETE]: [PLAN_PHASE.EXECUTING],
|
|
14
|
+
[PLAN_PHASE.ERROR]: [PLAN_PHASE.EXECUTING],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Plan reducer for useReducer. Manages the UploadPlan state.
|
|
19
|
+
* Write domain isolation: processing actions only write processing-owned fields;
|
|
20
|
+
* user edit actions only write decisions.*.userOverride.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} state - Current UploadPlan
|
|
23
|
+
* @param {object} action - Dispatched action
|
|
24
|
+
* @returns {object} New UploadPlan
|
|
25
|
+
*/
|
|
26
|
+
export function planReducer(state, action) {
|
|
27
|
+
switch (action.type) {
|
|
28
|
+
// ---------------------------------------------------------------
|
|
29
|
+
// Phase / Settings
|
|
30
|
+
// ---------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
case 'SET_PHASE': {
|
|
33
|
+
if (action.phase === PLAN_PHASE.IDLE) {
|
|
34
|
+
return { ...state, phase: PLAN_PHASE.IDLE };
|
|
35
|
+
}
|
|
36
|
+
const validNext = VALID_TRANSITIONS[state.phase] || [];
|
|
37
|
+
if (!validNext.includes(action.phase)) {
|
|
38
|
+
console.warn(`Invalid phase transition: ${state.phase} → ${action.phase}`);
|
|
39
|
+
return state;
|
|
40
|
+
}
|
|
41
|
+
const nextState = { ...state, phase: action.phase };
|
|
42
|
+
// Allow setting total file count when entering PROCESSING phase
|
|
43
|
+
if (typeof action.totalFiles === 'number') {
|
|
44
|
+
nextState.processingProgress = {
|
|
45
|
+
...state.processingProgress,
|
|
46
|
+
total: action.totalFiles,
|
|
47
|
+
completed: 0,
|
|
48
|
+
failed: 0,
|
|
49
|
+
currentFile: null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return nextState;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
case 'SET_SETTINGS': {
|
|
56
|
+
if (state.phase !== PLAN_PHASE.IDLE && state.phase !== PLAN_PHASE.REVIEWING && state.phase !== PLAN_PHASE.READY) {
|
|
57
|
+
console.warn('SET_SETTINGS blocked — settings locked during processing/execution');
|
|
58
|
+
return state;
|
|
59
|
+
}
|
|
60
|
+
return { ...state, settings: { ...state.settings, ...action.settings } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------
|
|
64
|
+
// Processing (Phase 1) — dispatched by buildUploadPlan callbacks
|
|
65
|
+
// ---------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
case 'UPDATE_PROCESSING_PROGRESS': {
|
|
68
|
+
return {
|
|
69
|
+
...state,
|
|
70
|
+
processingProgress: { ...state.processingProgress, ...action.progress },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
case 'ADD_PROCESSED_FONT': {
|
|
75
|
+
const { tempId, fontEntry } = action;
|
|
76
|
+
const fonts = { ...state.fonts };
|
|
77
|
+
|
|
78
|
+
if (fonts[tempId]) {
|
|
79
|
+
// Font already exists (user may have edited) — merge processing-owned fields only
|
|
80
|
+
const existing = fonts[tempId];
|
|
81
|
+
const merged = { ...existing };
|
|
82
|
+
for (const field of PROCESSING_OWNED_FIELDS) {
|
|
83
|
+
merged[field] = fontEntry[field];
|
|
84
|
+
}
|
|
85
|
+
fonts[tempId] = merged;
|
|
86
|
+
} else {
|
|
87
|
+
fonts[tempId] = fontEntry;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Update subfamily groups
|
|
91
|
+
const subfamilyGroups = { ...state.subfamilyGroups };
|
|
92
|
+
const sfName = fontEntry.subfamily || 'default';
|
|
93
|
+
if (!fontEntry.variableFont || fontEntry.subfamily) {
|
|
94
|
+
if (!subfamilyGroups[sfName]) {
|
|
95
|
+
subfamilyGroups[sfName] = { title: sfName, fontIds: [] };
|
|
96
|
+
}
|
|
97
|
+
// Guard against duplicate tempId in fontIds
|
|
98
|
+
if (!subfamilyGroups[sfName].fontIds.includes(tempId)) {
|
|
99
|
+
subfamilyGroups[sfName] = {
|
|
100
|
+
...subfamilyGroups[sfName],
|
|
101
|
+
fontIds: [...subfamilyGroups[sfName].fontIds, tempId],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
...state,
|
|
108
|
+
fonts,
|
|
109
|
+
subfamilyGroups,
|
|
110
|
+
processingProgress: {
|
|
111
|
+
...state.processingProgress,
|
|
112
|
+
completed: state.processingProgress.completed + 1,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case 'SET_PROCESSING_ERROR': {
|
|
118
|
+
const { tempId, error } = action;
|
|
119
|
+
if (!state.fonts[tempId]) return state;
|
|
120
|
+
return {
|
|
121
|
+
...state,
|
|
122
|
+
fonts: {
|
|
123
|
+
...state.fonts,
|
|
124
|
+
[tempId]: { ...state.fonts[tempId], status: FONT_STATUS.ERROR, error },
|
|
125
|
+
},
|
|
126
|
+
processingProgress: {
|
|
127
|
+
...state.processingProgress,
|
|
128
|
+
failed: state.processingProgress.failed + 1,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------
|
|
134
|
+
// User Edits (Review Step)
|
|
135
|
+
// ---------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
case 'SET_FONT_TITLE': {
|
|
138
|
+
const { tempId, title, source: titleSource } = action;
|
|
139
|
+
const font = state.fonts[tempId];
|
|
140
|
+
if (!font) return state;
|
|
141
|
+
|
|
142
|
+
// When source is provided (e.g. from clicking a name table suggestion), preserve it.
|
|
143
|
+
// The display layer appends "(user override)" — don't bake it into the source string.
|
|
144
|
+
const newSource = titleSource || 'user-override';
|
|
145
|
+
|
|
146
|
+
const updated = {
|
|
147
|
+
...font,
|
|
148
|
+
title,
|
|
149
|
+
decisions: {
|
|
150
|
+
...font.decisions,
|
|
151
|
+
title: { ...font.decisions.title, userOverride: title, source: newSource },
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Auto-derive documentId unless it has its own override
|
|
156
|
+
if (!font.decisions.documentId.userOverride) {
|
|
157
|
+
const newDocId = sanitizeForSanityId(title);
|
|
158
|
+
updated.documentId = newDocId;
|
|
159
|
+
updated.decisions = {
|
|
160
|
+
...updated.decisions,
|
|
161
|
+
documentId: { ...updated.decisions.documentId, generated: newDocId },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return updateFontAndCheckConflicts(state, tempId, updated);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case 'SET_FONT_DOCUMENT_ID': {
|
|
169
|
+
const { tempId, documentId } = action;
|
|
170
|
+
const font = state.fonts[tempId];
|
|
171
|
+
if (!font) return state;
|
|
172
|
+
|
|
173
|
+
const sanitized = sanitizeForSanityId(documentId);
|
|
174
|
+
const updated = {
|
|
175
|
+
...font,
|
|
176
|
+
documentId: sanitized,
|
|
177
|
+
decisions: {
|
|
178
|
+
...font.decisions,
|
|
179
|
+
documentId: { ...font.decisions.documentId, userOverride: documentId },
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return updateFontAndCheckConflicts(state, tempId, updated);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case 'SET_FONT_WEIGHT': {
|
|
187
|
+
const { tempId, weight } = action;
|
|
188
|
+
const font = state.fonts[tempId];
|
|
189
|
+
if (!font) return state;
|
|
190
|
+
|
|
191
|
+
const clamped = Math.max(1, Math.min(1000, weight));
|
|
192
|
+
return {
|
|
193
|
+
...state,
|
|
194
|
+
fonts: {
|
|
195
|
+
...state.fonts,
|
|
196
|
+
[tempId]: {
|
|
197
|
+
...font,
|
|
198
|
+
weight: clamped,
|
|
199
|
+
decisions: {
|
|
200
|
+
...font.decisions,
|
|
201
|
+
weight: { ...font.decisions.weight, userOverride: clamped },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
case 'SET_FONT_WEIGHT_NAME': {
|
|
209
|
+
const { tempId, weightName } = action;
|
|
210
|
+
const font = state.fonts[tempId];
|
|
211
|
+
if (!font) return state;
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
...state,
|
|
215
|
+
fonts: {
|
|
216
|
+
...state.fonts,
|
|
217
|
+
[tempId]: {
|
|
218
|
+
...font,
|
|
219
|
+
weightName,
|
|
220
|
+
decisions: {
|
|
221
|
+
...font.decisions,
|
|
222
|
+
weightName: { ...font.decisions.weightName, userOverride: weightName },
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
case 'SET_FONT_STYLE': {
|
|
230
|
+
const { tempId, style } = action;
|
|
231
|
+
const font = state.fonts[tempId];
|
|
232
|
+
if (!font) return state;
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
...state,
|
|
236
|
+
fonts: {
|
|
237
|
+
...state.fonts,
|
|
238
|
+
[tempId]: {
|
|
239
|
+
...font,
|
|
240
|
+
style,
|
|
241
|
+
decisions: {
|
|
242
|
+
...font.decisions,
|
|
243
|
+
style: { ...font.decisions.style, userOverride: style },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
case 'SET_FONT_SUBFAMILY': {
|
|
251
|
+
const { tempId, subfamily } = action;
|
|
252
|
+
const font = state.fonts[tempId];
|
|
253
|
+
if (!font) return state;
|
|
254
|
+
|
|
255
|
+
const oldSubfamily = font.subfamily || 'default';
|
|
256
|
+
const newSubfamily = subfamily || 'default';
|
|
257
|
+
|
|
258
|
+
const updated = {
|
|
259
|
+
...font,
|
|
260
|
+
subfamily,
|
|
261
|
+
decisions: {
|
|
262
|
+
...font.decisions,
|
|
263
|
+
subfamily: { ...font.decisions.subfamily, userOverride: subfamily },
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
let newState = { ...state, fonts: { ...state.fonts, [tempId]: updated } };
|
|
268
|
+
|
|
269
|
+
// Move between subfamily groups
|
|
270
|
+
if (oldSubfamily !== newSubfamily) {
|
|
271
|
+
newState = moveFontBetweenGroups(newState, tempId, oldSubfamily, newSubfamily);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return newState;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
case 'SET_FONT_ACTION': {
|
|
278
|
+
const { tempId, decision } = action;
|
|
279
|
+
const font = state.fonts[tempId];
|
|
280
|
+
if (!font) return state;
|
|
281
|
+
|
|
282
|
+
const existingDoc = { ...font.decisions.existingDocument, userChoice: decision };
|
|
283
|
+
if (decision === 'create') {
|
|
284
|
+
existingDoc.selectedCandidate = null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
...state,
|
|
289
|
+
fonts: {
|
|
290
|
+
...state.fonts,
|
|
291
|
+
[tempId]: {
|
|
292
|
+
...font,
|
|
293
|
+
decisions: { ...font.decisions, existingDocument: existingDoc },
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case 'SET_FONT_CANDIDATE': {
|
|
300
|
+
const { tempId, candidate } = action;
|
|
301
|
+
const font = state.fonts[tempId];
|
|
302
|
+
if (!font) return state;
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
...state,
|
|
306
|
+
fonts: {
|
|
307
|
+
...state.fonts,
|
|
308
|
+
[tempId]: {
|
|
309
|
+
...font,
|
|
310
|
+
documentId: candidate._id,
|
|
311
|
+
decisions: {
|
|
312
|
+
...font.decisions,
|
|
313
|
+
existingDocument: {
|
|
314
|
+
...font.decisions.existingDocument,
|
|
315
|
+
selectedCandidate: candidate,
|
|
316
|
+
userChoice: 'update',
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------
|
|
325
|
+
// Subfamily Organization
|
|
326
|
+
// ---------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
case 'MOVE_FONT_TO_SUBFAMILY': {
|
|
329
|
+
const { tempId, fromSubfamily, toSubfamily } = action;
|
|
330
|
+
const font = state.fonts[tempId];
|
|
331
|
+
if (!font) return state;
|
|
332
|
+
|
|
333
|
+
let newState = {
|
|
334
|
+
...state,
|
|
335
|
+
fonts: {
|
|
336
|
+
...state.fonts,
|
|
337
|
+
[tempId]: { ...font, subfamily: toSubfamily },
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
return moveFontBetweenGroups(newState, tempId, fromSubfamily, toSubfamily);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
case 'CREATE_SUBFAMILY_GROUP': {
|
|
344
|
+
const { title } = action;
|
|
345
|
+
if (state.subfamilyGroups[title]) return state;
|
|
346
|
+
return {
|
|
347
|
+
...state,
|
|
348
|
+
subfamilyGroups: {
|
|
349
|
+
...state.subfamilyGroups,
|
|
350
|
+
[title]: { title, fontIds: [] },
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
case 'REMOVE_SUBFAMILY_GROUP': {
|
|
356
|
+
const { title } = action;
|
|
357
|
+
const group = state.subfamilyGroups[title];
|
|
358
|
+
if (!group) return state;
|
|
359
|
+
if (group.fontIds.length > 0) {
|
|
360
|
+
console.warn('Cannot remove subfamily group with fonts — reassign fonts first');
|
|
361
|
+
return state;
|
|
362
|
+
}
|
|
363
|
+
const { [title]: _, ...remaining } = state.subfamilyGroups;
|
|
364
|
+
return { ...state, subfamilyGroups: remaining };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------
|
|
368
|
+
// Bulk Actions
|
|
369
|
+
// ---------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
case 'ACCEPT_ALL_SUGGESTIONS': {
|
|
372
|
+
const scope = action.scope || Object.keys(state.fonts);
|
|
373
|
+
const fonts = { ...state.fonts };
|
|
374
|
+
|
|
375
|
+
for (const tempId of scope) {
|
|
376
|
+
if (!fonts[tempId]) continue;
|
|
377
|
+
fonts[tempId] = resetFontToSuggestions(fonts[tempId]);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Rebuild subfamily groups after resetting
|
|
381
|
+
const subfamilyGroups = rebuildSubfamilyGroups(fonts);
|
|
382
|
+
return { ...state, fonts, subfamilyGroups };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
case 'RESET_FONT_TO_SUGGESTIONS': {
|
|
386
|
+
const { tempId } = action;
|
|
387
|
+
const font = state.fonts[tempId];
|
|
388
|
+
if (!font) return state;
|
|
389
|
+
|
|
390
|
+
const reset = resetFontToSuggestions(font);
|
|
391
|
+
const fonts = { ...state.fonts, [tempId]: reset };
|
|
392
|
+
const subfamilyGroups = rebuildSubfamilyGroups(fonts);
|
|
393
|
+
return { ...state, fonts, subfamilyGroups };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
case 'REMOVE_FONT': {
|
|
397
|
+
const { tempId } = action;
|
|
398
|
+
if (!state.fonts[tempId]) return state;
|
|
399
|
+
|
|
400
|
+
const { [tempId]: removed, ...remainingFonts } = state.fonts;
|
|
401
|
+
|
|
402
|
+
// Remove from subfamily groups
|
|
403
|
+
const subfamilyGroups = {};
|
|
404
|
+
for (const [key, group] of Object.entries(state.subfamilyGroups)) {
|
|
405
|
+
const filtered = group.fontIds.filter(id => id !== tempId);
|
|
406
|
+
if (filtered.length > 0) {
|
|
407
|
+
subfamilyGroups[key] = { ...group, fontIds: filtered };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return { ...state, fonts: remainingFonts, subfamilyGroups };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
default:
|
|
415
|
+
return state;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// -----------------------------------------------------------------------
|
|
420
|
+
// Helpers
|
|
421
|
+
// -----------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
/** Resets a font entry's overrides back to system-detected values */
|
|
424
|
+
function resetFontToSuggestions(font) {
|
|
425
|
+
const d = font.decisions;
|
|
426
|
+
return {
|
|
427
|
+
...font,
|
|
428
|
+
title: d.title.processed,
|
|
429
|
+
documentId: d.documentId.generated,
|
|
430
|
+
weight: d.weight.detected,
|
|
431
|
+
weightName: d.weightName.detected,
|
|
432
|
+
style: d.style.detected,
|
|
433
|
+
subfamily: d.subfamily.detected,
|
|
434
|
+
_idConflict: false,
|
|
435
|
+
decisions: {
|
|
436
|
+
...d,
|
|
437
|
+
title: { ...d.title, userOverride: null, source: d.title.original ? d.title.source : d.title.source },
|
|
438
|
+
documentId: { ...d.documentId, userOverride: null },
|
|
439
|
+
weight: { ...d.weight, userOverride: null },
|
|
440
|
+
weightName: { ...d.weightName, userOverride: null },
|
|
441
|
+
style: { ...d.style, userOverride: null },
|
|
442
|
+
subfamily: { ...d.subfamily, userOverride: null },
|
|
443
|
+
existingDocument: { ...d.existingDocument, userChoice: null, selectedCandidate: null },
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Moves a font between subfamily groups, creating/removing groups as needed */
|
|
449
|
+
function moveFontBetweenGroups(state, tempId, fromKey, toKey) {
|
|
450
|
+
const groups = { ...state.subfamilyGroups };
|
|
451
|
+
|
|
452
|
+
// Remove from old group
|
|
453
|
+
if (groups[fromKey]) {
|
|
454
|
+
const filtered = groups[fromKey].fontIds.filter(id => id !== tempId);
|
|
455
|
+
if (filtered.length === 0) {
|
|
456
|
+
delete groups[fromKey];
|
|
457
|
+
} else {
|
|
458
|
+
groups[fromKey] = { ...groups[fromKey], fontIds: filtered };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Add to new group (guard against duplicates)
|
|
463
|
+
if (!groups[toKey]) {
|
|
464
|
+
groups[toKey] = { title: toKey, fontIds: [] };
|
|
465
|
+
}
|
|
466
|
+
if (!groups[toKey].fontIds.includes(tempId)) {
|
|
467
|
+
groups[toKey] = { ...groups[toKey], fontIds: [...groups[toKey].fontIds, tempId] };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return { ...state, subfamilyGroups: groups };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Updates a font and checks for documentId collisions across all fonts */
|
|
474
|
+
function updateFontAndCheckConflicts(state, tempId, updatedFont) {
|
|
475
|
+
const fonts = { ...state.fonts, [tempId]: updatedFont };
|
|
476
|
+
|
|
477
|
+
// Clear old conflicts and detect new ones
|
|
478
|
+
const idMap = {};
|
|
479
|
+
for (const [id, font] of Object.entries(fonts)) {
|
|
480
|
+
fonts[id] = { ...font, _idConflict: false };
|
|
481
|
+
const docId = font.documentId;
|
|
482
|
+
if (!idMap[docId]) {
|
|
483
|
+
idMap[docId] = [id];
|
|
484
|
+
} else {
|
|
485
|
+
idMap[docId].push(id);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Mark conflicts
|
|
490
|
+
for (const ids of Object.values(idMap)) {
|
|
491
|
+
if (ids.length > 1) {
|
|
492
|
+
for (const id of ids) {
|
|
493
|
+
fonts[id] = { ...fonts[id], _idConflict: true };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return { ...state, fonts };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Rebuilds subfamily groups from the fonts map */
|
|
502
|
+
function rebuildSubfamilyGroups(fonts) {
|
|
503
|
+
const groups = {};
|
|
504
|
+
for (const [tempId, font] of Object.entries(fonts)) {
|
|
505
|
+
if (font.status === FONT_STATUS.ERROR) continue;
|
|
506
|
+
const sfName = font.subfamily || 'default';
|
|
507
|
+
if (!font.variableFont || font.subfamily) {
|
|
508
|
+
if (!groups[sfName]) {
|
|
509
|
+
groups[sfName] = { title: sfName, fontIds: [] };
|
|
510
|
+
}
|
|
511
|
+
if (!groups[sfName].fontIds.includes(tempId)) {
|
|
512
|
+
groups[sfName].fontIds.push(tempId);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return groups;
|
|
517
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Type constants and enums for the two-phase upload plan — single source of truth
|
|
2
|
+
|
|
3
|
+
/** @enum {string} — Font processing status within the plan */
|
|
4
|
+
export const FONT_STATUS = {
|
|
5
|
+
PENDING: 'pending',
|
|
6
|
+
PROCESSING: 'processing',
|
|
7
|
+
PROCESSED: 'processed',
|
|
8
|
+
ERROR: 'error',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** @enum {string} — Modal phase state machine */
|
|
12
|
+
export const PLAN_PHASE = {
|
|
13
|
+
IDLE: 'idle',
|
|
14
|
+
PROCESSING: 'processing',
|
|
15
|
+
REVIEWING: 'reviewing',
|
|
16
|
+
READY: 'ready',
|
|
17
|
+
EXECUTING: 'executing',
|
|
18
|
+
COMPLETE: 'complete',
|
|
19
|
+
ERROR: 'error',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** @enum {string} — Document resolution recommendation from resolveExistingFont */
|
|
23
|
+
export const RECOMMENDATION = {
|
|
24
|
+
USE_EXACT: 'use-exact',
|
|
25
|
+
USE_CANDIDATE: 'use-candidate',
|
|
26
|
+
AMBIGUOUS: 'ambiguous',
|
|
27
|
+
CREATE: 'create',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** @enum {string} — Per-font execution progress status */
|
|
31
|
+
export const EXECUTION_STATUS = {
|
|
32
|
+
QUEUED: 'queued',
|
|
33
|
+
UPLOADING_ASSETS: 'uploading-assets',
|
|
34
|
+
GENERATING_CSS: 'generating-css',
|
|
35
|
+
GENERATING_METADATA: 'generating-metadata',
|
|
36
|
+
CREATING_DOCUMENT: 'creating-document',
|
|
37
|
+
PATCHING_TYPEFACE: 'patching-typeface',
|
|
38
|
+
COMPLETE: 'complete',
|
|
39
|
+
ERROR: 'error',
|
|
40
|
+
SKIPPED: 'skipped',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Processing-owned fields — only written by ADD_PROCESSED_FONT, never by user edit actions */
|
|
44
|
+
export const PROCESSING_OWNED_FIELDS = ['parsedMetadata', 'glyphCount', 'opentypeFeatures', 'variationAxes', 'status'];
|
|
45
|
+
|
|
46
|
+
/** User-owned fields — only written by user edit actions via decisions.*.userOverride */
|
|
47
|
+
export const USER_OWNED_FIELDS = ['title', 'documentId', 'weight', 'weightName', 'style', 'subfamily'];
|
|
48
|
+
|
|
49
|
+
/** Current plan schema version — increment on breaking shape changes */
|
|
50
|
+
export const PLAN_VERSION = 1;
|
|
51
|
+
|
|
52
|
+
/** Maximum concurrent asset uploads */
|
|
53
|
+
export const CONCURRENCY_LIMIT = 3;
|
|
54
|
+
|
|
55
|
+
/** Maximum retry attempts for 429 rate-limited requests */
|
|
56
|
+
export const MAX_RETRIES = 3;
|
|
57
|
+
|
|
58
|
+
/** Base backoff delay in ms for exponential retry */
|
|
59
|
+
export const BASE_BACKOFF_MS = 1000;
|
|
60
|
+
|
|
61
|
+
/** Jitter factor for exponential backoff (±25%) */
|
|
62
|
+
export const JITTER_FACTOR = 0.25;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Calculate backoff delay with jitter for retry logic.
|
|
66
|
+
* @param {number} attempt - Zero-based attempt number
|
|
67
|
+
* @returns {number} Delay in milliseconds
|
|
68
|
+
*/
|
|
69
|
+
export function backoffWithJitter(attempt) {
|
|
70
|
+
const base = BASE_BACKOFF_MS * Math.pow(2, attempt);
|
|
71
|
+
const jitter = base * JITTER_FACTOR * (Math.random() * 2 - 1);
|
|
72
|
+
return Math.round(base + jitter);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Creates an empty FontDecisions object with default sources.
|
|
77
|
+
* @param {object} params
|
|
78
|
+
* @param {string} params.titleSource
|
|
79
|
+
* @param {string} params.title
|
|
80
|
+
* @param {string} params.titleOriginal
|
|
81
|
+
* @param {string} params.documentId
|
|
82
|
+
* @param {number} params.weight
|
|
83
|
+
* @param {string} params.weightSource
|
|
84
|
+
* @param {string|null} params.matchedKeyword
|
|
85
|
+
* @param {string} params.weightName
|
|
86
|
+
* @param {string} params.weightNameSource
|
|
87
|
+
* @param {string} params.style
|
|
88
|
+
* @param {string} params.styleSource
|
|
89
|
+
* @param {string} params.styleReason
|
|
90
|
+
* @param {string} params.subfamily
|
|
91
|
+
* @param {string} params.subfamilySource
|
|
92
|
+
* @param {Array} params.titleAlternatives
|
|
93
|
+
* @returns {object}
|
|
94
|
+
*/
|
|
95
|
+
export function createFontDecisions({
|
|
96
|
+
titleSource = 'fontkit-fullName',
|
|
97
|
+
title = '',
|
|
98
|
+
titleOriginal = '',
|
|
99
|
+
documentId = '',
|
|
100
|
+
weight = 400,
|
|
101
|
+
weightSource = 'default-400',
|
|
102
|
+
matchedKeyword = null,
|
|
103
|
+
weightName = '',
|
|
104
|
+
weightNameSource = 'nameId17-preferredSubfamily',
|
|
105
|
+
style = 'Regular',
|
|
106
|
+
styleSource = 'default-regular',
|
|
107
|
+
styleReason = '',
|
|
108
|
+
subfamily = '',
|
|
109
|
+
subfamilySource = 'default-empty',
|
|
110
|
+
titleAlternatives = [],
|
|
111
|
+
}) {
|
|
112
|
+
return {
|
|
113
|
+
title: {
|
|
114
|
+
source: titleSource,
|
|
115
|
+
original: titleOriginal,
|
|
116
|
+
processed: title,
|
|
117
|
+
alternatives: titleAlternatives,
|
|
118
|
+
userOverride: null,
|
|
119
|
+
},
|
|
120
|
+
documentId: {
|
|
121
|
+
source: 'derived-from-title',
|
|
122
|
+
generated: documentId,
|
|
123
|
+
userOverride: null,
|
|
124
|
+
},
|
|
125
|
+
weight: {
|
|
126
|
+
source: weightSource,
|
|
127
|
+
detected: weight,
|
|
128
|
+
matchedKeyword,
|
|
129
|
+
rawWeightName: weightName,
|
|
130
|
+
userOverride: null,
|
|
131
|
+
},
|
|
132
|
+
weightName: {
|
|
133
|
+
source: weightNameSource,
|
|
134
|
+
detected: weightName,
|
|
135
|
+
userOverride: null,
|
|
136
|
+
},
|
|
137
|
+
style: {
|
|
138
|
+
source: styleSource,
|
|
139
|
+
detected: style,
|
|
140
|
+
reason: styleReason,
|
|
141
|
+
userOverride: null,
|
|
142
|
+
},
|
|
143
|
+
subfamily: {
|
|
144
|
+
source: subfamilySource,
|
|
145
|
+
detected: subfamily,
|
|
146
|
+
userOverride: null,
|
|
147
|
+
},
|
|
148
|
+
existingDocument: {
|
|
149
|
+
recommendation: RECOMMENDATION.CREATE,
|
|
150
|
+
exact: null,
|
|
151
|
+
candidates: [],
|
|
152
|
+
userChoice: null,
|
|
153
|
+
selectedCandidate: null,
|
|
154
|
+
lookupFailed: false,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Creates an initial empty UploadPlan.
|
|
161
|
+
* @param {object} settings
|
|
162
|
+
* @returns {object}
|
|
163
|
+
*/
|
|
164
|
+
export function createEmptyPlan(settings = {}) {
|
|
165
|
+
return {
|
|
166
|
+
version: PLAN_VERSION,
|
|
167
|
+
settings: {
|
|
168
|
+
price: 0,
|
|
169
|
+
preserveShortenedNames: false,
|
|
170
|
+
preserveFileNames: false,
|
|
171
|
+
...settings,
|
|
172
|
+
},
|
|
173
|
+
fonts: {},
|
|
174
|
+
subfamilyGroups: {},
|
|
175
|
+
phase: PLAN_PHASE.IDLE,
|
|
176
|
+
processingProgress: {
|
|
177
|
+
total: 0,
|
|
178
|
+
completed: 0,
|
|
179
|
+
failed: 0,
|
|
180
|
+
currentFile: null,
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|