diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-27 15:21:44 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-27 15:21:44 +0900 |
| commit | cb24e2fd1fab1924084f2765bb870f01570544a2 (patch) | |
| tree | 0f00433e7203ef3c231be4f56ac8bc3faf9afa09 /scripts | |
| parent | 32d73749135dbe217fa94a96fe19bc966b3b8de6 (diff) | |
park-editor: 文字化けを保存時に警告し、場所を表示
保存を中止せず、該当セルを赤くハイライトして、保存前に公園名・項目・値の一覧を確認できるようにした。サーバー側も保存を継続し、警告を返す。
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/park-editor.html | 50 | ||||
| -rw-r--r-- | scripts/park-editor.mjs | 45 |
2 files changed, 90 insertions, 5 deletions
diff --git a/scripts/park-editor.html b/scripts/park-editor.html index 32e91c4..e320e5e 100644 --- a/scripts/park-editor.html +++ b/scripts/park-editor.html @@ -26,6 +26,8 @@ td input:not([type="checkbox"]) { width: 100%; box-sizing: border-box; border: none; padding: .25rem .35rem; font: inherit; background: transparent; color: inherit; } td input:not([type="checkbox"]):focus { outline: 2px solid #3b82f6; } tr.dirty td { background: #ff9d0033; } + td input.corrupt { background: #ffebee; box-shadow: inset 0 0 0 2px #e53935; } + #status.has-warning { color: #c62828; font-weight: 700; } tbody tr.is-focused td { background: #3b82f622 !important; } tbody tr.is-focused td.rownum { color: #1d4ed8; font-weight: 700; opacity: 1; } tbody tr.is-focused td:first-child { box-shadow: inset 3px 0 0 #2563eb; } @@ -448,6 +450,7 @@ function render() { inp.dataset.c = c; inp.addEventListener("input", () => { tr.classList.add("dirty"); + inp.classList.toggle("corrupt", inp.value.includes("\uFFFD")); statusEl.textContent = "未保存の変更があります"; }); td.appendChild(inp); @@ -458,6 +461,7 @@ function render() { tbody.appendChild(tr); }); filter(); + markCorrupt(); updateCheckedCount(); } @@ -477,6 +481,27 @@ function filter() { count.textContent = shown + " / " + rows.length + " 件"; } +function markCorrupt() { + tbody.querySelectorAll("input[data-r][data-c]").forEach((inp) => { + inp.classList.toggle("corrupt", inp.value.includes("\uFFFD")); + }); +} + +function corruptInputs() { + return Array.from(tbody.querySelectorAll("input[data-r][data-c]")).filter((inp) => + inp.value.includes("\uFFFD"), + ); +} + +function corruptLabel(inp) { + const row = rows[Number(inp.dataset.r)]; + const props = isFC ? row.properties || {} : row; + const name = + props["公園名"] || props["name"] || props["名称"] || `行${Number(inp.dataset.r) + 1}`; + const label = fields[Number(inp.dataset.c)].label; + return `${name} / ${label}: ${inp.value}`; +} + function collect() { const toyValues = new Map(); Array.prototype.forEach.call(tbody.querySelectorAll("input[data-r][data-c]"), (inp) => { @@ -510,8 +535,21 @@ function collect() { } document.getElementById("save").onclick = async function () { + const bad = corruptInputs(); + if (bad.length) { + const list = bad.slice(0, 20).map((inp) => `・${corruptLabel(inp)}`).join("\n"); + const more = bad.length > 20 ? `\n…ほか ${bad.length - 20} 件` : ""; + if (!confirm(`文字化け(U+FFFD)が ${bad.length} 件あります。このまま保存しますか?\n\n${list}${more}`)) { + statusEl.textContent = `文字化けが ${bad.length} 件あります(赤い枠のセル)。保存を中止しました。`; + statusEl.classList.add("has-warning"); + bad[0].scrollIntoView({ block: "center", behavior: "smooth" }); + bad[0].focus(); + return; + } + } collect(); statusEl.textContent = "保存中…"; + statusEl.classList.remove("has-warning"); try { const res = await fetch("/api/data/" + currentId, { method: "POST", @@ -520,13 +558,23 @@ document.getElementById("save").onclick = async function () { }); if (res.ok) { const result = await res.json(); - statusEl.textContent = `保存しました(${DATASETS[currentId].label} ${result.count}件)。git にコミットを忘れずに。`; tbody.querySelectorAll("tr.dirty").forEach((tr) => tr.classList.remove("dirty")); + markCorrupt(); + if (Array.isArray(result.warnings) && result.warnings.length) { + const list = result.warnings.slice(0, 10).map((w) => `・${w.where}`).join(" / "); + statusEl.textContent = `保存しました(${DATASETS[currentId].label} ${result.count}件)。文字化け ${result.warnings.length} 件: ${list}`; + statusEl.classList.add("has-warning"); + } else { + statusEl.textContent = `保存しました(${DATASETS[currentId].label} ${result.count}件)。git にコミットを忘れずに。`; + statusEl.classList.remove("has-warning"); + } } else { statusEl.textContent = "保存に失敗: " + (await res.text()); + statusEl.classList.add("has-warning"); } } catch (e) { statusEl.textContent = "保存に失敗: " + e.message; + statusEl.classList.add("has-warning"); } }; diff --git a/scripts/park-editor.mjs b/scripts/park-editor.mjs index d0c08da..e3bae45 100644 --- a/scripts/park-editor.mjs +++ b/scripts/park-editor.mjs @@ -51,6 +51,38 @@ function datasetFromUrl(url) { 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 server = createServer(async (req, res) => { try { const url = (req.url || "").split("?")[0]; @@ -104,9 +136,14 @@ const server = createServer(async (req, res) => { ? `${JSON.stringify(data, null, 2)}\n` : `${JSON.stringify(data)}\n`; if (text.includes("\uFFFD")) { - throw new Error( - "文字化け(U+FFFD)が含まれているため保存を中止しました。入力内容を確認してください。", - ); + // 保存は中止せず、どこが文字化けしているかを返す(入力内容を失わないため) + 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"); @@ -118,7 +155,7 @@ const server = createServer(async (req, res) => { res, 200, "application/json; charset=utf-8", - JSON.stringify({ ok: true, count }), + JSON.stringify({ ok: true, count, warnings }), ); return; } |
