// 公園・公共施設データの簡易エディタ(ローカル専用)。 // // 使い方: // npm run park-editor // → 表示された URL(既定 http://localhost:8787/)をブラウザで開く // → タブで「市内公園 / 周辺の公園 / 市内の公共施設 / 周辺の公共施設」を切り替え // → 表のセルを直接編集し、「保存」を押すと該当するJSONを更新 // → 反映確認後、git にコミットする // // 注意: ローカル(127.0.0.1)にのみ公開します。追加パッケージは不要です。 import { createServer } from "node:http"; import { readFile, writeFile, rename } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const HTML = join(root, "scripts/park-editor.html"); const PORT = Number(process.env.PORT || 8787); const DATASETS = { parks: { label: "市内公園", path: join(root, "src/data/kodaira-parks.json"), pretty: false, }, nearbyParks: { label: "周辺の公園", path: join(root, "src/data/kodaira-nearby-parks.json"), pretty: false, }, facilities: { label: "市内の公共施設", path: join(root, "src/data/kodaira-facilities.json"), pretty: true, }, nearbyFacilities: { label: "周辺の公共施設", path: join(root, "src/data/kodaira-nearby-facilities.json"), pretty: true, }, }; function send(res, code, type, body) { res.writeHead(code, { "content-type": type }); res.end(body); } function datasetFromUrl(url) { const match = /^\/api\/data\/([A-Za-z]+)$/.exec(url); if (!match) return null; return DATASETS[match[1]] ? { id: match[1], dataset: DATASETS[match[1]] } : null; } // 文字化け(U+FFFD)の場所を「レコード名 / 項目名」で返す。 // 保存は止めず、どこを直せばよいか分かるようにするための情報。 const NAME_KEYS = ["公園名", "name", "名称", "our_name"]; function recordLabel(obj) { const sources = []; if (obj && obj.properties) sources.push(obj.properties); sources.push(obj); for (const source of sources) { for (const key of NAME_KEYS) { const value = source && source[key]; if (typeof value === "string" && value) return value; } } return ""; } function findCorruptions(value, path, out) { if (typeof value === "string") { if (value.includes("\uFFFD")) out.push({ where: path, value }); } else if (Array.isArray(value)) { value.forEach((item, index) => { const label = recordLabel(item); findCorruptions(item, label || `${path}[${index + 1}]`, out); }); } else if (value && typeof value === "object") { for (const [key, child] of Object.entries(value)) { if (key.includes("\uFFFD")) out.push({ where: `${path} / ${key}(項目名)`, value: "" }); findCorruptions(child, path ? `${path} / ${key}` : key, out); } } return out; } const FFFD = "\uFFFD"; function recordKey(obj) { const src = obj && obj.properties ? obj.properties : obj; if (!src) return null; for (const key of ["公園名", "name", "名称", "our_name"]) { const value = src[key]; if (typeof value === "string" && value) return value; } return null; } function canonicalKeysFrom(lists) { const keys = new Set(); for (const list of lists) { for (const rec of list || []) { const src = rec && rec.properties ? rec.properties : rec; if (!src) continue; for (const key of Object.keys(src)) { if (!key.includes(FFFD)) keys.add(key); } } } return [...keys]; } // 「ベンチ・椅���」のような項目名の文字化けを、前後の文字から正しい項目名に戻す function normalizeRecordKeys(rec, canonicalKeys) { const src = rec && rec.properties ? rec.properties : rec; if (!src) return; for (const bad of Object.keys(src)) { if (!bad.includes(FFFD)) continue; const prefix = bad.split(FFFD)[0]; const suffix = bad.split(FFFD).slice(-1)[0]; const candidates = canonicalKeys.filter( (key) => key.startsWith(prefix) && key.endsWith(suffix), ); if (candidates.length !== 1) continue; const good = candidates[0]; const value = src[bad]; if (!(good in src)) { src[good] = value; delete src[bad]; } else if (!src[good] && value) { src[good] = value; delete src[bad]; } else if (src[good] === value) { delete src[bad]; } } } // 値の文字化けが「何文字かの欠落」だけなら、ディスク側の値で直す function repairValue(posted, current) { if (typeof posted !== "string" || !posted.includes(FFFD)) return posted; if (typeof current !== "string" || !current) return posted; const parts = posted.split(/\uFFFD+/); if (parts.length < 2) return posted; const pattern = "^" + parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("(.{1,4}?)") + "$"; return new RegExp(pattern, "s").test(current) ? current : posted; } function mergeInto(target, source) { const dst = target.properties || target; const src = source.properties || source; for (const [field, value] of Object.entries(src)) { if (!(field in dst) || (!dst[field] && value)) dst[field] = value; } } // ブラウザが古いデータを持っていても、後から足した項目やレコードを消さないようにする。 // 画面になくて編集できないキーは、ディスク側の値を残す。 function mergeMissing(postedList, currentList, canonicalKeys) { for (const rec of postedList) normalizeRecordKeys(rec, canonicalKeys); // 同じレコードが重複していたら1つにまとめる const deduped = []; const byKey = new Map(); for (const rec of postedList) { const key = recordKey(rec); if (!key) { deduped.push(rec); continue; } const existing = byKey.get(key); if (existing) { mergeInto(existing, rec); continue; } byKey.set(key, rec); deduped.push(rec); } postedList.length = 0; for (const rec of deduped) postedList.push(rec); const map = new Map(); for (const rec of currentList) { const key = recordKey(rec); if (key) map.set(key, rec); } const postedKeys = new Set(); for (const rec of postedList) { const key = recordKey(rec); if (!key) continue; postedKeys.add(key); const current = map.get(key); if (!current) continue; const dst = rec.properties || rec; const src = current.properties || current; for (const [field, value] of Object.entries(src)) { if (!(field in dst)) { dst[field] = value; } else if (typeof dst[field] === "string" && dst[field].includes(FFFD)) { dst[field] = repairValue(dst[field], value); } } } for (const [key, rec] of map) { if (!postedKeys.has(key)) postedList.push(rec); } } const server = createServer(async (req, res) => { try { const url = (req.url || "").split("?")[0]; if (req.method === "GET" && (url === "/" || url === "/index.html")) { send(res, 200, "text/html; charset=utf-8", await readFile(HTML, "utf-8")); return; } if (req.method === "GET" && url === "/api/datasets") { send( res, 200, "application/json; charset=utf-8", JSON.stringify( Object.entries(DATASETS).map(([id, dataset]) => ({ id, label: dataset.label })), ), ); return; } const target = datasetFromUrl(url); if (!target) { send(res, 404, "text/plain; charset=utf-8", "not found"); return; } if (req.method === "GET") { send( res, 200, "application/json; charset=utf-8", await readFile(target.dataset.path, "utf-8"), ); return; } if (req.method === "POST") { let body = ""; for await (const chunk of req) body += chunk; const data = JSON.parse(body); if (target.id === "nearbyParks") { if (!data || data.type !== "FeatureCollection" || !Array.isArray(data.features)) { throw new Error("周辺の公園は FeatureCollection ではありません"); } } else if (!Array.isArray(data) || data.length === 0) { throw new Error("データが配列ではありません"); } // 古い画面からの保存で、新しい項目・レコードが消えないようにマージする const current = JSON.parse(await readFile(target.dataset.path, "utf-8")); const canonicalKeys = canonicalKeysFrom([current, data.features || data]); if (target.id === "nearbyParks") { mergeMissing(data.features, current.features || [], canonicalKeys); } else { mergeMissing(data, current, canonicalKeys); } const text = target.dataset.pretty ? `${JSON.stringify(data, null, 2)}\n` : `${JSON.stringify(data)}\n`; if (text.includes("\uFFFD")) { // 保存は中止せず、どこが文字化けしているかを返す(入力内容を失わないため) console.warn("文字化け(U+FFFD)を含むデータを保存しました"); } const warnings = findCorruptions(data, "", []).slice(0, 50); if (warnings.length) { for (const warning of warnings.slice(0, 10)) { console.warn(` 文字化け: ${warning.where} = ${warning.value}`); } } const tmp = `${target.dataset.path}.tmp`; await writeFile(tmp, text, "utf-8"); await rename(tmp, target.dataset.path); const count = target.id === "nearbyParks" ? data.features.length : data.length; console.log(`保存しました: ${target.dataset.label} ${count}件 → ${target.dataset.path}`); send( res, 200, "application/json; charset=utf-8", JSON.stringify({ ok: true, count, warnings }), ); return; } send(res, 405, "text/plain; charset=utf-8", "method not allowed"); } catch (e) { console.error(e); send(res, 500, "text/plain; charset=utf-8", String(e)); } }); server.listen(PORT, "127.0.0.1", () => { console.log(`公園・施設データ編集: http://localhost:${PORT}/ (Ctrl+C で終了)`); });