"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", "nl", "de", "fr", "es", "pt", "pl", "sv", "tr", "ro", "hu", "cs", "sk", "da", "no", "el", "bg", "hr", "sr", "uk", "ru", ]), 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 = {}; 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 }); }, );