import { DEFAULT_GEOMETRY_TYPE_ID } from "@/uhm/lib/map/geo/geometryTypeOptions"; import { normalizeGeoTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geoTypeMap"; import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; import type { Change } from "@/uhm/lib/editor/draft/editorTypes"; import { normalizeBoundWithId, normalizeFeatureBoundWith, normalizeLegacyGeometryBindingIds, } from "@/uhm/lib/editor/geometry/geometryBinding"; import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntitySnapshotOperation } from "@/uhm/types/entities"; import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo"; import type { BattleReplay, EditorSnapshot, GeoFunctionName, MapFunctionName, NarrativeFunctionName, Project, ReplayAction, UIOptionName, } from "@/uhm/types/projects"; import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects"; type UnknownRecord = Record; function isRecord(value: unknown): value is UnknownRecord { return !!value && typeof value === "object" && !Array.isArray(value); } interface RawEntityRow extends UnknownRecord { id?: string | number; operation?: string; source?: string; ref?: { id?: string }; name?: string; description?: string; time_start?: unknown; time_end?: unknown; } interface RawWikiRow extends UnknownRecord { id?: string; operation?: string; source?: string; ref?: { id?: string }; title?: string; slug?: string; doc?: string; } interface RawGeometryRow extends UnknownRecord { id?: string | number; operation?: string; source?: string; ref?: { id?: string }; type?: unknown; geo_type?: unknown; draw_geometry?: unknown; geometry?: unknown; bound_with?: unknown; // Legacy parent->children geometry binding, migrated to child.bound_with on load. binding?: unknown; time_start?: unknown; time_end?: unknown; bbox?: unknown; } interface RawGeometryEntityRow extends UnknownRecord { geometry_id?: string | number; entity_id?: string | number; operation?: string; } interface RawEntityWikiRow extends UnknownRecord { entity_id?: string; wiki_id?: string; operation?: string; is_deleted?: boolean | number; } function sanitizeEntitySnapshotOperation(op: unknown): EntitySnapshotOperation { if (typeof op !== "string") return "reference"; const v = op.trim(); if (v === "create" || v === "update" || v === "delete" || v === "reference") return v; // Defensive: legacy/buggy data sometimes concatenates words (e.g. "reference delete"). // Never guess "delete" here; prefer non-destructive behavior. return "reference"; } function getStringId(value: unknown): string { if (typeof value === "string") return value; if (typeof value === "number") return String(value); return ""; } function getRefId(value: unknown): string { if (!isRecord(value)) return ""; return typeof value.id === "string" ? value.id : ""; } function normalizeApiTimeFields(row: UnknownRecord): void { if ("time_start" in row) row.time_start = normalizeTimelineYearValue(row.time_start); if ("time_end" in row) row.time_end = normalizeTimelineYearValue(row.time_end); } function buildLegacyBoundWithByChildId( geometriesRaw: unknown, fc: FeatureCollection | undefined ): Map { const boundWithByChildId = new Map(); const ingestParentBinding = (parentId: unknown, rawBinding: unknown) => { const normalizedParentId = normalizeBoundWithId(parentId); if (!normalizedParentId) return; for (const childId of normalizeLegacyGeometryBindingIds(rawBinding)) { if (childId === normalizedParentId || boundWithByChildId.has(childId)) continue; boundWithByChildId.set(childId, normalizedParentId); } }; if (Array.isArray(geometriesRaw)) { for (const raw of geometriesRaw) { if (!isRecord(raw)) continue; ingestParentBinding(raw.id, raw.binding); } } for (const feature of fc?.features || []) { ingestParentBinding(feature.properties?.id, (feature.properties as unknown as UnknownRecord)?.binding); } return boundWithByChildId; } export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { if (!isRecord(raw)) return null; const snapshot = raw as UnknownRecord; // Accept legacy snapshots (v1) and new ones (v2+). We only require that a FeatureCollection, // if present, is structurally valid. Everything else is treated as optional. const fcRaw = snapshot.editor_feature_collection; const fc: FeatureCollection | undefined = isRecord(fcRaw) && fcRaw.type === "FeatureCollection" && Array.isArray(fcRaw.features) ? (fcRaw as unknown as FeatureCollection) : undefined; const entitiesRaw = snapshot.entities; const entities: EntitySnapshot[] | undefined = Array.isArray(entitiesRaw) ? entitiesRaw .filter(isRecord) .map((e) => { const id = getStringId(e.id); 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 || opRaw === "reference" ? "ref" : "inline"); return { id, source, operation, name: typeof e.name === "string" ? e.name : undefined, description: typeof e.description === "string" ? e.description : e.description == null ? undefined : undefined, time_start: normalizeTimelineYearValue(e.time_start) ?? undefined, time_end: normalizeTimelineYearValue(e.time_end) ?? undefined, }; }) : undefined; const geometriesRaw = snapshot.geometries; const legacyBoundWithByChildId = buildLegacyBoundWithByChildId(geometriesRaw, fc); const geometries: GeometrySnapshot[] | undefined = Array.isArray(geometriesRaw) ? geometriesRaw .filter(isRecord) .map((g) => { const row = g as RawGeometryRow; const id = getStringId(row.id); const opRaw = typeof row.operation === "string" ? row.operation : undefined; const operation: GeometrySnapshot["operation"] = opRaw === "delete" ? "delete" : "reference"; const existingSource = row.source === "inline" || row.source === "ref" ? row.source : undefined; const refId = getRefId(row.ref); const hasInlineGeometry = "draw_geometry" in row || "geometry" in row; const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline"); const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type); const canonicalBoundWith = "bound_with" in row ? normalizeBoundWithId(row.bound_with) : undefined; const boundWith = canonicalBoundWith !== undefined ? canonicalBoundWith : legacyBoundWithByChildId.get(id); return { id, source, operation, type: typeKey, draw_geometry: row.draw_geometry as GeometrySnapshot["draw_geometry"], geometry: row.geometry as GeometrySnapshot["geometry"], bound_with: boundWith, time_start: normalizeTimelineYearValue(row.time_start) ?? undefined, time_end: normalizeTimelineYearValue(row.time_end) ?? undefined, bbox: isRecord(row.bbox) ? { min_lng: Number(row.bbox.min_lng), min_lat: Number(row.bbox.min_lat), max_lng: Number(row.bbox.max_lng), max_lat: Number(row.bbox.max_lat), } : row.bbox == null ? undefined : undefined, }; }) : undefined; const wikisRaw = snapshot.wikis; const wikis: WikiSnapshot[] | undefined = Array.isArray(wikisRaw) ? wikisRaw .filter(isRecord) .map((w) => { const row = w as RawWikiRow; const id = typeof w.id === "string" ? w.id : ""; 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 || opRaw === "reference" ? "ref" : "inline"); return { id, source, operation, title: typeof row.title === "string" ? row.title : "", slug: row.slug ?? null, doc: typeof row.doc === "string" ? row.doc : null, }; }) : undefined; // Legacy snapshots used link_scopes[{geometry_id, operation, entity_ids[]}]. New snapshots prefer // geometry_entity[{geometry_id, entity_id}]. If geometry_entity is missing but link_scopes exists, // migrate it by expanding each entity_id into a join row. const geometryEntityRaw = snapshot.geometry_entity; const geometryEntity: GeometryEntitySnapshot[] | undefined = Array.isArray(geometryEntityRaw) ? geometryEntityRaw .filter(isRecord) .map((r) => { const row = r as RawGeometryEntityRow; const geometry_id = getStringId(row.geometry_id); const entity_id = typeof row.entity_id === "string" ? row.entity_id : ""; return { geometry_id, entity_id, }; }) .filter((r) => r.geometry_id.length > 0 && r.entity_id.length > 0) : undefined; const legacyLinkScopes = snapshot.link_scopes; const migratedGeometryEntity: GeometryEntitySnapshot[] | undefined = !geometryEntity && Array.isArray(legacyLinkScopes) ? legacyLinkScopes .filter(isRecord) .flatMap((s) => { const geometry_id = getStringId(s.geometry_id); const entity_ids = Array.isArray(s.entity_ids) ? s.entity_ids.filter((x): x is string => typeof x === "string" && x.trim().length > 0) : []; return entity_ids.map((entity_id) => ({ geometry_id, entity_id: entity_id.trim() })) .filter((row) => row.geometry_id.length > 0 && row.entity_id.length > 0); }) : undefined; const entityWikisRaw = snapshot.entity_wiki ?? snapshot.entity_wikis; const entityWikis: EntityWikiLinkSnapshot[] | undefined = Array.isArray(entityWikisRaw) ? entityWikisRaw .filter(isRecord) .map((r) => { const entity_id = typeof r.entity_id === "string" ? r.entity_id : ""; const wiki_id = typeof r.wiki_id === "string" ? r.wiki_id : ""; const opRaw = typeof r.operation === "string" ? r.operation.trim() : ""; const isDeleted = typeof r.is_deleted === "number" ? r.is_deleted === 1 : typeof r.is_deleted === "boolean" ? r.is_deleted : false; const operation: EntityWikiLinkSnapshot["operation"] = isDeleted || opRaw === "delete" ? "delete" : opRaw === "binding" ? "binding" : "reference"; return { entity_id, wiki_id, operation }; }) .filter((r) => r.entity_id.length > 0 && r.wiki_id.length > 0) : undefined; // For editor UX, re-hydrate entity ids on features from geometry_entity. Snapshot persistence does not // store entity_id/entity_ids/entity_names on features anymore. const fcForEditor: FeatureCollection | undefined = (() => { if (!fc) return undefined; const hasLinks = Boolean(geometryEntity || migratedGeometryEntity); const links = geometryEntity || migratedGeometryEntity || []; const byGeom = new Map(); for (const row of links) { if ((row as RawGeometryEntityRow).operation === "delete") continue; const list = byGeom.get(row.geometry_id) || []; list.push(row.entity_id); byGeom.set(row.geometry_id, list); } const entityNameById = new Map(); const entityTimeById = new Map(); for (const r of entities || []) { const row = r as RawEntityRow; const id = typeof row?.id === "string" ? row.id : ""; if (!id) continue; const name = typeof row.name === "string" ? String(row.name).trim() : ""; if (name) entityNameById.set(id, name); entityTimeById.set(id, { time_start: normalizeTimelineYearValue(row.time_start), time_end: normalizeTimelineYearValue(row.time_end), }); } const geometryById = new Map(); for (const row of geometries || []) { const id = typeof row?.id === "string" ? row.id : ""; if (!id) continue; geometryById.set(id, row); } const cloned = JSON.parse(JSON.stringify(fc)) as FeatureCollection; for (const feature of cloned.features) { const gid = String(feature.properties.id); const entity_ids = byGeom.get(gid) || []; const p = feature.properties as unknown as UnknownRecord; const existingBoundWith = "bound_with" in p ? normalizeBoundWithId(p.bound_with) : undefined; const migratedBoundWith = legacyBoundWithByChildId.get(gid); if (existingBoundWith !== undefined) { p.bound_with = existingBoundWith; } else if (migratedBoundWith !== undefined) { p.bound_with = migratedBoundWith; } else { delete p.bound_with; } delete p.binding; const existingTimeStart = normalizeTimelineYearValue(p.time_start); const existingTimeEnd = normalizeTimelineYearValue(p.time_end); if (existingTimeStart !== null) { p.time_start = existingTimeStart; } else { delete p.time_start; } if (existingTimeEnd !== null) { p.time_end = existingTimeEnd; } else { delete p.time_end; } const existingTypeKey = normalizeGeoTypeKey(p.type) || normalizeGeoTypeKey(p.entity_type_id); const fallbackTypeKey = getDefaultTypeIdForFeature(feature); if (existingTypeKey) p.type = existingTypeKey; if (entity_ids.length || hasLinks) { p.entity_ids = entity_ids; p.entity_id = entity_ids[0] || null; // Generate denormalized names for UI/map usage. const primaryId = entity_ids[0] || null; const primaryName = primaryId ? (entityNameById.get(primaryId) || "") : ""; const names = entity_ids.map((id) => entityNameById.get(id) || "").filter((n) => n.length > 0); p.entity_name = primaryName || null; p.entity_names = names; p.entity_label_candidates = entity_ids .map((id) => { const name = entityNameById.get(id) || ""; if (!name) return null; const time = entityTimeById.get(id) || { time_start: null, time_end: null }; return { id, name, time_start: time.time_start, time_end: time.time_end, }; }) .filter((candidate) => candidate !== null); } // Generate geometry metadata onto feature properties (optional in persisted snapshot). const geo = geometryById.get(gid) || null; const existingSource = p.source === "inline" || p.source === "ref" ? p.source : undefined; if (geo) { const geoRecord = geo as unknown as UnknownRecord; // type can arrive as numeric geo_type, numeric string, or semantic key depending on backend version. const typeKey = normalizeGeoTypeKey(geoRecord.type) || normalizeGeoTypeKey(geoRecord.geo_type) || existingTypeKey || fallbackTypeKey; if (typeKey) p.type = typeKey; if (geo.bound_with !== undefined) { p.bound_with = geo.bound_with; } p.source = geo.source || existingSource || "inline"; const timeStart = normalizeTimelineYearValue(geo.time_start); const timeEnd = normalizeTimelineYearValue(geo.time_end); if (timeStart !== null) { p.time_start = timeStart; } else { delete p.time_start; } if (timeEnd !== null) { p.time_end = timeEnd; } else { delete p.time_end; } } else { p.source = existingSource || "inline"; if (!existingTypeKey) { p.type = fallbackTypeKey; } } } return cloned; })(); return { ...(snapshot as unknown as EditorSnapshot), editor_feature_collection: fcForEditor, entities, geometries, wikis, geometry_entity: geometryEntity || migratedGeometryEntity, entity_wiki: entityWikis, replays: normalizeReplaySnapshots(snapshot.replays), }; } export function buildEditorSnapshot(options: { project: Project; draft: FeatureCollection; changes: Change[]; snapshotEntityRows: EntitySnapshot[]; snapshotWikis: WikiSnapshot[]; snapshotEntityWikiLinks: EntityWikiLinkSnapshot[]; replays: BattleReplay[]; previousSnapshot: EditorSnapshot | null; hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean; }): EditorSnapshot { const changedIds = new Set(options.changes.map((change) => String(change.action === "create" ? change.feature.properties.id : change.id) )); const deletedIds = new Set( options.changes .filter((change): change is Extract => change.action === "delete") .map((change) => String(change.id)) ); const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id))); const previousFeatures = new globalThis.Map(); for (const feature of options.previousSnapshot?.editor_feature_collection?.features || []) { previousFeatures.set(String(feature.properties.id), feature); if (!currentDraftIds.has(String(feature.properties.id))) { deletedIds.add(String(feature.properties.id)); } } const previousGeometryOps = new globalThis.Map(); for (const item of options.previousSnapshot?.geometries || []) { const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : ""; const operation = item.operation; if (id && operation) previousGeometryOps.set(id, operation); } const entityRows = new globalThis.Map(); // 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; delete cloned.operation; entityRows.set(id, { id, source: "inline", operation: "reference", name: typeof cloned.name === "string" ? cloned.name : undefined, description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null, time_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined, time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined, }); } for (const row of options.snapshotEntityRows || []) { 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"; const opRaw = sanitizeEntitySnapshotOperation((cloned as RawEntityRow).operation); // Editor state should delete objects by removing them from the list. // Keep this defensive guard to avoid emitting delete markers unexpectedly. if (opRaw === "delete") continue; const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw; entityRows.set(id, { id, source, name, operation, description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null, time_start: normalizeTimelineYearValue(cloned.time_start) ?? undefined, time_end: normalizeTimelineYearValue(cloned.time_end) ?? undefined, }); } // Entities referenced by wiki links should be present as "reference" too. 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, description: null, }); } for (const feature of options.draft.features) { for (const entityId of normalizeFeatureEntityIds(feature)) { if (entityRows.has(entityId)) continue; entityRows.set(entityId, { id: entityId, source: "ref", operation: "reference", name: entityId, description: null, }); } } const geometries: GeometrySnapshot[] = options.draft.features.map((feature) => { const id = String(feature.properties.id); const previousOperation = previousGeometryOps.get(id); const previousFeature = previousFeatures.get(id); const changedFromPreviousSnapshot = previousFeature ? JSON.stringify(previousFeature) !== JSON.stringify(feature) : false; const operation: GeometrySnapshot["operation"] = previousOperation === "create" ? "create" : !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id)) ? "create" : changedIds.has(id) || changedFromPreviousSnapshot ? "update" : "reference"; const bbox = getFeatureBBox(feature); const typeKey = normalizeGeoTypeKey(feature.properties.type) || getDefaultTypeIdForFeature(feature); const timeStart = normalizeTimelineYearValue(feature.properties.time_start); const timeEnd = normalizeTimelineYearValue(feature.properties.time_end); return { id, operation, source: feature.properties.source || "inline", type: typeKey, draw_geometry: feature.geometry, bound_with: normalizeFeatureBoundWith(feature), time_start: timeStart, time_end: timeEnd, bbox: bbox ? { min_lng: bbox.minLng, min_lat: bbox.minLat, max_lng: bbox.maxLng, max_lat: bbox.maxLat, } : null, }; }); for (const id of deletedIds) { geometries.push({ id, source: "ref", operation: "delete", }); } const baselineGeometryEntity = new Set(); for (const r of options.previousSnapshot?.geometry_entity || []) { const row = r as RawGeometryEntityRow; if (!row) continue; if (row.operation === "delete") continue; const geometry_id = typeof row.geometry_id === "string" || typeof row.geometry_id === "number" ? String(row.geometry_id).trim() : ""; const entity_id = typeof row.entity_id === "string" || typeof row.entity_id === "number" ? String(row.entity_id).trim() : ""; if (!geometry_id || !entity_id) continue; baselineGeometryEntity.add(`${geometry_id}::${entity_id}`); } const currentGeometryEntityRows: GeometryEntitySnapshot[] = []; const currentGeometryEntityKeys = new Set(); for (const feature of options.draft.features) { const geometry_id = String(feature.properties.id).trim(); if (!geometry_id) continue; for (const entity_id of normalizeFeatureEntityIds(feature)) { const key = `${geometry_id}::${entity_id}`; if (currentGeometryEntityKeys.has(key)) continue; currentGeometryEntityKeys.add(key); currentGeometryEntityRows.push({ geometry_id, entity_id, operation: baselineGeometryEntity.has(key) ? "reference" : "binding", }); } } // Relations removed during this session are emitted as "delete" operations. // NOTE: The editor state itself should remove the relation row; the commit payload is the delta. for (const key of baselineGeometryEntity.values()) { if (currentGeometryEntityKeys.has(key)) continue; const [geometry_id, entity_id] = key.split("::"); if (!geometry_id || !entity_id) continue; currentGeometryEntityRows.push({ geometry_id, entity_id, operation: "delete" }); } const geometryEntity = dedupeAndSortGeometryEntity(currentGeometryEntityRows); // 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; for (const feature of draftForSnapshot.features) { const p = feature.properties as unknown as UnknownRecord; // Do not send generate-only fields on the API payload. These are re-generated on load. delete p.type; delete p.time_start; delete p.time_end; delete p.bound_with; delete p.binding; delete p.source; delete p.entity_id; delete p.entity_ids; delete p.entity_name; delete p.entity_names; delete p.entity_label_candidates; delete p.entity_type_id; } const previousWikis = new globalThis.Map(); for (const item of options.previousSnapshot?.wikis || []) { if (!item || typeof item !== "object") continue; if ((item as RawWikiRow).operation === "delete") continue; const id = (item as WikiSnapshot).id; if (typeof id === "string" && id.length > 0) previousWikis.set(id, item as WikiSnapshot); } // Wikis in snapshot_json are treated as current state (not a delta-table like geometries[]). // 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 wikisCurrent: 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") 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; // Ref wiki: always mark as reference (used for linking, not changed here). if (cloned.source === "ref") { cloned.operation = "reference"; return cloned; } // Inline wiki: if explicitly marked create/update/delete by UI, keep it. if (cloned.operation === "create" || cloned.operation === "update" || cloned.operation === "delete") { return cloned; } // Inline wiki with no explicit operation: // Keep a valid operation value, because backend validation may require it (oneof). if (!prev) { // New wiki that somehow has no op set: treat as create. cloned.operation = "create"; return cloned; } const changed = (() => { try { const prevComparable = { title: prev.title, doc: prev.doc }; const nextComparable = { title: cloned.title, doc: cloned.doc }; return JSON.stringify(prevComparable) !== JSON.stringify(nextComparable); } catch { return true; } })(); cloned.operation = changed ? "update" : "reference"; return cloned; }); // Wikis removed during this session are emitted as "delete" operations. const currentWikiIds = new Set(wikisCurrent.map((w) => w.id)); const deletedWikis: WikiSnapshot[] = []; for (const prev of previousWikis.values()) { if (!prev?.id) continue; if (currentWikiIds.has(prev.id)) continue; const row = prev as RawWikiRow; deletedWikis.push({ id: prev.id, source: prev.source === "inline" ? "inline" : "ref", operation: "delete", title: typeof prev.title === "string" ? prev.title : "Untitled wiki", slug: row.slug ?? null, doc: row.doc ?? null, } as WikiSnapshot); } const wikis = [...wikisCurrent, ...deletedWikis]; const baselineEntityWiki = new Set(); for (const r of options.previousSnapshot?.entity_wiki || []) { const row = r as RawEntityWikiRow; 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; baselineEntityWiki.add(`${entity_id}::${wiki_id}`); } const currentEntityWikiKeys = new Set(); const entityWikisDelta: EntityWikiLinkSnapshot[] = []; for (const l of options.snapshotEntityWikiLinks || []) { if (!l || typeof l.entity_id !== "string" || typeof l.wiki_id !== "string") continue; if (l.operation === "delete") continue; const entity_id = l.entity_id.trim(); const wiki_id = l.wiki_id.trim(); if (!entity_id || !wiki_id) continue; const key = `${entity_id}::${wiki_id}`; if (currentEntityWikiKeys.has(key)) continue; currentEntityWikiKeys.add(key); entityWikisDelta.push({ entity_id, wiki_id, operation: baselineEntityWiki.has(key) ? "reference" : "binding", }); } for (const key of baselineEntityWiki) { if (currentEntityWikiKeys.has(key)) continue; const [entity_id, wiki_id] = key.split("::"); if (!entity_id || !wiki_id) continue; entityWikisDelta.push({ entity_id, wiki_id, operation: "delete" }); } const entityWikis = dedupeAndSortEntityWiki(entityWikisDelta); return { editor_feature_collection: draftForSnapshot, entities: Array.from(entityRows.values()) .map((e) => ({ id: e.id, source: e.source, operation: e.operation, name: typeof e.name === "string" ? e.name : undefined, description: typeof (e as RawEntityRow).description === "string" ? (e as RawEntityRow).description : (e as RawEntityRow).description ?? null, time_start: normalizeTimelineYearValue(e.time_start) ?? undefined, time_end: normalizeTimelineYearValue(e.time_end) ?? undefined, })) .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 .map((w) => ({ id: w.id, source: w.source, operation: w.operation, title: w.title, slug: (w as RawWikiRow).slug ?? null, doc: (w as RawWikiRow).doc ?? null, })) .sort((a, b) => a.id.localeCompare(b.id)), entity_wiki: entityWikis, replays: options.replays, }; } export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot { const cloned = JSON.parse(JSON.stringify(snapshot)) as EditorSnapshot; if (Array.isArray(cloned.editor_feature_collection?.features)) { cloned.editor_feature_collection.features = cloned.editor_feature_collection.features.map((feature) => { const properties = { ...(feature.properties as unknown as UnknownRecord) }; normalizeApiTimeFields(properties); return { ...feature, properties: properties as unknown as Feature["properties"], }; }); } if (Array.isArray(cloned.entities)) { cloned.entities = cloned.entities.map((entity) => { const row = { ...(entity as unknown as UnknownRecord) }; normalizeApiTimeFields(row); return row as unknown as EntitySnapshot; }); } if (Array.isArray(cloned.geometries)) { cloned.geometries = cloned.geometries.map((geometry) => { const row = { ...(geometry as unknown as UnknownRecord) }; const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type); delete row.geo_type; normalizeApiTimeFields(row); if (typeKey) { const typeCode = typeKeyToGeoTypeCode(typeKey); row.type = typeCode == null ? null : String(typeCode); } else if ("type" in row) { row.type = null; } return row as unknown as GeometrySnapshot; }); } if (Array.isArray(cloned.replays)) { cloned.replays = cloned.replays.map((replay) => { const geometryId = typeof replay?.geometry_id === "string" ? replay.geometry_id : ""; return { id: geometryId, geometry_id: geometryId, target_geometry_ids: normalizeReplayTargetGeometryIds(replay as unknown, geometryId), detail: Array.isArray(replay?.detail) ? replay.detail : [], }; }); } return cloned; } function normalizeReplaySnapshots(value: unknown): BattleReplay[] | undefined { if (!Array.isArray(value)) return undefined; return value.map((replay) => normalizeReplaySnapshot(replay as BattleReplay)); } function normalizeReplaySnapshot(replay: BattleReplay): BattleReplay { const geometryId = typeof replay?.geometry_id === "string" && replay.geometry_id.trim().length > 0 ? replay.geometry_id : typeof (replay as unknown as { id?: unknown })?.id === "string" ? (replay as unknown as { id: string }).id : ""; return { id: geometryId, geometry_id: geometryId, target_geometry_ids: normalizeReplayTargetGeometryIds(replay, geometryId), detail: Array.isArray(replay.detail) ? replay.detail.map((stage) => ({ ...stage, steps: Array.isArray(stage.steps) ? stage.steps.map((step) => { const { mapActions, geoActions } = normalizeReplayMapAndGeoActions( isRecord(step) ? step.use_map_function : undefined, isRecord(step) ? step.use_geo_function : undefined ); return { ...step, use_UI_function: normalizeReplayUiActions(isRecord(step) ? step.use_UI_function : undefined), use_map_function: mapActions, use_geo_function: geoActions, use_narrow_function: normalizeReplayNarrativeActions( isRecord(step) ? step.use_narrow_function : undefined ), }; }) : [], })) : [], }; } function normalizeReplayTargetGeometryIds(replay: unknown, geometryId: string): string[] { const orderedIds: string[] = []; const seen = new Set(); const pushId = (rawId: unknown) => { if (typeof rawId !== "string" && typeof rawId !== "number") return; const id = String(rawId).trim(); if (!id || seen.has(id)) return; seen.add(id); orderedIds.push(id); }; pushId(geometryId); if (isRecord(replay) && Array.isArray(replay.target_geometry_ids)) { for (const rawId of replay.target_geometry_ids) pushId(rawId); } if (isRecord(replay) && isRecord(replay.replay_features) && Array.isArray(replay.replay_features.features)) { for (const feature of replay.replay_features.features) { if (!isRecord(feature) || !isRecord(feature.properties)) continue; pushId(feature.properties.id); } } return orderedIds; } function normalizeReplayUiActions(actions: unknown): ReplayAction[] { if (!Array.isArray(actions)) return []; const normalized: ReplayAction[] = []; for (const action of actions) { if (!isRecord(action)) continue; let functionName = action.function_name; let params = Array.isArray(action.params) ? action.params : []; if (functionName === "UI") { functionName = params[0]; params = params.slice(1); } switch (functionName) { case "timeline": case "layer_panel": case "zoom_panel": case "toast": normalized.push({ function_name: functionName, params: [params[0]], }); break; case "wiki": normalized.push({ function_name: "wiki", params: [params[0] || null], }); break; case "close_wiki_panel": case "wiki_panel": if (functionName === "close_wiki_panel" || (functionName === "wiki_panel" && !params[0])) { normalized.push({ function_name: "wiki", params: [null], }); } break; default: break; } } return normalized; } function normalizeReplayMapAndGeoActions( mapActions: unknown, geoActions: unknown ): { mapActions: ReplayAction[]; geoActions: ReplayAction[]; } { const combinedActions = [ ...(Array.isArray(mapActions) ? mapActions : []), ...(Array.isArray(geoActions) ? geoActions : []), ]; const normalizedMapActions: ReplayAction[] = []; const normalizedGeoActions: ReplayAction[] = []; for (const action of combinedActions) { if (!isRecord(action)) continue; const functionName = action.function_name; const params = Array.isArray(action.params) ? action.params : []; switch (functionName) { // --- Map Functions --- case "set_camera_view": normalizedMapActions.push({ function_name: "set_camera_view", params: [params[0]], }); break; case "set_timeline_filter": normalizedMapActions.push({ function_name: "set_timeline_filter", params: [Boolean(params[0])], }); break; case "enable_timeline_filter": case "disable_timeline_filter": normalizedMapActions.push({ function_name: "set_timeline_filter", params: [functionName === "enable_timeline_filter"], }); break; case "set_labels_visible": normalizedMapActions.push({ function_name: "set_labels_visible", params: [Boolean(params[0])], }); break; case "toggle_labels": normalizedMapActions.push({ function_name: "set_labels_visible", params: [Boolean(params[0])], }); break; case "show_labels": case "hide_labels": normalizedMapActions.push({ function_name: "set_labels_visible", params: [functionName === "show_labels"], }); break; case "reset_camera_north": normalizedMapActions.push({ function_name: "set_camera_view", params: [{ bearing: 0 }], }); break; // --- Geo Functions --- case "fly_to_geometry": { const id = String(params[0] || ""); if (id) { const duration = typeof params[3] === "number" ? params[3] : undefined; normalizedGeoActions.push({ function_name: "fly_to_geometries", params: [[id], duration], }); } break; } case "fly_to_geometries": { const ids = Array.isArray(params[0]) ? params[0].map(String) : []; if (ids.length > 0) { const duration = typeof params[1] === "number" ? params[1] : undefined; normalizedGeoActions.push({ function_name: "fly_to_geometries", params: [ids, duration], }); } break; } case "fit_to_geometries": { const ids = Array.isArray(params[0]) ? params[0].map(String) : []; if (ids.length > 0) { const duration = typeof params[1] === "number" ? params[1] : undefined; normalizedGeoActions.push({ function_name: "fly_to_geometries", params: [ids, duration], }); } break; } case "set_geometry_visibility": { const ids = Array.isArray(params[0]) ? params[0].map(String) : []; const visible = params[1] !== undefined ? Boolean(params[1]) : true; if (ids.length > 0) { normalizedGeoActions.push({ function_name: "set_geometry_visibility", params: [ids, visible], }); } break; } case "show_geometries": { const ids = Array.isArray(params[0]) ? params[0].map(String) : []; if (ids.length > 0) { normalizedGeoActions.push({ function_name: "set_geometry_visibility", params: [ids, true], }); } break; } case "hide_geometries": { const ids = Array.isArray(params[0]) ? params[0].map(String) : []; if (ids.length > 0) { normalizedGeoActions.push({ function_name: "set_geometry_visibility", params: [ids, false], }); } break; } case "follow_geometry_path": { const id = String(params[0] || ""); if (id) { const duration = typeof params[1] === "number" ? params[1] : undefined; const zoom = typeof params[2] === "number" ? params[2] : undefined; const pitch = typeof params[3] === "number" ? params[3] : undefined; normalizedGeoActions.push({ function_name: "follow_geometries_path", params: [[id], duration, zoom, pitch], }); } break; } case "follow_geometries_path": { const ids = Array.isArray(params[0]) ? params[0].map(String) : []; if (ids.length > 0) { const duration = typeof params[1] === "number" ? params[1] : undefined; const zoom = typeof params[2] === "number" ? params[2] : undefined; const pitch = typeof params[3] === "number" ? params[3] : undefined; normalizedGeoActions.push({ function_name: "follow_geometries_path", params: [ids, duration, zoom, pitch], }); } break; } case "dim_other_geometries": case "hide_others_geometries": { const ids = Array.isArray(params[0]) ? params[0].map(String) : []; normalizedGeoActions.push({ function_name: "hide_others_geometries", params: [ids], }); break; } case "pulse_geometry": normalizedGeoActions.push({ function_name: "pulse_geometry", params, }); break; case "animate_dashed_border": normalizedGeoActions.push({ function_name: "animate_dashed_border", params, }); break; case "set_geometry_style": normalizedGeoActions.push({ function_name: "set_geometry_style", params, }); break; case "orbit_camera_around_geometry": normalizedGeoActions.push({ function_name: "orbit_camera_around_geometry", params, }); break; default: break; } } return { mapActions: normalizedMapActions, geoActions: normalizedGeoActions, }; } function normalizeReplayNarrativeActions(actions: unknown): ReplayAction[] { if (!Array.isArray(actions)) return []; let avatar = ""; let text = ""; let image_url = ""; let image_caption = ""; let hasDialog = false; let isCleared = false; for (const action of actions) { if (!isRecord(action)) continue; const functionName = action.function_name; const params = Array.isArray(action.params) ? action.params : []; switch (functionName) { case "set_dialog": { const data = params[0]; if (data && typeof data === "object") { hasDialog = true; avatar = String((data as any).avatar || avatar); text = String((data as any).text || text); image_url = String((data as any).image_url || image_url); image_caption = String((data as any).image_caption || image_caption); } else if (data === null) { isCleared = true; } break; } case "show_dialog_box": hasDialog = true; avatar = String(params[0] || avatar); text = String(params[1] || text); break; case "set_title": hasDialog = true; if (!text) { text = String(params[0] || ""); } break; case "set_descriptions": hasDialog = true; if (!text) { text = String(params[0] || ""); } else if (params[0]) { text = text + "\n" + String(params[0]); } break; case "set_step_subtitle": hasDialog = true; if (!text) { text = String(params[0] || ""); } break; case "display_historical_image": hasDialog = true; image_url = String(params[0] || image_url); image_caption = String(params[1] || image_caption); break; case "clear_dialog_box": case "clear_title": case "clear_descriptions": case "clear_historical_image": case "clear_step_subtitle": isCleared = true; break; default: break; } } if (hasDialog) { const dialogData: any = { avatar, text, }; if (image_url) { dialogData.image_url = image_url; } if (image_caption) { dialogData.image_caption = image_caption; } return [{ function_name: "set_dialog", params: [dialogData], }]; } if (isCleared) { return [{ function_name: "set_dialog", params: [null], }]; } return []; } function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEntitySnapshot[] { const seen = new Set(); 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 opRaw = (row as RawGeometryEntityRow).operation; const operation: GeometryEntitySnapshot["operation"] = opRaw === "delete" ? "delete" : opRaw === "binding" || opRaw === "reference" ? opRaw : undefined; const key = `${geometry_id}::${entity_id}`; if (seen.has(key)) continue; seen.add(key); deduped.push({ ...row, geometry_id, entity_id, operation }); } 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(); 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 opRaw = row.operation; const operation: EntityWikiLinkSnapshot["operation"] = opRaw === "delete" ? "delete" : opRaw === "binding" || opRaw === "reference" ? opRaw : "reference"; 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"; if (preset === "point") return "city"; if (preset === "circle-area") return "war"; if (preset === "polygon") return DEFAULT_GEOMETRY_TYPE_ID; const geometryType = feature.geometry.type; if (geometryType === "LineString" || geometryType === "MultiLineString") { return "defense_line"; } if (geometryType === "Point" || geometryType === "MultiPoint") { return "city"; } return DEFAULT_GEOMETRY_TYPE_ID; } export function normalizeFeatureEntityIds(feature: Feature): string[] { const fromArray = Array.isArray(feature.properties.entity_ids) ? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0) : []; if (fromArray.length) { return uniqueEntityIds(fromArray); } const single = feature.properties.entity_id; if (typeof single === "string" && single.trim().length > 0) { return [single.trim()]; } return []; } export function uniqueEntityIds(ids: string[]): string[] { const deduped: string[] = []; const seen = new Set(); for (const rawId of ids) { const id = rawId.trim(); if (!id || seen.has(id)) continue; seen.add(id); deduped.push(id); } return deduped; } function getFeatureBBox(feature: Feature): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null { const points = collectCoordinatePairs(feature.geometry.coordinates); if (!points.length) return null; let minLng = Number.POSITIVE_INFINITY; let minLat = Number.POSITIVE_INFINITY; let maxLng = Number.NEGATIVE_INFINITY; let maxLat = Number.NEGATIVE_INFINITY; for (const [lng, lat] of points) { minLng = Math.min(minLng, lng); minLat = Math.min(minLat, lat); maxLng = Math.max(maxLng, lng); maxLat = Math.max(maxLat, lat); } return { minLng, minLat, maxLng, maxLat }; } function collectCoordinatePairs(value: unknown): Array<[number, number]> { if (!Array.isArray(value)) return []; if ( value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number" && Number.isFinite(value[0]) && Number.isFinite(value[1]) ) { return [[value[0], value[1]]]; } return value.flatMap((item) => collectCoordinatePairs(item)); }