sqlite-hub 0.9.0 → 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.
package/changelog.md CHANGED
@@ -1,3 +1,8 @@
1
+ # v0.9.1
2
+
3
+ - Improvements in tagging queue
4
+ - rotate right, rotate left
5
+
1
6
  # v0.9.0
2
7
 
3
8
  - tagging view
@@ -87,6 +87,7 @@ import {
87
87
  toggleChartsResultsPanel,
88
88
  toggleChartsSqlPanel,
89
89
  setMediaTaggingWorkflowMediaDetailsVisible,
90
+ setMediaTaggingWorkflowMediaRotationDegrees,
90
91
  queueTableDesignerCsvImport,
91
92
  showToast,
92
93
  submitCreateConnection,
@@ -137,6 +138,7 @@ const shellRefs = {
137
138
  toast: document.querySelector('#toast-root'),
138
139
  };
139
140
  let lastRenderedRoutePath = null;
141
+ let lastRenderedRouteName = null;
140
142
  let lastRenderedTopNavMarkup = '';
141
143
  let lastRenderedSidebarMarkup = '';
142
144
  let lastRenderedStatusBarMarkup = '';
@@ -148,11 +150,16 @@ let lastRenderedPanelOpen = false;
148
150
  let lastRenderedLockedRoute = false;
149
151
  let pendingNewTableDesignerAutofocus = false;
150
152
  let pendingQueryEditorFocus = false;
153
+ let pendingMediaTaggingTagSearchFocus = false;
151
154
 
152
155
  function invalidateMainRenderCache() {
153
156
  lastRenderedMainMarkup = null;
154
157
  }
155
158
 
159
+ function isMediaTaggingRouteName(routeName) {
160
+ return routeName === 'mediaTaggingSetup' || routeName === 'mediaTaggingQueue';
161
+ }
162
+
156
163
  function resetStructureGraphForDatabaseChange() {
157
164
  resetPersistedStructureGraphState();
158
165
  }
@@ -258,6 +265,45 @@ function syncMediaTaggingCurrentMediaUi(button, detailsVisible) {
258
265
  return true;
259
266
  }
260
267
 
268
+ function normalizeMediaTaggingRotationDegrees(value) {
269
+ const numericValue = Number(value);
270
+
271
+ if (!Number.isFinite(numericValue)) {
272
+ return 0;
273
+ }
274
+
275
+ return ((Math.round(numericValue / 90) * 90) % 360 + 360) % 360;
276
+ }
277
+
278
+ function syncMediaTaggingMediaRotationUi(node, rotationDegrees) {
279
+ if (!(node instanceof HTMLElement)) {
280
+ return false;
281
+ }
282
+
283
+ const preview = node.closest('.media-tagging-preview');
284
+ const target = preview?.querySelector('[data-media-tagging-rotation-target]');
285
+
286
+ if (!(preview instanceof HTMLElement) || !(target instanceof HTMLElement)) {
287
+ return false;
288
+ }
289
+
290
+ const normalizedRotation = normalizeMediaTaggingRotationDegrees(rotationDegrees);
291
+
292
+ target.style.setProperty('--media-tagging-preview-rotation', `${normalizedRotation}deg`);
293
+ target.dataset.rotationDegrees = String(normalizedRotation);
294
+ target.classList.toggle('is-rotated-quarter', normalizedRotation === 90 || normalizedRotation === 270);
295
+
296
+ const resetButton = preview.querySelector(
297
+ '[data-action="rotate-media-tagging-current-media"][data-rotation-command="reset"]',
298
+ );
299
+
300
+ if (resetButton instanceof HTMLButtonElement) {
301
+ resetButton.disabled = normalizedRotation === 0;
302
+ }
303
+
304
+ return true;
305
+ }
306
+
261
307
  function syncMediaTaggingTagSearchUi(input) {
262
308
  if (!(input instanceof HTMLInputElement)) {
263
309
  return false;
@@ -615,6 +661,52 @@ function focusQueryEditorInput() {
615
661
  return true;
616
662
  }
617
663
 
664
+ function focusMediaTaggingTagSearchInput() {
665
+ const input = document.querySelector('[data-bind="media-tagging-tag-search"]');
666
+
667
+ if (!(input instanceof HTMLInputElement)) {
668
+ return false;
669
+ }
670
+
671
+ input.focus({ preventScroll: true });
672
+ input.setSelectionRange(input.value.length, input.value.length);
673
+ return true;
674
+ }
675
+
676
+ function syncSidebarActiveRoute(routeName) {
677
+ if (!isMediaTaggingRouteName(routeName)) {
678
+ return false;
679
+ }
680
+
681
+ const mediaTaggingLink = shellRefs.sidebar.querySelector('a.sidebar-link[data-group="mediaTagging"]');
682
+ const setupLink = shellRefs.sidebar.querySelector('a.sidebar-sublink[href="#/media-tagging"]');
683
+ const queueLink = shellRefs.sidebar.querySelector('a.sidebar-sublink[href="#/media-tagging/queue"]');
684
+
685
+ if (
686
+ !(mediaTaggingLink instanceof HTMLAnchorElement) ||
687
+ !(setupLink instanceof HTMLAnchorElement) ||
688
+ !(queueLink instanceof HTMLAnchorElement)
689
+ ) {
690
+ return false;
691
+ }
692
+
693
+ mediaTaggingLink.classList.add('is-active');
694
+ setupLink.classList.toggle('is-active', routeName === 'mediaTaggingSetup');
695
+ queueLink.classList.toggle('is-active', routeName === 'mediaTaggingQueue');
696
+ return true;
697
+ }
698
+
699
+ async function applyMediaTaggingAndFocusSearch() {
700
+ pendingMediaTaggingTagSearchFocus = true;
701
+ const result = await applyCurrentMediaTaggingSelection();
702
+
703
+ if (!result) {
704
+ pendingMediaTaggingTagSearchFocus = false;
705
+ }
706
+
707
+ return result;
708
+ }
709
+
618
710
  async function handleTableDesignerCsvImport(fileInput) {
619
711
  if (!(fileInput instanceof HTMLInputElement)) {
620
712
  return;
@@ -656,6 +748,7 @@ async function handleTableDesignerCsvImport(fileInput) {
656
748
 
657
749
  function renderApp(state) {
658
750
  const previousRoutePath = lastRenderedRoutePath;
751
+ const previousRouteName = lastRenderedRouteName;
659
752
  const { main, panel } = resolveView(state);
660
753
  const topNavMarkup = renderTopNav(state);
661
754
  const sidebarMarkup = renderSidebar(state);
@@ -722,7 +815,14 @@ function renderApp(state) {
722
815
  }
723
816
 
724
817
  if (sidebarChanged) {
725
- shellRefs.sidebar.innerHTML = sidebarMarkup;
818
+ const sidebarSynced =
819
+ isMediaTaggingRouteName(previousRouteName) &&
820
+ isMediaTaggingRouteName(state.route.name) &&
821
+ syncSidebarActiveRoute(state.route.name);
822
+
823
+ if (!sidebarSynced) {
824
+ shellRefs.sidebar.innerHTML = sidebarMarkup;
825
+ }
726
826
  }
727
827
 
728
828
  if (statusBarChanged) {
@@ -757,6 +857,10 @@ function renderApp(state) {
757
857
  if (focusQueryEditorInput()) {
758
858
  pendingQueryEditorFocus = false;
759
859
  }
860
+ } else if (pendingMediaTaggingTagSearchFocus && state.route.name === 'mediaTaggingQueue') {
861
+ if (focusMediaTaggingTagSearchInput()) {
862
+ pendingMediaTaggingTagSearchFocus = false;
863
+ }
760
864
  } else if (
761
865
  pendingNewTableDesignerAutofocus &&
762
866
  state.route.name === 'tableDesigner' &&
@@ -771,6 +875,7 @@ function renderApp(state) {
771
875
  }
772
876
 
773
877
  lastRenderedRoutePath = state.route.path;
878
+ lastRenderedRouteName = state.route.name;
774
879
  lastRenderedTopNavMarkup = topNavMarkup;
775
880
  lastRenderedSidebarMarkup = sidebarMarkup;
776
881
  lastRenderedStatusBarMarkup = statusBarMarkup;
@@ -1071,7 +1176,7 @@ async function handleAction(actionNode) {
1071
1176
  await resetSkippedMediaTaggingItems();
1072
1177
  return;
1073
1178
  case 'apply-media-tagging':
1074
- await applyCurrentMediaTaggingSelection();
1179
+ await applyMediaTaggingAndFocusSearch();
1075
1180
  return;
1076
1181
  case 'toggle-media-tagging-current-media':
1077
1182
  if (actionNode instanceof HTMLButtonElement) {
@@ -1080,6 +1185,20 @@ async function handleAction(actionNode) {
1080
1185
  setMediaTaggingWorkflowMediaDetailsVisible(nextValue, { notify: false });
1081
1186
  }
1082
1187
  return;
1188
+ case 'rotate-media-tagging-current-media': {
1189
+ const currentRotation = getState().mediaTagging.workflowMediaRotationDegrees ?? 0;
1190
+ const command = actionNode.dataset.rotationCommand;
1191
+ const nextRotation =
1192
+ command === 'left'
1193
+ ? currentRotation - 90
1194
+ : command === 'right'
1195
+ ? currentRotation + 90
1196
+ : 0;
1197
+
1198
+ syncMediaTaggingMediaRotationUi(actionNode, nextRotation);
1199
+ setMediaTaggingWorkflowMediaRotationDegrees(nextRotation, { notify: false });
1200
+ return;
1201
+ }
1083
1202
  case 'open-media-tagging-current-in-data': {
1084
1203
  const currentState = getState();
1085
1204
  const mediaTableName = currentState.mediaTagging.draft?.mediaTable ?? '';
@@ -1093,6 +1212,17 @@ async function handleAction(actionNode) {
1093
1212
  router.navigate(`/data/${encodeURIComponent(mediaTableName)}`);
1094
1213
  return;
1095
1214
  }
1215
+ case 'open-media-tagging-current-in-structure': {
1216
+ const mediaTableName = String(getState().mediaTagging.draft?.mediaTable ?? '').trim();
1217
+
1218
+ if (!mediaTableName) {
1219
+ showToast('The current media table could not be opened in Structure.', 'alert');
1220
+ return;
1221
+ }
1222
+
1223
+ router.navigate(`/structure/${encodeURIComponent(mediaTableName)}`);
1224
+ return;
1225
+ }
1096
1226
  case 'import-table-designer-csv': {
1097
1227
  const fileInput = document.querySelector('[data-bind="table-designer-import-file"]');
1098
1228
 
@@ -1149,6 +1279,19 @@ async function handleAction(actionNode) {
1149
1279
  }
1150
1280
  }
1151
1281
 
1282
+ function isMediaTaggingMenuActive(routeName) {
1283
+ return routeName === 'mediaTaggingSetup' || routeName === 'mediaTaggingQueue';
1284
+ }
1285
+
1286
+ function canApplyMediaTaggingShortcut(state) {
1287
+ return (
1288
+ isMediaTaggingMenuActive(state.route.name) &&
1289
+ Boolean(state.mediaTagging.workflow?.currentItem) &&
1290
+ !state.mediaTagging.applying &&
1291
+ (state.mediaTagging.selectedTagKeys?.length ?? 0) > 0
1292
+ );
1293
+ }
1294
+
1152
1295
  document.addEventListener('click', event => {
1153
1296
  const actionNode = event.target.closest('[data-action]');
1154
1297
 
@@ -1161,6 +1304,7 @@ document.addEventListener('click', event => {
1161
1304
 
1162
1305
  document.addEventListener('keydown', event => {
1163
1306
  const target = event.target;
1307
+ const state = getState();
1164
1308
 
1165
1309
  // Handle Enter key in tag form fields to trigger create tag
1166
1310
  if (
@@ -1181,6 +1325,20 @@ document.addEventListener('keydown', event => {
1181
1325
  return;
1182
1326
  }
1183
1327
 
1328
+ if (
1329
+ (event.key === 'Enter' || event.code === 'Enter' || event.code === 'NumpadEnter') &&
1330
+ event.shiftKey &&
1331
+ !event.altKey &&
1332
+ !event.ctrlKey &&
1333
+ !event.metaKey &&
1334
+ !event.defaultPrevented &&
1335
+ canApplyMediaTaggingShortcut(state)
1336
+ ) {
1337
+ event.preventDefault();
1338
+ void applyMediaTaggingAndFocusSearch();
1339
+ return;
1340
+ }
1341
+
1184
1342
  if (
1185
1343
  event.key === 'Enter' &&
1186
1344
  event.shiftKey &&
@@ -1204,8 +1362,6 @@ document.addEventListener('keydown', event => {
1204
1362
  return;
1205
1363
  }
1206
1364
 
1207
- const state = getState();
1208
-
1209
1365
  if (state.modal) {
1210
1366
  event.preventDefault();
1211
1367
  closeModal();
@@ -23,6 +23,18 @@ function canOpenQueryHistoryInCharts(item) {
23
23
  return Boolean(item?.chartsEligible);
24
24
  }
25
25
 
26
+ function getQueryTypeTone(queryType) {
27
+ if (queryType === "select" || queryType === "update") {
28
+ return "success";
29
+ }
30
+
31
+ if (queryType === "pragma") {
32
+ return "primary";
33
+ }
34
+
35
+ return "muted";
36
+ }
37
+
26
38
  function renderRunItem(run) {
27
39
  return `
28
40
  <div class="border border-outline-variant/10 bg-surface-container px-3 py-3">
@@ -135,9 +147,9 @@ export function renderQueryHistoryDetail({
135
147
  </button>
136
148
  </div>
137
149
  <div class="mt-4 flex flex-wrap gap-2">
138
- ${renderStatusBadge(item.queryType, item.isDestructive ? "alert" : "primary")}
150
+ ${renderStatusBadge(item.queryType, getQueryTypeTone(item.queryType))}
139
151
  ${item.isSaved ? renderStatusBadge("saved", "primary") : ""}
140
- ${item.isDestructive ? renderStatusBadge("destructive", "alert") : ""}
152
+ ${item.isDestructive ? renderStatusBadge("destructive", "warning") : ""}
141
153
  ${item.lastRun ? renderStatusBadge(item.lastRun.status, item.lastRun.status === "error" ? "alert" : "success") : ""}
142
154
  </div>
143
155
  </div>
@@ -4,12 +4,8 @@ import {
4
4
  } from "../utils/format.js";
5
5
  import { renderStatusBadge } from "./badges.js";
6
6
 
7
- function getQueryTypeTone(queryType, isDestructive) {
8
- if (isDestructive) {
9
- return "alert";
10
- }
11
-
12
- if (queryType === "select") {
7
+ function getQueryTypeTone(queryType) {
8
+ if (queryType === "select" || queryType === "update") {
13
9
  return "success";
14
10
  }
15
11
 
@@ -39,9 +35,9 @@ export function renderQueryHistoryListItem(item, activeHistoryId, selectedHistor
39
35
  <span class="truncate font-headline text-sm font-bold uppercase tracking-tight text-on-surface">
40
36
  ${escapeHtml(item.displayTitle)}
41
37
  </span>
42
- ${renderStatusBadge(item.queryType, getQueryTypeTone(item.queryType, item.isDestructive))}
38
+ ${renderStatusBadge(item.queryType, getQueryTypeTone(item.queryType))}
43
39
  ${item.isSaved ? renderStatusBadge("saved", "primary") : ""}
44
- ${item.isDestructive ? renderStatusBadge("destructive", "alert") : ""}
40
+ ${item.isDestructive ? renderStatusBadge("destructive", "warning") : ""}
45
41
  </div>
46
42
  <p class="query-history-sql-preview mt-2 text-left font-mono text-xs leading-5 text-on-surface-variant/75">
47
43
  ${escapeHtml(item.previewSql)}
@@ -194,6 +194,7 @@ const state = {
194
194
  dismissedIssueKeys: [],
195
195
  selectedTagKeys: [],
196
196
  workflowMediaDetailsVisible: true,
197
+ workflowMediaRotationDegrees: 0,
197
198
  skippedMediaKeys: [],
198
199
  tagFormValues: {},
199
200
  },
@@ -348,6 +349,35 @@ function requiresActiveDatabase(routeName) {
348
349
  ].includes(routeName);
349
350
  }
350
351
 
352
+ function isMediaTaggingRouteName(routeName) {
353
+ return routeName === 'mediaTaggingSetup' || routeName === 'mediaTaggingQueue';
354
+ }
355
+
356
+ function getConnectionIdentity(connection) {
357
+ return connection?.id ?? connection?.path ?? null;
358
+ }
359
+
360
+ function hasLoadedMediaTaggingForActiveConnection() {
361
+ const activeConnectionId = getConnectionIdentity(state.connections.active);
362
+
363
+ return (
364
+ Boolean(activeConnectionId) &&
365
+ getConnectionIdentity(state.mediaTagging.connection) === activeConnectionId &&
366
+ !state.mediaTagging.loading &&
367
+ (state.mediaTagging.draft !== null || state.mediaTagging.suggestedConfig !== null)
368
+ );
369
+ }
370
+
371
+ function normalizeMediaTaggingRotationDegrees(value) {
372
+ const numericValue = Number(value);
373
+
374
+ if (!Number.isFinite(numericValue)) {
375
+ return 0;
376
+ }
377
+
378
+ return ((Math.round(numericValue / 90) * 90) % 360 + 360) % 360;
379
+ }
380
+
351
381
  function normalizeDataPageSize(value, fallback = 50) {
352
382
  const numericValue = Number(value);
353
383
 
@@ -844,6 +874,7 @@ function setMissingDatabaseState() {
844
874
  state.mediaTagging.issues = [];
845
875
  state.mediaTagging.dismissedIssueKeys = [];
846
876
  state.mediaTagging.selectedTagKeys = [];
877
+ state.mediaTagging.workflowMediaRotationDegrees = 0;
847
878
  state.mediaTagging.skippedMediaKeys = [];
848
879
  state.mediaTagging.tagFormValues = {};
849
880
  state.mediaTagging.error = error;
@@ -1579,6 +1610,9 @@ function applyMediaTaggingResponse(data, options = {}) {
1579
1610
  };
1580
1611
  state.mediaTagging.tags = data?.tags ?? [];
1581
1612
  state.mediaTagging.workflow = data?.workflow ?? null;
1613
+ if (previousCurrentKey !== nextCurrentKey) {
1614
+ state.mediaTagging.workflowMediaRotationDegrees = 0;
1615
+ }
1582
1616
  state.mediaTagging.issues = data?.issues ?? [];
1583
1617
  state.mediaTagging.error = null;
1584
1618
  syncDismissedMediaTaggingIssues();
@@ -1650,6 +1684,7 @@ async function loadMediaTagging(version) {
1650
1684
  state.mediaTagging.issues = [];
1651
1685
  state.mediaTagging.dismissedIssueKeys = [];
1652
1686
  state.mediaTagging.selectedTagKeys = [];
1687
+ state.mediaTagging.workflowMediaRotationDegrees = 0;
1653
1688
  state.mediaTagging.skippedMediaKeys = [];
1654
1689
  state.mediaTagging.tagFormValues = {};
1655
1690
  state.mediaTagging.removingTagKey = null;
@@ -1759,6 +1794,7 @@ function invalidateDatabaseCaches() {
1759
1794
  state.mediaTagging.issues = [];
1760
1795
  state.mediaTagging.dismissedIssueKeys = [];
1761
1796
  state.mediaTagging.selectedTagKeys = [];
1797
+ state.mediaTagging.workflowMediaRotationDegrees = 0;
1762
1798
  state.mediaTagging.skippedMediaKeys = [];
1763
1799
  state.mediaTagging.tagFormValues = {};
1764
1800
  }
@@ -1772,6 +1808,10 @@ async function loadRouteData(route) {
1772
1808
  return;
1773
1809
  }
1774
1810
 
1811
+ if (isMediaTaggingRouteName(route.name) && hasLoadedMediaTaggingForActiveConnection()) {
1812
+ return;
1813
+ }
1814
+
1775
1815
  const version = ++routeLoadVersion;
1776
1816
 
1777
1817
  if (route.name === 'landing' || route.name === 'connections') {
@@ -2878,6 +2918,20 @@ export function setMediaTaggingWorkflowMediaDetailsVisible(value, options = {})
2878
2918
  }
2879
2919
  }
2880
2920
 
2921
+ export function setMediaTaggingWorkflowMediaRotationDegrees(value, options = {}) {
2922
+ const nextValue = normalizeMediaTaggingRotationDegrees(value);
2923
+
2924
+ if (state.mediaTagging.workflowMediaRotationDegrees === nextValue) {
2925
+ return;
2926
+ }
2927
+
2928
+ state.mediaTagging.workflowMediaRotationDegrees = nextValue;
2929
+
2930
+ if (options.notify !== false) {
2931
+ emitChange();
2932
+ }
2933
+ }
2934
+
2881
2935
  export async function refreshMediaTaggingPreview() {
2882
2936
  return previewMediaTaggingDraft({
2883
2937
  keepSelectedTags: true,
@@ -326,7 +326,10 @@ function renderCreatedTagList(tags = [], { canRemove = false, removingTagKey = n
326
326
  `;
327
327
  }
328
328
 
329
- function renderPreviewMedia(currentItem, { detailsVisible = true, mediaTableName = '' } = {}) {
329
+ function renderPreviewMedia(
330
+ currentItem,
331
+ { detailsVisible = true, mediaTableName = '', rotationDegrees = 0, status = null } = {},
332
+ ) {
330
333
  if (!currentItem) {
331
334
  return `
332
335
  <div class="media-tagging-preview__empty">
@@ -344,6 +347,15 @@ function renderPreviewMedia(currentItem, { detailsVisible = true, mediaTableName
344
347
  const pathValue = String(currentItem.path ?? '');
345
348
  const fileName = pathValue.split(/[\\/]/).filter(Boolean).pop() || 'Audio file';
346
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' : '';
347
359
  let assetMarkup = `
348
360
  <div class="media-tagging-preview__placeholder">
349
361
  <span class="material-symbols-outlined text-5xl">perm_media</span>
@@ -353,16 +365,27 @@ function renderPreviewMedia(currentItem, { detailsVisible = true, mediaTableName
353
365
  if (currentItem.previewUrl && currentItem.previewKind === 'image') {
354
366
  assetMarkup = `
355
367
  <img
356
- class="media-tagging-preview__asset"
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)}"
357
372
  src="${escapeHtml(currentItem.previewUrl)}"
358
373
  alt="${escapeHtml(pathValue || 'Current media item')}"
359
374
  />
360
375
  `;
361
376
  } else if (currentItem.previewUrl && currentItem.previewKind === 'video') {
362
377
  assetMarkup = `
363
- <video class="media-tagging-preview__asset" controls preload="metadata" src="${escapeHtml(
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(
364
386
  currentItem.previewUrl,
365
- )}"></video>
387
+ )}"
388
+ ></video>
366
389
  `;
367
390
  } else if (currentItem.previewUrl && currentItem.previewKind === 'audio') {
368
391
  assetMarkup = `
@@ -395,36 +418,100 @@ function renderPreviewMedia(currentItem, { detailsVisible = true, mediaTableName
395
418
  <div class="media-tagging-preview ${detailsVisible ? '' : 'media-tagging-preview--meta-hidden'}">
396
419
  <div class="media-tagging-preview__media${isAudioPreview ? ' media-tagging-preview__media--audio' : ''}">
397
420
  <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>
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}
409
482
  </div>
410
- ${assetMarkup}
411
483
  </div>
412
484
  <div class="media-tagging-preview__meta">
413
485
  <div class="media-tagging-preview__meta-header">
414
486
  <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
- }
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>
428
515
  </div>
429
516
  ${
430
517
  metadata.length
@@ -548,7 +635,7 @@ function renderTaggingSection(state) {
548
635
  const booleanCandidates = state.mediaTagging.booleanCandidates ?? [];
549
636
 
550
637
  return `
551
- <section class="media-tagging-card shell-section">
638
+ <section class="media-tagging-card media-tagging-card--tagging shell-section">
552
639
  <div class="media-tagging-card__header">
553
640
  <div>
554
641
  <div class="media-tagging-card__eyebrow">2. Tagging</div>
@@ -739,7 +826,12 @@ function renderWorkflowSection(state) {
739
826
  `
740
827
  : ''
741
828
  }
742
- ${renderPreviewMedia(workflow?.currentItem ?? null, { detailsVisible, mediaTableName })}
829
+ ${renderPreviewMedia(workflow?.currentItem ?? null, {
830
+ detailsVisible,
831
+ mediaTableName,
832
+ rotationDegrees: state.mediaTagging.workflowMediaRotationDegrees,
833
+ status,
834
+ })}
743
835
  <div class="media-tagging-workflow-sidebar">
744
836
  <div class="media-tagging-tag-panel">
745
837
  <div class="media-tagging-tag-panel__header">
@@ -750,12 +842,6 @@ function renderWorkflowSection(state) {
750
842
  ${escapeHtml(formatNumber(selectableTags.length))} selectable tag(s)
751
843
  </div>
752
844
  </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
845
  </div>
760
846
  <label class="media-tagging-field mt-4">
761
847
  <span class="media-tagging-field__label">Search</span>
@@ -769,24 +855,29 @@ function renderWorkflowSection(state) {
769
855
  </div>
770
856
  ${renderTagList(selectableTags, selectedTagKeys)}
771
857
  <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>
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>
790
881
  </div>
791
882
  ${
792
883
  workflow?.allRemainingSkipped
@@ -801,9 +892,6 @@ function renderWorkflowSection(state) {
801
892
  `
802
893
  : ''
803
894
  }
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
895
  </div>
808
896
  </div>
809
897
  </div>
@@ -820,24 +908,6 @@ export function renderMediaTaggingView(state, { subView = 'setup' } = {}) {
820
908
  main: `
821
909
  <section class="view-surface media-tagging-view">
822
910
  <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
911
  ${renderIssueList(state)}
842
912
 
843
913
  ${
@@ -287,6 +287,12 @@
287
287
  color: var(--color-error);
288
288
  }
289
289
 
290
+ .status-badge--warning {
291
+ background: rgba(245, 158, 11, 0.12);
292
+ border-color: rgba(245, 158, 11, 0.28);
293
+ color: #f59e0b;
294
+ }
295
+
290
296
  .status-badge--success {
291
297
  background: rgba(0, 220, 225, 0.1);
292
298
  color: var(--color-tertiary-dim);
@@ -389,11 +389,6 @@
389
389
  padding: var(--spacing-6);
390
390
  }
391
391
 
392
- .media-tagging-header {
393
- border-bottom: 1px solid rgba(75, 71, 50, 0.18);
394
- padding-bottom: var(--spacing-4);
395
- }
396
-
397
392
  .media-tagging-grid {
398
393
  align-content: stretch;
399
394
  display: grid;
@@ -415,6 +410,7 @@
415
410
  }
416
411
 
417
412
  .media-tagging-card--workflow {
413
+ flex: 1;
418
414
  min-height: 32rem;
419
415
  }
420
416
 
@@ -602,6 +598,11 @@
602
598
  font-size: 1.25rem;
603
599
  }
604
600
 
601
+ .media-tagging-status--preview {
602
+ align-items: flex-start;
603
+ min-width: max-content;
604
+ }
605
+
605
606
  .media-tagging-mapping-list {
606
607
  display: grid;
607
608
  gap: var(--spacing-3);
@@ -641,7 +642,7 @@
641
642
  border: 1px solid rgba(75, 71, 50, 0.16);
642
643
  display: flex;
643
644
  flex-direction: column;
644
- justify-content: center;
645
+ justify-content: flex-start;
645
646
  min-height: 26rem;
646
647
  overflow: hidden;
647
648
  padding: var(--spacing-4);
@@ -653,17 +654,52 @@
653
654
  }
654
655
 
655
656
  .media-tagging-preview__media--audio .media-tagging-preview__media-toolbar {
656
- justify-content: flex-end;
657
- position: static;
657
+ justify-content: space-between;
658
658
  }
659
659
 
660
660
  .media-tagging-preview__media-toolbar {
661
+ align-items: center;
662
+ border-bottom: 1px solid rgba(75, 71, 50, 0.18);
661
663
  display: flex;
664
+ flex: 0 0 auto;
665
+ flex-wrap: wrap;
662
666
  gap: var(--spacing-3);
663
- position: absolute;
664
- right: var(--spacing-4);
665
- top: var(--spacing-4);
666
- z-index: 1;
667
+ justify-content: space-between;
668
+ margin: calc(var(--spacing-4) * -1) calc(var(--spacing-4) * -1) var(--spacing-4);
669
+ min-height: calc(var(--control-height) + var(--spacing-4));
670
+ padding: var(--spacing-2) var(--spacing-4);
671
+ width: calc(100% + (var(--spacing-4) * 2));
672
+ }
673
+
674
+ .media-tagging-preview__toolbar-actions {
675
+ display: flex;
676
+ flex-wrap: wrap;
677
+ gap: var(--spacing-3);
678
+ justify-content: flex-end;
679
+ margin-left: auto;
680
+ }
681
+
682
+ .media-tagging-preview__asset-shell {
683
+ align-items: center;
684
+ display: flex;
685
+ flex: 1;
686
+ justify-content: center;
687
+ min-height: 0;
688
+ overflow: hidden;
689
+ width: 100%;
690
+ }
691
+
692
+ .media-tagging-preview__rotation-controls {
693
+ display: flex;
694
+ gap: var(--spacing-2);
695
+ }
696
+
697
+ .media-tagging-preview__icon-button {
698
+ backdrop-filter: blur(8px);
699
+ background: rgba(20, 20, 20, 0.78);
700
+ min-width: var(--control-height);
701
+ padding: 0;
702
+ width: var(--control-height);
667
703
  }
668
704
 
669
705
  .media-tagging-preview__toggle {
@@ -674,10 +710,19 @@
674
710
  .media-tagging-preview__asset {
675
711
  flex: 1;
676
712
  height: 100%;
713
+ min-height: 0;
677
714
  object-fit: contain;
715
+ transform: rotate(var(--media-tagging-preview-rotation, 0deg));
716
+ transform-origin: center;
717
+ transition: transform var(--transition-standard);
678
718
  width: 100%;
679
719
  }
680
720
 
721
+ .media-tagging-preview__asset.is-rotated-quarter {
722
+ height: min(100%, 72vw);
723
+ width: min(100%, 72vh);
724
+ }
725
+
681
726
  .media-tagging-audio-preview {
682
727
  align-items: center;
683
728
  align-self: center;
@@ -765,6 +810,7 @@
765
810
  .media-tagging-preview__meta-header {
766
811
  align-items: center;
767
812
  display: flex;
813
+ flex-wrap: wrap;
768
814
  gap: var(--spacing-3);
769
815
  justify-content: space-between;
770
816
  }
@@ -782,6 +828,13 @@
782
828
  flex: 0 0 auto;
783
829
  }
784
830
 
831
+ .media-tagging-preview__meta-actions {
832
+ display: flex;
833
+ flex-wrap: wrap;
834
+ gap: var(--spacing-2);
835
+ justify-content: flex-end;
836
+ }
837
+
785
838
  .media-tagging-preview__metadata {
786
839
  border-top: 1px solid rgba(75, 71, 50, 0.14);
787
840
  display: flex;
@@ -858,6 +911,32 @@
858
911
  gap: var(--spacing-3);
859
912
  }
860
913
 
914
+ .media-tagging-tag-panel__footer-main {
915
+ align-items: center;
916
+ display: flex;
917
+ gap: var(--spacing-3);
918
+ justify-content: space-between;
919
+ }
920
+
921
+ .media-tagging-tag-panel__remaining {
922
+ color: rgba(205, 199, 171, 0.45);
923
+ flex: 1 1 auto;
924
+ font-family: var(--font-family-mono);
925
+ font-size: 0.6875rem;
926
+ letter-spacing: 0.14em;
927
+ min-width: 0;
928
+ text-transform: uppercase;
929
+ }
930
+
931
+ .media-tagging-tag-panel__actions {
932
+ align-items: center;
933
+ display: flex;
934
+ flex: 0 0 auto;
935
+ flex-wrap: wrap;
936
+ gap: var(--spacing-3);
937
+ justify-content: flex-end;
938
+ }
939
+
861
940
  .media-tagging-tag-list {
862
941
  align-content: start;
863
942
  display: grid;
@@ -1139,9 +1218,44 @@
1139
1218
  grid-template-columns: 1fr;
1140
1219
  }
1141
1220
 
1221
+ .sql-highlight-shell--media {
1222
+ min-height: 9rem;
1223
+ }
1224
+
1142
1225
  .media-tagging-audio-preview {
1143
1226
  grid-template-columns: 1fr;
1144
1227
  justify-items: center;
1145
1228
  text-align: center;
1146
1229
  }
1147
1230
  }
1231
+
1232
+ @media (min-width: 1024px) and (max-height: 900px) {
1233
+ .media-tagging-card--tagging .media-tagging-card__header,
1234
+ .media-tagging-card--tagging .media-tagging-card__body {
1235
+ padding: var(--spacing-4);
1236
+ }
1237
+
1238
+ .media-tagging-card--tagging .media-tagging-card__body {
1239
+ gap: var(--spacing-3);
1240
+ }
1241
+
1242
+ .media-tagging-card--tagging .media-tagging-form-grid,
1243
+ .media-tagging-card--tagging .media-tagging-query-grid {
1244
+ gap: var(--spacing-3);
1245
+ }
1246
+
1247
+ .media-tagging-card--tagging .media-tagging-field {
1248
+ gap: 0.35rem;
1249
+ }
1250
+
1251
+ .media-tagging-card--tagging .sql-highlight-shell--media {
1252
+ min-height: 4.5rem;
1253
+ }
1254
+
1255
+ .media-tagging-card--tagging .sql-highlight-content,
1256
+ .media-tagging-card--tagging .sql-highlight-input {
1257
+ font-size: 0.72rem;
1258
+ line-height: 1.5;
1259
+ padding: 0.65rem 0.75rem;
1260
+ }
1261
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-hub",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "SQLite-only local management app backend and SPA shell",
5
5
  "main": "server/server.js",
6
6
  "bin": {
@@ -100,8 +100,11 @@ function normalizeMediaTaggingConfigRecord(config = {}) {
100
100
  };
101
101
  }
102
102
 
103
- function isChartCompatibleQueryType(queryType) {
104
- return String(queryType ?? "").trim().toLowerCase() === "select";
103
+ function isChartCompatibleQuery(queryType, rawSql = "") {
104
+ return (
105
+ String(queryType ?? "").trim().toLowerCase() === "select" ||
106
+ detectQueryType(rawSql) === "select"
107
+ );
105
108
  }
106
109
 
107
110
  class AppStateStore {
@@ -752,7 +755,7 @@ class AppStateStore {
752
755
  previewSql: buildSqlPreview(row.raw_sql ?? row.rawSql),
753
756
  lastRun,
754
757
  chartsEligible:
755
- isChartCompatibleQueryType(queryType) &&
758
+ isChartCompatibleQuery(queryType, row.raw_sql ?? row.rawSql) &&
756
759
  (!lastRun || String(lastRun.status ?? "").trim().toLowerCase() !== "error"),
757
760
  };
758
761
  }
@@ -1252,12 +1255,12 @@ class AppStateStore {
1252
1255
  LIMIT 1
1253
1256
  )
1254
1257
  WHERE q.database_key = ?
1255
- AND q.query_type = 'select'
1256
1258
  AND COALESCE(latest.status, 'success') != 'error'
1257
1259
  ORDER BY q.last_used_at DESC, q.id DESC
1258
1260
  `)
1259
1261
  .all(normalizedDatabaseKey)
1260
- .map((row) => this.decorateQueryHistoryRow(row));
1262
+ .map((row) => this.decorateQueryHistoryRow(row))
1263
+ .filter((item) => item.chartsEligible);
1261
1264
  }
1262
1265
 
1263
1266
  getQueryHistoryItemById(historyId) {
@@ -1367,7 +1370,7 @@ class AppStateStore {
1367
1370
  getChartQueryHistoryItemForDatabase(historyId, databaseKey) {
1368
1371
  const item = this.getQueryHistoryItemForDatabase(historyId, databaseKey);
1369
1372
 
1370
- if (!isChartCompatibleQueryType(item.queryType)) {
1373
+ if (!isChartCompatibleQuery(item.queryType, item.rawSql)) {
1371
1374
  throw new ValidationError("Only SELECT queries can be opened in Charts.");
1372
1375
  }
1373
1376
 
@@ -37,11 +37,174 @@ function getLeadingKeywords(sql = "", limit = 3) {
37
37
  return matches.slice(0, limit).map((token) => token.toLowerCase());
38
38
  }
39
39
 
40
+ function readWordAt(value = "", index = 0) {
41
+ const match = String(value).slice(index).match(/^[A-Za-z]+/);
42
+ return match ? match[0].toLowerCase() : "";
43
+ }
44
+
45
+ function skipWhitespace(value = "", index = 0) {
46
+ let cursor = index;
47
+
48
+ while (cursor < value.length && /\s/.test(value[cursor])) {
49
+ cursor += 1;
50
+ }
51
+
52
+ return cursor;
53
+ }
54
+
55
+ function advanceQuotedSql(value = "", index = 0) {
56
+ const quote = value[index];
57
+ const closingQuote = quote === "[" ? "]" : quote;
58
+ let cursor = index + 1;
59
+
60
+ while (cursor < value.length) {
61
+ if (value[cursor] === closingQuote) {
62
+ if ((quote === "'" || quote === '"') && value[cursor + 1] === closingQuote) {
63
+ cursor += 2;
64
+ continue;
65
+ }
66
+
67
+ return cursor + 1;
68
+ }
69
+
70
+ cursor += 1;
71
+ }
72
+
73
+ return value.length;
74
+ }
75
+
76
+ function isSqlWordBoundary(value = "", index = 0) {
77
+ return index < 0 || index >= value.length || !/[A-Za-z0-9_]/.test(value[index]);
78
+ }
79
+
80
+ function findTopLevelWord(value = "", word = "", startIndex = 0) {
81
+ const normalizedWord = String(word).toLowerCase();
82
+ let cursor = startIndex;
83
+ let depth = 0;
84
+
85
+ while (cursor < value.length) {
86
+ const char = value[cursor];
87
+
88
+ if (char === "'" || char === '"' || char === "`" || char === "[") {
89
+ cursor = advanceQuotedSql(value, cursor);
90
+ continue;
91
+ }
92
+
93
+ if (char === "(") {
94
+ depth += 1;
95
+ cursor += 1;
96
+ continue;
97
+ }
98
+
99
+ if (char === ")") {
100
+ depth = Math.max(0, depth - 1);
101
+ cursor += 1;
102
+ continue;
103
+ }
104
+
105
+ if (
106
+ depth === 0 &&
107
+ value.slice(cursor, cursor + normalizedWord.length).toLowerCase() === normalizedWord &&
108
+ isSqlWordBoundary(value, cursor - 1) &&
109
+ isSqlWordBoundary(value, cursor + normalizedWord.length)
110
+ ) {
111
+ return cursor;
112
+ }
113
+
114
+ cursor += 1;
115
+ }
116
+
117
+ return -1;
118
+ }
119
+
120
+ function findMatchingParen(value = "", openIndex = 0) {
121
+ let cursor = openIndex;
122
+ let depth = 0;
123
+
124
+ while (cursor < value.length) {
125
+ const char = value[cursor];
126
+
127
+ if (char === "'" || char === '"' || char === "`" || char === "[") {
128
+ cursor = advanceQuotedSql(value, cursor);
129
+ continue;
130
+ }
131
+
132
+ if (char === "(") {
133
+ depth += 1;
134
+ } else if (char === ")") {
135
+ depth -= 1;
136
+
137
+ if (depth === 0) {
138
+ return cursor;
139
+ }
140
+ }
141
+
142
+ cursor += 1;
143
+ }
144
+
145
+ return -1;
146
+ }
147
+
148
+ function resolveCteStatementKeyword(sql = "") {
149
+ const stripped = stripBlockComments(stripLineComments(sql)).trim();
150
+ let cursor = skipWhitespace(stripped, 0);
151
+
152
+ if (readWordAt(stripped, cursor) !== "with") {
153
+ return "";
154
+ }
155
+
156
+ cursor += 4;
157
+ cursor = skipWhitespace(stripped, cursor);
158
+
159
+ if (readWordAt(stripped, cursor) === "recursive") {
160
+ cursor += "recursive".length;
161
+ }
162
+
163
+ while (cursor < stripped.length) {
164
+ const asIndex = findTopLevelWord(stripped, "as", cursor);
165
+
166
+ if (asIndex < 0) {
167
+ return "with";
168
+ }
169
+
170
+ cursor = skipWhitespace(stripped, asIndex + 2);
171
+
172
+ if (readWordAt(stripped, cursor) === "not") {
173
+ cursor = skipWhitespace(stripped, cursor + 3);
174
+ }
175
+
176
+ if (readWordAt(stripped, cursor) === "materialized") {
177
+ cursor = skipWhitespace(stripped, cursor + "materialized".length);
178
+ }
179
+
180
+ if (stripped[cursor] !== "(") {
181
+ return "with";
182
+ }
183
+
184
+ const closeIndex = findMatchingParen(stripped, cursor);
185
+
186
+ if (closeIndex < 0) {
187
+ return "with";
188
+ }
189
+
190
+ cursor = skipWhitespace(stripped, closeIndex + 1);
191
+
192
+ if (stripped[cursor] === ",") {
193
+ cursor = skipWhitespace(stripped, cursor + 1);
194
+ continue;
195
+ }
196
+
197
+ return readWordAt(stripped, cursor) || "with";
198
+ }
199
+
200
+ return "with";
201
+ }
202
+
40
203
  function resolveEffectiveKeyword(sql = "") {
41
- const [firstKeyword, secondKeyword, thirdKeyword] = getLeadingKeywords(sql, 3);
204
+ const [firstKeyword, secondKeyword] = getLeadingKeywords(sql, 3);
42
205
 
43
206
  if (firstKeyword === "with") {
44
- return secondKeyword === "recursive" ? thirdKeyword ?? "with" : secondKeyword ?? "with";
207
+ return resolveCteStatementKeyword(sql);
45
208
  }
46
209
 
47
210
  if (firstKeyword === "explain") {
package/shortkeys.md ADDED
@@ -0,0 +1,5 @@
1
+ # Meddia Tagging
2
+
3
+ ## tagging queue
4
+
5
+ `shift + enter` triggers tagged & next