feat(frontend): support nested objects in the metadata editor
deploy / deploy (push) Successful in 22s
deploy / deploy (push) Successful in 22s
Replace the flat key/value metadata editor with a recursive tree editor. Each entry is a leaf (scalar edited as text) or a nested object with its own children; a per-row toggle switches between the two, expanding pasted JSON into rows and collapsing groups back to JSON text without losing data. Leaf values still round-trip as JSON where possible, so numbers and booleans keep their type. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,9 @@
|
|||||||
import { authStore } from '$lib/stores/auth';
|
import { authStore } from '$lib/stores/auth';
|
||||||
import TagPicker from '$lib/components/file/TagPicker.svelte';
|
import TagPicker from '$lib/components/file/TagPicker.svelte';
|
||||||
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
|
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
|
||||||
|
import MetadataEditor from '$lib/components/file/MetadataEditor.svelte';
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
import { type MetaNode, objectToNodes, nodesToObject } from '$lib/utils/metadata';
|
||||||
import type { File, Tag } from '$lib/api/types';
|
import type { File, Tag } from '$lib/api/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -31,13 +33,6 @@
|
|||||||
onReviewChange
|
onReviewChange
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
/** One editable metadata entry. `id` is local-only, for keyed list rendering. */
|
|
||||||
interface MetaRow {
|
|
||||||
id: number;
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let file = $state<File | null>(null);
|
let file = $state<File | null>(null);
|
||||||
let fileTags = $state<Tag[]>([]);
|
let fileTags = $state<Tag[]>([]);
|
||||||
let previewSrc = $state<string | null>(null);
|
let previewSrc = $state<string | null>(null);
|
||||||
@@ -68,11 +63,10 @@
|
|||||||
let notes = $state('');
|
let notes = $state('');
|
||||||
let contentDatetime = $state('');
|
let contentDatetime = $state('');
|
||||||
let isPublic = $state(false);
|
let isPublic = $state(false);
|
||||||
// User-editable metadata, held as ordered key/value rows (the API field is a
|
// User-editable metadata (the API field is a free-form, possibly nested JSON
|
||||||
// free-form JSON object). Stable ids key the {#each} so removing a middle row
|
// object). Held as a node tree that MetadataEditor renders; converted to/from
|
||||||
// keeps the bound inputs aligned with their data.
|
// the plain object on load and save.
|
||||||
let metadataRows = $state<MetaRow[]>([]);
|
let metadataNodes = $state<MetaNode[]>([]);
|
||||||
let metaRowId = 0;
|
|
||||||
let dirty = $state(false);
|
let dirty = $state(false);
|
||||||
|
|
||||||
let exifEntries = $derived(
|
let exifEntries = $derived(
|
||||||
@@ -111,7 +105,7 @@
|
|||||||
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
|
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
|
||||||
: '';
|
: '';
|
||||||
isPublic = fileData.is_public ?? false;
|
isPublic = fileData.is_public ?? false;
|
||||||
metadataRows = metadataToRows(fileData.metadata);
|
metadataNodes = objectToNodes(fileData.metadata);
|
||||||
dirty = false;
|
dirty = false;
|
||||||
void fetchPreview(id);
|
void fetchPreview(id);
|
||||||
void fetchContentToken(id);
|
void fetchContentToken(id);
|
||||||
@@ -247,10 +241,10 @@
|
|||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
content_datetime: contentDatetime ? new Date(contentDatetime).toISOString() : undefined,
|
content_datetime: contentDatetime ? new Date(contentDatetime).toISOString() : undefined,
|
||||||
is_public: isPublic,
|
is_public: isPublic,
|
||||||
metadata: rowsToMetadata(metadataRows)
|
metadata: nodesToObject(metadataNodes)
|
||||||
});
|
});
|
||||||
file = updated;
|
file = updated;
|
||||||
metadataRows = metadataToRows(updated.metadata);
|
metadataNodes = objectToNodes(updated.metadata);
|
||||||
dirty = false;
|
dirty = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to save';
|
error = e instanceof ApiError ? e.message : 'Failed to save';
|
||||||
@@ -372,59 +366,6 @@
|
|||||||
if (typeof val === 'object') return JSON.stringify(val);
|
if (typeof val === 'object') return JSON.stringify(val);
|
||||||
return String(val);
|
return String(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Metadata (free-form key/value object) ----
|
|
||||||
// Expand the stored object into editable rows. Non-string values (numbers,
|
|
||||||
// booleans, nested objects) are shown as their JSON text so they survive a
|
|
||||||
// round-trip even when the user never touches them.
|
|
||||||
function metadataToRows(m: unknown): MetaRow[] {
|
|
||||||
if (!m || typeof m !== 'object') return [];
|
|
||||||
return Object.entries(m as Record<string, unknown>).map(([key, val]) => ({
|
|
||||||
id: metaRowId++,
|
|
||||||
key,
|
|
||||||
value: metaValueToString(val)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function metaValueToString(val: unknown): string {
|
|
||||||
if (val === null || val === undefined) return '';
|
|
||||||
if (typeof val === 'string') return val;
|
|
||||||
return JSON.stringify(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild the object for the PATCH body. Blank keys are dropped. A value that
|
|
||||||
// parses as JSON (number, boolean, object, array) is stored as that type;
|
|
||||||
// everything else is kept as a plain string.
|
|
||||||
function rowsToMetadata(rows: MetaRow[]): Record<string, unknown> {
|
|
||||||
const out: Record<string, unknown> = {};
|
|
||||||
for (const { key, value } of rows) {
|
|
||||||
const k = key.trim();
|
|
||||||
if (k) out[k] = parseMetaValue(value);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMetaValue(value: string): unknown {
|
|
||||||
const v = value.trim();
|
|
||||||
if (v === '') return '';
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(v);
|
|
||||||
if (parsed !== null && typeof parsed !== 'string') return parsed;
|
|
||||||
} catch {
|
|
||||||
// not JSON — keep the raw string
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addMetaRow() {
|
|
||||||
metadataRows = [...metadataRows, { id: metaRowId++, key: '', value: '' }];
|
|
||||||
dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeMetaRow(id: number) {
|
|
||||||
metadataRows = metadataRows.filter((r) => r.id !== id);
|
|
||||||
dirty = true;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
@@ -640,47 +581,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Metadata (free-form key/value pairs) -->
|
<!-- Metadata (free-form, possibly nested JSON object) -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="field-label">Metadata</div>
|
<div class="field-label">Metadata</div>
|
||||||
{#if metadataRows.length > 0}
|
<MetadataEditor bind:nodes={metadataNodes} onchange={() => (dirty = true)} />
|
||||||
<div class="kv-list">
|
|
||||||
{#each metadataRows as row (row.id)}
|
|
||||||
<div class="kv-row">
|
|
||||||
<input
|
|
||||||
class="kv-input kv-key"
|
|
||||||
placeholder="key"
|
|
||||||
bind:value={row.key}
|
|
||||||
oninput={() => (dirty = true)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
class="kv-input kv-val"
|
|
||||||
placeholder="value"
|
|
||||||
bind:value={row.value}
|
|
||||||
oninput={() => (dirty = true)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="kv-del"
|
|
||||||
onclick={() => removeMetaRow(row.id)}
|
|
||||||
aria-label="Remove field"
|
|
||||||
title="Remove field"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M3 3l8 8M11 3l-8 8"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="kv-empty">No metadata.</p>
|
|
||||||
{/if}
|
|
||||||
<button class="kv-add" onclick={addMetaRow}>+ Add field</button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<button class="save-btn" onclick={save} disabled={!dirty || saving}>
|
<button class="save-btn" onclick={save} disabled={!dirty || saving}>
|
||||||
@@ -1086,87 +990,6 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Metadata editor ---- */
|
|
||||||
.kv-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-input {
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0 9px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-input:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-key {
|
|
||||||
flex: 0 0 38%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-val {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-del {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-del:hover {
|
|
||||||
color: var(--color-danger);
|
|
||||||
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-empty {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-add {
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px dashed color-mix(in srgb, var(--color-accent) 40%, transparent);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-add:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- EXIF ---- */
|
/* ---- EXIF ---- */
|
||||||
.exif {
|
.exif {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Recursive editor for one level of the metadata object. Nested objects render
|
||||||
|
// another instance of this component (self-import), indented under their key.
|
||||||
|
import Self from './MetadataEditor.svelte';
|
||||||
|
import {
|
||||||
|
type MetaNode,
|
||||||
|
newValueNode,
|
||||||
|
newObjectNode,
|
||||||
|
nodesToObject,
|
||||||
|
objectToNodes,
|
||||||
|
parseObject
|
||||||
|
} from '$lib/utils/metadata';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Entries at this level; bound so nested edits flow back to the parent. */
|
||||||
|
nodes: MetaNode[];
|
||||||
|
/** Fired on any structural or value change (parent marks the form dirty). */
|
||||||
|
onchange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { nodes = $bindable(), onchange }: Props = $props();
|
||||||
|
|
||||||
|
function addValue() {
|
||||||
|
nodes = [...nodes, newValueNode()];
|
||||||
|
onchange();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addObject() {
|
||||||
|
nodes = [...nodes, newObjectNode()];
|
||||||
|
onchange();
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: number) {
|
||||||
|
nodes = nodes.filter((n) => n.id !== id);
|
||||||
|
onchange();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip a leaf to a nested object and back. Converting keeps the data where it
|
||||||
|
// can: a leaf whose text is a JSON object expands into rows; a group collapses
|
||||||
|
// back to its JSON text.
|
||||||
|
function toggleKind(node: MetaNode) {
|
||||||
|
if (node.kind === 'value') {
|
||||||
|
const obj = parseObject(node.value);
|
||||||
|
node.children = obj ? objectToNodes(obj) : [];
|
||||||
|
node.value = '';
|
||||||
|
node.kind = 'object';
|
||||||
|
} else {
|
||||||
|
node.value = node.children.length ? JSON.stringify(nodesToObject(node.children)) : '';
|
||||||
|
node.children = [];
|
||||||
|
node.kind = 'value';
|
||||||
|
}
|
||||||
|
onchange();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="meta-editor">
|
||||||
|
{#each nodes as node (node.id)}
|
||||||
|
<div class="node">
|
||||||
|
<div class="node-head">
|
||||||
|
<input class="key" placeholder="key" bind:value={node.key} oninput={onchange} />
|
||||||
|
<button
|
||||||
|
class="kind"
|
||||||
|
class:obj={node.kind === 'object'}
|
||||||
|
onclick={() => toggleKind(node)}
|
||||||
|
title={node.kind === 'object'
|
||||||
|
? 'Nested object — click for a plain value'
|
||||||
|
: 'Plain value — click to nest an object'}
|
||||||
|
aria-label="Toggle value / nested object"
|
||||||
|
>
|
||||||
|
{node.kind === 'object' ? '{ }' : 'a'}
|
||||||
|
</button>
|
||||||
|
{#if node.kind === 'value'}
|
||||||
|
<input class="val" placeholder="value" bind:value={node.value} oninput={onchange} />
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="del"
|
||||||
|
onclick={() => remove(node.id)}
|
||||||
|
aria-label="Remove field"
|
||||||
|
title="Remove field"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M3 3l8 8M11 3l-8 8"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if node.kind === 'object'}
|
||||||
|
<div class="children">
|
||||||
|
<Self bind:nodes={node.children} {onchange} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="add-row">
|
||||||
|
<button class="add" onclick={addValue}>+ Field</button>
|
||||||
|
<button class="add" onclick={addObject}>+ Group</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.meta-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-head {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key,
|
||||||
|
.val {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key:focus,
|
||||||
|
.val:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
flex: 0 0 38%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.val {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type toggle: shows 'a' for a plain value, '{ }' for a nested object. */
|
||||||
|
.kind {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind.obj {
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.del {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.del:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested level: indent and hang a rail off the parent key. */
|
||||||
|
.children {
|
||||||
|
margin-left: 12px;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 2px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
padding: 5px 11px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px dashed color-mix(in srgb, var(--color-accent) 40%, transparent);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* File metadata tree model.
|
||||||
|
*
|
||||||
|
* The `metadata` API field is a free-form JSON object. The editor renders it as
|
||||||
|
* a tree of nodes: each node is either a leaf (a scalar edited as text) or a
|
||||||
|
* branch (a nested object with its own children). Arrays and other non-object
|
||||||
|
* values stay leaves and round-trip as JSON text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One entry at some level of the metadata object. */
|
||||||
|
export interface MetaNode {
|
||||||
|
/** Local-only id, for keyed list rendering. Not persisted. */
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
/** `value` holds a leaf's text; `object` nests `children`. */
|
||||||
|
kind: 'value' | 'object';
|
||||||
|
value: string;
|
||||||
|
children: MetaNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
/** Monotonic id for keyed rendering; uniqueness within a list is all that matters. */
|
||||||
|
export function nextMetaId(): number {
|
||||||
|
return counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plain object (not null, not an array). */
|
||||||
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||||
|
return !!v && typeof v === 'object' && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expand a stored object into editor nodes. Non-object input yields no nodes. */
|
||||||
|
export function objectToNodes(m: unknown): MetaNode[] {
|
||||||
|
if (!isPlainObject(m)) return [];
|
||||||
|
return Object.entries(m).map(([key, val]) => valueToNode(key, val));
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueToNode(key: string, val: unknown): MetaNode {
|
||||||
|
if (isPlainObject(val)) {
|
||||||
|
return { id: nextMetaId(), key, kind: 'object', value: '', children: objectToNodes(val) };
|
||||||
|
}
|
||||||
|
return { id: nextMetaId(), key, kind: 'value', value: valueToString(val), children: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a leaf value for the text input. Strings pass through; everything else
|
||||||
|
* (numbers, booleans, arrays) shows as JSON so it survives a round-trip. */
|
||||||
|
export function valueToString(val: unknown): string {
|
||||||
|
if (val === null || val === undefined) return '';
|
||||||
|
if (typeof val === 'string') return val;
|
||||||
|
return JSON.stringify(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse the node tree back into a plain object for the PATCH body. Blank keys
|
||||||
|
* are dropped; a later duplicate key wins. */
|
||||||
|
export function nodesToObject(nodes: MetaNode[]): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const n of nodes) {
|
||||||
|
const k = n.key.trim();
|
||||||
|
if (!k) continue;
|
||||||
|
out[k] = n.kind === 'object' ? nodesToObject(n.children) : parseValue(n.value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a leaf's text: JSON-typed values (number, boolean, array, object) keep
|
||||||
|
* their type; anything else stays a plain string. */
|
||||||
|
export function parseValue(value: string): unknown {
|
||||||
|
const v = value.trim();
|
||||||
|
if (v === '') return '';
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(v);
|
||||||
|
if (parsed !== null && typeof parsed !== 'string') return parsed;
|
||||||
|
} catch {
|
||||||
|
// not JSON — keep the raw string
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If the text is a JSON object, return it (used when expanding a leaf into a
|
||||||
|
* nested group); otherwise null. */
|
||||||
|
export function parseObject(value: string): Record<string, unknown> | null {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return null;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(v);
|
||||||
|
if (isPlainObject(parsed)) return parsed;
|
||||||
|
} catch {
|
||||||
|
// not JSON
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newValueNode(): MetaNode {
|
||||||
|
return { id: nextMetaId(), key: '', kind: 'value', value: '', children: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newObjectNode(): MetaNode {
|
||||||
|
return { id: nextMetaId(), key: '', kind: 'object', value: '', children: [] };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user