refactor state storge, UI editor

This commit is contained in:
taDuc
2026-05-07 13:38:52 +07:00
parent a29a3a2049
commit 8b1df73797
46 changed files with 3345 additions and 3112 deletions

View File

@@ -1,38 +1,8 @@
import type { Entity } from "@/uhm/types/entities";
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import { newId } from "@/uhm/lib/id";
export function mergeEntitiesWithPending(
persistedEntities: Entity[],
pendingCreates: PendingEntityCreate[]
): Entity[] {
if (!pendingCreates.length) {
return persistedEntities;
}
const seen = new Set<string>();
const pendingAsEntities: Entity[] = [];
for (const pending of pendingCreates) {
if (seen.has(pending.id)) continue;
seen.add(pending.id);
pendingAsEntities.push({
id: pending.id,
name: pending.name,
slug: pending.slug,
type_id: pending.type_id,
status: pending.status,
geometry_count: 0,
created_at: undefined,
updated_at: undefined,
});
}
const nextPersisted = persistedEntities.filter((entity) => !seen.has(entity.id));
return [...pendingAsEntities, ...nextPersisted];
}
export function mergeEntitySearchResults(
remoteRows: Entity[],
localRows: Entity[]
@@ -70,7 +40,7 @@ export function buildClientEntityId(): string {
}
export function buildFeatureEntityPatch(
feature: Feature,
_feature: Feature,
entityIds: string[],
entities: Entity[]
): Partial<FeatureProperties> {
@@ -78,29 +48,14 @@ export function buildFeatureEntityPatch(
const primaryEntity = primaryEntityId
? entities.find((entity) => entity.id === primaryEntityId) || null
: null;
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds, entities) ||
feature.properties.type ||
null;
const entityNames = entityIds
.map((id) => entities.find((entity) => entity.id === id)?.name || "")
.filter((name) => name.length > 0);
return {
type: nextGeometryType,
entity_id: primaryEntityId,
entity_ids: entityIds,
entity_name: primaryEntity?.name || null,
entity_names: entityNames,
entity_type_id: primaryEntity?.type_id || null,
};
}
function resolveGeometryTypeFromEntityIds(
entityIds: string[],
entities: Entity[]
): string | null {
const primaryEntityId = entityIds[0] || null;
if (!primaryEntityId) return null;
const primaryEntity = entities.find((entity) => entity.id === primaryEntityId) || null;
return primaryEntity?.type_id || null;
}

View File

@@ -11,6 +11,7 @@ export type GeometryMetadataPatch = {
};
export function buildGeometryMetadataPatch(form: GeometryMetaFormState): GeometryMetadataPatch {
const typeKey = form.type_key.trim();
const timeStart = parseOptionalYearInput(form.time_start, "time_start");
const timeEnd = parseOptionalYearInput(form.time_end, "time_end");
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
@@ -20,11 +21,13 @@ export function buildGeometryMetadataPatch(form: GeometryMetaFormState): Geometr
const bindingIds = parseBindingInput(form.binding);
return {
patch: {
type: typeKey.length ? typeKey : undefined,
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
},
formState: {
type_key: typeKey,
time_start: timeStart != null ? String(timeStart) : "",
time_end: timeEnd != null ? String(timeEnd) : "",
binding: bindingIds.join(", "),
@@ -47,4 +50,3 @@ function parseOptionalYearInput(raw: string, fieldName: string): number | null {
}
return Math.trunc(parsed);
}

View File

@@ -7,12 +7,10 @@ import {
fetchSectionCommits,
fetchSections,
openSectionEditor,
restoreSectionCommit,
submitSection,
} from "@/uhm/api/sections";
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { CreatedEntitySummary, PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
import type { Feature, FeatureCollection, FeatureId } from "@/uhm/types/geo";
import type { EditorSnapshot, Section, SectionCommit, SectionState, EntityWikiLinkSnapshot } from "@/uhm/types/sections";
import type { EntitySnapshot } from "@/uhm/types/entities";
@@ -34,24 +32,21 @@ type Options = {
selectedSectionId: string;
newSectionTitle: string;
pendingSaveCount: number;
pendingEntityCreates: PendingEntityCreate[];
projectEntityRefs: EntitySnapshot[];
wikis: WikiSnapshot[];
entityWikiLinks: EntityWikiLinkSnapshot[];
lastSectionSnapshot: EditorSnapshot | null;
snapshotEntities: EntitySnapshot[];
snapshotWikis: WikiSnapshot[];
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
baselineSnapshot: EditorSnapshot | null;
commitTitle: string;
commitNote: string;
setActiveSection: Dispatch<SetStateAction<Section | null>>;
setSelectedSectionId: Dispatch<SetStateAction<string>>;
setSectionState: Dispatch<SetStateAction<SectionState | null>>;
setLastSectionSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
setBaselineSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
setPendingEntityCreates: Dispatch<SetStateAction<PendingEntityCreate[]>>;
setProjectEntityRefs: Dispatch<SetStateAction<EntitySnapshot[]>>;
setCreatedEntities: Dispatch<SetStateAction<CreatedEntitySummary[]>>;
setWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
setEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
setEntityStatus: Dispatch<SetStateAction<string | null>>;
@@ -68,33 +63,21 @@ export function useSectionCommands(options: Options) {
const openSectionForEditing = useCallback(async (sectionId: string) => {
const editorPayload = await openSectionEditor(sectionId);
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
// When starting a fresh editor session from a commit snapshot, treat all rows as baseline state:
// operations should not carry over as deltas into the next commit.
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
const commits = await fetchSectionCommits(sectionId);
const nextInitialData = snapshot?.editor_feature_collection || options.emptyFeatureCollection;
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
options.setActiveSection(editorPayload.section);
options.setSelectedSectionId(editorPayload.section.id);
options.setSectionState(editorPayload.state);
options.setLastSectionSnapshot(snapshot);
options.setBaselineSnapshot(sessionSnapshot);
options.setInitialData(nextInitialData);
options.setSectionCommits(commits);
options.setPendingEntityCreates([]);
options.setCreatedEntities([]);
const geoEntityIds = new Set((snapshot?.geometry_entity || []).map((row) => row.entity_id));
const linkedByWikiIds = new Set(
(snapshot?.entity_wiki || [])
.filter((l) => l?.operation !== "delete")
.map((l) => l.entity_id)
);
options.setProjectEntityRefs((snapshot?.entities || []).filter((e) =>
e?.source === "ref"
&& !geoEntityIds.has(e.id)
&& !linkedByWikiIds.has(e.id)
&& e.operation !== "create"
&& e.operation !== "update"
&& e.operation !== "delete"
));
options.setWikis(snapshot?.wikis || []);
options.setEntityWikiLinks(snapshot?.entity_wiki || []);
options.setSnapshotEntities(sessionSnapshot?.entities || []);
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
options.setSelectedFeatureId(null);
options.setEntityFormStatus(null);
}, [options]);
@@ -104,6 +87,10 @@ export function useSectionCommands(options: Options) {
options.setEntityStatus("Chưa mở được section editor.");
return;
}
if (options.pendingSaveCount <= 0) {
options.setEntityStatus("Không có thay đổi để Commit.");
return;
}
const geometryChanges = options.editor.buildPayload();
options.setIsSaving(true);
@@ -113,26 +100,46 @@ export function useSectionCommands(options: Options) {
section: options.activeSection,
draft: options.editor.draft,
changes: geometryChanges,
pendingEntities: options.pendingEntityCreates,
projectEntityRefs: options.projectEntityRefs,
wikis: options.wikis,
entityWikiLinks: options.entityWikiLinks,
previousSnapshot: options.lastSectionSnapshot,
snapshotEntities: options.snapshotEntities,
snapshotWikis: options.snapshotWikis,
snapshotEntityWikiLinks: options.snapshotEntityWikiLinks,
previousSnapshot: options.baselineSnapshot,
hasPersistedFeature: options.editor.hasPersistedFeature,
});
const editSummary = options.commitTitle.trim()
|| options.commitNote.trim()
|| `Edit ${new Date().toLocaleString()}`;
// Guardrail: commit payload can get large and some deployments reject/close connections for big bodies.
// When that happens, browsers often surface it as "TypeError: Failed to fetch".
try {
const payloadText = JSON.stringify({ snapshot_json: snapshot, edit_summary: editSummary });
const bytes = typeof Blob !== "undefined" ? new Blob([payloadText]).size : payloadText.length;
const limitBytes = 3_500_000; // ~3.5MB (conservative vs common default body limits)
if (bytes > limitBytes) {
options.setEntityStatus(
`Commit payload quá lớn (~${(bytes / (1024 * 1024)).toFixed(2)}MB). ` +
`Hãy giảm bớt nội dung snapshot/changes hoặc chạy BE local với body limit lớn hơn.`
);
return;
}
} catch {
// If stringify fails, let API call throw a more actionable error downstream.
}
const result = await createSectionCommit(options.activeSection.id, {
snapshot,
edit_summary: options.commitTitle.trim()
|| options.commitNote.trim()
|| `Edit ${new Date().toLocaleString()}`,
edit_summary: editSummary,
});
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
options.setSectionState(result.state);
options.setLastSectionSnapshot(snapshot);
options.setBaselineSnapshot(sessionSnapshot);
options.setSnapshotEntities(sessionSnapshot.entities || []);
options.setSnapshotWikis(sessionSnapshot.wikis || []);
options.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
options.setInitialData(options.editor.draft);
options.editor.clearChanges();
options.setPendingEntityCreates([]);
options.setCreatedEntities([]);
options.setCommitTitle("");
options.setCommitNote("");
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
@@ -250,19 +257,30 @@ export function useSectionCommands(options: Options) {
options.setIsSaving(true);
options.setEntityStatus(null);
try {
const result = await restoreSectionCommit(options.activeSection.id, {
commit_id: commitId,
});
const editorPayload = await openSectionEditor(options.activeSection.id);
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
options.setSectionState(result.state);
options.setLastSectionSnapshot(snapshot);
if (snapshot?.editor_feature_collection) {
options.setInitialData(snapshot.editor_feature_collection);
// FE-only restore: load snapshot from selected commit and apply to editor state.
// Do NOT move project's head commit on backend.
const commits = await fetchSectionCommits(options.activeSection.id);
const target = commits.find((c: SectionCommit) => c.id === commitId) || null;
if (!target) {
options.setEntityStatus("Không tìm thấy commit để restore.");
return;
}
options.setWikis(snapshot?.wikis || []);
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
options.setEntityFormStatus("Đã restore commit.");
const snapshot = normalizeEditorSnapshot(target.snapshot_json);
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
options.setBaselineSnapshot(sessionSnapshot);
options.setInitialData(nextInitialData);
options.setSnapshotEntities(sessionSnapshot?.entities || []);
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
options.setSelectedFeatureId(null);
options.setEntityFormStatus(null);
// Refresh commits list for UI, but keep sectionState/head as-is.
options.setSectionCommits(commits);
options.setEntityFormStatus("Đã load snapshot từ commit (không đổi head trên BE).");
} catch (err) {
if (err instanceof ApiError) {
options.setEntityStatus(`Restore thất bại: ${err.body}`);
@@ -283,3 +301,63 @@ export function useSectionCommands(options: Options) {
restoreCommit,
};
}
function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
return {
...snapshot,
entities: toEditorSessionEntities(snapshot.entities),
wikis: toEditorSessionWikis(snapshot.wikis),
entity_wiki: toEditorSessionEntityWikiLinks(snapshot.entity_wiki),
};
}
function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnapshot[] {
const rows = Array.isArray(input) ? input : [];
return rows
.filter((e) => e && (typeof e.id === "string" || typeof e.id === "number"))
.map((e) => {
const { operation: _op, ...rest } = e;
const id = String(e.id);
const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref";
return {
...(rest as Omit<EntitySnapshot, "id" | "source" | "operation">),
id,
source,
operation: "reference",
};
});
}
function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] {
const rows = Array.isArray(input) ? input : [];
return rows
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
.map((w) => {
const { operation: _op, ...rest } = w;
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
return {
...(rest as Omit<WikiSnapshot, "source" | "operation">),
source,
operation: "reference",
};
});
}
function toEditorSessionEntityWikiLinks(input: EditorSnapshot["entity_wiki"]): EntityWikiLinkSnapshot[] {
const rows = Array.isArray(input) ? input : [];
const deduped = new globalThis.Map<string, EntityWikiLinkSnapshot>();
for (const row of rows) {
if (!row || typeof row.entity_id !== "string" || typeof row.wiki_id !== "string") continue;
if (row.operation === "delete") continue;
const entity_id = row.entity_id.trim();
const wiki_id = row.wiki_id.trim();
if (!entity_id || !wiki_id) continue;
const key = `${entity_id}::${wiki_id}`;
deduped.set(key, { entity_id, wiki_id, operation: "binding" });
}
return Array.from(deduped.values()).sort((a, b) => {
const e = a.entity_id.localeCompare(b.entity_id);
if (e !== 0) return e;
return a.wiki_id.localeCompare(b.wiki_id);
});
}

View File

@@ -16,11 +16,11 @@ export type TimelineRange = {
export type EntityFormState = {
name: string;
slug: string;
type_id: string;
description: string;
};
export type GeometryMetaFormState = {
type_key: string;
time_start: string;
time_end: string;
binding: string;
@@ -29,16 +29,13 @@ export type GeometryMetaFormState = {
export type PendingEntityCreate = {
id: string;
name: string;
slug: string | null;
type_id: string;
description: string | null;
status: number;
};
export type CreatedEntitySummary = {
id: string;
name: string;
type_id?: string | null;
};
export type GeometryPreset = EntityGeometryPreset;

View File

@@ -2,23 +2,16 @@ import { useState } from "react";
import type { Entity } from "@/uhm/types/entities";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { FeatureId } from "@/uhm/types/geo";
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
import type {
CreatedEntitySummary,
EntityFormState,
GeometryMetaFormState,
PendingEntityCreate,
} from "@/uhm/lib/editor/session/sessionTypes";
export function useEntitySessionState() {
// Entities đã persisted từ backend (dùng cho search/binding).
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
// Entities được "pin" vào project dưới dạng reference (không cần chọn geometry).
const [projectEntityRefs, setProjectEntityRefs] = useState<EntitySnapshot[]>([]);
// Entities tạo mới trong phiên nhưng chưa commit lên backend.
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
// Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar).
const [createdEntities, setCreatedEntities] = useState<CreatedEntitySummary[]>([]);
// Entity catalog loaded from backend (global list, used for search/lookup).
const [entityCatalog, setEntityCatalog] = useState<Entity[]>([]);
// Snapshot entity store for the current editor session (single source of truth for snapshot.entities).
const [snapshotEntities, setSnapshotEntities] = useState<EntitySnapshot[]>([]);
// Thông báo trạng thái/lỗi liên quan entity/session.
const [entityStatus, setEntityStatus] = useState<string | null>(null);
// Feature đang được chọn để thao tác bind entities/metadata.
@@ -26,13 +19,13 @@ export function useEntitySessionState() {
// Form tạo entity mới (độc lập).
const [entityForm, setEntityForm] = useState<EntityFormState>({
name: "",
slug: "",
type_id: DEFAULT_ENTITY_TYPE_ID,
description: "",
});
// Danh sách entity IDs đang chọn để bind vào geometry hiện tại.
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
// Form metadata geometry (time range + binding ids).
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
type_key: "",
time_start: "",
time_end: "",
binding: "",
@@ -51,14 +44,10 @@ export function useEntitySessionState() {
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
return {
persistedEntities,
setPersistedEntities,
projectEntityRefs,
setProjectEntityRefs,
pendingEntityCreates,
setPendingEntityCreates,
createdEntities,
setCreatedEntities,
entityCatalog,
setEntityCatalog,
snapshotEntities,
setSnapshotEntities,
entityStatus,
setEntityStatus,
selectedFeatureId,

View File

@@ -51,8 +51,8 @@ export function useSectionSessionState(options: Options) {
const [sectionState, setSectionState] = useState<SectionState | null>(null);
// Danh sách commits của section đang mở.
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
// Snapshot gần nhất đã load (để build snapshot diff/metadata).
const [lastSectionSnapshot, setLastSectionSnapshot] = useState<EditorSnapshot | null>(null);
// Baseline snapshot currently loaded for this editor session.
const [baselineSnapshot, setBaselineSnapshot] = useState<EditorSnapshot | null>(null);
return {
isSaving,
@@ -79,7 +79,7 @@ export function useSectionSessionState(options: Options) {
setSectionState,
sectionCommits,
setSectionCommits,
lastSectionSnapshot,
setLastSectionSnapshot,
baselineSnapshot,
setBaselineSnapshot,
};
}

View File

@@ -3,7 +3,7 @@ import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
export function useWikiSessionState() {
const [wikis, setWikis] = useState<WikiSnapshot[]>([]);
const [entityWikiLinks, setEntityWikiLinks] = useState<EntityWikiLinkSnapshot[]>([]);
return { wikis, setWikis, entityWikiLinks, setEntityWikiLinks };
const [snapshotWikis, setSnapshotWikis] = useState<WikiSnapshot[]>([]);
const [snapshotEntityWikiLinks, setSnapshotEntityWikiLinks] = useState<EntityWikiLinkSnapshot[]>([]);
return { snapshotWikis, setSnapshotWikis, snapshotEntityWikiLinks, setSnapshotEntityWikiLinks };
}

View File

@@ -1,6 +1,6 @@
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
import { typeKeyToGeoTypeCode } from "@/uhm/lib/geoTypeMap";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
@@ -42,17 +42,21 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
.filter(isRecord)
.map((e) => {
const id = getStringId(e.id);
const op = typeof e.operation === "string" ? e.operation : undefined;
const opRaw = typeof e.operation === "string" ? e.operation : undefined;
const operation: EntitySnapshot["operation"] =
opRaw === "delete" ? "delete" : "reference";
const existingSource = e.source === "inline" || e.source === "ref" ? e.source : undefined;
const refId = getRefId(e.ref);
const source: "inline" | "ref" = existingSource || (refId || op === "reference" ? "ref" : "inline");
const source: "inline" | "ref" =
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
const rest: UnknownRecord = { ...e };
delete rest.ref;
return {
...(rest as unknown as Omit<EntitySnapshot, "id" | "source">),
...(rest as unknown as Omit<EntitySnapshot, "id" | "source" | "operation">),
id,
source,
operation,
};
})
: undefined;
@@ -63,6 +67,9 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
.filter(isRecord)
.map((g) => {
const id = getStringId(g.id);
const opRaw = typeof g.operation === "string" ? g.operation : undefined;
const operation: GeometrySnapshot["operation"] =
opRaw === "delete" ? "delete" : "reference";
const existingSource = g.source === "inline" || g.source === "ref" ? g.source : undefined;
const refId = getRefId(g.ref);
const hasInlineGeometry = "draw_geometry" in g || "geometry" in g;
@@ -71,9 +78,10 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
delete rest.ref;
return {
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source">),
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source" | "operation">),
id,
source,
operation,
};
})
: undefined;
@@ -84,17 +92,21 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
.filter(isRecord)
.map((w) => {
const id = typeof w.id === "string" ? w.id : "";
const op = typeof w.operation === "string" ? w.operation : undefined;
const opRaw = typeof w.operation === "string" ? w.operation : undefined;
const operation: WikiSnapshot["operation"] =
opRaw === "delete" ? "delete" : "reference";
const existingSource = w.source === "inline" || w.source === "ref" ? w.source : undefined;
const refId = getRefId(w.ref);
const source: "inline" | "ref" = existingSource || (refId || op === "reference" ? "ref" : "inline");
const source: "inline" | "ref" =
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
const rest: UnknownRecord = { ...w };
delete rest.ref;
return {
...(rest as unknown as Omit<WikiSnapshot, "id" | "source">),
...(rest as unknown as Omit<WikiSnapshot, "id" | "source" | "operation">),
id,
source,
operation,
};
})
: undefined;
@@ -147,8 +159,14 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
: typeof r.is_deleted === "boolean"
? r.is_deleted
: false;
const operation: "reference" | "delete" =
opRaw === "delete" ? "delete" : opRaw === "reference" ? "reference" : isDeleted ? "delete" : "reference";
const operation: "binding" | "delete" =
opRaw === "delete"
? "delete"
: opRaw === "binding" || opRaw === "reference"
? "binding"
: isDeleted
? "delete"
: "binding";
return { entity_id, wiki_id, operation };
})
.filter((r) => r.entity_id.length > 0 && r.wiki_id.length > 0)
@@ -194,10 +212,9 @@ export function buildEditorSnapshot(options: {
section: Section;
draft: FeatureCollection;
changes: Change[];
pendingEntities: PendingEntityCreate[];
projectEntityRefs: EntitySnapshot[];
wikis: WikiSnapshot[];
entityWikiLinks: EntityWikiLinkSnapshot[];
snapshotEntities: EntitySnapshot[];
snapshotWikis: WikiSnapshot[];
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
previousSnapshot: EditorSnapshot | null;
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
}): EditorSnapshot {
@@ -225,40 +242,58 @@ export function buildEditorSnapshot(options: {
if (id && operation) previousGeometryOps.set(id, operation);
}
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
const entityRows = new globalThis.Map<string, EntitySnapshot>();
for (const entity of options.pendingEntities) {
entityRows.set(entity.id, {
id: entity.id,
// Persist inline entity records across commits even when they're not currently bound.
// Without this, "create entity" can disappear on the next commit unless the entity is referenced
// by geometry_entity/entity_wiki or pinned via projectEntityRefs.
for (const prev of options.previousSnapshot?.entities || []) {
if (!prev) continue;
const id = typeof prev.id === "string" || typeof prev.id === "number" ? String(prev.id) : "";
if (!id || entityRows.has(id)) continue;
if (prev.operation === "delete") continue;
if (prev.source !== "inline") continue;
// Carry forward as current-state inline entity; operation is a per-commit delta signal.
const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot;
const { operation: _op, ...rest } = cloned;
entityRows.set(id, {
...rest,
id,
source: "inline",
operation: "create",
name: entity.name,
slug: entity.slug,
description: null,
type_id: entity.type_id,
status: entity.status,
operation: "reference",
});
}
for (const ref of options.projectEntityRefs || []) {
const id = typeof ref?.id === "string" || typeof ref?.id === "number" ? String(ref.id) : "";
if (!id || entityRows.has(id)) continue;
const cloned = JSON.parse(JSON.stringify(ref)) as EntitySnapshot;
for (const row of options.snapshotEntities || []) {
if (!row) continue;
const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : "";
if (!id) continue;
const cloned = JSON.parse(JSON.stringify(row)) as EntitySnapshot;
const name =
typeof cloned?.name === "string" && cloned.name.trim().length
? cloned.name.trim()
: id;
const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref";
entityRows.set(id, {
...cloned,
id,
source: "ref",
source,
name,
operation: cloned.operation ?? "reference",
});
}
// Entities referenced by wiki links should be present as "reference" too.
for (const link of options.entityWikiLinks || []) {
for (const link of options.snapshotEntityWikiLinks || []) {
const id = typeof link?.entity_id === "string" ? link.entity_id : "";
if (!id || entityRows.has(id)) continue;
entityRows.set(id, {
id,
source: "ref",
operation: "reference",
name: id,
slug: null,
description: null,
status: 1,
});
}
@@ -272,7 +307,6 @@ export function buildEditorSnapshot(options: {
name: entityId,
slug: null,
description: null,
type_id: feature.properties.type || DEFAULT_ENTITY_TYPE_ID,
status: 1,
});
}
@@ -294,11 +328,14 @@ export function buildEditorSnapshot(options: {
? "update"
: undefined;
const bbox = getFeatureBBox(feature);
const typeKey = feature.properties.type || getDefaultTypeIdForFeature(feature);
const typeCode = typeKeyToGeoTypeCode(typeKey);
return {
id,
operation,
source: "inline",
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
// BE currently expects geometries[].type as a string. We send the geo_type SMALLINT code as a string.
type: String(typeCode ?? 0),
draw_geometry: feature.geometry,
binding: normalizeFeatureBindingIds(feature),
time_start: feature.properties.time_start ?? null,
@@ -322,11 +359,12 @@ export function buildEditorSnapshot(options: {
});
}
const geometryEntity: GeometryEntitySnapshot[] = options.draft.features.flatMap((feature) => {
const geometryEntityRaw: GeometryEntitySnapshot[] = options.draft.features.flatMap((feature) => {
const geometry_id = String(feature.properties.id);
const entityIds = normalizeFeatureEntityIds(feature);
return entityIds.map((entity_id) => ({ geometry_id, entity_id }));
});
const geometryEntity = dedupeAndSortGeometryEntity(geometryEntityRaw);
// Persist snapshot without denormalized entity fields on features (many-to-many lives in geometry_entity[]).
const draftForSnapshot = JSON.parse(JSON.stringify(options.draft)) as FeatureCollection;
@@ -350,8 +388,17 @@ export function buildEditorSnapshot(options: {
// Operation semantics:
// - create/update/delete: this commit changes the wiki itself
// - reference: this wiki is a ref used for linking (entity<->wiki), not a modification
const wikis: WikiSnapshot[] = (options.wikis || [])
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
const wikis: WikiSnapshot[] = (options.snapshotWikis || [])
.filter((w) => {
if (!w || typeof w.id !== "string" || w.id.trim().length === 0) return false;
if (w.source === "ref") return true;
// Keep explicit operations (e.g. delete) even if content is empty.
if (w.operation === "create" || w.operation === "update" || w.operation === "delete") return true;
// Inline wiki with no content: don't persist it (treat as not written).
const title = typeof w.title === "string" ? w.title.trim() : "";
const doc = typeof w.doc === "string" ? w.doc.trim() : "";
return title.length > 0 || (w.doc !== null && doc.length > 0);
})
.map((w) => {
const prev = previousWikis.get(w.id) || null;
const cloned = JSON.parse(JSON.stringify(w)) as WikiSnapshot;
@@ -388,28 +435,66 @@ export function buildEditorSnapshot(options: {
return cloned;
});
const entityWikis: EntityWikiLinkSnapshot[] = (options.entityWikiLinks || [])
const entityWikisRaw: EntityWikiLinkSnapshot[] = (options.snapshotEntityWikiLinks || [])
.filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string")
.map((l) => ({
entity_id: l.entity_id,
wiki_id: l.wiki_id,
operation: l.operation === "delete" ? "delete" : "reference",
operation: l.operation === "delete" ? "delete" : "binding",
}));
const entityWikis = dedupeAndSortEntityWiki(entityWikisRaw);
return {
editor_feature_collection: draftForSnapshot,
entities: Array.from(entityRows.values()).map((entity) => {
const id = String(entity.id || "");
if (pendingEntityIds.has(id)) return entity;
return entity;
}),
geometries,
entities: Array.from(entityRows.values()).sort((a, b) => String(a.id).localeCompare(String(b.id))),
geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
geometry_entity: geometryEntity,
wikis,
wikis: wikis.slice().sort((a, b) => a.id.localeCompare(b.id)),
entity_wiki: entityWikis,
};
}
function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEntitySnapshot[] {
const seen = new Set<string>();
const deduped: GeometryEntitySnapshot[] = [];
for (const row of rows) {
const geometry_id = typeof row.geometry_id === "string" ? row.geometry_id : "";
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
if (!geometry_id || !entity_id) continue;
const key = `${geometry_id}::${entity_id}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push({ ...row, geometry_id, entity_id });
}
deduped.sort((a, b) => {
const g = a.geometry_id.localeCompare(b.geometry_id);
if (g !== 0) return g;
return a.entity_id.localeCompare(b.entity_id);
});
return deduped;
}
function dedupeAndSortEntityWiki(rows: EntityWikiLinkSnapshot[]): EntityWikiLinkSnapshot[] {
const seen = new Set<string>();
const deduped: EntityWikiLinkSnapshot[] = [];
for (const row of rows) {
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
const wiki_id = typeof row.wiki_id === "string" ? row.wiki_id : "";
if (!entity_id || !wiki_id) continue;
const operation = row.operation === "delete" ? "delete" : "binding";
const key = `${entity_id}::${wiki_id}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push({ entity_id, wiki_id, operation });
}
deduped.sort((a, b) => {
const e = a.entity_id.localeCompare(b.entity_id);
if (e !== 0) return e;
return a.wiki_id.localeCompare(b.wiki_id);
});
return deduped;
}
export function getDefaultTypeIdForFeature(feature: Feature): string {
const preset = feature.properties.geometry_preset;
if (preset === "line") return "defense_line";