renew commit snapshot

This commit is contained in:
taDuc
2026-05-03 19:33:33 +07:00
parent fca188f0be
commit 34a5c3d041
18 changed files with 381 additions and 371 deletions

View File

@@ -1,301 +1,154 @@
# Commit Snapshot (`commits.snapshot_json`) - Cấu Trúc Hiện Tại # Commit Snapshot (`commits.snapshot_json`) - Chuẩn Hiện Tại (FrontEndAdmin)
Tài liệu này mô tả **commit snapshot** đang được lưu trong `BackEndGo.commits.snapshot_json` (JSONB) và được `FrontEndAdmin` tạo ra khi bấm **Commit** trong `/editor`. Tài liệu này mô tả **commit snapshot** được `FrontEndAdmin` tạo ra khi bấm **Commit** trong `/editor`, và được lưu vào `BackEndGo.commits.snapshot_json` (JSONB).
Mục tiêu: nhìn vào đây là hiểu commit snapshot gồm những phần nào, ý nghĩa ra sao, và `source`/`operation` có vai trò gì.
Nguồn tham chiếu trong code: Nguồn tham chiếu trong code:
- Type snapshot: `FrontEndAdmin/src/uhm/types/sections.ts` (`EditorSnapshot`) - Type snapshot: `FrontEndAdmin/src/uhm/types/sections.ts` (`EditorSnapshot`)
- Build snapshot khi commit: `FrontEndAdmin/src/uhm/lib/editor/snapshot/editorSnapshot.ts` (`buildEditorSnapshot`) - Build snapshot: `FrontEndAdmin/src/uhm/lib/editor/snapshot/editorSnapshot.ts` (`buildEditorSnapshot`)
## 1) Schema tổng quan (v2) ## 1) Tổng Quan Schema
Hiện tại snapshot mới được ghi với `schema_version: 2`**đã bỏ hẳn `section`** (vì flow BEGo đã có `commits.project_id`). Snapshot hiện tại:
- Không có `schema_version`.
- Không lưu `section` (project/section được xác định bằng context record `commits.project_id`).
- Không dùng `ref:{id}` nữa: **`id` là canonical**, `source:"ref"` nghĩa là tham chiếu theo `id`.
```ts ```ts
export type CommitSnapshotV2 = { export type CommitSnapshot = {
schema_version: 2;
// GeoJSON draft để render map + làm nguồn dựng geometries/link_scopes
editor_feature_collection?: FeatureCollection; editor_feature_collection?: FeatureCollection;
// Operation-based rows
entities?: EntitySnapshot[]; entities?: EntitySnapshot[];
geometries?: GeometrySnapshot[]; geometries?: GeometrySnapshot[];
link_scopes?: LinkScopeSnapshot[];
// Wiki list (tiptap JSON hoặc reference)
wikis?: WikiSnapshot[]; wikis?: WikiSnapshot[];
// Join table inside snapshot: links between entities and wikis (project-level) geometry_entity?: GeometryEntitySnapshot[]; // geometry ↔ entity (many-to-many)
entity_wikis?: EntityWikiLinkSnapshot[]; entity_wikis?: EntityWikiLinkSnapshot[]; // entity ↔ wiki
}; };
``` ```
## 2) `operation` có những giá trị nào? ## 2) Quy Ước `source` và `operation`
Trong commit snapshot hiện tại có 4 nơi dùng `operation`: ### 2.1 `source` (bắt buộc)
1. `entities[].operation`: `source` bắt buộc là một trong:
- `create` | `update` | `delete` | `reference` - `inline`: dữ liệu được embed trong snapshot_json.
- `ref`: dữ liệu là tham chiếu (theo `id`), cần fetch bên ngoài nếu muốn đầy đủ.
2. `geometries[].operation`: FE hiện tại luôn ghi `source` cho `entities[]`, `geometries[]`, `wikis[]`.
- `create` | `update` | `delete` | `reference` ### 2.2 `operation` (tùy chọn)
3. `link_scopes[].operation`: `operation` là tùy chọn. Khi **không có** `operation` thì hiểu là:
- `reference` - row được đưa vào snapshot như **project context** (hoặc không đổi trong commit này),
- commit này không sửa record, và cũng không cần đánh dấu là `"reference"` để làm “đầu mối nối”.
4. `wikis[].operation`: `operation` có thể xuất hiện ở:
- `create` | `update` | `delete` | `reference` - `entities[].operation`: `create` | `update` | `delete` | `reference`
- `geometries[].operation`: `create` | `update` | `delete` | `reference`
- `wikis[].operation`: `create` | `update` | `delete` | `reference`
Ghi chú về semantics: `geometry_entity[]` không có `operation` (join table state).
- `create/update/delete`: bản ghi bị thay đổi trong commit này `entity_wikis[]` dùng `operation:"reference"|"delete"` để biểu diễn link/unlink **trong snapshot** (không phải delete trong DB).
- `reference`: bản ghi được đưa vào snapshot để làm đầu mối **nối (link)** (vd: geometry↔entity, entity↔wiki), không phải “không đổi”
Ngoài ra snapshot có `entity_wikis[]` để nối entity <-> wiki. ## 3) Ý Nghĩa Từng Phần
## 3) Sơ đồ trực quan (Mermaid) ### 3.1 `editor_feature_collection`
```mermaid
classDiagram
class CommitSnapshotV2 {
+number schema_version
+FeatureCollection editor_feature_collection?
+EntitySnapshot[] entities?
+GeometrySnapshot[] geometries?
+LinkScopeSnapshot[] link_scopes?
+WikiSnapshot[] wikis?
+EntityWikiLinkSnapshot[] entity_wikis?
}
class FeatureCollection {
+string type // "FeatureCollection"
+Feature[] features
}
class Feature {
+string type // "Feature"
+FeatureProperties properties
+Geometry geometry
}
class FeatureProperties {
+string|number id
+string type?
+number time_start?
+number time_end?
+string[] binding?
+string entity_id?
+string[] entity_ids?
+string entity_name?
+string[] entity_names?
+string entity_type_id?
}
class EntitySnapshot {
+string id
+string source? // inline|ref
+Ref ref?
+string operation? // create|update|delete|reference
+string name?
+string slug?
+string description?
+string type_id?
+number status?
+number is_deleted?
+string base_updated_at?
+string base_hash?
}
class GeometrySnapshot {
+string id
+string source? // inline|ref
+Ref ref?
+string operation? // create|update|delete|reference
+string type?
+Geometry draw_geometry?
+string[] binding?
+number time_start?
+number time_end?
+BBox bbox?
+number is_deleted?
+string base_updated_at?
+string base_hash?
}
class BBox {
+number min_lng
+number min_lat
+number max_lng
+number max_lat
}
class LinkScopeSnapshot {
+string geometry_id
+string operation // reference
+string[] entity_ids
+string base_links_hash?
}
class WikiSnapshot {
+string id
+string source? // inline|ref
+Ref ref?
+string operation? // create|update|delete|reference
+string title
+any doc
+string updated_at?
+number is_deleted?
}
class EntityWikiLinkSnapshot {
+string entity_id
+string wiki_id
+string operation? // reference|delete
+number is_deleted?
}
class Ref {
+string id
}
CommitSnapshotV2 --> FeatureCollection
FeatureCollection --> Feature
Feature --> FeatureProperties
CommitSnapshotV2 --> EntitySnapshot
CommitSnapshotV2 --> GeometrySnapshot
CommitSnapshotV2 --> LinkScopeSnapshot
CommitSnapshotV2 --> WikiSnapshot
CommitSnapshotV2 --> EntityWikiLinkSnapshot
```
## 4) Ý nghĩa từng phần
### 4.1 (Bỏ) `section`
Từ `schema_version: 2`, snapshot **không còn** field `section`.
Nguồn chuẩn để biết commit thuộc project nào là `commits.project_id` (record/endpoint context), không phải snapshot.
### 4.2 `editor_feature_collection`
GeoJSON `FeatureCollection` là nguồn để: GeoJSON `FeatureCollection` là nguồn để:
- render map trong editor - render map trong editor,
- build `geometries[]` + `link_scopes[]` khi commit - làm cơ sở build `geometries[]` và join table `geometry_entity[]`.
Trong thực tế, nó là “bản đồ draft state” của commit. Lưu ý quan trọng:
### 4.3 `entities[]` - Snapshot persist **không lưu** các field entity denormalize trên `feature.properties`:
`entity_id/entity_ids/entity_name/entity_names/entity_type_id`.
- Quan hệ geometry ↔ entity nằm ở `geometry_entity[]`.
- Khi load commit vào editor, FE có thể rehydrate `entity_ids/entity_id` lên features từ `geometry_entity[]` để UI hoạt động, nhưng đó không phải dữ liệu persist.
`entities[]` là tập các entity rows kèm `source`/`operation`. Trong `buildEditorSnapshot` hiện tại, nó được dựng từ: ### 3.2 `entities[]`
1. `pending entities` tạo trong editor: `entities[]` là danh sách entity liên quan tới project/commit. Mỗi row có `source` và có thể có/không có `operation`.
- `source: "inline"`, `operation: "create"`
2. `projectEntityRefs` (entity được user “pin” vào project từ thanh search):
- `source: "ref"`, `ref: {id}`, `operation: "reference"`
3. Các entity IDs đang được gắn vào geometries trong `editor_feature_collection` (nếu chưa có trong list):
- `source: "ref"`, `ref: {id}`, `operation: "reference"`
4. Các entity IDs xuất hiện trong `entity_wikis[]`:
- `source: "ref"`, `ref: {id}`, `operation: "reference"`
=> Nghĩa là: `entities[]` trong commit snapshot hiện tại hoạt động như một “danh sách entity liên quan tới project”, không nhất thiết phải gắn vào một geometry cụ thể. FE build `entities[]` từ:
### 4.4 `geometries[]` 1. Pending entities tạo mới trong editor:
`source:"inline"`, `operation:"create"`.
Mỗi `Feature` trong `editor_feature_collection.features[]` sẽ sinh ra một `GeometrySnapshot` row: 2. Entity được user “pin” vào project từ search (không gắn geometry, không link wiki):
`source:"ref"`, không có `operation`.
- `id`: `String(feature.properties.id)` 3. Entities xuất hiện trong `geometry_entity[]`:
- `draw_geometry`: lấy từ `feature.geometry` `source:"ref"`, `operation:"reference"`.
- `type`: `feature.properties.type || getDefaultTypeIdForFeature(feature)`
- `binding`: normalize từ `feature.properties.binding`
- `time_start/time_end`
- `bbox`: tính từ geometry
- `is_deleted: 0`
`operation` được suy ra dựa vào `changes` + so sánh với snapshot trước: 4. Entities xuất hiện trong `entity_wikis[]`:
`source:"ref"`, `operation:"reference"`.
### 3.3 `geometries[]`
Mỗi `Feature` trong `editor_feature_collection.features[]` sinh 1 `GeometrySnapshot` row:
- `id = String(feature.properties.id)`
- `source:"inline"`
- `draw_geometry = feature.geometry`
- kèm `type`, `binding`, `time_start/time_end`, `bbox` (nếu tính được)
`operation` cho geometry:
- `create`: feature mới - `create`: feature mới
- `update`: feature thay đổi - `update`: feature thay đổi
- (không có `operation`): feature không đổi (không delta trong commit) - (không có `operation`): feature không đổi (không delta trong commit)
- `delete`: feature bị xoá khỏi draft (FE sẽ thêm 1 row `{ id, operation:"delete", is_deleted:1 }`)
### 4.5 `link_scopes[]` Nếu feature bị xoá khỏi draft, FE thêm 1 delete row:
FE build link scopes từ GeoJSON features: ```json
{ "id": "g_1", "source": "ref", "operation": "delete" }
- `geometry_id = String(feature.properties.id)`
- `operation = "reference"`
- `entity_ids` lấy từ `feature.properties.entity_ids` hoặc `entity_id`
Chỉ add scope nếu `entity_ids.length > 0`.
### 4.6 `wikis[]`
`wikis[]` là danh sách wiki của project tại thời điểm commit.
Type hiện tại:
```ts
export type WikiSnapshot = {
id: string;
source?: "inline" | "ref";
ref?: { id: string };
operation?: "create" | "update" | "delete" | "reference";
title: string;
doc: unknown; // tiptap JSON doc (inline) hoặc null (reference)
updated_at?: string;
is_deleted?: number;
};
``` ```
Quy ước FE đang dùng: Lưu ý: geometry `operation:"delete"` **không xuất hiện trên map**, vì map render theo `editor_feature_collection.features[]`.
- Wiki tạo mới trong editor: `operation: "create"`, `doc` là tiptap JSON. ### 3.4 `geometry_entity[]` (join table Geometry ↔ Entity)
- Wiki sửa: `operation: "update"`, `doc` là tiptap JSON.
- Wiki không đổi so với snapshot trước: thường **không có** `operation` (không delta).
- Wiki add từ thanh search (wiki đã tồn tại trong DB): `source:"ref"`, `ref:{id}`, `operation:"reference"`, **`doc` có thể là `null`**.
Ghi chú quan trọng: Join table many-to-many giữa geometry và entity. Mỗi cặp geometry↔entity là một row:
- Hiện tại FE **chưa generate “delete rows” cho wikis** (khác với geometries). Khi bạn remove một wiki khỏi list thì snapshot mới sẽ đơn giản là không còn wiki đó nữa. ```ts
{ geometry_id: string; entity_id: string }
```
### 4.7 `entity_wikis[]` (bảng nối Entity ↔ Wiki) ### 3.5 `wikis[]`
`entity_wikis[]` là bảng nối trong snapshot để thể hiện “wiki nào thuộc entity nào” ở mức project/commit. Danh sách wiki của project tại thời điểm commit:
- Wiki tạo mới: `source:"inline"`, `operation:"create"`, `doc` là tiptap JSON.
- Wiki sửa: `source:"inline"`, `operation:"update"`, `doc` là tiptap JSON.
- Wiki không đổi: thường không có `operation`.
- Wiki add từ search (wiki đã có trong DB): `source:"ref"`, `operation:"reference"`, `doc` có thể là `null`.
### 3.6 `entity_wikis[]` (join table Entity ↔ Wiki)
```ts ```ts
export type EntityWikiLinkSnapshot = { export type EntityWikiLinkSnapshot = {
entity_id: string; entity_id: string;
wiki_id: string; wiki_id: string;
operation?: "reference" | "delete"; operation?: "reference" | "delete";
is_deleted?: number;
}; };
``` ```
FE hiện dùng panel “Entity ↔ Wiki” để toggle link: Toggle link trong UI:
- Tick checkbox => `{ operation:"reference", is_deleted:0 }` - Tick checkbox: `{ operation: "reference" }`
- Untick checkbox => `{ operation:"delete", is_deleted:1 }` - Untick checkbox: `{ operation: "delete" }`
## 5) Ví dụ JSON (rút gọn) ## 4) Ví Dụ JSON (rút gọn)
Ví dụ dưới đây thể hiện:
- 1 geometry gắn entity `e_1`
- 1 entity ref “pin” vào project (`e_2`) dù chưa gắn geometry
- 1 wiki inline và 1 wiki reference (search từ DB)
```json ```json
{ {
"schema_version": 2,
"editor_feature_collection": { "editor_feature_collection": {
"type": "FeatureCollection", "type": "FeatureCollection",
"features": [ "features": [
@@ -306,31 +159,32 @@ Ví dụ dưới đây thể hiện:
"type": "city", "type": "city",
"time_start": 1200, "time_start": 1200,
"time_end": 1300, "time_end": 1300,
"entity_ids": ["e_1"], "binding": []
"entity_names": ["Ha Noi"]
}, },
"geometry": { "type": "Point", "coordinates": [105.8, 21.0] } "geometry": { "type": "Point", "coordinates": [105.8, 21.0] }
} }
] ]
}, },
"entities": [ "entities": [
{ "id": "e_2", "operation": "reference", "name": "Pinned Entity", "is_deleted": 0 }, { "id": "e_2", "source": "ref", "name": "Pinned Entity" },
{ "id": "e_1", "operation": "reference", "name": "Ha Noi", "type_id": "city", "status": 1, "is_deleted": 0 } { "id": "e_1", "source": "ref", "operation": "reference", "name": "Ha Noi", "type_id": "city", "status": 1 }
], ],
"geometries": [ "geometries": [
{ {
"id": "g_1", "id": "g_1",
"source": "inline",
"operation": "update", "operation": "update",
"type": "city", "type": "city",
"draw_geometry": { "type": "Point", "coordinates": [105.8, 21.0] }, "draw_geometry": { "type": "Point", "coordinates": [105.8, 21.0] },
"binding": [], "binding": [],
"time_start": 1200, "time_start": 1200,
"time_end": 1300, "time_end": 1300,
"bbox": { "min_lng": 105.8, "min_lat": 21.0, "max_lng": 105.8, "max_lat": 21.0 }, "bbox": { "min_lng": 105.8, "min_lat": 21.0, "max_lng": 105.8, "max_lat": 21.0 }
"is_deleted": 0
} }
], ],
"link_scopes": [{ "geometry_id": "g_1", "operation": "reference", "entity_ids": ["e_1"] }], "geometry_entity": [
{ "geometry_id": "g_1", "entity_id": "e_1" }
],
"wikis": [ "wikis": [
{ {
"id": "w_inline_1", "id": "w_inline_1",
@@ -342,26 +196,13 @@ Ví dụ dưới đây thể hiện:
{ {
"id": "019d...wiki_from_db", "id": "019d...wiki_from_db",
"source": "ref", "source": "ref",
"ref": { "id": "019d...wiki_from_db" },
"operation": "reference", "operation": "reference",
"title": "Existing Wiki (DB)", "title": "Existing Wiki (DB)",
"doc": null "doc": null
} }
], ],
"entity_wikis": [ "entity_wikis": [
{ "entity_id": "e_1", "wiki_id": "w_inline_1", "operation": "reference", "is_deleted": 0 } { "entity_id": "e_1", "wiki_id": "w_inline_1", "operation": "reference" }
] ]
} }
``` ```
## 6) Các điểm cần chốt khi muốn đi xa hơn với “ref”
Để `source:"ref"` thực sự “lấy từ bên ngoài snapshot”, cần thống nhất:
1. Wiki DB format:
- BackEndGo `wikis.content` hiện là `TEXT`, trong khi editor wiki dùng TipTap JSON (`doc`).
- Nếu muốn `ref` load content khi cần, phải chốt format lưu trữ (JSON string / HTML / Markdown).
2. Semantics `operation:"reference"`:
- `reference` được dùng theo nghĩa “đầu mối để nối (link)” và thường đi kèm `source:"ref"` (ref tới DB/global).
- Các bản ghi inline không thay đổi nên **không có `operation`** (không delta).

