@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.
Files changed (36) hide show
  1. package/dist/UploadModal-ADNRGQUI.mjs +6 -0
  2. package/dist/UploadModal-WPK2CXLR.js +6 -0
  3. package/dist/chunk-JCDZ7SWZ.js +7711 -0
  4. package/dist/chunk-TMDE4A54.mjs +7711 -0
  5. package/dist/index.js +666 -1647
  6. package/dist/index.mjs +319 -1209
  7. package/package.json +5 -5
  8. package/src/components/BatchUploadFonts.jsx +57 -44
  9. package/src/components/BulkActions.jsx +99 -0
  10. package/src/components/ExistingDocumentResolver.jsx +152 -0
  11. package/src/components/FontReviewCard.jsx +455 -0
  12. package/src/components/SingleUploaderTool.jsx +3 -4
  13. package/src/components/UploadModal.jsx +304 -0
  14. package/src/components/UploadScriptsComponent.jsx +23 -21
  15. package/src/components/UploadStep1Settings.jsx +272 -0
  16. package/src/components/UploadStep2Review.jsx +474 -0
  17. package/src/components/UploadStep3Execute.jsx +234 -0
  18. package/src/components/UploadStep3bInstances.jsx +396 -0
  19. package/src/components/UploadSummary.jsx +196 -0
  20. package/src/index.js +46 -0
  21. package/src/utils/buildUploadPlan.js +326 -0
  22. package/src/utils/executeUploadPlan.js +430 -0
  23. package/src/utils/executionReducer.js +56 -0
  24. package/src/utils/fontHelpers.js +267 -0
  25. package/src/utils/generateCssFile.js +79 -77
  26. package/src/utils/generateFontData.js +47 -94
  27. package/src/utils/getEmptyFontKit.js +19 -17
  28. package/src/utils/parseFont.js +55 -0
  29. package/src/utils/parseVariableFontInstances.js +237 -147
  30. package/src/utils/planReducer.js +517 -0
  31. package/src/utils/planTypes.js +183 -0
  32. package/src/utils/processFontFiles.js +121 -78
  33. package/src/utils/regenerateFontData.js +2 -2
  34. package/src/utils/resolveExistingFont.js +87 -0
  35. package/src/utils/updateTypefaceDocument.js +15 -2
  36. 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
+ }