diff --git a/src/uhm/components/preview/PreviewLayout.tsx b/src/uhm/components/preview/PreviewLayout.tsx index c114d55..d0e132b 100644 --- a/src/uhm/components/preview/PreviewLayout.tsx +++ b/src/uhm/components/preview/PreviewLayout.tsx @@ -7,11 +7,12 @@ import type { MapHoverPopupContent } from "@/uhm/components/map/useMapHoverPopup import PresentPlaceSearch, { type HistoricalGeometryFocusPayload, type PresentPlaceSelection } from "@/uhm/components/editor/PresentPlaceSearch"; import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay"; import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel"; -import { PublicMapZoomPanel } from "@/uhm/components/preview/PublicPreviewClientPage"; +import { PublicMapZoomPanel } from "@/uhm/components/preview/PublicMapZoomPanel"; import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; import TimelineBar from "@/uhm/components/ui/TimelineBar"; import RelatedEntityPopup from "./RelatedEntityPopup"; import WikiSelectionPanel from "./WikiSelectionPanel"; +import { cleanWikiPreviewQuote, extractWikiBlockquoteText } from "@/uhm/lib/preview/previewUtils"; import { fitMapToFeatureCollection } from "@/uhm/components/map/mapUtils"; import { fetchWikiById, type Wiki } from "@/uhm/api/wikis"; @@ -990,42 +991,7 @@ function EditorPreviewModePanel({ // Helper functions // ========================================== -function extractWikiBlockquoteText(content: string | null | undefined): string { - if (!content) return ""; - const decoded = decodeHtmlEntities(content); - const blockquoteMatch = decoded.match(/]*>([\s\S]*?)<\/blockquote>/i); - const rawText = blockquoteMatch?.[1]?.trim() || ""; - if (!rawText) return ""; - - return cleanWikiPlainText(rawText); -} - -function cleanWikiPreviewQuote(raw: string | null | undefined): string { - const decoded = decodeHtmlEntities(String(raw || "")); - const blockquote = extractWikiBlockquoteText(decoded); - return cleanWikiPlainText(blockquote || decoded); -} - -function cleanWikiPlainText(raw: string): string { - return decodeHtmlEntities(raw) - .replace(/<[^>]*>/g, "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function decodeHtmlEntities(raw: string): string { - return raw - .replace(/ /gi, " ") - .replace(/ /gi, " ") - .replace(/&/gi, "&") - .replace(/</gi, "<") - .replace(/>/gi, ">") - .replace(/"/gi, '"') - .replace(/'/g, "'") - .replace(/'/gi, "'"); -} function getWikiHoverTitle(wiki: { title?: string | null } | null | undefined, fallbackTitle: string): string { return String(wiki?.title || "").trim() || fallbackTitle; diff --git a/src/uhm/components/preview/PublicMapZoomPanel.tsx b/src/uhm/components/preview/PublicMapZoomPanel.tsx new file mode 100644 index 0000000..a97d928 --- /dev/null +++ b/src/uhm/components/preview/PublicMapZoomPanel.tsx @@ -0,0 +1,401 @@ +"use client"; + +import { useEffect, useRef, useState, type RefObject } from "react"; +import type { MapHandle } from "@/uhm/components/Map"; +import { MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "@/uhm/lib/map/constants"; + +export function PublicMapZoomPanel({ + mapHandleRef, + onPlayPreviewReplay, + onResumePreviewReplay, + onStopPreviewReplay, +}: { + mapHandleRef: RefObject; + onPlayPreviewReplay?: () => void; + onResumePreviewReplay?: () => void; + onStopPreviewReplay?: () => void; +}) { + const [zoomLevel, setZoomLevel] = useState(2); + const [isGlobeProjection, setIsGlobeProjection] = useState(false); + const isDraggingRef = useRef(false); + + useEffect(() => { + let disposed = false; + let cleanup: (() => void) | null = null; + let retryTimer: number | null = null; + + const bind = () => { + if (disposed) return; + const map = mapHandleRef.current?.getMap(); + if (!map) { + retryTimer = window.setTimeout(bind, 120); + return; + } + + const syncProjection = () => { + const projection = mapHandleRef.current?.getViewState()?.projection; + setIsGlobeProjection(projection === "globe"); + }; + + const syncZoom = () => { + if (isDraggingRef.current) return; + setZoomLevel(roundPanelZoom(map.getZoom())); + }; + + syncZoom(); + syncProjection(); + map.on("zoom", syncZoom); + map.on("zoomend", syncZoom); + map.on("styledata", syncProjection); + cleanup = () => { + map.off("zoom", syncZoom); + map.off("zoomend", syncZoom); + map.off("styledata", syncProjection); + }; + }; + + bind(); + return () => { + disposed = true; + if (retryTimer) window.clearTimeout(retryTimer); + cleanup?.(); + }; + }, [mapHandleRef]); + + const toggleProjection = () => { + const next = !isGlobeProjection; + setIsGlobeProjection(next); + mapHandleRef.current?.setGlobeProjection(next); + }; + + const zoomByStep = (delta: number) => { + const map = mapHandleRef.current?.getMap(); + if (!map) return; + const next = clampZoom(zoomLevel + delta); + setZoomLevel(next); + map.easeTo({ zoom: next, duration: 120 }); + }; + + const handleSliderChange = (nextRaw: number) => { + const map = mapHandleRef.current?.getMap(); + if (!map || !Number.isFinite(nextRaw)) return; + const next = clampZoom(nextRaw); + setZoomLevel(next); + map.jumpTo({ zoom: next }); + }; + + return ( +
+ + + {onPlayPreviewReplay ? ( + + ) : null} + {onResumePreviewReplay ? ( + + ) : null} + {onStopPreviewReplay ? ( + + ) : null} + + { + isDraggingRef.current = true; + }} + onPointerUp={() => { + isDraggingRef.current = false; + const map = mapHandleRef.current?.getMap(); + if (map) setZoomLevel(roundPanelZoom(map.getZoom())); + }} + onPointerCancel={() => { + isDraggingRef.current = false; + }} + onBlur={() => { + isDraggingRef.current = false; + }} + onChange={(event) => handleSliderChange(Number(event.target.value))} + aria-label="Mức thu phóng bản đồ" + /> + +
+ {zoomLevel.toFixed(1)}x +
+
+ ); +} + +function clampZoom(value: number): number { + if (!Number.isFinite(value)) return MAP_MIN_ZOOM; + return Math.max(MAP_MIN_ZOOM, Math.min(MAP_MAX_ZOOM, value)); +} + +function roundPanelZoom(value: number): number { + if (!Number.isFinite(value)) return MAP_MIN_ZOOM; + return Math.round(value * 10) / 10; +} diff --git a/src/uhm/components/preview/PublicPreviewClientPage.tsx b/src/uhm/components/preview/PublicPreviewClientPage.tsx index eae2380..f030836 100644 --- a/src/uhm/components/preview/PublicPreviewClientPage.tsx +++ b/src/uhm/components/preview/PublicPreviewClientPage.tsx @@ -18,7 +18,6 @@ import { usePublicPreviewData } from "@/uhm/components/preview/hooks/usePublicPr import { fetchEntitiesByWikiIds, fetchGeometriesByEntityIds, - type RelationGeometry, } from "@/uhm/api/relations"; import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview"; import type { MapFeaturePayload, MapHandle } from "@/uhm/components/Map"; @@ -29,10 +28,8 @@ import PresentPlaceSearch, { type PresentPlaceSelection, } from "@/uhm/components/editor/PresentPlaceSearch"; import type { Entity } from "@/uhm/api/entities"; -import { reverseGeocodePresentPlace } from "@/uhm/api/goongPlaces"; import { fetchWikiBySlug, type Wiki } from "@/uhm/api/wikis"; import type { FeatureCollection } from "@/uhm/types/geo"; -import { getGeometryRepresentativePoint } from "@/uhm/components/map/mapUtils"; import { type BackgroundLayerId, type BackgroundLayerVisibility, @@ -43,8 +40,21 @@ import { persistBackgroundLayerVisibility, } from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap"; -import { MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "@/uhm/lib/map/constants"; import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/utils/timeline"; +import RelatedEntityPopup from "@/uhm/components/preview/RelatedEntityPopup"; +import { PublicMapZoomPanel } from "@/uhm/components/preview/PublicMapZoomPanel"; +import { + cleanWikiPreviewQuote, + extractWikiBlockquoteText, + buildGeometrySelectionRows, + filterRelationGeometriesByEarliestStartTime, + relationGeometriesToFeatureCollection, + getEntityPreferredTimeStart, + findRelationWikiBySlug, + findRelationEntityIdsByWikiSlug, + cloneStringArrayRecord, + appendUnique, +} from "@/uhm/lib/preview/previewUtils"; const CURRENT_YEAR = new Date().getUTCFullYear(); @@ -920,48 +930,22 @@ export default function PublicPreviewClientPage({ )} {linkEntityPopup ? ( -
-
-
- Related Entities -
-
- /wiki/{linkEntityPopup.slug} -
-
-
- {linkEntityPopup.entities.length ? ( -
- {linkEntityPopup.entities.map((entity) => ( - - ))} -
- ) : ( -
- Không có entity liên quan. -
- )} -
-
+ setLinkEntityPopup(null)} + onSelectEntity={(entityId) => { + if (replayMode !== "idle") { + handleExitReplay(); + } + setWikiSelectionPanelAnchor(null); + setRightPanelMode("wiki"); + selectEntity(entityId, { preferredWikiSlug: linkEntityPopup.slug }); + setLinkEntityPopup(null); + }} + /> ) : null} {isGeometryChooserOpen && geometrySelectionPanel ? ( @@ -1042,595 +1026,3 @@ export default function PublicPreviewClientPage({ ); } -export function PublicMapZoomPanel({ - mapHandleRef, - onPlayPreviewReplay, - onResumePreviewReplay, - onStopPreviewReplay, -}: { - mapHandleRef: RefObject; - onPlayPreviewReplay?: () => void; - onResumePreviewReplay?: () => void; - onStopPreviewReplay?: () => void; -}) { - const [zoomLevel, setZoomLevel] = useState(2); - const [isGlobeProjection, setIsGlobeProjection] = useState(false); - const isDraggingRef = useRef(false); - - useEffect(() => { - let disposed = false; - let cleanup: (() => void) | null = null; - let retryTimer: number | null = null; - - const bind = () => { - if (disposed) return; - const map = mapHandleRef.current?.getMap(); - if (!map) { - retryTimer = window.setTimeout(bind, 120); - return; - } - - const syncProjection = () => { - const projection = mapHandleRef.current?.getViewState()?.projection; - setIsGlobeProjection(projection === "globe"); - }; - - const syncZoom = () => { - if (isDraggingRef.current) return; - setZoomLevel(roundPanelZoom(map.getZoom())); - }; - - syncZoom(); - syncProjection(); - map.on("zoom", syncZoom); - map.on("zoomend", syncZoom); - map.on("styledata", syncProjection); - cleanup = () => { - map.off("zoom", syncZoom); - map.off("zoomend", syncZoom); - map.off("styledata", syncProjection); - }; - }; - - bind(); - return () => { - disposed = true; - if (retryTimer) window.clearTimeout(retryTimer); - cleanup?.(); - }; - }, [mapHandleRef]); - - const toggleProjection = () => { - const next = !isGlobeProjection; - setIsGlobeProjection(next); - mapHandleRef.current?.setGlobeProjection(next); - }; - - const zoomByStep = (delta: number) => { - const map = mapHandleRef.current?.getMap(); - if (!map) return; - const next = clampZoom(zoomLevel + delta); - setZoomLevel(next); - map.easeTo({ zoom: next, duration: 120 }); - }; - - const handleSliderChange = (nextRaw: number) => { - const map = mapHandleRef.current?.getMap(); - if (!map || !Number.isFinite(nextRaw)) return; - const next = clampZoom(nextRaw); - setZoomLevel(next); - map.jumpTo({ zoom: next }); - }; - - return ( -
- - - {onPlayPreviewReplay ? ( - - ) : null} - {onResumePreviewReplay ? ( - - ) : null} - {onStopPreviewReplay ? ( - - ) : null} - - { - isDraggingRef.current = true; - }} - onPointerUp={() => { - isDraggingRef.current = false; - const map = mapHandleRef.current?.getMap(); - if (map) setZoomLevel(roundPanelZoom(map.getZoom())); - }} - onPointerCancel={() => { - isDraggingRef.current = false; - }} - onBlur={() => { - isDraggingRef.current = false; - }} - onChange={(event) => handleSliderChange(Number(event.target.value))} - aria-label="Mức thu phóng bản đồ" - /> - -
- {zoomLevel.toFixed(1)}x -
-
- ); -} - -function clampZoom(value: number): number { - if (!Number.isFinite(value)) return MAP_MIN_ZOOM; - return Math.max(MAP_MIN_ZOOM, Math.min(MAP_MAX_ZOOM, value)); -} - -function roundPanelZoom(value: number): number { - if (!Number.isFinite(value)) return MAP_MIN_ZOOM; - return Math.round(value * 10) / 10; -} - -function cleanWikiPreviewQuote(raw: string | null | undefined): string { - const decoded = decodeHtmlEntities(String(raw || "")); - const blockquote = extractWikiBlockquoteText(decoded); - return cleanWikiPlainText(blockquote || decoded); -} - -function extractWikiBlockquoteText(content: string | null | undefined): string { - if (!content) return ""; - - const decoded = decodeHtmlEntities(content); - const blockquoteMatch = decoded.match(/]*>([\s\S]*?)<\/blockquote>/i); - const rawText = blockquoteMatch?.[1]?.trim() || ""; - if (!rawText) return ""; - - return cleanWikiPlainText(rawText); -} - -function cleanWikiPlainText(raw: string): string { - return decodeHtmlEntities(raw) - .replace(/<[^>]*>/g, "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function decodeHtmlEntities(raw: string): string { - return raw - .replace(/ /gi, " ") - .replace(/ /gi, " ") - .replace(/&/gi, "&") - .replace(/</gi, "<") - .replace(/>/gi, ">") - .replace(/"/gi, '"') - .replace(/'/g, "'") - .replace(/'/gi, "'"); -} - -async function buildGeometrySelectionRows( - entities: Entity[], - geometriesByEntityId: Record -): Promise { - return Promise.all(entities.map(async (entity) => { - const geometries = geometriesByEntityId[entity.id] || []; - const displayGeometries = await Promise.all(geometries.map(async (geometry) => { - const center = geometry.draw_geometry ? getGeometryRepresentativePoint(geometry.draw_geometry) : null; - if (!center) { - return { - id: geometry.id, - center: null, - adminLabel: null, - adminAddress: null, - }; - } - - try { - const place = await reverseGeocodePresentPlace(center[0], center[1]); - return { - id: geometry.id, - center, - adminLabel: place.label, - adminAddress: place.address, - }; - } catch { - return { - id: geometry.id, - center, - adminLabel: null, - adminAddress: null, - }; - } - })); - - return { - entity, - geometries: displayGeometries, - featureCollection: relationGeometriesToFeatureCollection(geometries), - }; - })); -} - -function filterRelationGeometriesByEarliestStartTime( - source: Record -): Record { - const result: Record = {}; - - for (const [entityId, geometries] of Object.entries(source)) { - const rows = (geometries || []).filter((geometry) => Boolean(geometry?.id && geometry.draw_geometry)); - if (!rows.length) { - result[entityId] = []; - continue; - } - - const timedRows = rows.filter((geometry) => Number.isFinite(geometry.time_start)); - const candidateRows = timedRows.length ? timedRows : rows; - const minStartTime = Math.min(...candidateRows.map((geometry) => - Number.isFinite(geometry.time_start) ? Number(geometry.time_start) : Number.POSITIVE_INFINITY - )); - - result[entityId] = Number.isFinite(minStartTime) - ? candidateRows.filter((geometry) => Number(geometry.time_start) === minStartTime) - : candidateRows; - } - - return result; -} - -function relationGeometriesToFeatureCollection(geometries: RelationGeometry[]): FeatureCollection { - return { - type: "FeatureCollection", - features: geometries - .filter((geometry) => Boolean(geometry?.id && geometry.draw_geometry)) - .map((geometry) => ({ - type: "Feature" as const, - properties: { - id: geometry.id, - type: geometry.type, - time_start: geometry.time_start, - time_end: geometry.time_end, - bound_with: geometry.bound_with, - }, - geometry: geometry.draw_geometry, - })), - }; -} - -function getFeatureCollectionMinTimeStart(fc: FeatureCollection): number | null { - const values = fc.features - .map((feature) => feature.properties.time_start) - .filter((value): value is number => Number.isFinite(value)); - if (!values.length) return null; - return Math.min(...values); -} - -function getEntityPreferredTimeStart(entity: Entity | null, fallbackGeometries: FeatureCollection): number | null { - if (Number.isFinite(entity?.time_start)) { - return Number(entity?.time_start); - } - return getFeatureCollectionMinTimeStart(fallbackGeometries); -} - -function findRelationWikiBySlug(source: Record, slug: string): Wiki | undefined { - const direct = source[slug]; - if (direct) return direct; - - const target = normalizeWikiSlugForCompare(slug); - if (!target) return undefined; - return Object.entries(source).find(([key, wiki]) => - normalizeWikiSlugForCompare(key) === target || - normalizeWikiSlugForCompare(wiki.slug) === target - )?.[1]; -} - -function findRelationEntityIdsByWikiSlug(source: Record, slug: string): string[] { - const direct = source[slug]; - if (direct?.length) return direct; - - const target = normalizeWikiSlugForCompare(slug); - if (!target) return []; - for (const [key, ids] of Object.entries(source)) { - if (normalizeWikiSlugForCompare(key) === target) return ids; - } - return []; -} - -function normalizeWikiSlugForCompare(value: string | null | undefined): string { - let raw = String(value || "").trim(); - if (!raw) return ""; - try { - raw = decodeURIComponent(raw); - } catch { - // Keep the original value if it is not valid percent-encoded text. - } - return raw - .replace(/^\/+/, "") - .replace(/^wiki\//i, "") - .replace(/_/g, " ") - .replace(/\s+/g, " ") - .trim() - .toLocaleLowerCase("vi-VN"); -} - -function cloneStringArrayRecord(source: Record): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(source)) { - result[key] = [...value]; - } - return result; -} - -function appendUnique(target: Record, key: string, value: string) { - if (!target[key]) { - target[key] = [value]; - return; - } - if (!target[key].includes(value)) target[key].push(value); -} diff --git a/src/uhm/lib/preview/previewUtils.ts b/src/uhm/lib/preview/previewUtils.ts new file mode 100644 index 0000000..3ea9bdf --- /dev/null +++ b/src/uhm/lib/preview/previewUtils.ts @@ -0,0 +1,214 @@ +import type { Entity } from "@/uhm/api/entities"; +import type { RelationGeometry } from "@/uhm/api/relations"; +import type { Wiki } from "@/uhm/api/wikis"; +import type { FeatureCollection } from "@/uhm/types/geo"; +import { getGeometryRepresentativePoint } from "@/uhm/components/map/mapUtils"; +import { reverseGeocodePresentPlace } from "@/uhm/api/goongPlaces"; + +export interface GeometrySelectionRow { + entity: Entity; + geometries: Array<{ + id: string; + center: [number, number] | null; + adminLabel: string | null; + adminAddress: string | null; + }>; + featureCollection: FeatureCollection; +} + +export function cleanWikiPreviewQuote(raw: string | null | undefined): string { + const decoded = decodeHtmlEntities(String(raw || "")); + const blockquote = extractWikiBlockquoteText(decoded); + return cleanWikiPlainText(blockquote || decoded); +} + +export function extractWikiBlockquoteText(content: string | null | undefined): string { + if (!content) return ""; + + const decoded = decodeHtmlEntities(content); + const blockquoteMatch = decoded.match(/]*>([\s\S]*?)<\/blockquote>/i); + const rawText = blockquoteMatch?.[1]?.trim() || ""; + if (!rawText) return ""; + + return cleanWikiPlainText(rawText); +} + +export function cleanWikiPlainText(raw: string): string { + return decodeHtmlEntities(raw) + .replace(/<[^>]*>/g, "") + .replace(/\u00a0/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export function decodeHtmlEntities(raw: string): string { + return raw + .replace(/ /gi, " ") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/'/gi, "'"); +} + +export async function buildGeometrySelectionRows( + entities: Entity[], + geometriesByEntityId: Record +): Promise { + return Promise.all(entities.map(async (entity) => { + const geometries = geometriesByEntityId[entity.id] || []; + const displayGeometries = await Promise.all(geometries.map(async (geometry) => { + const center = geometry.draw_geometry ? getGeometryRepresentativePoint(geometry.draw_geometry) : null; + if (!center) { + return { + id: geometry.id, + center: null, + adminLabel: null, + adminAddress: null, + }; + } + + try { + const place = await reverseGeocodePresentPlace(center[0], center[1]); + return { + id: geometry.id, + center, + adminLabel: place.label, + adminAddress: place.address, + }; + } catch { + return { + id: geometry.id, + center, + adminLabel: null, + adminAddress: null, + }; + } + })); + + return { + entity, + geometries: displayGeometries, + featureCollection: relationGeometriesToFeatureCollection(geometries), + }; + })); +} + +export function filterRelationGeometriesByEarliestStartTime( + source: Record +): Record { + const result: Record = {}; + + for (const [entityId, geometries] of Object.entries(source)) { + const rows = (geometries || []).filter((geometry) => Boolean(geometry?.id && geometry.draw_geometry)); + if (!rows.length) { + result[entityId] = []; + continue; + } + + const timedRows = rows.filter((geometry) => Number.isFinite(geometry.time_start)); + const candidateRows = timedRows.length ? timedRows : rows; + const minStartTime = Math.min(...candidateRows.map((geometry) => + Number.isFinite(geometry.time_start) ? Number(geometry.time_start) : Number.POSITIVE_INFINITY + )); + + result[entityId] = Number.isFinite(minStartTime) + ? candidateRows.filter((geometry) => Number(geometry.time_start) === minStartTime) + : candidateRows; + } + + return result; +} + +export function relationGeometriesToFeatureCollection(geometries: RelationGeometry[]): FeatureCollection { + return { + type: "FeatureCollection", + features: geometries + .filter((geometry) => Boolean(geometry?.id && geometry.draw_geometry)) + .map((geometry) => ({ + type: "Feature" as const, + properties: { + id: geometry.id, + type: geometry.type, + time_start: geometry.time_start, + time_end: geometry.time_end, + bound_with: geometry.bound_with, + }, + geometry: geometry.draw_geometry, + })), + }; +} + +export function getFeatureCollectionMinTimeStart(fc: FeatureCollection): number | null { + const values = fc.features + .map((feature) => feature.properties.time_start) + .filter((value): value is number => Number.isFinite(value)); + if (!values.length) return null; + return Math.min(...values); +} + +export function getEntityPreferredTimeStart(entity: Entity | null, fallbackGeometries: FeatureCollection): number | null { + if (Number.isFinite(entity?.time_start)) { + return Number(entity?.time_start); + } + return getFeatureCollectionMinTimeStart(fallbackGeometries); +} + +export function findRelationWikiBySlug(source: Record, slug: string): Wiki | undefined { + const direct = source[slug]; + if (direct) return direct; + + const target = normalizeWikiSlugForCompare(slug); + if (!target) return undefined; + return Object.entries(source).find(([key, wiki]) => + normalizeWikiSlugForCompare(key) === target || + normalizeWikiSlugForCompare(wiki.slug) === target + )?.[1]; +} + +export function findRelationEntityIdsByWikiSlug(source: Record, slug: string): string[] { + const direct = source[slug]; + if (direct?.length) return direct; + + const target = normalizeWikiSlugForCompare(slug); + if (!target) return []; + for (const [key, ids] of Object.entries(source)) { + if (normalizeWikiSlugForCompare(key) === target) return ids; + } + return []; +} + +export function normalizeWikiSlugForCompare(value: string | null | undefined): string { + let raw = String(value || "").trim(); + if (!raw) return ""; + try { + raw = decodeURIComponent(raw); + } catch { + // Keep the original value if it is not valid percent-encoded text. + } + return raw + .replace(/^\/+/, "") + .replace(/^wiki\//i, "") + .replace(/_/g, " ") + .replace(/\s+/g, " ") + .trim() + .toLocaleLowerCase("vi-VN"); +} + +export function cloneStringArrayRecord(source: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(source)) { + result[key] = [...value]; + } + return result; +} + +export function appendUnique(target: Record, key: string, value: string) { + if (!target[key]) { + target[key] = [value]; + return; + } + if (!target[key].includes(value)) target[key].push(value); +}