sqlite-hub 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/changelog.md +11 -0
  2. package/frontend/index.html +1 -1
  3. package/frontend/js/api.js +67 -0
  4. package/frontend/js/app.js +1398 -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 +20 -10
  8. package/frontend/js/components/queryHistoryPanel.js +4 -3
  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 +3091 -2184
  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 +861 -0
  20. package/frontend/styles/base.css +11 -1
  21. package/frontend/styles/components.css +48 -6
  22. package/frontend/styles/views.css +796 -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 +321 -2
@@ -0,0 +1,861 @@
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(currentItem, { detailsVisible = true, mediaTableName = '' } = {}) {
330
+ if (!currentItem) {
331
+ return `
332
+ <div class="media-tagging-preview__empty">
333
+ <span class="material-symbols-outlined text-4xl">photo_size_select_large</span>
334
+ <div class="mt-3 font-headline text-lg uppercase tracking-[0.06em] text-primary-container">
335
+ Queue Empty
336
+ </div>
337
+ <div class="mt-2 text-sm text-on-surface-variant/55">
338
+ No current media item is available with the active configuration.
339
+ </div>
340
+ </div>
341
+ `;
342
+ }
343
+
344
+ const pathValue = String(currentItem.path ?? '');
345
+ const fileName = pathValue.split(/[\\/]/).filter(Boolean).pop() || 'Audio file';
346
+ const isAudioPreview = Boolean(currentItem.previewUrl && currentItem.previewKind === 'audio');
347
+ let assetMarkup = `
348
+ <div class="media-tagging-preview__placeholder">
349
+ <span class="material-symbols-outlined text-5xl">perm_media</span>
350
+ </div>
351
+ `;
352
+
353
+ if (currentItem.previewUrl && currentItem.previewKind === 'image') {
354
+ assetMarkup = `
355
+ <img
356
+ class="media-tagging-preview__asset"
357
+ src="${escapeHtml(currentItem.previewUrl)}"
358
+ alt="${escapeHtml(pathValue || 'Current media item')}"
359
+ />
360
+ `;
361
+ } else if (currentItem.previewUrl && currentItem.previewKind === 'video') {
362
+ assetMarkup = `
363
+ <video class="media-tagging-preview__asset" controls preload="metadata" src="${escapeHtml(
364
+ currentItem.previewUrl,
365
+ )}"></video>
366
+ `;
367
+ } else if (currentItem.previewUrl && currentItem.previewKind === 'audio') {
368
+ assetMarkup = `
369
+ <div class="media-tagging-audio-preview">
370
+ <div class="media-tagging-audio-preview__icon">
371
+ <span class="material-symbols-outlined">audio_file</span>
372
+ </div>
373
+ <div class="media-tagging-audio-preview__content">
374
+ <div class="media-tagging-audio-preview__eyebrow">Audio Preview</div>
375
+ <div class="media-tagging-audio-preview__title" title="${escapeHtml(fileName)}">
376
+ ${escapeHtml(truncateMiddle(fileName, 56))}
377
+ </div>
378
+ <audio
379
+ class="media-tagging-audio-preview__player"
380
+ controls
381
+ preload="metadata"
382
+ src="${escapeHtml(currentItem.previewUrl)}"
383
+ ></audio>
384
+ </div>
385
+ </div>
386
+ `;
387
+ }
388
+
389
+ const metadata = getWorkflowMetadataEntries(currentItem);
390
+ const toggleLabel = detailsVisible
391
+ ? '<span class="material-symbols-outlined">visibility_off</span> Shrink Media Viewer'
392
+ : 'Show Media Viewer';
393
+
394
+ return `
395
+ <div class="media-tagging-preview ${detailsVisible ? '' : 'media-tagging-preview--meta-hidden'}">
396
+ <div class="media-tagging-preview__media${isAudioPreview ? ' media-tagging-preview__media--audio' : ''}">
397
+ <div class="media-tagging-preview__media-toolbar">
398
+ <button
399
+ class="standard-button media-tagging-preview__toggle"
400
+ data-action="toggle-media-tagging-current-media"
401
+ data-next-value="${detailsVisible ? 'false' : 'true'}"
402
+ data-expanded-label="Shrink Media Viewer"
403
+ data-collapsed-label="Show Media Viewer"
404
+ aria-expanded="${detailsVisible ? 'true' : 'false'}"
405
+ type="button"
406
+ >
407
+ ${toggleLabel}
408
+ </button>
409
+ </div>
410
+ ${assetMarkup}
411
+ </div>
412
+ <div class="media-tagging-preview__meta">
413
+ <div class="media-tagging-preview__meta-header">
414
+ <div class="media-tagging-preview__eyebrow">Current Media</div>
415
+ ${
416
+ mediaTableName && currentItem.identity
417
+ ? `
418
+ <button
419
+ class="standard-button media-tagging-preview__meta-action"
420
+ data-action="open-media-tagging-current-in-data"
421
+ type="button"
422
+ >
423
+ Open In Data
424
+ </button>
425
+ `
426
+ : ''
427
+ }
428
+ </div>
429
+ ${
430
+ metadata.length
431
+ ? `
432
+ <div class="media-tagging-preview__metadata">
433
+ ${metadata
434
+ .map(
435
+ ([key, value]) => `
436
+ <div class="media-tagging-preview__metadata-row">
437
+ <span>${escapeHtml(key)}</span>
438
+ <span title="${escapeHtml(formatCellValue(value))}">
439
+ ${escapeHtml(formatCellValue(value))}
440
+ </span>
441
+ </div>
442
+ `,
443
+ )
444
+ .join('')}
445
+ </div>
446
+ `
447
+ : ''
448
+ }
449
+ </div>
450
+ </div>
451
+ `;
452
+ }
453
+
454
+ function renderTagsSection(state) {
455
+ const tagColumns = state.mediaTagging.tagTableColumns ?? [];
456
+ const tagFormValues = state.mediaTagging.tagFormValues ?? {};
457
+ const tags = state.mediaTagging.tags ?? [];
458
+ const tagTableExists = hasDefaultMediaTaggingTagTable(state.mediaTagging.schemaTables ?? []);
459
+ const parentToggleColumn = tagColumns.find(column => column.uiRole === 'parent-toggle') ?? null;
460
+ const parentSelectColumn = tagColumns.find(column => column.uiRole === 'parent-select') ?? null;
461
+ const standardTagColumns = tagColumns.filter(column => !column.uiRole);
462
+ const parentTagOptions = tags.filter(tag => tag.isParentTag);
463
+
464
+ return `
465
+ <section class="media-tagging-card shell-section">
466
+ <div class="media-tagging-card__header">
467
+ <div>
468
+ <div class="media-tagging-card__eyebrow">1. Tags</div>
469
+ <h2 class="media-tagging-card__title">Tag Table</h2>
470
+ </div>
471
+ <div class="flex flex-col items-end gap-3">
472
+ <span class="status-badge ${tagTableExists ? 'status-badge--success' : ''}">
473
+ ${tagTableExists ? 'Available' : 'Missing'}
474
+ </span>
475
+ <button
476
+ class="standard-button"
477
+ data-action="open-modal"
478
+ data-modal="create-media-tagging-tag-table"
479
+ type="button"
480
+ >
481
+ Create Tag Table
482
+ </button>
483
+ </div>
484
+ </div>
485
+ <div class="media-tagging-card__body">
486
+ ${
487
+ tagColumns.length
488
+ ? `
489
+ <div class="media-tagging-form-grid">
490
+ ${standardTagColumns.map(column => renderTagFormField(column, tagFormValues[column.name])).join('')}
491
+ ${renderParentTagFields({
492
+ parentToggleColumn,
493
+ parentSelectColumn,
494
+ tagFormValues,
495
+ parentTagOptions,
496
+ })}
497
+ </div>
498
+ <div class="flex flex-wrap items-center gap-3">
499
+ <button
500
+ class="standard-button"
501
+ data-action="create-media-tag"
502
+ type="button"
503
+ ${state.mediaTagging.creatingTag || state.mediaTagging.connection?.readOnly ? 'disabled' : ''}
504
+ ><span class="material-symbols-outlined">sell</span>
505
+ ${state.mediaTagging.creatingTag ? 'Saving...' : 'Create Tag'}
506
+ </button>
507
+ <div class="text-[11px] font-mono uppercase tracking-[0.14em] text-on-surface-variant/45">
508
+ ${escapeHtml(formatNumber(tags.length))} existing tag(s)
509
+ </div>
510
+ </div>
511
+ <div class="media-tagging-created-tags-shell">
512
+ <div class="media-tagging-field__label">Created Tags</div>
513
+ <div class="media-tagging-created-tags-frame mt-3">
514
+ ${renderCreatedTagList(tags, {
515
+ canRemove: !state.mediaTagging.connection?.readOnly,
516
+ removingTagKey: state.mediaTagging.removingTagKey,
517
+ })}
518
+ </div>
519
+ <div class="mt-3">
520
+ <button
521
+ class="standard-button"
522
+ data-action="copy-media-tags"
523
+ type="button"
524
+ ${tags.length ? '' : 'disabled'}
525
+ >
526
+ <span class="material-symbols-outlined text-sm">content_copy</span>
527
+ Copy Tags to clipboard
528
+ </button>
529
+ </div>
530
+ </div>
531
+ `
532
+ : `
533
+ <div class="text-sm text-on-surface-variant/55">
534
+ ${escapeHtml(MEDIA_TAGGING_DEFAULT_TAG_TABLE)} must exist to generate the tag creation form.
535
+ </div>
536
+ `
537
+ }
538
+ </div>
539
+ </section>
540
+ `;
541
+ }
542
+
543
+ function renderTaggingSection(state) {
544
+ const tables = state.mediaTagging.schemaTables ?? [];
545
+ const draft = normalizeDraft(state.mediaTagging.draft ?? {});
546
+ const mediaTableColumns = state.mediaTagging.mediaTableColumns ?? [];
547
+ const pathCandidates = state.mediaTagging.pathCandidates ?? [];
548
+ const booleanCandidates = state.mediaTagging.booleanCandidates ?? [];
549
+
550
+ return `
551
+ <section class="media-tagging-card shell-section">
552
+ <div class="media-tagging-card__header">
553
+ <div>
554
+ <div class="media-tagging-card__eyebrow">2. Tagging</div>
555
+ <h2 class="media-tagging-card__title">Media Source</h2>
556
+ </div>
557
+ </div>
558
+ <div class="media-tagging-card__body">
559
+ <div class="media-tagging-form-grid">
560
+ <label class="media-tagging-field">
561
+ <span class="media-tagging-field__label">Media Table</span>
562
+ <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">
563
+ ${renderOptionList(
564
+ tables
565
+ .map(table => ({ value: table.name, label: table.name }))
566
+ .sort((a, b) => a.label.localeCompare(b.label)),
567
+ draft.mediaTable,
568
+ 'Select a media table',
569
+ )}
570
+ </select>
571
+ </label>
572
+ <div></div>
573
+ <label class="media-tagging-field">
574
+ <span class="media-tagging-field__label">Path Column</span>
575
+ <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">
576
+ ${renderOptionList(
577
+ mediaTableColumns
578
+ .map(column => ({
579
+ value: column.name,
580
+ label: pathCandidates.includes(column.name) ? `${column.name} // suggested` : column.name,
581
+ }))
582
+ .sort((a, b) => a.label.localeCompare(b.label)),
583
+ draft.pathColumn,
584
+ 'Select the media path column',
585
+ )}
586
+ </select>
587
+ </label>
588
+ <label class="media-tagging-field">
589
+ <span class="media-tagging-field__label">Tagged Boolean Column</span>
590
+ <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">
591
+ ${renderOptionList(
592
+ mediaTableColumns
593
+ .map(column => ({
594
+ value: column.name,
595
+ label: booleanCandidates.includes(column.name) ? `${column.name} // suggested` : column.name,
596
+ }))
597
+ .sort((a, b) => a.label.localeCompare(b.label)),
598
+ draft.taggedColumn,
599
+ mediaTableColumns.length ? 'Select the tagged flag column' : 'No columns available',
600
+ )}
601
+ </select>
602
+ </label>
603
+ </div>
604
+
605
+ <div class="media-tagging-query-grid">
606
+ ${renderSqlTextarea({
607
+ label: 'Untagged Query',
608
+ value: draft.untaggedQuery,
609
+ dataBind: 'media-tagging-field',
610
+ dataField: 'untaggedQuery',
611
+ })}
612
+ ${renderSqlTextarea({
613
+ label: 'Tagged Query',
614
+ value: draft.taggedQuery,
615
+ dataBind: 'media-tagging-field',
616
+ dataField: 'taggedQuery',
617
+ })}
618
+ </div>
619
+
620
+ <div class="flex flex-wrap items-center gap-3">
621
+ <button
622
+ class="standard-button"
623
+ data-action="reset-media-tagging-queries"
624
+ type="button"
625
+ >
626
+ Reset Queries to Defaults
627
+ </button>
628
+ </div>
629
+ </div>
630
+ </section>
631
+ `;
632
+ }
633
+
634
+ function renderMappingSection(state) {
635
+ const candidates = state.mediaTagging.mappingCandidates ?? [];
636
+ const mappingExists = hasDefaultMediaTaggingMappingTable(state.mediaTagging.schemaTables ?? []);
637
+ const mappingCandidate =
638
+ candidates.find(candidate => candidate.tableName === MEDIA_TAGGING_DEFAULT_MAPPING_TABLE) ?? null;
639
+
640
+ return `
641
+ <section class="media-tagging-card shell-section">
642
+ <div class="media-tagging-card__header">
643
+ <div>
644
+ <div class="media-tagging-card__eyebrow">3. Mapping</div>
645
+ <h2 class="media-tagging-card__title">Join Table</h2>
646
+ </div>
647
+ <div class="flex flex-col items-end gap-3">
648
+ <span class="status-badge ${mappingExists ? 'status-badge--success' : ''}">
649
+ ${mappingExists ? 'Available' : 'Missing'}
650
+ </span>
651
+ <button
652
+ class="standard-button"
653
+ data-action="open-modal"
654
+ data-modal="create-media-tagging-mapping-table"
655
+ type="button"
656
+ >
657
+ Create Mapping Table
658
+ </button>
659
+ </div>
660
+ </div>
661
+ <div class="media-tagging-card__body media-tagging-card__body--mapping">
662
+ <article class="media-tagging-mapping-card ${mappingCandidate ? 'is-selected' : ''}">
663
+ <div class="font-headline text-sm uppercase tracking-[0.08em] text-primary-container">
664
+ ${escapeHtml(MEDIA_TAGGING_DEFAULT_MAPPING_TABLE)}
665
+ </div>
666
+ ${
667
+ mappingCandidate
668
+ ? `
669
+ <div class="mt-2 text-xs text-on-surface-variant/65">
670
+ Media FK:
671
+ ${escapeHtml(
672
+ mappingCandidate.mediaForeignKey.mappings
673
+ .map(mapping => `${mapping.from} -> ${mapping.to}`)
674
+ .join(', '),
675
+ )}
676
+ </div>
677
+ <div class="mt-1 text-xs text-on-surface-variant/65">
678
+ Tag FK:
679
+ ${escapeHtml(
680
+ mappingCandidate.tagForeignKey.mappings
681
+ .map(mapping => `${mapping.from} -> ${mapping.to}`)
682
+ .join(', '),
683
+ )}
684
+ </div>
685
+ `
686
+ : `
687
+ <div class="mt-2 text-xs text-on-surface-variant/65">
688
+ ${
689
+ mappingExists
690
+ ? `${MEDIA_TAGGING_DEFAULT_MAPPING_TABLE} exists, but it does not currently resolve as the active media-to-tag mapping.`
691
+ : `${MEDIA_TAGGING_DEFAULT_MAPPING_TABLE} is missing. Open the create flow to inspect the SQL and create it.`
692
+ }
693
+ </div>
694
+ `
695
+ }
696
+ </article>
697
+
698
+ <div class="media-tagging-card__footer">
699
+ <button
700
+ class="signature-button w-full"
701
+ data-action="save-media-tagging"
702
+ type="button"
703
+ ${state.mediaTagging.saving ? 'disabled' : ''}
704
+ >
705
+ ${state.mediaTagging.saving ? 'Saving...' : 'Save Configuration'}
706
+ </button>
707
+ </div>
708
+ </div>
709
+
710
+ </section>
711
+ `;
712
+ }
713
+
714
+ function renderWorkflowSection(state) {
715
+ const workflow = state.mediaTagging.workflow ?? null;
716
+ const selectedTagKeys = state.mediaTagging.selectedTagKeys ?? [];
717
+ const selectedTagCount = selectedTagKeys.length;
718
+ const selectableTags = (state.mediaTagging.tags ?? []).filter(tag => !tag.isParentTag);
719
+ const canWrite = Boolean(workflow?.canWrite);
720
+ const hasResetNotice = hasConfigResetNotice(state);
721
+ const detailsVisible = state.mediaTagging.workflowMediaDetailsVisible !== false;
722
+ const mediaTableName = String(state.mediaTagging.draft?.mediaTable ?? '').trim();
723
+ const status = workflow?.status ?? {
724
+ taggedCount: 0,
725
+ remainingCount: 0,
726
+ totalCount: 0,
727
+ ratioLabel: '0 / 0',
728
+ };
729
+
730
+ return `
731
+ <section class="media-tagging-card shell-section media-tagging-card--workflow">
732
+ <div class="media-tagging-card__body media-tagging-card__body--workflow">
733
+ ${
734
+ hasResetNotice
735
+ ? `
736
+ <div class="media-tagging-inline-warning">
737
+ Changing mapping or tagging settings replaces the stored workflow configuration for this database.
738
+ </div>
739
+ `
740
+ : ''
741
+ }
742
+ ${renderPreviewMedia(workflow?.currentItem ?? null, { detailsVisible, mediaTableName })}
743
+ <div class="media-tagging-workflow-sidebar">
744
+ <div class="media-tagging-tag-panel">
745
+ <div class="media-tagging-tag-panel__header">
746
+ <div class="media-tagging-tag-panel__header-row">
747
+ <div>
748
+ <div class="media-tagging-field__label">Available Tags</div>
749
+ <div class="mt-2 text-xs uppercase tracking-[0.12em] text-on-surface-variant/45">
750
+ ${escapeHtml(formatNumber(selectableTags.length))} selectable tag(s)
751
+ </div>
752
+ </div>
753
+ <div class="media-tagging-status media-tagging-status--compact">
754
+ <div class="media-tagging-status__value">${escapeHtml(status.ratioLabel)}</div>
755
+ <div class="media-tagging-status__label">
756
+ tagged / total
757
+ </div>
758
+ </div>
759
+ </div>
760
+ <label class="media-tagging-field mt-4">
761
+ <span class="media-tagging-field__label">Search</span>
762
+ <input
763
+ 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"
764
+ data-bind="media-tagging-tag-search"
765
+ placeholder="Filter available tags"
766
+ type="text"
767
+ />
768
+ </label>
769
+ </div>
770
+ ${renderTagList(selectableTags, selectedTagKeys)}
771
+ <div class="media-tagging-tag-panel__footer">
772
+ <div class="flex flex-wrap items-center self-end gap-3">
773
+ <button
774
+ class="standard-button"
775
+ data-action="skip-media-tagging-item"
776
+ type="button"
777
+ ${workflow?.currentItem && canWrite && !state.mediaTagging.previewLoading && !state.mediaTagging.applying ? '' : 'disabled'}
778
+ >
779
+ ${state.mediaTagging.applying ? 'Saving...' : 'Skip'}
780
+ </button>
781
+ <button
782
+ class="signature-button"
783
+ data-action="apply-media-tagging"
784
+ data-can-apply="${workflow?.currentItem && canWrite && !state.mediaTagging.applying ? 'true' : 'false'}"
785
+ type="button"
786
+ ${workflow?.currentItem && canWrite && !state.mediaTagging.applying && selectedTagCount > 0 ? '' : 'disabled'}
787
+ >
788
+ ${state.mediaTagging.applying ? 'Saving...' : `${selectedTagCount} tagged & next`}
789
+ </button>
790
+ </div>
791
+ ${
792
+ workflow?.allRemainingSkipped
793
+ ? `
794
+ <button
795
+ class="standard-button"
796
+ data-action="reset-skipped-media-tagging"
797
+ type="button"
798
+ >
799
+ Show Skipped Items Again
800
+ </button>
801
+ `
802
+ : ''
803
+ }
804
+ <div class="text-[11px] font-mono uppercase tracking-[0.14em] text-on-surface-variant/45">
805
+ ${escapeHtml(formatNumber(workflow?.status?.remainingCount ?? 0))} remaining
806
+ </div>
807
+ </div>
808
+ </div>
809
+ </div>
810
+ </div>
811
+ </section>
812
+ `;
813
+ }
814
+
815
+ export function renderMediaTaggingView(state, { subView = 'setup' } = {}) {
816
+ const showSetup = subView === 'setup';
817
+ const showQueue = subView === 'queue';
818
+
819
+ return {
820
+ main: `
821
+ <section class="view-surface media-tagging-view">
822
+ <div class="media-tagging-shell">
823
+ <header class="media-tagging-header">
824
+ <div class="media-tagging-header__copy">
825
+ <div class="text-[10px] font-bold uppercase tracking-[0.22em] text-primary-container">
826
+ Media Tagging
827
+ </div>
828
+ <h1 class="mt-3 font-headline text-4xl font-black uppercase tracking-tight text-primary-container">
829
+ ${showSetup ? 'Setup' : 'Tagging Queue'}
830
+ </h1>
831
+ <p class="mt-3 max-w-3xl text-sm leading-7 text-on-surface-variant/65">
832
+ ${
833
+ showSetup
834
+ ? 'Configure tags, media queries, and mapping once per database.'
835
+ : 'Review the next untagged media item, select tags, skip it, or mark it as tagged.'
836
+ }
837
+ </p>
838
+ </div>
839
+ </header>
840
+
841
+ ${renderIssueList(state)}
842
+
843
+ ${
844
+ showSetup
845
+ ? `
846
+ <div class="media-tagging-grid">
847
+ ${renderTagsSection(state)}
848
+ ${renderTaggingSection(state)}
849
+ ${renderMappingSection(state)}
850
+ </div>
851
+ `
852
+ : ''
853
+ }
854
+
855
+ ${showQueue ? renderWorkflowSection(state) : ''}
856
+ </div>
857
+ </section>
858
+ `,
859
+ panel: '',
860
+ };
861
+ }