127 lines
3.2 KiB
TypeScript
127 lines
3.2 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",
|
|
"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<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 });
|
|
},
|
|
);
|