74 lines
3.0 KiB
TypeScript
74 lines
3.0 KiB
TypeScript
"use server";
|
|
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import JSON5 from "json5";
|
|
import { z } from "zod";
|
|
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from "@/lib/client-translation-files";
|
|
import { patchJson5 } from "@/lib/json5-patch";
|
|
import { adminAction } from "@/lib/safe-action";
|
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
|
|
|
const saveTranslationsSchema = z.object({
|
|
locale: z.enum(["en", "it"]),
|
|
data: z.record(z.string(), z.unknown()),
|
|
});
|
|
|
|
export const saveTranslations = adminAction({ schema: saveTranslationsSchema }, async (ctx) => {
|
|
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
|
|
|
const filePath = path.join(process.cwd(), "messages", `${ctx.data.locale}.json`);
|
|
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), "utf-8");
|
|
|
|
return actionOk();
|
|
});
|
|
|
|
const saveClientTranslationsSchema = z.object({
|
|
fileId: z.enum(CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]]),
|
|
data: z.record(z.string(), z.string()),
|
|
});
|
|
|
|
export const saveClientTranslations = adminAction({ schema: saveClientTranslationsSchema }, async (ctx) => {
|
|
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
|
|
|
const file = getClientTranslationFile(ctx.data.fileId);
|
|
if (!file) throw new ActionError("Unknown file");
|
|
if (file.readOnly) throw new ActionError("File is read-only");
|
|
|
|
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
|
|
// Turbopack's static tracer can't prove that — without the hint it
|
|
// pulls the entire project into the NFT list.
|
|
const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath);
|
|
const raw = await fs.readFile(absPath, "utf-8");
|
|
|
|
if (file.format === "json") {
|
|
// Plain JSON — no comments to preserve, just round-trip.
|
|
await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), "utf-8");
|
|
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] });
|
|
}
|
|
|
|
// JSON5: surgical line-level patch keeps headers and section comments
|
|
// intact. Falls back to a full re-serialization (which DOES drop comments)
|
|
// only when an edited key cannot be located via the patch contract.
|
|
const original: Record<string, string> = {};
|
|
const parsed = JSON5.parse(raw);
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
for (const [k, v] of Object.entries(parsed)) {
|
|
original[k] = v == null ? "" : String(v);
|
|
}
|
|
}
|
|
|
|
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data);
|
|
|
|
if (unpatchedKeys.length === 0) {
|
|
await fs.writeFile(absPath, content, "utf-8");
|
|
return actionOk({ commentsLost: false, unpatchedKeys });
|
|
}
|
|
|
|
// At least one key could not be patched surgically (e.g. unusual
|
|
// formatting or a brand-new key). Fall back to a full re-serialization
|
|
// and warn the caller that comments were lost.
|
|
await fs.writeFile(absPath, JSON5.stringify(ctx.data.data, null, 4), "utf-8");
|
|
return actionOk({ commentsLost: true, unpatchedKeys });
|
|
});
|