24
package-lock.json generated
View File

@@ -41,6 +41,7 @@
"sweetalert2": "^11.26.24", "sweetalert2": "^11.26.24",
"swiper": "^11.2.10", "swiper": "^11.2.10",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"uuid": "^13.0.0",
"yet-another-react-lightbox": "^3.30.1" "yet-another-react-lightbox": "^3.30.1"
}, },
"devDependencies": { "devDependencies": {
@@ -9261,6 +9262,16 @@
"react-dom": "16.x || 17.x || 18.x || 19.x" "react-dom": "16.x || 17.x || 18.x || 19.x"
} }
}, },
"node_modules/react-d3-tree/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/react-dnd": { "node_modules/react-dnd": {
"version": "16.0.1", "version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
@@ -10639,13 +10650,16 @@
} }
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "8.3.2", "version": "13.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist-node/bin/uuid"
} }
}, },
"node_modules/w3c-keyname": { "node_modules/w3c-keyname": {

View File

@@ -38,6 +38,7 @@
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-quill-new": "^3.8.3", "react-quill-new": "^3.8.3",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"uuid": "^13.0.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"sweetalert2": "^11.26.24", "sweetalert2": "^11.26.24",
"swiper": "^11.2.10", "swiper": "^11.2.10",

View File

@@ -16,6 +16,7 @@ import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/captions.css"; import "yet-another-react-lightbox/plugins/captions.css";
import { createHistorianCV } from "@/service/historianService"; import { createHistorianCV } from "@/service/historianService";
import { toast } from "sonner"; import { toast } from "sonner";
import { newId } from "@/uhm/lib/id";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { PresignedUrlResponse } from "@/interface/media"; import { PresignedUrlResponse } from "@/interface/media";
@@ -94,7 +95,7 @@ export default function RoleUpgrade() {
const presigned = await getPresignedUrl(file); const presigned = await getPresignedUrl(file);
return { return {
id: Math.random().toString(36).substring(7), id: newId(),
file: file, file: file,
previewUrl: isImage ? URL.createObjectURL(file) : "", previewUrl: isImage ? URL.createObjectURL(file) : "",
name: file.name, name: file.name,

View File

@@ -13,6 +13,7 @@ import Map from "@/uhm/components/Map";
import { DEFAULT_BACKGROUND_LAYER_VISIBILITY } from "@/uhm/lib/backgroundLayers"; import { DEFAULT_BACKGROUND_LAYER_VISIBILITY } from "@/uhm/lib/backgroundLayers";
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/geo/constants"; import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/geo/constants";
import { fetchSectionCommits } from "@/uhm/api/sections"; import { fetchSectionCommits } from "@/uhm/api/sections";
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { EditorSnapshot, SectionCommit } from "@/uhm/types/sections"; import type { EditorSnapshot, SectionCommit } from "@/uhm/types/sections";
import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntitySnapshot } from "@/uhm/types/entities";
@@ -89,7 +90,7 @@ export default function SubmissionDetailPage() {
setCommits(commitRows || []); setCommits(commitRows || []);
const commit = (commitRows || []).find((c) => c.id === row.commit_id) || null; const commit = (commitRows || []).find((c) => c.id === row.commit_id) || null;
const snap = (commit?.snapshot_json || null) as EditorSnapshot | null; const snap = normalizeEditorSnapshot(commit?.snapshot_json || null);
setSnapshot(snap); setSnapshot(snap);
setSnapshotEntities((snap?.entities || []) as EntitySnapshot[]); setSnapshotEntities((snap?.entities || []) as EntitySnapshot[]);
} catch (err) { } catch (err) {
@@ -197,9 +198,7 @@ export default function SubmissionDetailPage() {
{isLoadingExtras ? ( {isLoadingExtras ? (
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">Dang tai snapshot/commits...</div> <div className="mt-3 text-xs text-gray-500 dark:text-gray-400">Dang tai snapshot/commits...</div>
) : snapshot ? ( ) : snapshot ? (
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-3 text-xs text-gray-500 dark:text-gray-400">Da tai snapshot.</div>
Snapshot schema_version: {snapshot.schema_version}
</div>
) : ( ) : (
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
Khong tim thay snapshot cho commit nay. Khong tim thay snapshot cho commit nay.

View File

@@ -12,6 +12,7 @@ import {
} from "@fullcalendar/core"; } from "@fullcalendar/core";
import { useModal } from "@/hooks/useModal"; import { useModal } from "@/hooks/useModal";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import { newId } from "@/uhm/lib/id";
interface CalendarEvent extends EventInput { interface CalendarEvent extends EventInput {
extendedProps: { extendedProps: {
@@ -99,7 +100,7 @@ const Calendar: React.FC = () => {
} else { } else {
// Add new event // Add new event
const newEvent: CalendarEvent = { const newEvent: CalendarEvent = {
id: Date.now().toString(), id: newId(),
title: eventTitle, title: eventTitle,
start: eventStartDate, start: eventStartDate,
end: eventEndDate, end: eventEndDate,

View File

@@ -41,7 +41,7 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
const set = new Set<string>(); const set = new Set<string>();
for (const l of links || []) { for (const l of links || []) {
if (!l || l.entity_id !== activeEntityId) continue; if (!l || l.entity_id !== activeEntityId) continue;
if (l.is_deleted) continue; if (l.operation === "delete") continue;
set.add(l.wiki_id); set.add(l.wiki_id);
} }
return set; return set;
@@ -57,11 +57,10 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
const idx = next.findIndex((l) => l.entity_id === activeEntityId && l.wiki_id === id); const idx = next.findIndex((l) => l.entity_id === activeEntityId && l.wiki_id === id);
if (idx >= 0) { if (idx >= 0) {
const existing = next[idx]; const existing = next[idx];
const currentlyOn = !existing.is_deleted; const currentlyOn = existing.operation !== "delete";
next[idx] = { next[idx] = {
...existing, ...existing,
operation: currentlyOn ? "delete" : "reference", operation: currentlyOn ? "delete" : "reference",
is_deleted: currentlyOn ? 1 : 0,
}; };
return next; return next;
} }
@@ -69,7 +68,6 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
entity_id: activeEntityId, entity_id: activeEntityId,
wiki_id: id, wiki_id: id,
operation: "reference", operation: "reference",
is_deleted: 0,
}); });
return next; return next;
}); });
@@ -125,7 +123,7 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
<div style={{ display: "grid", gap: "6px" }}> <div style={{ display: "grid", gap: "6px" }}>
{wikiChoices.slice(0, 12).map((w) => { {wikiChoices.slice(0, 12).map((w) => {
const checked = activeLinks.has(w.id); const checked = activeLinks.has(w.id);
const isRefWiki = (wikis.find((x) => x.id === w.id)?.source || "inline") === "ref"; const isRefWiki = wikis.find((x) => x.id === w.id)?.source === "ref";
return ( return (
<label <label
key={w.id} key={w.id}

View File

@@ -36,6 +36,7 @@ import {
POLYGON_OPACITY_BY_TYPE, POLYGON_OPACITY_BY_TYPE,
POLYGON_STROKE_BY_TYPE, POLYGON_STROKE_BY_TYPE,
} from "@/uhm/lib/map/style"; } from "@/uhm/lib/map/style";
import { newId } from "@/uhm/lib/id";
type MapProps = { type MapProps = {
mode: EditorMode; mode: EditorMode;
@@ -1662,11 +1663,7 @@ function roundZoom(value: number): number {
} }
function buildClientFeatureId(): string { function buildClientFeatureId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return newId();
return crypto.randomUUID();
}
// Fallback đảm bảo tránh collision khi user tạo nhiều feature trong cùng 1ms.
return `feature-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
} }
function clampNumber(value: number, min: number, max: number): number { function clampNumber(value: number, min: number, max: number): number {

View File

@@ -58,11 +58,8 @@ export default function ProjectEntityRefsPanel({ entityRefs, setEntityRefs }: Pr
{ {
id, id,
source: "ref", source: "ref",
ref: { id },
operation: "reference",
name: e.name, name: e.name,
description: e.description ?? null, description: e.description ?? null,
is_deleted: 0,
}, },
...prev, ...prev,
]); ]);

View File

@@ -12,6 +12,7 @@ import Badge from "@/components/ui/badge/Badge";
import Label from "@/components/form/Label"; import Label from "@/components/form/Label";
import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { WikiSnapshot } from "@/uhm/types/wiki";
import { newId } from "@/uhm/lib/id";
type Props = { type Props = {
projectId: string; projectId: string;
@@ -20,14 +21,6 @@ type Props = {
autoOpen?: boolean; autoOpen?: boolean;
}; };
function newId() {
try {
return crypto.randomUUID();
} catch {
return `wiki_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
}
function clampTitle(title: string) { function clampTitle(title: string) {
const t = title.trim(); const t = title.trim();
return t.length ? t.slice(0, 120) : "Untitled wiki"; return t.length ? t.slice(0, 120) : "Untitled wiki";
@@ -135,7 +128,6 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
{ {
id, id,
source: "ref", source: "ref",
ref: { id },
operation: "reference", operation: "reference",
title, title,
doc: null, doc: null,
@@ -193,7 +185,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
? w ? w
: { : {
...w, ...w,
source: w.source || "inline", source: w.source,
operation: w.operation === "create" ? "create" : "update", operation: w.operation === "create" ? "create" : "update",
title: nextTitle, title: nextTitle,
doc: payload, doc: payload,

View File

@@ -2,6 +2,7 @@ import type { Entity } from "@/uhm/types/entities";
import type { Feature, FeatureProperties } from "@/uhm/types/geo"; import type { Feature, FeatureProperties } from "@/uhm/types/geo";
import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes"; import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot"; import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import { newId } from "@/uhm/lib/id";
export function mergeEntitiesWithPending( export function mergeEntitiesWithPending(
persistedEntities: Entity[], persistedEntities: Entity[],
@@ -65,10 +66,7 @@ export function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]
} }
export function buildClientEntityId(): string { export function buildClientEntityId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return newId();
return crypto.randomUUID();
}
return `entity-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
} }
export function buildFeatureEntityPatch( export function buildFeatureEntityPatch(
@@ -106,4 +104,3 @@ function resolveGeometryTypeFromEntityIds(
const primaryEntity = entities.find((entity) => entity.id === primaryEntityId) || null; const primaryEntity = entities.find((entity) => entity.id === primaryEntityId) || null;
return primaryEntity?.type_id || null; return primaryEntity?.type_id || null;
} }

View File

@@ -79,7 +79,20 @@ export function useSectionCommands(options: Options) {
options.setSectionCommits(commits); options.setSectionCommits(commits);
options.setPendingEntityCreates([]); options.setPendingEntityCreates([]);
options.setCreatedEntities([]); options.setCreatedEntities([]);
options.setProjectEntityRefs((snapshot?.entities || []).filter((e) => e?.operation === "reference")); const geoEntityIds = new Set((snapshot?.geometry_entity || []).map((row) => row.entity_id));
const linkedByWikiIds = new Set(
(snapshot?.entity_wikis || [])
.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.setWikis(snapshot?.wikis || []);
options.setEntityWikiLinks(snapshot?.entity_wikis || []); options.setEntityWikiLinks(snapshot?.entity_wikis || []);
options.setSelectedFeatureId(null); options.setSelectedFeatureId(null);

View File

@@ -2,23 +2,191 @@ import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes"; import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes"; import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntitySnapshot } from "@/uhm/types/entities";
import type { Feature, FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo"; import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { EditorSnapshot, Section } from "@/uhm/types/sections"; import type { EditorSnapshot, Section } from "@/uhm/types/sections";
import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections"; import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
type UnknownRecord = Record<string, unknown>;
function isRecord(value: unknown): value is UnknownRecord {
return !!value && typeof value === "object" && !Array.isArray(value);
}
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 : "";
}
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; if (!isRecord(raw)) return null;
const snapshot = raw as EditorSnapshot; const snapshot = raw as UnknownRecord;
// Accept legacy snapshots (v1) and new ones (v2+). We only require that a FeatureCollection, // 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. // if present, is structurally valid. Everything else is treated as optional.
const fc = (snapshot as any).editor_feature_collection as FeatureCollection | undefined; const fcRaw = snapshot.editor_feature_collection;
if (fc && fc.type === "FeatureCollection" && Array.isArray(fc.features)) { const fc: FeatureCollection | undefined =
return snapshot; 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 op = typeof e.operation === "string" ? e.operation : undefined;
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 rest: UnknownRecord = { ...e };
delete rest.ref;
return { return {
...snapshot, ...(rest as unknown as Omit<EntitySnapshot, "id" | "source">),
editor_feature_collection: undefined, id,
source,
};
})
: undefined;
const geometriesRaw = snapshot.geometries;
const geometries: GeometrySnapshot[] | undefined = Array.isArray(geometriesRaw)
? geometriesRaw
.filter(isRecord)
.map((g) => {
const id = getStringId(g.id);
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;
const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline");
const rest: UnknownRecord = { ...g };
delete rest.ref;
return {
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source">),
id,
source,
};
})
: undefined;
const wikisRaw = snapshot.wikis;
const wikis: WikiSnapshot[] | undefined = Array.isArray(wikisRaw)
? wikisRaw
.filter(isRecord)
.map((w) => {
const id = typeof w.id === "string" ? w.id : "";
const op = typeof w.operation === "string" ? w.operation : undefined;
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 rest: UnknownRecord = { ...w };
delete rest.ref;
return {
...(rest as unknown as Omit<WikiSnapshot, "id" | "source">),
id,
source,
};
})
: 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 geometry_id = getStringId(r.geometry_id);
const entity_id = typeof r.entity_id === "string" ? r.entity_id : "";
return {
...(r as unknown as Omit<GeometryEntitySnapshot, "geometry_id" | "entity_id">),
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_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 : "";
const isDeleted =
typeof r.is_deleted === "number"
? r.is_deleted === 1
: typeof r.is_deleted === "boolean"
? r.is_deleted
: false;
const operation: "reference" | "delete" =
opRaw === "delete" ? "delete" : opRaw === "reference" ? "reference" : isDeleted ? "delete" : "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;
if (!geometryEntity && !migratedGeometryEntity) return fc;
const links = geometryEntity || migratedGeometryEntity || [];
const byGeom = new Map<string, string[]>();
for (const row of links) {
const list = byGeom.get(row.geometry_id) || [];
list.push(row.entity_id);
byGeom.set(row.geometry_id, list);
}
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) {
const props = feature.properties as unknown as UnknownRecord;
props.entity_ids = entity_ids;
props.entity_id = entity_ids[0];
}
}
return cloned;
})();
return {
...(snapshot as unknown as EditorSnapshot),
editor_feature_collection: fcForEditor,
entities,
geometries,
wikis,
geometry_entity: geometryEntity || migratedGeometryEntity,
entity_wikis: entityWikis,
}; };
} }
@@ -69,7 +237,6 @@ export function buildEditorSnapshot(options: {
description: null, description: null,
type_id: entity.type_id, type_id: entity.type_id,
status: entity.status, status: entity.status,
is_deleted: 0,
}); });
} }
@@ -80,10 +247,7 @@ export function buildEditorSnapshot(options: {
entityRows.set(id, { entityRows.set(id, {
...cloned, ...cloned,
id, id,
source: cloned.source || "ref", source: "ref",
ref: cloned.ref || { id },
operation: "reference",
is_deleted: cloned.is_deleted ?? 0,
}); });
} }
@@ -94,9 +258,7 @@ export function buildEditorSnapshot(options: {
entityRows.set(id, { entityRows.set(id, {
id, id,
source: "ref", source: "ref",
ref: { id },
operation: "reference", operation: "reference",
is_deleted: 0,
}); });
} }
@@ -106,14 +268,12 @@ export function buildEditorSnapshot(options: {
entityRows.set(entityId, { entityRows.set(entityId, {
id: entityId, id: entityId,
source: "ref", source: "ref",
ref: { id: entityId },
operation: "reference", operation: "reference",
name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId, name: entityId,
slug: null, slug: null,
description: null, description: null,
type_id: feature.properties.entity_type_id || feature.properties.type || DEFAULT_ENTITY_TYPE_ID, type_id: feature.properties.type || DEFAULT_ENTITY_TYPE_ID,
status: 1, status: 1,
is_deleted: 0,
}); });
} }
} }
@@ -151,31 +311,39 @@ export function buildEditorSnapshot(options: {
max_lat: bbox.maxLat, max_lat: bbox.maxLat,
} }
: null, : null,
is_deleted: 0,
}; };
}); });
for (const id of deletedIds) { for (const id of deletedIds) {
geometries.push({ geometries.push({
id, id,
source: "ref",
operation: "delete", operation: "delete",
is_deleted: 1,
}); });
} }
const linkScopes: LinkScopeSnapshot[] = options.draft.features const geometryEntity: GeometryEntitySnapshot[] = options.draft.features.flatMap((feature) => {
.map((feature) => ({ const geometry_id = String(feature.properties.id);
geometry_id: String(feature.properties.id), const entityIds = normalizeFeatureEntityIds(feature);
operation: "reference" as const, return entityIds.map((entity_id) => ({ geometry_id, entity_id }));
entity_ids: normalizeFeatureEntityIds(feature), });
}))
.filter((scope) => scope.entity_ids.length > 0); // 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;
delete p.entity_id;
delete p.entity_ids;
delete p.entity_name;
delete p.entity_names;
delete p.entity_type_id;
}
const previousWikis = new globalThis.Map<string, WikiSnapshot>(); const previousWikis = new globalThis.Map<string, WikiSnapshot>();
for (const item of options.previousSnapshot?.wikis || []) { for (const item of options.previousSnapshot?.wikis || []) {
if (!item || typeof item !== "object") continue; if (!item || typeof item !== "object") continue;
const id = typeof (item as any).id === "string" ? String((item as any).id) : ""; const id = (item as WikiSnapshot).id;
if (id) previousWikis.set(id, item as WikiSnapshot); 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[]). // Wikis in snapshot_json are treated as current state (not a delta-table like geometries[]).
@@ -188,11 +356,8 @@ export function buildEditorSnapshot(options: {
const prev = previousWikis.get(w.id) || null; const prev = previousWikis.get(w.id) || null;
const cloned = JSON.parse(JSON.stringify(w)) as WikiSnapshot; const cloned = JSON.parse(JSON.stringify(w)) as WikiSnapshot;
cloned.source = cloned.source || "inline";
// Ref wiki: always mark as reference (used for linking, not changed here). // Ref wiki: always mark as reference (used for linking, not changed here).
if (cloned.source === "ref") { if (cloned.source === "ref") {
cloned.ref = cloned.ref || { id: cloned.id };
cloned.operation = "reference"; cloned.operation = "reference";
return cloned; return cloned;
} }
@@ -211,8 +376,8 @@ export function buildEditorSnapshot(options: {
const changed = (() => { const changed = (() => {
try { try {
const prevComparable = { title: (prev as any).title, doc: (prev as any).doc }; const prevComparable = { title: prev.title, doc: prev.doc };
const nextComparable = { title: (cloned as any).title, doc: (cloned as any).doc }; const nextComparable = { title: cloned.title, doc: cloned.doc };
return JSON.stringify(prevComparable) !== JSON.stringify(nextComparable); return JSON.stringify(prevComparable) !== JSON.stringify(nextComparable);
} catch { } catch {
return true; return true;
@@ -223,18 +388,25 @@ export function buildEditorSnapshot(options: {
return cloned; return cloned;
}); });
const entityWikis: EntityWikiLinkSnapshot[] = (options.entityWikiLinks || [])
.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",
}));
return { return {
schema_version: 2, editor_feature_collection: draftForSnapshot,
editor_feature_collection: JSON.parse(JSON.stringify(options.draft)) as FeatureCollection,
entities: Array.from(entityRows.values()).map((entity) => { entities: Array.from(entityRows.values()).map((entity) => {
const id = String(entity.id || ""); const id = String(entity.id || "");
if (pendingEntityIds.has(id)) return entity; if (pendingEntityIds.has(id)) return entity;
return entity; return entity;
}), }),
geometries, geometries,
link_scopes: linkScopes, geometry_entity: geometryEntity,
wikis, wikis,
entity_wikis: JSON.parse(JSON.stringify(options.entityWikiLinks || [])) as EntityWikiLinkSnapshot[], entity_wikis: entityWikis,
}; };
} }

8
src/uhm/lib/id.ts Normal file
View File

@@ -0,0 +1,8 @@
import { v7 as uuidv7 } from "uuid";
// Centralized ID generator for all client-created identifiers in FrontEndAdmin.
// UUIDv7 is time-ordered (RFC 9562) and works well for sorting by creation time.
export function newId(): string {
return uuidv7();
}

View File

@@ -21,9 +21,8 @@ export type EntitySnapshot = {
id: string; id: string;
// Where this entity's data comes from. // Where this entity's data comes from.
// - inline: data is embedded in snapshot_json // - inline: data is embedded in snapshot_json
// - ref: data should be fetched externally by ref.id (DB/global) // - ref: data should be fetched externally by id (DB/global)
source?: "inline" | "ref"; source: "inline" | "ref";
ref?: { id: string };
// Delta semantics for this commit: // Delta semantics for this commit:
// - create/update/delete: this commit modifies the entity record // - create/update/delete: this commit modifies the entity record
// - reference: this entity is referenced/linked (e.g., geometry<->entity, entity<->wiki) but not modified // - reference: this entity is referenced/linked (e.g., geometry<->entity, entity<->wiki) but not modified
@@ -33,7 +32,6 @@ export type EntitySnapshot = {
description?: string | null; description?: string | null;
type_id?: string | null; type_id?: string | null;
status?: number | null; status?: number | null;
is_deleted?: number;
base_updated_at?: string; base_updated_at?: string;
base_hash?: string; base_hash?: string;
}; };

View File

@@ -39,8 +39,7 @@ export type GeometrySnapshotOperation = "create" | "update" | "delete" | "refere
export type GeometrySnapshot = { export type GeometrySnapshot = {
id: string; id: string;
source?: "inline" | "ref"; source: "inline" | "ref";
ref?: { id: string };
operation?: GeometrySnapshotOperation; operation?: GeometrySnapshotOperation;
type?: string | null; type?: string | null;
draw_geometry?: Geometry; draw_geometry?: Geometry;
@@ -54,16 +53,14 @@ export type GeometrySnapshot = {
max_lng: number; max_lng: number;
max_lat: number; max_lat: number;
} | null; } | null;
is_deleted?: number;
base_updated_at?: string; base_updated_at?: string;
base_hash?: string; base_hash?: string;
}; };
export type LinkScopeSnapshot = { // Snapshot join table (geometry ↔ entity).
export type GeometryEntitySnapshot = {
geometry_id: string; geometry_id: string;
// Link deltas should be represented as "reference" operations (no replace in the current flow). entity_id: string;
operation: "reference";
entity_ids: string[];
base_links_hash?: string; base_links_hash?: string;
}; };

View File

@@ -1,12 +1,11 @@
import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntitySnapshot } from "@/uhm/types/entities";
import type { FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo"; import type { FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { WikiSnapshot } from "@/uhm/types/wiki";
export type EntityWikiLinkSnapshot = { export type EntityWikiLinkSnapshot = {
entity_id: string; entity_id: string;
wiki_id: string; wiki_id: string;
operation?: "reference" | "delete"; operation?: "reference" | "delete";
is_deleted?: number;
}; };
// API mới (BackEndGo) dùng Projects/Commits/Submissions. // API mới (BackEndGo) dùng Projects/Commits/Submissions.
@@ -60,34 +59,22 @@ export type SectionSubmission = {
content?: string | null; content?: string | null;
}; };
export type EditorSnapshotV1 = { export type EditorSnapshot = {
schema_version: 1;
// Legacy: before BEGo flow moved fully to project/commit records, FE stored a minimal "section" ref // Legacy: before BEGo flow moved fully to project/commit records, FE stored a minimal "section" ref
// inside snapshot_json. New snapshots omit this entirely. // inside snapshot_json. New snapshots omit this entirely.
section: { section?: {
id: string; id: string;
title: string; title: string;
}; };
editor_feature_collection?: FeatureCollection; editor_feature_collection?: FeatureCollection;
entities?: EntitySnapshot[]; entities?: EntitySnapshot[];
geometries?: GeometrySnapshot[]; geometries?: GeometrySnapshot[];
link_scopes?: LinkScopeSnapshot[]; // Join table geometry ↔ entity (many-to-many).
geometry_entity?: GeometryEntitySnapshot[];
wikis?: WikiSnapshot[]; wikis?: WikiSnapshot[];
entity_wikis?: EntityWikiLinkSnapshot[]; entity_wikis?: EntityWikiLinkSnapshot[];
}; };
export type EditorSnapshotV2 = {
schema_version: 2;
editor_feature_collection?: FeatureCollection;
entities?: EntitySnapshot[];
geometries?: GeometrySnapshot[];
link_scopes?: LinkScopeSnapshot[];
wikis?: WikiSnapshot[];
entity_wikis?: EntityWikiLinkSnapshot[];
};
export type EditorSnapshot = EditorSnapshotV1 | EditorSnapshotV2;
export type EditorLoadResponse = { export type EditorLoadResponse = {
section: Section; section: Section;
state: SectionState; state: SectionState;

View File

@@ -4,13 +4,10 @@ export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference"
export type WikiSnapshot = { export type WikiSnapshot = {
id: string; id: string;
source?: "inline" | "ref"; source: "inline" | "ref";
ref?: { id: string };
// Optional for backwards-compat with older commits. New commits should include it. // Optional for backwards-compat with older commits. New commits should include it.
operation?: WikiSnapshotOperation; operation?: WikiSnapshotOperation;
title: string; title: string;
doc: WikiDoc; doc: WikiDoc;
updated_at?: string; updated_at?: string;
// Optional, used when representing a delete operation row.
is_deleted?: number;
}; };