feat: editable unit/group IDs with full cascade

- Unit ID rename: updates group.baseUnitId refs + {old-id:*} in all descriptions
- Group ID rename: updates unit.group refs + {old-id:*} in all descriptions
- Admin form ID fields unlocked; hint text updated
- renameIdInDescription() shared helper in both PUT handlers
This commit is contained in:
Falkan
2026-03-18 20:46:36 -04:00
parent 7d79284fd7
commit 1308b1f96f
4 changed files with 86 additions and 17 deletions

View File

@@ -24,7 +24,9 @@
"labelPlural": "barrels",
"symbol": "bbl",
"group": "dickloads",
"toBase": 19
"toBase": 13.319,
"description": "Weight is based on pure water at 4 degrees celsius.",
"hidden": false
},
{
"id": "hogshead",
@@ -101,6 +103,14 @@
"group": "another-group",
"toBase": 1,
"hidden": true
},
{
"id": "us-fluid-bbl",
"label": "US fluid barrel",
"labelPlural": "US fluid barrels",
"symbol": "us fluid bbl",
"group": "dickloads",
"toBase": 9.9894438
}
],
"groups": [

View File

@@ -918,8 +918,8 @@
<div onkeydown={onUnitFormKeydown}>
<h3>{unitFormNew ? 'New Unit' : 'Edit Unit'}</h3>
<label>
ID {#if !unitFormNew}<small class="muted">(readonly)</small>{/if}
<input type="text" bind:value={unitForm.id} readonly={!unitFormNew} placeholder="e.g. my-unit"
ID {#if !unitFormNew}<small class="muted">(changing will update all references)</small>{/if}
<input type="text" bind:value={unitForm.id} placeholder="e.g. my-unit"
class:field-error={unitFieldError === 'unit-id'}
oninput={() => unitFormNew && onUnitIdInput()} />
</label>
@@ -977,8 +977,8 @@
<div onkeydown={onGroupFormKeydown}>
<h3>{groupFormNew ? 'New Group' : 'Edit Group'}</h3>
<label>
ID {#if !groupFormNew}<small class="muted">(readonly)</small>{/if}
<input type="text" bind:value={groupForm.id} readonly={!groupFormNew} placeholder="e.g. my-group"
ID {#if !groupFormNew}<small class="muted">(changing will update all references)</small>{/if}
<input type="text" bind:value={groupForm.id} placeholder="e.g. my-group"
class:field-error={groupFieldError === 'group-id'}
oninput={() => groupFormNew && onGroupIdInput()} />
</label>

View File

@@ -1,9 +1,18 @@
import { json } from '@sveltejs/kit';
import { loadData, saveData } from '$lib/server/data';
import { authRequest } from '$lib/server/auth';
import { authRequest, KEBAB_RE } from '$lib/server/auth';
import type { RequestHandler } from './$types';
import type { Group } from '$lib/types';
function renameIdInDescription(desc: string, oldId: string, newId: string): string {
const re = new RegExp(`\\{${escapeRegex(oldId)}(:[^}]*)?\\}`, 'g');
return desc.replace(re, (_, suffix) => `{${newId}${suffix ?? ''}}`);
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export const PUT: RequestHandler = async ({ request, params }) => {
if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
@@ -17,6 +26,18 @@ export const PUT: RequestHandler = async ({ request, params }) => {
const patch = body as Partial<Group>;
const currentGroup = data.groups[gIdx];
const newId = patch.id;
const idChanging = newId !== undefined && newId !== id;
// Validate new ID if changing
if (idChanging) {
if (!KEBAB_RE.test(newId!)) {
return json({ error: 'id must be kebab-case' }, { status: 400 });
}
if (data.groups.some((g) => g.id === newId)) {
return json({ error: `Group id '${newId}' already exists` }, { status: 400 });
}
}
// Special case: changing baseUnitId requires recalculating all unit toBase values
if (patch.baseUnitId !== undefined && patch.baseUnitId !== currentGroup.baseUnitId) {
@@ -25,10 +46,7 @@ export const PUT: RequestHandler = async ({ request, params }) => {
if (!newBaseUnit) {
return json({ error: `baseUnitId '${newBaseId}' not found in this group` }, { status: 400 });
}
const X = newBaseUnit.toBase; // Current toBase of the new base unit
// Recalculate all units in this group: unit.toBase = unit.toBase / X
// Then set new base unit toBase = 1.0
const X = newBaseUnit.toBase;
for (const unit of data.units) {
if (unit.group === id) {
unit.toBase = unit.id === newBaseId ? 1.0 : unit.toBase / X;
@@ -38,8 +56,22 @@ export const PUT: RequestHandler = async ({ request, params }) => {
const updated: Group = { ...currentGroup, ...patch };
data.groups[gIdx] = updated;
saveData(data);
// Cascade ID rename across the dataset
if (idChanging) {
// Update unit.group references
for (const u of data.units) {
if (u.group === id) u.group = newId!;
}
// Update {id:*} placeholders in all unit descriptions
for (const u of data.units) {
if (u.description) {
u.description = renameIdInDescription(u.description, id, newId!);
}
}
}
saveData(data);
return json(updated);
};

View File

@@ -4,6 +4,17 @@ import { authRequest, KEBAB_RE } from '$lib/server/auth';
import type { RequestHandler } from './$types';
import type { Unit } from '$lib/types';
/** Replace all {oldId}, {oldId:field} placeholder tokens in a description string. */
function renameIdInDescription(desc: string, oldId: string, newId: string): string {
// Match {oldId} and {oldId:anything}
const re = new RegExp(`\\{${escapeRegex(oldId)}(:[^}]*)?\\}`, 'g');
return desc.replace(re, (_, suffix) => `{${newId}${suffix ?? ''}}`);
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export const PUT: RequestHandler = async ({ request, params }) => {
if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
@@ -16,14 +27,16 @@ export const PUT: RequestHandler = async ({ request, params }) => {
if (idx === -1) return json({ error: 'Unit not found' }, { status: 404 });
const patch = body as Partial<Unit>;
const newId = patch.id;
const idChanging = newId !== undefined && newId !== id;
// If id is changing, validate new id
if (patch.id !== undefined && patch.id !== id) {
if (!KEBAB_RE.test(patch.id)) {
// Validate new ID if changing
if (idChanging) {
if (!KEBAB_RE.test(newId!)) {
return json({ error: 'id must be kebab-case' }, { status: 400 });
}
if (data.units.some((u) => u.id === patch.id)) {
return json({ error: `Unit id '${patch.id}' already exists` }, { status: 400 });
if (data.units.some((u) => u.id === newId)) {
return json({ error: `Unit id '${newId}' already exists` }, { status: 400 });
}
}
if (patch.toBase !== undefined && patch.toBase <= 0) {
@@ -35,8 +48,22 @@ export const PUT: RequestHandler = async ({ request, params }) => {
const updated: Unit = { ...data.units[idx], ...patch };
data.units[idx] = updated;
saveData(data);
// Cascade ID rename across the dataset
if (idChanging) {
// Update group.baseUnitId references
for (const g of data.groups) {
if (g.baseUnitId === id) g.baseUnitId = newId!;
}
// Update {id:*} placeholders in all unit descriptions
for (const u of data.units) {
if (u.description) {
u.description = renameIdInDescription(u.description, id, newId!);
}
}
}
saveData(data);
return json(updated);
};