sqlite-hub 0.8.7 → 0.9.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 (34) hide show
  1. package/changelog.md +16 -0
  2. package/frontend/index.html +1 -1
  3. package/frontend/js/api.js +67 -0
  4. package/frontend/js/app.js +1554 -999
  5. package/frontend/js/components/modal.js +208 -3
  6. package/frontend/js/components/queryEditor.js +9 -4
  7. package/frontend/js/components/queryHistoryDetail.js +34 -12
  8. package/frontend/js/components/queryHistoryPanel.js +8 -11
  9. package/frontend/js/components/rowEditorPanel.js +26 -5
  10. package/frontend/js/components/sidebar.js +49 -5
  11. package/frontend/js/components/structureGraph.js +614 -632
  12. package/frontend/js/components/tableDesignerSqlPreview.js +24 -25
  13. package/frontend/js/lib/mediaTaggingDefaults.js +27 -0
  14. package/frontend/js/router.js +81 -71
  15. package/frontend/js/store.js +3140 -2179
  16. package/frontend/js/views/charts.js +2 -2
  17. package/frontend/js/views/data.js +28 -14
  18. package/frontend/js/views/editor.js +172 -177
  19. package/frontend/js/views/mediaTagging.js +931 -0
  20. package/frontend/styles/base.css +11 -1
  21. package/frontend/styles/components.css +54 -6
  22. package/frontend/styles/views.css +910 -0
  23. package/package.json +1 -1
  24. package/server/routes/charts.js +4 -1
  25. package/server/routes/data.js +14 -0
  26. package/server/routes/mediaTagging.js +166 -0
  27. package/server/routes/sql.js +1 -0
  28. package/server/server.js +4 -0
  29. package/server/services/sqlite/dataBrowserService.js +25 -0
  30. package/server/services/sqlite/exportService.js +31 -1
  31. package/server/services/sqlite/mediaTaggingService.js +1689 -0
  32. package/server/services/storage/appStateStore.js +325 -3
  33. package/server/services/storage/queryHistoryUtils.js +165 -2
  34. package/shortkeys.md +5 -0
