change snapshot commit to new format
This commit is contained in:
+3
-1
@@ -1,5 +1,6 @@
|
||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { jsonRequestInit, requestJson } from "@/uhm/api/http";
|
||||
import { clearStoredTokens, setStoredTokens } from "@/auth/tokenStore";
|
||||
|
||||
export type AuthTokens = {
|
||||
access_token: string;
|
||||
@@ -18,14 +19,15 @@ export async function signIn(email: string, password: string): Promise<AuthToken
|
||||
const res = await requestJson<AuthTokens>(
|
||||
API_ENDPOINTS.authSignin,
|
||||
jsonRequestInit("POST", { email, password }),
|
||||
// Sign-in sets httpOnly cookies in BackEndGo.
|
||||
{ skipAuth: true }
|
||||
);
|
||||
if (res?.access_token && res?.refresh_token) setStoredTokens(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await requestJson(API_ENDPOINTS.authLogout, { method: "POST" });
|
||||
clearStoredTokens();
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(): Promise<CurrentUser> {
|
||||
|
||||
+64
-12
@@ -1,5 +1,6 @@
|
||||
import type { ApiEnvelope } from "@/uhm/types/api";
|
||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { getAccessToken, getRefreshToken, setStoredTokens, type StoredTokens, extractTokensFromResponsePayload } from "@/auth/tokenStore";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
@@ -15,12 +16,12 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// BackEndGo auth flow: cookie-based (httpOnly access_token/refresh_token).
|
||||
// We intentionally do not store bearer tokens in localStorage in this FE.
|
||||
// History API auth flow supports Bearer JWT and (in some deployments) cookie-based sessions.
|
||||
|
||||
type RequestJsonOptions = {
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
authToken?: string | null; // Override bearer token (used for refresh).
|
||||
};
|
||||
|
||||
export async function requestJson<T>(
|
||||
@@ -142,25 +143,76 @@ function stringifyPayload(payload: unknown): string {
|
||||
function withAuthHeaders(init: RequestInit | undefined, options?: RequestJsonOptions): RequestInit | undefined {
|
||||
const baseInit: RequestInit = {
|
||||
...init,
|
||||
// Always include cookies (BackEndGo sets httpOnly access_token/refresh_token cookies).
|
||||
credentials: init?.credentials ?? "include",
|
||||
};
|
||||
|
||||
// Cookie-based auth only.
|
||||
// Keep the function so call sites don't change, but never inject Authorization headers.
|
||||
const headers = new Headers(baseInit.headers || undefined);
|
||||
|
||||
const override = options?.authToken;
|
||||
if (override) {
|
||||
headers.set("Authorization", `Bearer ${override}`);
|
||||
return { ...baseInit, headers };
|
||||
}
|
||||
|
||||
if (options?.skipAuth) return baseInit;
|
||||
return baseInit;
|
||||
|
||||
const access = getAccessToken();
|
||||
if (access) headers.set("Authorization", `Bearer ${access}`);
|
||||
return { ...baseInit, headers };
|
||||
}
|
||||
|
||||
let refreshInFlight: Promise<boolean> | null = null;
|
||||
|
||||
async function tryRefreshTokens(): Promise<boolean> {
|
||||
// Single-flight refresh for concurrent 401s.
|
||||
if (refreshInFlight) return refreshInFlight;
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
await requestJson(
|
||||
API_ENDPOINTS.authRefresh,
|
||||
{ method: "POST" },
|
||||
{ skipAuth: true, skipRefresh: true }
|
||||
);
|
||||
return true;
|
||||
const refreshToken = getRefreshToken();
|
||||
|
||||
// Try header-based refresh first (per swagger), but fall back to cookie-based refresh if needed.
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await requestJsonInternal<unknown>(
|
||||
API_ENDPOINTS.authRefresh,
|
||||
{ method: "POST" },
|
||||
refreshToken
|
||||
? { skipRefresh: true, authToken: refreshToken }
|
||||
: { skipRefresh: true, skipAuth: true }
|
||||
);
|
||||
} catch (err) {
|
||||
if (refreshToken && err instanceof ApiError && err.status === 401) {
|
||||
payload = await requestJsonInternal<unknown>(
|
||||
API_ENDPOINTS.authRefresh,
|
||||
{ method: "POST" },
|
||||
{ skipRefresh: true, skipAuth: true }
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const next = extractTokensFromResponsePayload(payload) as StoredTokens | null;
|
||||
if (next) {
|
||||
setStoredTokens(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: if server returns only access_token, keep existing refresh token (if any).
|
||||
const maybeAccess = (payload as any)?.access_token ?? (payload as any)?.data?.access_token;
|
||||
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
|
||||
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
try {
|
||||
return await refreshInFlight;
|
||||
} finally {
|
||||
refreshInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,12 @@ type EngineBinding = {
|
||||
clearSelection?: () => void;
|
||||
};
|
||||
|
||||
const MAP_PROJECTION_STORAGE_KEY = "uhm:mapProjection";
|
||||
|
||||
function applyMapProjection(map: maplibregl.Map, isGlobe: boolean) {
|
||||
map.setProjection({ type: isGlobe ? "globe" : "mercator" });
|
||||
}
|
||||
|
||||
export default function Map({
|
||||
mode,
|
||||
draft,
|
||||
@@ -106,6 +112,15 @@ export default function Map({
|
||||
const [zoomLevel, setZoomLevel] = useState(2);
|
||||
// Min/max zoom dùng cho slider và clamp thao tác zoom.
|
||||
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||
// Projection mode: phang (mercator) vs hinh cau (globe).
|
||||
const [isGlobeProjection, setIsGlobeProjection] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(MAP_PROJECTION_STORAGE_KEY) === "globe";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Engine chỉnh sửa polygon (kéo đỉnh/insert đỉnh), chỉ khởi tạo 1 lần.
|
||||
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||
@@ -120,6 +135,18 @@ export default function Map({
|
||||
// Lưu mode trước đó để cancel engine đúng lúc khi switch mode.
|
||||
const previousModeRef = useRef<MapProps["mode"]>(mode);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
MAP_PROJECTION_STORAGE_KEY,
|
||||
isGlobeProjection ? "globe" : "mercator"
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [isGlobeProjection]);
|
||||
|
||||
useEffect(() => {
|
||||
fitToDraftBoundsRef.current = fitToDraftBounds;
|
||||
}, [fitToDraftBounds]);
|
||||
@@ -1034,6 +1061,32 @@ export default function Map({
|
||||
};
|
||||
}, [allowGeometryEditing, applyDraftToMap, tryCenterToUserLocation]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const apply = () => {
|
||||
// Map instance có thể đã bị replace/unmount trước khi event fire.
|
||||
if (mapRef.current !== map) return;
|
||||
// setProjection sẽ throw nếu style chưa load xong.
|
||||
if (typeof map.isStyleLoaded === "function" && !map.isStyleLoaded()) return;
|
||||
applyMapProjection(map, isGlobeProjection);
|
||||
};
|
||||
|
||||
// Nếu style đã sẵn sàng thì apply ngay.
|
||||
if (typeof map.isStyleLoaded === "function" && map.isStyleLoaded()) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
// Chưa load xong: đợi load/style.load.
|
||||
map.once("load", apply);
|
||||
map.once("style.load", apply);
|
||||
return () => {
|
||||
map.off("load", apply);
|
||||
map.off("style.load", apply);
|
||||
};
|
||||
}, [isGlobeProjection]);
|
||||
|
||||
const handleZoomByStep = (delta: number) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
@@ -1122,6 +1175,69 @@ export default function Map({
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
<label
|
||||
title={
|
||||
isGlobeProjection
|
||||
? "Dang o che do hinh cau (globe)"
|
||||
: "Dang o che do trai phang (flat)"
|
||||
}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "0 6px",
|
||||
userSelect: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isGlobeProjection}
|
||||
onChange={(e) => setIsGlobeProjection(e.target.checked)}
|
||||
aria-label="Toggle globe projection"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "42px",
|
||||
height: "22px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||
background: isGlobeProjection
|
||||
? "rgba(56, 189, 248, 0.30)"
|
||||
: "rgba(148, 163, 184, 0.18)",
|
||||
boxShadow: "inset 0 0 0 1px rgba(15, 23, 42, 0.35)",
|
||||
transition: "background 160ms ease",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
left: isGlobeProjection ? "22px" : "2px",
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "999px",
|
||||
background: isGlobeProjection ? "#38bdf8" : "#e2e8f0",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.35)",
|
||||
transition: "left 160ms ease, background 160ms ease",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: isGlobeProjection ? "#7dd3fc" : "#cbd5e1",
|
||||
fontWeight: 700,
|
||||
minWidth: "40px",
|
||||
}}
|
||||
>
|
||||
{isGlobeProjection ? "Globe" : "Flat"}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleZoomByStep(-0.8)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
|
||||
export type UnifiedSearchKind = "entity" | "wiki" | "geo";
|
||||
|
||||
@@ -10,9 +10,53 @@ type Props = {
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
disabledGeo?: boolean;
|
||||
debounceMs?: number;
|
||||
onLocalQueryChange?: (query: string) => void;
|
||||
};
|
||||
|
||||
export default function UnifiedSearchBar({ kind, onKindChange, query, onQueryChange, disabledGeo }: Props) {
|
||||
export default function UnifiedSearchBar({
|
||||
kind,
|
||||
onKindChange,
|
||||
query,
|
||||
onQueryChange,
|
||||
disabledGeo,
|
||||
debounceMs = 300,
|
||||
onLocalQueryChange,
|
||||
}: Props) {
|
||||
// Local input state to avoid propagating query changes (and triggering API) on every keystroke.
|
||||
const [localQuery, setLocalQuery] = useState(query);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Keep local input in sync when parent updates `query` externally (e.g. reset, preset, navigation).
|
||||
useEffect(() => {
|
||||
setLocalQuery(query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
onLocalQueryChange?.(localQuery);
|
||||
}, [localQuery, onLocalQueryChange]);
|
||||
|
||||
// Debounce propagation upwards.
|
||||
useEffect(() => {
|
||||
if (localQuery === query) return;
|
||||
|
||||
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = window.setTimeout(() => {
|
||||
onQueryChange(localQuery);
|
||||
}, debounceMs);
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
};
|
||||
}, [localQuery, query, onQueryChange, debounceMs]);
|
||||
|
||||
const commitNow = () => {
|
||||
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
if (localQuery !== query) onQueryChange(localQuery);
|
||||
};
|
||||
|
||||
const selectStyle: CSSProperties = {
|
||||
width: 110,
|
||||
border: "1px solid #1f2937",
|
||||
@@ -74,8 +118,12 @@ export default function UnifiedSearchBar({ kind, onKindChange, query, onQueryCha
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
value={localQuery}
|
||||
onChange={(e) => setLocalQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitNow();
|
||||
}}
|
||||
onBlur={() => commitNow()}
|
||||
placeholder={kind === "entity" ? "Nhập tên entity…" : kind === "wiki" ? "Nhập title wiki…" : "Nhập tên entity…"}
|
||||
style={inputStyle}
|
||||
aria-label="Search query"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"snapshot_json": {
|
||||
"editor_feature_collection": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[102.00266149687627, 23.428776811740278],
|
||||
[112.17600134062593, 23.166423207385705],
|
||||
[109.14377477812644, 17.104884122540454],
|
||||
[100.55246618437678, 18.15190838931096],
|
||||
[102.00266149687627, 23.428776811740278]
|
||||
]
|
||||
]
|
||||
},
|
||||
"properties": {
|
||||
"id": "019e0363-9a8d-72a9-b1b1-552e1113ae53",
|
||||
"type": "country",
|
||||
"time_start": 1000,
|
||||
"time_end": 1500,
|
||||
"geometry_preset": "polygon"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"id": "019e0364-10ca-7282-840a-c0e6f4069807",
|
||||
"source": "inline",
|
||||
"operation": "reference",
|
||||
"name": "ent1",
|
||||
"slug": null,
|
||||
"description": null,
|
||||
"status": 1
|
||||
}
|
||||
],
|
||||
"geometries": [
|
||||
{
|
||||
"id": "019e0363-9a8d-72a9-b1b1-552e1113ae53",
|
||||
"source": "inline",
|
||||
"operation": "update",
|
||||
"type": "9",
|
||||
"draw_geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[102.00266149687627, 23.428776811740278],
|
||||
[112.17600134062593, 23.166423207385705],
|
||||
[109.14377477812644, 17.104884122540454],
|
||||
[100.55246618437678, 18.15190838931096],
|
||||
[102.00266149687627, 23.428776811740278]
|
||||
]
|
||||
]
|
||||
},
|
||||
"binding": [],
|
||||
"time_start": 1000,
|
||||
"time_end": 1500,
|
||||
"bbox": {
|
||||
"min_lng": 100.55246618437678,
|
||||
"min_lat": 17.104884122540454,
|
||||
"max_lng": 112.17600134062593,
|
||||
"max_lat": 23.428776811740278
|
||||
}
|
||||
}
|
||||
],
|
||||
"geometry_entity": [
|
||||
{
|
||||
"geometry_id": "019e0363-9a8d-72a9-b1b1-552e1113ae53",
|
||||
"entity_id": "019e0364-10ca-7282-840a-c0e6f4069807",
|
||||
"base_links_hash": ""
|
||||
}
|
||||
],
|
||||
"wikis": [
|
||||
{
|
||||
"id": "019e0363-dfce-769c-920a-cf23c7755463",
|
||||
"source": "inline",
|
||||
"operation": "reference",
|
||||
"title": "wiki1",
|
||||
"slug": null,
|
||||
"doc": "<p>example</p>",
|
||||
"updated_at": "2026-05-08T00:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"entity_wiki": [
|
||||
{
|
||||
"entity_id": "019e0364-10ca-7282-840a-c0e6f4069807",
|
||||
"wiki_id": "019e0363-dfce-769c-920a-cf23c7755463",
|
||||
"operation": "reference",
|
||||
"is_deleted": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"edit_summary": "Edit 2026-05-08T00:00:00.000Z"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "uhm.commit_snapshot.contract",
|
||||
"title": "History API Commit Snapshot (snapshot_json) - Contract",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"editor_feature_collection",
|
||||
"entities",
|
||||
"geometries",
|
||||
"geometry_entity",
|
||||
"wikis",
|
||||
"entity_wiki"
|
||||
],
|
||||
"properties": {
|
||||
"editor_feature_collection": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "features"],
|
||||
"properties": {
|
||||
"type": { "const": "FeatureCollection" },
|
||||
"features": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/feature" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/entitySnapshot" }
|
||||
},
|
||||
"geometries": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/geometrySnapshot" }
|
||||
},
|
||||
"geometry_entity": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/geometryEntitySnapshot" }
|
||||
},
|
||||
"wikis": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/wikiSnapshot" }
|
||||
},
|
||||
"entity_wiki": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/entityWikiLinkSnapshot" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"uuidv7": {
|
||||
"type": "string",
|
||||
"description": "UUID v7 string",
|
||||
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||
},
|
||||
"feature": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "geometry", "properties"],
|
||||
"properties": {
|
||||
"type": { "const": "Feature" },
|
||||
"geometry": {
|
||||
"type": "object",
|
||||
"description": "GeoJSON geometry. Backend expects JSON raw payload."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Geometry id. FE uses uuidv7 strings but backend accepts 'any' for editor_feature_collection properties.id",
|
||||
"oneOf": [{ "$ref": "#/$defs/uuidv7" }, { "type": "string" }, { "type": "number" }]
|
||||
},
|
||||
"type": { "type": "string" },
|
||||
"geometry_preset": { "type": "string" },
|
||||
"time_start": { "type": "number" },
|
||||
"time_end": { "type": "number" },
|
||||
"binding": { "type": "array", "items": { "type": "string" } },
|
||||
"entity_id": { "type": "string" },
|
||||
"entity_ids": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entitySnapshot": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/uuidv7" },
|
||||
"source": { "type": "string", "enum": ["inline", "ref"] },
|
||||
"operation": { "type": "string", "enum": ["create", "update", "delete", "reference"] },
|
||||
"name": { "type": "string" },
|
||||
"slug": { "type": ["string", "null"] },
|
||||
"description": { "type": ["string", "null"] },
|
||||
"status": { "type": ["number", "null"], "enum": [0, 1, null] },
|
||||
"time_start": { "type": ["number", "null"] },
|
||||
"time_end": { "type": ["number", "null"] },
|
||||
"base_updated_at": { "type": ["string", "null"] },
|
||||
"base_hash": { "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"geometrySnapshot": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "type"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/uuidv7" },
|
||||
"source": { "type": "string", "enum": ["inline", "ref"] },
|
||||
"operation": { "type": "string", "enum": ["create", "update", "delete", "reference"] },
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Backend expects a string; FE currently sends the geo_type smallint code as a string."
|
||||
},
|
||||
"draw_geometry": {
|
||||
"type": ["object", "null"],
|
||||
"description": "GeoJSON geometry raw payload"
|
||||
},
|
||||
"binding": { "type": "array", "items": { "type": "string" } },
|
||||
"time_start": { "type": ["number", "null"] },
|
||||
"time_end": { "type": ["number", "null"] },
|
||||
"bbox": {
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"required": ["min_lng", "min_lat", "max_lng", "max_lat"],
|
||||
"properties": {
|
||||
"min_lng": { "type": "number" },
|
||||
"min_lat": { "type": "number" },
|
||||
"max_lng": { "type": "number" },
|
||||
"max_lat": { "type": "number" }
|
||||
}
|
||||
},
|
||||
"base_updated_at": { "type": ["string", "null"] },
|
||||
"base_hash": { "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"geometryEntitySnapshot": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["geometry_id", "entity_id"],
|
||||
"properties": {
|
||||
"geometry_id": { "$ref": "#/$defs/uuidv7" },
|
||||
"entity_id": { "$ref": "#/$defs/uuidv7" },
|
||||
"base_links_hash": { "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"wikiSnapshot": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "title"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/uuidv7" },
|
||||
"source": { "type": "string", "enum": ["inline", "ref"] },
|
||||
"operation": { "type": "string", "enum": ["create", "update", "delete", "reference"] },
|
||||
"title": { "type": "string" },
|
||||
"slug": { "type": ["string", "null"] },
|
||||
"doc": { "type": ["string", "null"] },
|
||||
"updated_at": { "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"entityWikiLinkSnapshot": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["entity_id", "wiki_id"],
|
||||
"properties": {
|
||||
"entity_id": { "$ref": "#/$defs/uuidv7" },
|
||||
"wiki_id": { "$ref": "#/$defs/uuidv7" },
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"description": "Server swagger.json currently enumerates ['reference','delete'] for this field. Some deployments may also accept 'binding'.",
|
||||
"enum": ["reference", "delete", "binding"]
|
||||
},
|
||||
"is_deleted": { "type": ["number", "null"], "enum": [0, 1, null] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
||||
import { typeKeyToGeoTypeCode } from "@/uhm/lib/geoTypeMap";
|
||||
import { geoTypeCodeToTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/geoTypeMap";
|
||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||
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 { EditorSnapshot, Section } from "@/uhm/types/sections";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
@@ -13,6 +14,15 @@ function isRecord(value: unknown): value is UnknownRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -176,7 +186,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
// store entity_id/entity_ids/entity_names on features anymore.
|
||||
const fcForEditor: FeatureCollection | undefined = (() => {
|
||||
if (!fc) return undefined;
|
||||
if (!geometryEntity && !migratedGeometryEntity) return fc;
|
||||
const hasLinks = Boolean(geometryEntity || migratedGeometryEntity);
|
||||
const links = geometryEntity || migratedGeometryEntity || [];
|
||||
const byGeom = new Map<string, string[]>();
|
||||
for (const row of links) {
|
||||
@@ -184,14 +194,47 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
list.push(row.entity_id);
|
||||
byGeom.set(row.geometry_id, list);
|
||||
}
|
||||
const entityNameById = new Map<string, string>();
|
||||
for (const row of entities || []) {
|
||||
const id = typeof row?.id === "string" ? row.id : "";
|
||||
if (!id) continue;
|
||||
const name = typeof (row as any)?.name === "string" ? String((row as any).name).trim() : "";
|
||||
if (name) entityNameById.set(id, name);
|
||||
}
|
||||
const geometryById = new Map<string, GeometrySnapshot>();
|
||||
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) || [];
|
||||
if (entity_ids.length) {
|
||||
if (entity_ids.length || hasLinks) {
|
||||
const props = feature.properties as unknown as UnknownRecord;
|
||||
props.entity_ids = entity_ids;
|
||||
props.entity_id = entity_ids[0];
|
||||
props.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);
|
||||
props.entity_name = primaryName || null;
|
||||
props.entity_names = names;
|
||||
}
|
||||
|
||||
// Generate geometry metadata onto feature properties (optional in persisted snapshot).
|
||||
const geo = geometryById.get(gid) || null;
|
||||
if (geo) {
|
||||
const p = feature.properties as unknown as UnknownRecord;
|
||||
// type (semantic key) is derived from geometries[].type (numeric code in string form).
|
||||
const typeCode = typeof geo.type === "string" && geo.type.trim().length ? Number(geo.type) : NaN;
|
||||
const typeKey = geoTypeCodeToTypeKey(Number.isFinite(typeCode) ? typeCode : null);
|
||||
if (typeKey) p.type = typeKey;
|
||||
if (Array.isArray(geo.binding) && geo.binding.length) p.binding = geo.binding;
|
||||
if (typeof geo.time_start === "number") p.time_start = geo.time_start;
|
||||
if (typeof geo.time_end === "number") p.time_end = geo.time_end;
|
||||
}
|
||||
}
|
||||
return cloned;
|
||||
@@ -273,12 +316,13 @@ export function buildEditorSnapshot(options: {
|
||||
? cloned.name.trim()
|
||||
: id;
|
||||
const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref";
|
||||
const operation = sanitizeEntitySnapshotOperation((cloned as any).operation);
|
||||
entityRows.set(id, {
|
||||
...cloned,
|
||||
id,
|
||||
source,
|
||||
name,
|
||||
operation: cloned.operation ?? "reference",
|
||||
operation,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -370,6 +414,11 @@ export function buildEditorSnapshot(options: {
|
||||
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.binding;
|
||||
delete p.entity_id;
|
||||
delete p.entity_ids;
|
||||
delete p.entity_name;
|
||||
@@ -414,7 +463,8 @@ export function buildEditorSnapshot(options: {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
// Inline wiki with no explicit operation: mark update only if changed; otherwise omit operation.
|
||||
// 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";
|
||||
@@ -431,7 +481,7 @@ export function buildEditorSnapshot(options: {
|
||||
}
|
||||
})();
|
||||
|
||||
cloned.operation = changed ? "update" : undefined;
|
||||
cloned.operation = changed ? "update" : "reference";
|
||||
return cloned;
|
||||
});
|
||||
|
||||
@@ -440,16 +490,34 @@ export function buildEditorSnapshot(options: {
|
||||
.map((l) => ({
|
||||
entity_id: l.entity_id,
|
||||
wiki_id: l.wiki_id,
|
||||
operation: l.operation === "delete" ? "delete" : "binding",
|
||||
// Backend API expects "reference" to indicate an active link (not "binding").
|
||||
operation: l.operation === "delete" ? "delete" : "reference",
|
||||
}));
|
||||
const entityWikis = dedupeAndSortEntityWiki(entityWikisRaw);
|
||||
|
||||
return {
|
||||
editor_feature_collection: draftForSnapshot,
|
||||
entities: Array.from(entityRows.values()).sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||
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 any).description === "string" ? (e as any).description : (e as any).description ?? null,
|
||||
}))
|
||||
.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.slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||
wikis: wikis
|
||||
.map((w) => ({
|
||||
id: w.id,
|
||||
source: w.source,
|
||||
operation: w.operation,
|
||||
title: w.title,
|
||||
slug: (w as any).slug ?? null,
|
||||
doc: (w as any).doc ?? null,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
entity_wiki: entityWikis,
|
||||
};
|
||||
}
|
||||
@@ -481,7 +549,13 @@ function dedupeAndSortEntityWiki(rows: EntityWikiLinkSnapshot[]): EntityWikiLink
|
||||
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 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);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
|
||||
// ---- Root request ----
|
||||
|
||||
export type CreateCommitRequest = {
|
||||
snapshot_json: CommitSnapshot;
|
||||
edit_summary: string;
|
||||
};
|
||||
|
||||
// ---- Snapshot root ----
|
||||
|
||||
export type CommitSnapshot = {
|
||||
editor_feature_collection: FeatureCollection;
|
||||
entities: EntitySnapshot[];
|
||||
geometries: GeometrySnapshot[];
|
||||
geometry_entity: GeometryEntitySnapshot[];
|
||||
wikis: WikiSnapshot[];
|
||||
entity_wiki: EntityWikiLinkSnapshot[];
|
||||
};
|
||||
|
||||
// ---- GeoJSON / FeatureCollection ----
|
||||
|
||||
export type Geometry =
|
||||
| { type: "Point"; coordinates: [number, number] }
|
||||
| { type: "MultiPoint"; coordinates: [number, number][] }
|
||||
| { type: "LineString"; coordinates: [number, number][] }
|
||||
| { type: "MultiLineString"; coordinates: [number, number][][] }
|
||||
| { type: "Polygon"; coordinates: [number, number][][] }
|
||||
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
|
||||
|
||||
export type FeatureId = string | number;
|
||||
|
||||
export type FeatureProperties = {
|
||||
id: FeatureId;
|
||||
type?: string | null; //generate
|
||||
geometry_preset?: string | null;
|
||||
time_start?: number | null; //generate
|
||||
time_end?: number | null; //generate
|
||||
binding?: string[]; //generate
|
||||
|
||||
// Legacy/UI-only fields should not be relied on by the backend.
|
||||
// FE strips these when building snapshot_json, but we keep them optional here
|
||||
// because older snapshots may still contain them.
|
||||
entity_id?: string | null; //generate
|
||||
entity_ids?: string[]; //generate
|
||||
entity_name?: string | null; //generate
|
||||
entity_names?: string[]; //generate
|
||||
entity_type_id?: string | null; //generate
|
||||
};
|
||||
|
||||
export type Feature = {
|
||||
type: "Feature";
|
||||
properties: FeatureProperties;
|
||||
geometry: Geometry;
|
||||
};
|
||||
|
||||
export type FeatureCollection = {
|
||||
type: "FeatureCollection";
|
||||
features: Feature[];
|
||||
};
|
||||
|
||||
// ---- Snapshot rows ----
|
||||
|
||||
export type SnapshotSource = "inline" | "ref";
|
||||
|
||||
export type SnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
|
||||
export type EntitySnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: SnapshotOperation;
|
||||
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type GeometrySnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: SnapshotOperation;
|
||||
type?: string | null;
|
||||
draw_geometry?: Geometry;
|
||||
binding?: string[];
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
bbox?: {
|
||||
min_lng: number;
|
||||
min_lat: number;
|
||||
max_lng: number;
|
||||
max_lat: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type GeometryEntitySnapshot = {
|
||||
geometry_id: string;
|
||||
entity_id: string;
|
||||
operation?: "reference" | "delete" | "binding";
|
||||
};
|
||||
|
||||
// FE stores wiki doc as a string (often HTML) or null for ref-only rows.
|
||||
export type WikiDoc = string | null;
|
||||
|
||||
export type WikiSnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: SnapshotOperation;
|
||||
|
||||
title: string;
|
||||
slug?: string | null;
|
||||
doc: WikiDoc;
|
||||
};
|
||||
|
||||
export type EntityWikiLinkSnapshot = {
|
||||
entity_id: string;
|
||||
wiki_id: string;
|
||||
operation?: "reference" | "delete" | "binding";
|
||||
};
|
||||
@@ -6,9 +6,9 @@ export type EntityWikiLinkSnapshot = {
|
||||
entity_id: string;
|
||||
wiki_id: string;
|
||||
// Relationship semantics (entity ↔ wiki).
|
||||
// - binding: the link exists (assigned)
|
||||
// - reference/binding: the link exists (assigned)
|
||||
// - delete: the link is removed
|
||||
operation?: "binding" | "delete";
|
||||
operation?: "reference" | "binding" | "delete";
|
||||
};
|
||||
|
||||
// BackEndGo uses Projects/Commits/Submissions. "Section" is legacy naming in FE.
|
||||
|
||||
Reference in New Issue
Block a user