@@ -0,0 +1,931 @@
1
+ import { escapeHtml, formatCellValue, formatNumber, highlightSql, truncateMiddle } from '../utils/format.js';
2
+ import {
3
+ hasDefaultMediaTaggingTagTable,
4
+ hasDefaultMediaTaggingMappingTable,
5
+ MEDIA_TAGGING_DEFAULT_MAPPING_TABLE,
6
+ MEDIA_TAGGING_DEFAULT_TAG_TABLE,
7
+ } from '../lib/mediaTaggingDefaults.js';
8
+
9
+ function normalizeDraft(draft = {}) {
10
+ return {
11
+ tagTable: MEDIA_TAGGING_DEFAULT_TAG_TABLE,
12
+ mediaTable: String(draft.mediaTable ?? '').trim(),
13
+ pathColumn: String(draft.pathColumn ?? '').trim(),
14
+ taggedColumn: String(draft.taggedColumn ?? '').trim(),
15
+ untaggedQuery: String(draft.untaggedQuery ?? ''),
16
+ taggedQuery: String(draft.taggedQuery ?? ''),
17
+ mappingTable: MEDIA_TAGGING_DEFAULT_MAPPING_TABLE,
18
+ };
19
+ }
20
+
21
+ function hasConfigResetNotice(state) {
22
+ if (!state.mediaTagging.persistedConfig) {
23
+ return false;
24
+ }
25
+
26
+ const persisted = normalizeDraft(state.mediaTagging.persistedConfig ?? {});
27
+ const draft = normalizeDraft(state.mediaTagging.draft ?? {});
28
+
29
+ return JSON.stringify(persisted) !== JSON.stringify(draft);
30
+ }
31
+
32
+ function getMediaTaggingIssueKey(issue = {}) {
33
+ return `issue:${String(issue.scope ?? '').trim()}:${String(issue.code ?? '').trim()}:${String(issue.message ?? '').trim()}`;
34
+ }
35
+
36
+ function getMediaTaggingRouteErrorKey(error = {}) {
37
+ return `route:${String(error.code ?? '').trim()}:${String(error.message ?? '').trim()}`;
38
+ }
39
+
40
+ function renderIssueCard({ code, message, toneClass = '', issueKey }) {
41
+ return `
42
+ <article class="media-tagging-issue ${toneClass}">
43
+ <button
44
+ class="media-tagging-issue__dismiss"
45
+ data-action="dismiss-media-tagging-issue"
46
+ data-issue-key="${escapeHtml(issueKey)}"
47
+ type="button"
48
+ aria-label="Hide issue"
49
+ title="Hide issue"
50
+ >
51
+ ×
52
+ </button>
53
+ <div class="media-tagging-issue__code">${escapeHtml(code)}</div>
54
+ <div class="mt-2">${escapeHtml(message)}</div>
55
+ </article>
56
+ `;
57
+ }
58
+
59
+ function renderIssueList(state) {
60
+ const routeError = state.mediaTagging.error;
61
+ const issues = state.mediaTagging.issues ?? [];
62
+ const dismissedIssueKeys = new Set(state.mediaTagging.dismissedIssueKeys ?? []);
63
+
64
+ const routeErrorMarkup =
65
+ routeError && !dismissedIssueKeys.has(getMediaTaggingRouteErrorKey(routeError))
66
+ ? renderIssueCard({
67
+ code: routeError.code,
68
+ message: routeError.message,
69
+ toneClass: 'is-error',
70
+ issueKey: getMediaTaggingRouteErrorKey(routeError),
71
+ })
72
+ : '';
73
+ const issuesMarkup = issues
74
+ .filter(issue => !dismissedIssueKeys.has(getMediaTaggingIssueKey(issue)))
75
+ .map(issue =>
76
+ renderIssueCard({
77
+ code: issue.code,
78
+ message: issue.message,
79
+ toneClass: issue.severity === 'success' ? 'is-success' : '',
80
+ issueKey: getMediaTaggingIssueKey(issue),
81
+ }),
82
+ )
83
+ .join('');
84
+
85
+ if (!routeErrorMarkup && !issuesMarkup) {
86
+ return '';
87
+ }
88
+
89
+ return `
90
+ <section class="media-tagging-issues">
91
+ ${routeErrorMarkup}
92
+ ${issuesMarkup}
93
+ </section>
94
+ `;
95
+ }
96
+
97
+ function renderSqlTextarea({ label, value = '', dataBind, dataField }) {
98
+ return `
99
+ <label class="media-tagging-field">
100
+ <span class="media-tagging-field__label">${escapeHtml(label)}</span>
101
+ <div class="sql-highlight-shell sql-highlight-shell--media">
102
+ <div class="query-editor-layer sql-highlight-layer">
103
+ <div
104
+ aria-hidden="true"
105
+ class="query-editor-highlight sql-highlight-content"
106
+ data-query-editor-highlight
107
+ >${value ? highlightSql(value) : ''}</div>
108
+ <textarea
109
+ class="query-editor-input sql-highlight-input custom-scrollbar"
110
+ data-bind="${escapeHtml(dataBind)}"
111
+ data-field="${escapeHtml(dataField)}"
112
+ data-sql-highlight="true"
113
+ spellcheck="false"
114
+ >${escapeHtml(value)}</textarea>
115
+ </div>
116
+ </div>
117
+ </label>
118
+ `;
119
+ }
120
+
121
+ function renderOptionList(options = [], selectedValue, placeholder) {
122
+ return [
123
+ `<option value="">${escapeHtml(placeholder)}</option>`,
124
+ ...options.map(
125
+ option => `
126
+ <option value="${escapeHtml(option.value)}" ${option.value === selectedValue ? 'selected' : ''}>
127
+ ${escapeHtml(option.label)}
128
+ </option>
129
+ `,
130
+ ),
131
+ ].join('');
132
+ }
133
+
134
+ function renderTagFormField(column, value) {
135
+ if (column.inputKind === 'checkbox') {
136
+ return `
137
+ <div class="media-tagging-field">
138
+ <label class="standard-checkbox table-designer-check table-designer-checkbox-override">
139
+ <input
140
+ data-bind="media-tagging-tag-form-field"
141
+ data-field="${escapeHtml(column.name)}"
142
+ type="checkbox"
143
+ ${value ? 'checked' : ''}
144
+ />
145
+ <span class="media-tagging-field__meta">
146
+ <span class="media-tagging-field__label">${escapeHtml(column.name)}</span>
147
+ <span class="media-tagging-field__hint">${escapeHtml(
148
+ column.declaredType || column.affinity || 'BOOLEAN',
149
+ )}</span>
150
+ </span>
151
+ </label>
152
+ </div>
153
+ `;
154
+ }
155
+
156
+ return `
157
+ <label class="media-tagging-field">
158
+ <span class="media-tagging-field__label">${escapeHtml(column.name)}</span>
159
+ <input
160
+ class="control-input w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
161
+ data-bind="media-tagging-tag-form-field"
162
+ data-field="${escapeHtml(column.name)}"
163
+ placeholder="name…"
164
+ type="${column.inputKind === 'number' ? 'number' : 'text'}"
165
+ value="${escapeHtml(value ?? '')}"
166
+ />
167
+ </label>
168
+ `;
169
+ }
170
+
171
+ function renderParentTagFields({
172
+ parentToggleColumn = null,
173
+ parentSelectColumn = null,
174
+ tagFormValues = {},
175
+ parentTagOptions = [],
176
+ } = {}) {
177
+ if (!parentToggleColumn && !parentSelectColumn) {
178
+ return '';
179
+ }
180
+
181
+ const isCreatingParentTag = Boolean(parentToggleColumn ? tagFormValues[parentToggleColumn.name] : false);
182
+ const selectedParentTagId = parentSelectColumn ? String(tagFormValues[parentSelectColumn.name] ?? '') : '';
183
+
184
+ return `
185
+ ${
186
+ parentSelectColumn
187
+ ? `
188
+ <label class="media-tagging-field">
189
+ <span class="media-tagging-field__label">Parent Tag</span>
190
+ <select
191
+ class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
192
+ data-bind="media-tagging-tag-form-field"
193
+ data-field="${escapeHtml(parentSelectColumn.name)}"
194
+ ${isCreatingParentTag ? 'disabled' : ''}
195
+ >
196
+ ${renderOptionList(
197
+ parentTagOptions.map(tag => ({
198
+ value: String(tag.identityValue ?? ''),
199
+ label: tag.label,
200
+ })),
201
+ selectedParentTagId,
202
+ parentTagOptions.length ? 'Select a parent tag' : 'No parent tags available',
203
+ )}
204
+ </select>
205
+ </label>
206
+ `
207
+ : ''
208
+ }
209
+ ${
210
+ parentToggleColumn
211
+ ? `
212
+ <div class="media-tagging-field">
213
+ <label class="standard-checkbox table-designer-check table-designer-checkbox-override">
214
+ <input
215
+ data-bind="media-tagging-tag-form-field"
216
+ data-field="${escapeHtml(parentToggleColumn.name)}"
217
+ type="checkbox"
218
+ ${isCreatingParentTag ? 'checked' : ''}
219
+ />
220
+ <span class="media-tagging-field__meta">
221
+ <span class="media-tagging-field__label">Create Parent Tag</span>
222
+ </span>
223
+ </label>
224
+ </div>
225
+ `
226
+ : ''
227
+ }
228
+ `;
229
+ }
230
+
231
+ function renderTagList(tags = [], selectedTagKeys = []) {
232
+ const selectedSet = new Set(selectedTagKeys);
233
+
234
+ return `
235
+ <div class="media-tagging-tag-list custom-scrollbar">
236
+ ${
237
+ tags.length
238
+ ? tags
239
+ .map(
240
+ tag => `
241
+ <label
242
+ class="media-tagging-tag-option ${selectedSet.has(tag.key) ? 'is-selected' : ''}"
243
+ data-tag-search-text="${escapeHtml(
244
+ `${String(tag.label ?? '')} ${String(tag.parentTagLabel ?? '')}`.trim().toLowerCase(),
245
+ )}"
246
+ >
247
+ <input
248
+ class="media-tagging-tag-option__checkbox"
249
+ data-bind="media-tagging-tag-selection"
250
+ data-tag-key="${escapeHtml(tag.key)}"
251
+ type="checkbox"
252
+ ${selectedSet.has(tag.key) ? 'checked' : ''}
253
+ />
254
+ <span class="media-tagging-tag-option__content">
255
+ <span class="media-tagging-tag-option__text">${escapeHtml(tag.label)}</span>
256
+ </span>
257
+ ${
258
+ tag.parentTagLabel
259
+ ? `<span class="media-tagging-tag-option__badge">${escapeHtml(tag.parentTagLabel)}</span>`
260
+ : ''
261
+ }
262
+ </label>
263
+ `,
264
+ )
265
+ .join('')
266
+ : `<div class="text-sm text-on-surface-variant/55">No tags available yet.</div>`
267
+ }
268
+ </div>
269
+ `;
270
+ }
271
+
272
+ function getWorkflowMetadataEntries(currentItem) {
273
+ return Object.entries(currentItem?.row ?? {}).filter(([key]) => key !== '__sqlite_hub_media_rowid');
274
+ }
275
+
276
+ function renderCreatedTagList(tags = [], { canRemove = false, removingTagKey = null } = {}) {
277
+ if (!tags.length) {
278
+ return `
279
+ <div class="text-sm text-on-surface-variant/55">
280
+ No tags have been created yet for the selected tag table.
281
+ </div>
282
+ `;
283
+ }
284
+
285
+ return `
286
+ <div class="media-tagging-created-tags custom-scrollbar">
287
+ ${tags
288
+ .map(
289
+ tag => `
290
+ <article class="media-tagging-created-tag">
291
+ <div class="media-tagging-created-tag__content">
292
+ <div class="media-tagging-created-tag__label">${escapeHtml(tag.label)}</div>
293
+ ${
294
+ tag.isParentTag || tag.parentTagLabel
295
+ ? `
296
+ <div class="media-tagging-created-tag__meta">
297
+ ${tag.isParentTag ? '<span class="media-tagging-created-tag__badge">Parent</span>' : ''}
298
+ ${
299
+ tag.parentTagLabel
300
+ ? `<span class="media-tagging-created-tag__badge">${escapeHtml(tag.parentTagLabel)}</span>`
301
+ : ''
302
+ }
303
+ </div>
304
+ `
305
+ : ''
306
+ }
307
+ </div>
308
+ <button
309
+ aria-label="Remove tag"
310
+ class="delete-button media-tagging-created-tag__remove"
311
+ data-action="remove-media-tag"
312
+ data-tag-key="${escapeHtml(tag.key)}"
313
+ type="button"
314
+ title="Remove tag"
315
+ ${canRemove && removingTagKey !== tag.key ? '' : 'disabled'}
316
+ >
317
+ <span class="material-symbols-outlined text-[18px]">
318
+ ${removingTagKey === tag.key ? 'hourglass_top' : 'delete'}
319
+ </span>
320
+ </button>
321
+ </article>
322
+ `,
323
+ )
324
+ .join('')}
325
+ </div>
326
+ `;
327
+ }
328
+
329
+ function renderPreviewMedia(
330
+ currentItem,
331
+ { detailsVisible = true, mediaTableName = '', rotationDegrees = 0, status = null } = {},
332
+ ) {
333
+ if (!currentItem) {
334
+ return `
335
+ <div class="media-tagging-preview__empty">
336
+ <span class="material-symbols-outlined text-4xl">photo_size_select_large</span>
337
+ <div class="mt-3 font-headline text-lg uppercase tracking-[0.06em] text-primary-container">
338
+ Queue Empty
339
+ </div>
340
+ <div class="mt-2 text-sm text-on-surface-variant/55">
341
+ No current media item is available with the active configuration.
342
+ </div>
343
+ </div>
344
+ `;
345
+ }
346
+
347
+ const pathValue = String(currentItem.path ?? '');
348
+ const fileName = pathValue.split(/[\\/]/).filter(Boolean).pop() || 'Audio file';
349
+ const isAudioPreview = Boolean(currentItem.previewUrl && currentItem.previewKind === 'audio');
350
+ const isRotatablePreview = Boolean(
351
+ currentItem.previewUrl && (currentItem.previewKind === 'image' || currentItem.previewKind === 'video'),
352
+ );
353
+ const numericRotation = Number(rotationDegrees);
354
+ const normalizedRotation = Number.isFinite(numericRotation)
355
+ ? ((Math.round(numericRotation / 90) * 90) % 360 + 360) % 360
356
+ : 0;
357
+ const rotationStyle = `--media-tagging-preview-rotation: ${normalizedRotation}deg;`;
358
+ const rotationClass = normalizedRotation === 90 || normalizedRotation === 270 ? ' is-rotated-quarter' : '';
359
+ let assetMarkup = `
360
+ <div class="media-tagging-preview__placeholder">
361
+ <span class="material-symbols-outlined text-5xl">perm_media</span>
362
+ </div>
363
+ `;
364
+
365
+ if (currentItem.previewUrl && currentItem.previewKind === 'image') {
366
+ assetMarkup = `
367
+ <img
368
+ class="media-tagging-preview__asset${rotationClass}"
369
+ data-media-tagging-rotation-target="true"
370
+ data-rotation-degrees="${escapeHtml(String(normalizedRotation))}"
371
+ style="${escapeHtml(rotationStyle)}"
372
+ src="${escapeHtml(currentItem.previewUrl)}"
373
+ alt="${escapeHtml(pathValue || 'Current media item')}"
374
+ />
375
+ `;
376
+ } else if (currentItem.previewUrl && currentItem.previewKind === 'video') {
377
+ assetMarkup = `
378
+ <video
379
+ class="media-tagging-preview__asset${rotationClass}"
380
+ data-media-tagging-rotation-target="true"
381
+ data-rotation-degrees="${escapeHtml(String(normalizedRotation))}"
382
+ style="${escapeHtml(rotationStyle)}"
383
+ controls
384
+ preload="metadata"
385
+ src="${escapeHtml(
386
+ currentItem.previewUrl,
387
+ )}"
388
+ ></video>
389
+ `;
390
+ } else if (currentItem.previewUrl && currentItem.previewKind === 'audio') {
391
+ assetMarkup = `
392
+ <div class="media-tagging-audio-preview">
393
+ <div class="media-tagging-audio-preview__icon">
394
+ <span class="material-symbols-outlined">audio_file</span>
395
+ </div>
396
+ <div class="media-tagging-audio-preview__content">
397
+ <div class="media-tagging-audio-preview__eyebrow">Audio Preview</div>
398
+ <div class="media-tagging-audio-preview__title" title="${escapeHtml(fileName)}">
399
+ ${escapeHtml(truncateMiddle(fileName, 56))}
400
+ </div>
401
+ <audio
402
+ class="media-tagging-audio-preview__player"
403
+ controls
404
+ preload="metadata"
405
+ src="${escapeHtml(currentItem.previewUrl)}"
406
+ ></audio>
407
+ </div>
408
+ </div>
409
+ `;
410
+ }
411
+
412
+ const metadata = getWorkflowMetadataEntries(currentItem);
413
+ const toggleLabel = detailsVisible
414
+ ? '<span class="material-symbols-outlined">visibility_off</span> Shrink Media Viewer'
415
+ : 'Show Media Viewer';
416
+
417
+ return `
418
+ <div class="media-tagging-preview ${detailsVisible ? '' : 'media-tagging-preview--meta-hidden'}">
419
+ <div class="media-tagging-preview__media${isAudioPreview ? ' media-tagging-preview__media--audio' : ''}">
420
+ <div class="media-tagging-preview__media-toolbar">
421
+ <div class="media-tagging-status media-tagging-status--compact media-tagging-status--preview">
422
+ <div class="media-tagging-status__value">${escapeHtml(status?.ratioLabel ?? '0 / 0')}</div>
423
+ <div class="media-tagging-status__label">
424
+ tagged / total
425
+ </div>
426
+ </div>
427
+ <div class="media-tagging-preview__toolbar-actions">
428
+ ${
429
+ isRotatablePreview
430
+ ? `
431
+ <div class="media-tagging-preview__rotation-controls" aria-label="Rotate media preview">
432
+ <button
433
+ class="standard-button media-tagging-preview__icon-button"
434
+ data-action="rotate-media-tagging-current-media"
435
+ data-rotation-command="left"
436
+ type="button"
437
+ aria-label="Rotate left 90 degrees"
438
+ title="Rotate left 90 degrees"
439
+ >
440
+ <span class="material-symbols-outlined">rotate_left</span>
441
+ </button>
442
+ <button
443
+ class="standard-button media-tagging-preview__icon-button"
444
+ data-action="rotate-media-tagging-current-media"
445
+ data-rotation-command="right"
446
+ type="button"
447
+ aria-label="Rotate right 90 degrees"
448
+ title="Rotate right 90 degrees"
449
+ >
450
+ <span class="material-symbols-outlined">rotate_right</span>
451
+ </button>
452
+ <button
453
+ class="standard-button media-tagging-preview__icon-button"
454
+ data-action="rotate-media-tagging-current-media"
455
+ data-rotation-command="reset"
456
+ type="button"
457
+ aria-label="Reset rotation"
458
+ title="Reset rotation"
459
+ ${normalizedRotation === 0 ? 'disabled' : ''}
460
+ >
461
+ <span class="material-symbols-outlined">restart_alt</span>
462
+ </button>
463
+ </div>
464
+ `
465
+ : ''
466
+ }
467
+ <button
468
+ class="standard-button media-tagging-preview__toggle"
469
+ data-action="toggle-media-tagging-current-media"
470
+ data-next-value="${detailsVisible ? 'false' : 'true'}"
471
+ data-expanded-label="Shrink Media Viewer"
472
+ data-collapsed-label="Show Media Viewer"
473
+ aria-expanded="${detailsVisible ? 'true' : 'false'}"
474
+ type="button"
475
+ >
476
+ ${toggleLabel}
477
+ </button>
478
+ </div>
479
+ </div>
480
+ <div class="media-tagging-preview__asset-shell">
481
+ ${assetMarkup}
482
+ </div>
483
+ </div>
484
+ <div class="media-tagging-preview__meta">
485
+ <div class="media-tagging-preview__meta-header">
486
+ <div class="media-tagging-preview__eyebrow">Current Media</div>
487
+ <div class="media-tagging-preview__meta-actions">
488
+ ${
489
+ mediaTableName
490
+ ? `
491
+ <button
492
+ class="standard-button media-tagging-preview__meta-action"
493
+ data-action="open-media-tagging-current-in-structure"
494
+ type="button"
495
+ >
496
+ Open Structure
497
+ </button>
498
+ `
499
+ : ''
500
+ }
501
+ ${
502
+ mediaTableName && currentItem.identity
503
+ ? `
504
+ <button
505
+ class="standard-button media-tagging-preview__meta-action"
506
+ data-action="open-media-tagging-current-in-data"
507
+ type="button"
508
+ >
509
+ Open In Data
510
+ </button>
511
+ `
512
+ : ''
513
+ }
514
+ </div>
515
+ </div>
516
+ ${
517
+ metadata.length
518
+ ? `
519
+ <div class="media-tagging-preview__metadata">
520
+ ${metadata
521
+ .map(
522
+ ([key, value]) => `
523
+ <div class="media-tagging-preview__metadata-row">
524
+ <span>${escapeHtml(key)}</span>
525
+ <span title="${escapeHtml(formatCellValue(value))}">
526
+ ${escapeHtml(formatCellValue(value))}
527
+ </span>
528
+ </div>
529
+ `,
530
+ )
531
+ .join('')}
532
+ </div>
533
+ `
534
+ : ''
535
+ }
536
+ </div>
537
+ </div>
538
+ `;
539
+ }
540
+
541
+ function renderTagsSection(state) {
542
+ const tagColumns = state.mediaTagging.tagTableColumns ?? [];
543
+ const tagFormValues = state.mediaTagging.tagFormValues ?? {};
544
+ const tags = state.mediaTagging.tags ?? [];
545
+ const tagTableExists = hasDefaultMediaTaggingTagTable(state.mediaTagging.schemaTables ?? []);
546
+ const parentToggleColumn = tagColumns.find(column => column.uiRole === 'parent-toggle') ?? null;
547
+ const parentSelectColumn = tagColumns.find(column => column.uiRole === 'parent-select') ?? null;
548
+ const standardTagColumns = tagColumns.filter(column => !column.uiRole);
549
+ const parentTagOptions = tags.filter(tag => tag.isParentTag);
550
+
551
+ return `
552
+ <section class="media-tagging-card shell-section">
553
+ <div class="media-tagging-card__header">
554
+ <div>
555
+ <div class="media-tagging-card__eyebrow">1. Tags</div>
556
+ <h2 class="media-tagging-card__title">Tag Table</h2>
557
+ </div>
558
+ <div class="flex flex-col items-end gap-3">
559
+ <span class="status-badge ${tagTableExists ? 'status-badge--success' : ''}">
560
+ ${tagTableExists ? 'Available' : 'Missing'}
561
+ </span>
562
+ <button
563
+ class="standard-button"
564
+ data-action="open-modal"
565
+ data-modal="create-media-tagging-tag-table"
566
+ type="button"
567
+ >
568
+ Create Tag Table
569
+ </button>
570
+ </div>
571
+ </div>
572
+ <div class="media-tagging-card__body">
573
+ ${
574
+ tagColumns.length
575
+ ? `
576
+ <div class="media-tagging-form-grid">
577
+ ${standardTagColumns.map(column => renderTagFormField(column, tagFormValues[column.name])).join('')}
578
+ ${renderParentTagFields({
579
+ parentToggleColumn,
580
+ parentSelectColumn,
581
+ tagFormValues,
582
+ parentTagOptions,
583
+ })}
584
+ </div>
585
+ <div class="flex flex-wrap items-center gap-3">
586
+ <button
587
+ class="standard-button"
588
+ data-action="create-media-tag"
589
+ type="button"
590
+ ${state.mediaTagging.creatingTag || state.mediaTagging.connection?.readOnly ? 'disabled' : ''}
591
+ ><span class="material-symbols-outlined">sell</span>
592
+ ${state.mediaTagging.creatingTag ? 'Saving...' : 'Create Tag'}
593
+ </button>
594
+ <div class="text-[11px] font-mono uppercase tracking-[0.14em] text-on-surface-variant/45">
595
+ ${escapeHtml(formatNumber(tags.length))} existing tag(s)
596
+ </div>
597
+ </div>
598
+ <div class="media-tagging-created-tags-shell">
599
+ <div class="media-tagging-field__label">Created Tags</div>
600
+ <div class="media-tagging-created-tags-frame mt-3">
601
+ ${renderCreatedTagList(tags, {
602
+ canRemove: !state.mediaTagging.connection?.readOnly,
603
+ removingTagKey: state.mediaTagging.removingTagKey,
604
+ })}
605
+ </div>
606
+ <div class="mt-3">
607
+ <button
608
+ class="standard-button"
609
+ data-action="copy-media-tags"
610
+ type="button"
611
+ ${tags.length ? '' : 'disabled'}
612
+ >
613
+ <span class="material-symbols-outlined text-sm">content_copy</span>
614
+ Copy Tags to clipboard
615
+ </button>
616
+ </div>
617
+ </div>
618
+ `
619
+ : `
620
+ <div class="text-sm text-on-surface-variant/55">
621
+ ${escapeHtml(MEDIA_TAGGING_DEFAULT_TAG_TABLE)} must exist to generate the tag creation form.
622
+ </div>
623
+ `
624
+ }
625
+ </div>
626
+ </section>
627
+ `;
628
+ }
629
+
630
+ function renderTaggingSection(state) {
631
+ const tables = state.mediaTagging.schemaTables ?? [];
632
+ const draft = normalizeDraft(state.mediaTagging.draft ?? {});
633
+ const mediaTableColumns = state.mediaTagging.mediaTableColumns ?? [];
634
+ const pathCandidates = state.mediaTagging.pathCandidates ?? [];
635
+ const booleanCandidates = state.mediaTagging.booleanCandidates ?? [];
636
+
637
+ return `
638
+ <section class="media-tagging-card media-tagging-card--tagging shell-section">
639
+ <div class="media-tagging-card__header">
640
+ <div>
641
+ <div class="media-tagging-card__eyebrow">2. Tagging</div>
642
+ <h2 class="media-tagging-card__title">Media Source</h2>
643
+ </div>
644
+ </div>
645
+ <div class="media-tagging-card__body">
646
+ <div class="media-tagging-form-grid">
647
+ <label class="media-tagging-field">
648
+ <span class="media-tagging-field__label">Media Table</span>
649
+ <select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" data-bind="media-tagging-field" data-field="mediaTable">
650
+ ${renderOptionList(
651
+ tables
652
+ .map(table => ({ value: table.name, label: table.name }))
653
+ .sort((a, b) => a.label.localeCompare(b.label)),
654
+ draft.mediaTable,
655
+ 'Select a media table',
656
+ )}
657
+ </select>
658
+ </label>
659
+ <div></div>
660
+ <label class="media-tagging-field">
661
+ <span class="media-tagging-field__label">Path Column</span>
662
+ <select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" data-bind="media-tagging-field" data-field="pathColumn">
663
+ ${renderOptionList(
664
+ mediaTableColumns
665
+ .map(column => ({
666
+ value: column.name,
667
+ label: pathCandidates.includes(column.name) ? `${column.name} // suggested` : column.name,
668
+ }))
669
+ .sort((a, b) => a.label.localeCompare(b.label)),
670
+ draft.pathColumn,
671
+ 'Select the media path column',
672
+ )}
673
+ </select>
674
+ </label>
675
+ <label class="media-tagging-field">
676
+ <span class="media-tagging-field__label">Tagged Boolean Column</span>
677
+ <select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" data-bind="media-tagging-field" data-field="taggedColumn">
678
+ ${renderOptionList(
679
+ mediaTableColumns
680
+ .map(column => ({
681
+ value: column.name,
682
+ label: booleanCandidates.includes(column.name) ? `${column.name} // suggested` : column.name,
683
+ }))
684
+ .sort((a, b) => a.label.localeCompare(b.label)),
685
+ draft.taggedColumn,
686
+ mediaTableColumns.length ? 'Select the tagged flag column' : 'No columns available',
687
+ )}
688
+ </select>
689
+ </label>
690
+ </div>
691
+
692
+ <div class="media-tagging-query-grid">
693
+ ${renderSqlTextarea({
694
+ label: 'Untagged Query',
695
+ value: draft.untaggedQuery,
696
+ dataBind: 'media-tagging-field',
697
+ dataField: 'untaggedQuery',
698
+ })}
699
+ ${renderSqlTextarea({
700
+ label: 'Tagged Query',
701
+ value: draft.taggedQuery,
702
+ dataBind: 'media-tagging-field',
703
+ dataField: 'taggedQuery',
704
+ })}
705
+ </div>
706
+
707
+ <div class="flex flex-wrap items-center gap-3">
708
+ <button
709
+ class="standard-button"
710
+ data-action="reset-media-tagging-queries"
711
+ type="button"
712
+ >
713
+ Reset Queries to Defaults
714
+ </button>
715
+ </div>
716
+ </div>
717
+ </section>
718
+ `;
719
+ }
720
+
721
+ function renderMappingSection(state) {
722
+ const candidates = state.mediaTagging.mappingCandidates ?? [];
723
+ const mappingExists = hasDefaultMediaTaggingMappingTable(state.mediaTagging.schemaTables ?? []);
724
+ const mappingCandidate =
725
+ candidates.find(candidate => candidate.tableName === MEDIA_TAGGING_DEFAULT_MAPPING_TABLE) ?? null;
726
+
727
+ return `
728
+ <section class="media-tagging-card shell-section">
729
+ <div class="media-tagging-card__header">
730
+ <div>
731
+ <div class="media-tagging-card__eyebrow">3. Mapping</div>
732
+ <h2 class="media-tagging-card__title">Join Table</h2>
733
+ </div>
734
+ <div class="flex flex-col items-end gap-3">
735
+ <span class="status-badge ${mappingExists ? 'status-badge--success' : ''}">
736
+ ${mappingExists ? 'Available' : 'Missing'}
737
+ </span>
738
+ <button
739
+ class="standard-button"
740
+ data-action="open-modal"
741
+ data-modal="create-media-tagging-mapping-table"
742
+ type="button"
743
+ >
744
+ Create Mapping Table
745
+ </button>
746
+ </div>
747
+ </div>
748
+ <div class="media-tagging-card__body media-tagging-card__body--mapping">
749
+ <article class="media-tagging-mapping-card ${mappingCandidate ? 'is-selected' : ''}">
750
+ <div class="font-headline text-sm uppercase tracking-[0.08em] text-primary-container">
751
+ ${escapeHtml(MEDIA_TAGGING_DEFAULT_MAPPING_TABLE)}
752
+ </div>
753
+ ${
754
+ mappingCandidate
755
+ ? `
756
+ <div class="mt-2 text-xs text-on-surface-variant/65">
757
+ Media FK:
758
+ ${escapeHtml(
759
+ mappingCandidate.mediaForeignKey.mappings
760
+ .map(mapping => `${mapping.from} -> ${mapping.to}`)
761
+ .join(', '),
762
+ )}
763
+ </div>
764
+ <div class="mt-1 text-xs text-on-surface-variant/65">
765
+ Tag FK:
766
+ ${escapeHtml(
767
+ mappingCandidate.tagForeignKey.mappings
768
+ .map(mapping => `${mapping.from} -> ${mapping.to}`)
769
+ .join(', '),
770
+ )}
771
+ </div>
772
+ `
773
+ : `
774
+ <div class="mt-2 text-xs text-on-surface-variant/65">
775
+ ${
776
+ mappingExists
777
+ ? `${MEDIA_TAGGING_DEFAULT_MAPPING_TABLE} exists, but it does not currently resolve as the active media-to-tag mapping.`
778
+ : `${MEDIA_TAGGING_DEFAULT_MAPPING_TABLE} is missing. Open the create flow to inspect the SQL and create it.`
779
+ }
780
+ </div>
781
+ `
782
+ }
783
+ </article>
784
+
785
+ <div class="media-tagging-card__footer">
786
+ <button
787
+ class="signature-button w-full"
788
+ data-action="save-media-tagging"
789
+ type="button"
790
+ ${state.mediaTagging.saving ? 'disabled' : ''}
791
+ >
792
+ ${state.mediaTagging.saving ? 'Saving...' : 'Save Configuration'}
793
+ </button>
794
+ </div>
795
+ </div>
796
+
797
+ </section>
798
+ `;
799
+ }
800
+
801
+ function renderWorkflowSection(state) {
802
+ const workflow = state.mediaTagging.workflow ?? null;
803
+ const selectedTagKeys = state.mediaTagging.selectedTagKeys ?? [];
804
+ const selectedTagCount = selectedTagKeys.length;
805
+ const selectableTags = (state.mediaTagging.tags ?? []).filter(tag => !tag.isParentTag);
806
+ const canWrite = Boolean(workflow?.canWrite);
807
+ const hasResetNotice = hasConfigResetNotice(state);
808
+ const detailsVisible = state.mediaTagging.workflowMediaDetailsVisible !== false;
809
+ const mediaTableName = String(state.mediaTagging.draft?.mediaTable ?? '').trim();
810
+ const status = workflow?.status ?? {
811
+ taggedCount: 0,
812
+ remainingCount: 0,
813
+ totalCount: 0,
814
+ ratioLabel: '0 / 0',
815
+ };
816
+
817
+ return `
818
+ <section class="media-tagging-card shell-section media-tagging-card--workflow">
819
+ <div class="media-tagging-card__body media-tagging-card__body--workflow">
820
+ ${
821
+ hasResetNotice
822
+ ? `
823
+ <div class="media-tagging-inline-warning">
824
+ Changing mapping or tagging settings replaces the stored workflow configuration for this database.
825
+ </div>
826
+ `
827
+ : ''
828
+ }
829
+ ${renderPreviewMedia(workflow?.currentItem ?? null, {
830
+ detailsVisible,
831
+ mediaTableName,
832
+ rotationDegrees: state.mediaTagging.workflowMediaRotationDegrees,
833
+ status,
834
+ })}
835
+ <div class="media-tagging-workflow-sidebar">
836
+ <div class="media-tagging-tag-panel">
837
+ <div class="media-tagging-tag-panel__header">
838
+ <div class="media-tagging-tag-panel__header-row">
839
+ <div>
840
+ <div class="media-tagging-field__label">Available Tags</div>
841
+ <div class="mt-2 text-xs uppercase tracking-[0.12em] text-on-surface-variant/45">
842
+ ${escapeHtml(formatNumber(selectableTags.length))} selectable tag(s)
843
+ </div>
844
+ </div>
845
+ </div>
846
+ <label class="media-tagging-field mt-4">
847
+ <span class="media-tagging-field__label">Search</span>
848
+ <input
849
+ class="control-input w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
850
+ data-bind="media-tagging-tag-search"
851
+ placeholder="Filter available tags"
852
+ type="text"
853
+ />
854
+ </label>
855
+ </div>
856
+ ${renderTagList(selectableTags, selectedTagKeys)}
857
+ <div class="media-tagging-tag-panel__footer">
858
+ <div class="media-tagging-tag-panel__footer-main">
859
+ <div class="media-tagging-tag-panel__remaining">
860
+ ${escapeHtml(formatNumber(workflow?.status?.remainingCount ?? 0))} remaining
861
+ </div>
862
+ <div class="media-tagging-tag-panel__actions">
863
+ <button
864
+ class="standard-button"
865
+ data-action="skip-media-tagging-item"
866
+ type="button"
867
+ ${workflow?.currentItem && canWrite && !state.mediaTagging.previewLoading && !state.mediaTagging.applying ? '' : 'disabled'}
868
+ >
869
+ ${state.mediaTagging.applying ? 'Saving...' : 'Skip'}
870
+ </button>
871
+ <button
872
+ class="signature-button"
873
+ data-action="apply-media-tagging"
874
+ data-can-apply="${workflow?.currentItem && canWrite && !state.mediaTagging.applying ? 'true' : 'false'}"
875
+ type="button"
876
+ ${workflow?.currentItem && canWrite && !state.mediaTagging.applying && selectedTagCount > 0 ? '' : 'disabled'}
877
+ >
878
+ ${state.mediaTagging.applying ? 'Saving...' : `${selectedTagCount} tagged & next`}
879
+ </button>
880
+ </div>
881
+ </div>
882
+ ${
883
+ workflow?.allRemainingSkipped
884
+ ? `
885
+ <button
886
+ class="standard-button"
887
+ data-action="reset-skipped-media-tagging"
888
+ type="button"
889
+ >
890
+ Show Skipped Items Again
891
+ </button>
892
+ `
893
+ : ''
894
+ }
895
+ </div>
896
+ </div>
897
+ </div>
898
+ </div>
899
+ </section>
900
+ `;
901
+ }
902
+
903
+ export function renderMediaTaggingView(state, { subView = 'setup' } = {}) {
904
+ const showSetup = subView === 'setup';
905
+ const showQueue = subView === 'queue';
906
+
907
+ return {
908
+ main: `
909
+ <section class="view-surface media-tagging-view">
910
+ <div class="media-tagging-shell">
911
+ ${renderIssueList(state)}
912
+
913
+ ${
914
+ showSetup
915
+ ? `
916
+ <div class="media-tagging-grid">
917
+ ${renderTagsSection(state)}
918
+ ${renderTaggingSection(state)}
919
+ ${renderMappingSection(state)}
920
+ </div>
921
+ `
922
+ : ''
923
+ }
924
+
925
+ ${showQueue ? renderWorkflowSection(state) : ''}
926
+ </div>
927
+ </section>
928
+ `,
929
+ panel: '',
930
+ };
931
+ }