feat(cms): add recovery history and operational reliability tools
This commit is contained in:
1 parent
89526af344
commit
1ef405be0a
92 files changed
+6384
-267
No files matched your search
@@ -8,6 +8,10 @@ const state = vi.hoisted(() => ({
|
||||
log: vi.fn(() => "news-error-1"),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/features/history/server", () => ({
|
||||
historySnapshot: async () => ({}),
|
||||
recordHistory: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({
|
||||
requirePermission: async () => ({ id: 1, username: "staff" }),
|
||||
}));
|
||||
|
||||
@@ -5,11 +5,13 @@ import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor";
|
||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { articleEditToken } from "@/lib/article-edit-token";
|
||||
import {
|
||||
ArticleInputError,
|
||||
type ArticleSaveResult,
|
||||
articleInputField,
|
||||
readArticleInput,
|
||||
} from "@/lib/article-input";
|
||||
import {
|
||||
@@ -53,8 +55,14 @@ async function saveFailure(
|
||||
operation: string,
|
||||
): Promise<ArticleSaveResult> {
|
||||
const t = await getTranslations("pages.admin.articles.form");
|
||||
if (error instanceof ArticleInputError)
|
||||
return { ok: false, error: t(error.code) };
|
||||
if (error instanceof ArticleInputError) {
|
||||
const field = articleInputField(error.code);
|
||||
return {
|
||||
ok: false,
|
||||
error: t(error.code),
|
||||
...(field ? { fieldErrors: { [field]: [t(error.code)] } } : {}),
|
||||
};
|
||||
}
|
||||
const reference = logger.error("News save failed", {
|
||||
module: "news",
|
||||
operation,
|
||||
@@ -124,6 +132,8 @@ export async function updateArticle(
|
||||
if (!existing) throw new ArticleInputError("articleNotFound");
|
||||
if (formData.get("baseToken") !== articleEditToken(existing))
|
||||
throw new ArticleInputError("editConflict");
|
||||
const historyId = String(id);
|
||||
const historyBefore = await historySnapshot(tx, "news", historyId);
|
||||
await tx.insert(ArticleRevisions).values({
|
||||
articleId: id,
|
||||
userId: staff.id,
|
||||
@@ -158,6 +168,7 @@ export async function updateArticle(
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
await recordHistory(tx, "news", historyId, staff.id, historyBefore);
|
||||
});
|
||||
} catch (error) {
|
||||
return saveFailure(error, "update");
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function updateBcPage({
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
try {
|
||||
await updatePageCommand("bc", id, data, expected);
|
||||
await updatePageCommand("bc", id, data, expected, staff.id);
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
@@ -176,9 +176,9 @@ export async function toggleBcPage({
|
||||
id: number;
|
||||
field: "enabled" | "visible";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
await togglePageCommand("bc", id, field);
|
||||
await togglePageCommand("bc", id, field, staff.id);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const };
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function applyBulkOffers(
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
try {
|
||||
return await withCatalogExport(async () => {
|
||||
const data = await applyBulkOffersCommand(input, fingerprint);
|
||||
const data = await applyBulkOffersCommand(input, fingerprint, staff.id);
|
||||
if (data.changedCount > 0) {
|
||||
await logAudit({
|
||||
userId: staff.id,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
inspect: vi.fn(),
|
||||
preview: vi.fn(),
|
||||
apply: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("@/lib/admin/guard", () => ({
|
||||
requirePermissionRateLimited: mocks.guard,
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs"));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({
|
||||
actionOk: (data: unknown) => ({ ok: true, data }),
|
||||
actionError: (error: string) => ({ ok: false, error }),
|
||||
handleActionError: () => ({ ok: false, error: "denied" }),
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-audit", () => ({
|
||||
inspectLiveCatalogIntegrity: mocks.inspect,
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-repair", () => ({
|
||||
previewCatalogParentRepair: mocks.preview,
|
||||
applyCatalogParentRepair: mocks.apply,
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||
withCatalogExport: (fn: () => unknown) => fn(),
|
||||
}));
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
applyCatalogIntegrityRepairAction,
|
||||
inspectCatalogIntegrityAction,
|
||||
previewCatalogIntegrityRepairAction,
|
||||
} from "./catalog-integrity";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.guard.mockResolvedValue({ id: 7 });
|
||||
mocks.apply.mockResolvedValue({ applied: 1 });
|
||||
});
|
||||
describe("integrity action permissions", () => {
|
||||
it("allows inspection only after catalog view and preview only after catalog edit", async () => {
|
||||
await inspectCatalogIntegrityAction("normal");
|
||||
expect(mocks.guard).toHaveBeenLastCalledWith(PERMS.CATALOG_VIEW);
|
||||
await previewCatalogIntegrityRepairAction("bc");
|
||||
expect(mocks.guard).toHaveBeenLastCalledWith(PERMS.CATALOG_EDIT);
|
||||
expect(mocks.preview).toHaveBeenCalledWith("bc");
|
||||
});
|
||||
it("denies apply before reading or mutating data without edit permission", async () => {
|
||||
mocks.guard.mockRejectedValue(new Error("denied"));
|
||||
expect(await applyCatalogIntegrityRepairAction("normal", "abc")).toEqual({
|
||||
ok: false,
|
||||
error: "denied",
|
||||
});
|
||||
expect(mocks.guard).toHaveBeenCalledWith(PERMS.CATALOG_EDIT);
|
||||
expect(mocks.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
it("forwards actor identity and translates changed state to a recoverable conflict", async () => {
|
||||
const error = new Error("changed");
|
||||
error.name = "CatalogPreviewConflict";
|
||||
mocks.apply.mockRejectedValue(error);
|
||||
expect(
|
||||
await applyCatalogIntegrityRepairAction("bc", "fingerprint"),
|
||||
).toEqual({ ok: false, error: "stalePreview" });
|
||||
expect(mocks.apply).toHaveBeenCalledWith("bc", "fingerprint", 7);
|
||||
});
|
||||
it("rejects unknown catalog names before executing queries", async () => {
|
||||
await inspectCatalogIntegrityAction("catalog_pages; DELETE");
|
||||
expect(mocks.inspect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import {
|
||||
actionError,
|
||||
actionOk,
|
||||
handleActionError,
|
||||
} from "@/lib/safe-action-shared";
|
||||
import { inspectLiveCatalogIntegrity } from "@/lib/services/catalog-audit";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { integrityCatalog } from "@/lib/services/catalog-integrity";
|
||||
import {
|
||||
applyCatalogParentRepair,
|
||||
previewCatalogParentRepair,
|
||||
} from "@/lib/services/catalog-repair";
|
||||
|
||||
export async function inspectCatalogIntegrityAction(catalog: unknown) {
|
||||
try {
|
||||
await requirePermissionRateLimited(PERMS.CATALOG_VIEW);
|
||||
return actionOk(
|
||||
await inspectLiveCatalogIntegrity(integrityCatalog(catalog)),
|
||||
);
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewCatalogIntegrityRepairAction(catalog: unknown) {
|
||||
try {
|
||||
await requirePermissionRateLimited(PERMS.CATALOG_EDIT);
|
||||
return actionOk(
|
||||
await previewCatalogParentRepair(integrityCatalog(catalog)),
|
||||
);
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyCatalogIntegrityRepairAction(
|
||||
catalog: unknown,
|
||||
fingerprint: unknown,
|
||||
) {
|
||||
try {
|
||||
const staff = await requirePermissionRateLimited(PERMS.CATALOG_EDIT);
|
||||
const kind = integrityCatalog(catalog);
|
||||
if (typeof fingerprint !== "string") return actionError("Invalid preview");
|
||||
const result = await withCatalogExport(() =>
|
||||
applyCatalogParentRepair(kind, fingerprint, staff.id),
|
||||
);
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return actionOk(result);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "CatalogPreviewConflict")
|
||||
return { ok: false as const, error: "stalePreview" as const };
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export async function updateCatalogItem({
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
try {
|
||||
await updateOfferCommand({ id, catalogFields, baseItem });
|
||||
await updateOfferCommand({ id, catalogFields, baseItem }, staff.id);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function publishCatalogPackage(input: PublishCatalogPackageInput) {
|
||||
const warnings: string[] = [];
|
||||
try {
|
||||
await withCatalogExport(async () => {
|
||||
committed = await publishCatalogPackageCommand(input);
|
||||
committed = await publishCatalogPackageCommand(input, staff.id);
|
||||
return committed;
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function updateCatalogPage({
|
||||
data.captionSave = data.caption.slice(0, 25);
|
||||
}
|
||||
try {
|
||||
await updatePageCommand("normal", id, data, expected);
|
||||
await updatePageCommand("normal", id, data, expected, staff.id);
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
@@ -108,12 +108,13 @@ export async function toggleCatalogPage({
|
||||
id: number;
|
||||
action: "toggleEnabled" | "toggleVisible";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
await togglePageCommand(
|
||||
"normal",
|
||||
id,
|
||||
action === "toggleEnabled" ? "enabled" : "visible",
|
||||
staff.id,
|
||||
);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
apply: vi.fn(),
|
||||
snapshot: vi.fn(),
|
||||
entry: {
|
||||
kind: "prices",
|
||||
targetId: 1,
|
||||
before: { costCredits: 1 },
|
||||
after: { costCredits: 2 },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
transaction: async (callback: (tx: object) => unknown) => callback({}),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.guard }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { LOGS_VIEW: "logs", CATALOG_EDIT: "catalog", NEWS_EDIT: "news" },
|
||||
}));
|
||||
vi.mock("@/features/history/server", () => ({
|
||||
readHistory: async () => state.entry,
|
||||
historySnapshot: state.snapshot,
|
||||
applyHistory: state.apply,
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||
withCatalogExport: async (fn: () => unknown) => fn(),
|
||||
}));
|
||||
vi.mock("@/lib/services/news-cache", () => ({ invalidateNewsCache: vi.fn() }));
|
||||
vi.mock("@/features/catalog/server/sync-status", () => ({
|
||||
sendCatalogUpdate: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { previewHistoryRestore, restoreHistory } from "./history";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.guard.mockResolvedValue({ id: 7 });
|
||||
state.snapshot.mockResolvedValue(state.entry.after);
|
||||
state.apply.mockResolvedValue(undefined);
|
||||
state.entry.kind = "prices";
|
||||
});
|
||||
it("requires logs access and catalog edit on preview and restore", async () => {
|
||||
await previewHistoryRestore(1);
|
||||
await restoreHistory(1);
|
||||
expect(state.guard.mock.calls.map((call) => call[0])).toEqual([
|
||||
"logs",
|
||||
"catalog",
|
||||
"logs",
|
||||
"catalog",
|
||||
]);
|
||||
});
|
||||
it("requires news edit for news history", async () => {
|
||||
state.entry.kind = "news";
|
||||
await restoreHistory(1);
|
||||
expect(state.guard).toHaveBeenCalledWith("news");
|
||||
});
|
||||
it("rejects forbidden restore before any mutation", async () => {
|
||||
state.guard.mockRejectedValue(Error("Forbidden"));
|
||||
await expect(restoreHistory(1)).rejects.toThrow("Forbidden");
|
||||
expect(state.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
it("returns conflict when current data changed after preview", async () => {
|
||||
await previewHistoryRestore(1);
|
||||
state.apply.mockRejectedValue(Error("conflict"));
|
||||
await expect(restoreHistory(1)).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "conflict",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { historyChanges, sameSnapshot } from "@/features/history/model";
|
||||
import {
|
||||
applyHistory,
|
||||
historySnapshot,
|
||||
readHistory,
|
||||
} from "@/features/history/server";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { invalidateNewsCache } from "@/lib/services/news-cache";
|
||||
|
||||
async function authorized(id: number) {
|
||||
await requirePermission(PERMS.LOGS_VIEW);
|
||||
if (!Number.isSafeInteger(id) || id < 1) throw Error("unavailable");
|
||||
const entry = await db.transaction((tx) => readHistory(tx, id));
|
||||
const staff = await requirePermission(
|
||||
entry.kind === "news" ? PERMS.NEWS_EDIT : PERMS.CATALOG_EDIT,
|
||||
);
|
||||
return { entry, staff };
|
||||
}
|
||||
export async function previewHistoryRestore(id: number) {
|
||||
const { entry } = await authorized(id);
|
||||
return db.transaction(async (tx) => {
|
||||
const current = await historySnapshot(tx, entry.kind, entry.targetId);
|
||||
return {
|
||||
canRestore: sameSnapshot(current, entry.after),
|
||||
changes: historyChanges(current, entry.before),
|
||||
};
|
||||
});
|
||||
}
|
||||
export async function restoreHistory(id: number) {
|
||||
const { entry, staff } = await authorized(id);
|
||||
try {
|
||||
const restore = () =>
|
||||
db.transaction((tx) => applyHistory(tx, entry, staff.id));
|
||||
if (entry.kind === "news") await restore();
|
||||
else
|
||||
await withCatalogExport(async () => {
|
||||
await restore();
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "conflict")
|
||||
return { ok: false as const, code: "conflict" as const };
|
||||
logger.error("History restore failed", { module: "history", error });
|
||||
return { ok: false as const, code: "failed" as const };
|
||||
}
|
||||
try {
|
||||
if (entry.kind === "news") {
|
||||
await invalidateNewsCache();
|
||||
revalidatePath("/news", "layout");
|
||||
revalidatePath("/admin/articles", "layout");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/me");
|
||||
} else {
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog", "layout");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("History refresh failed", { module: "history", error });
|
||||
}
|
||||
revalidatePath("/admin/logs/audit");
|
||||
return { ok: true as const };
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel";
|
||||
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -8,5 +10,26 @@ export default async function CatalogMaintenancePage() {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
return <CatalogMaintenancePanel />;
|
||||
const canRepair = canAccess(
|
||||
permissions,
|
||||
PERMS.CATALOG_EDIT,
|
||||
session.user.rank,
|
||||
);
|
||||
const t = await getTranslations("pages.admin.catalog.integrity");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<CatalogIntegrityPanel canRepair={canRepair} />
|
||||
{canRepair && (
|
||||
<details className="rounded-xl border p-4">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
{t("legacyTitle")}
|
||||
</summary>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{t("legacyHint")}
|
||||
</p>
|
||||
<CatalogMaintenancePanel />
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server";
|
||||
|
||||
import Link from "@/components/link";
|
||||
|
||||
import { queryErrorGroups } from "@/lib/error-group-query";
|
||||
import { summarizeErrorGroup } from "@/lib/error-groups";
|
||||
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
@@ -26,8 +27,6 @@ export default async function CmsErrorsPage({
|
||||
|
||||
const params = await searchParams;
|
||||
|
||||
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
|
||||
|
||||
let result: Awaited<ReturnType<ErrorStore["read"]>>;
|
||||
|
||||
try {
|
||||
@@ -40,30 +39,13 @@ export default async function CmsErrorsPage({
|
||||
);
|
||||
}
|
||||
|
||||
const allGroups = new Map<string, typeof result.records>();
|
||||
|
||||
for (const record of result.records) {
|
||||
const events = allGroups.get(record.fingerprint) ?? [];
|
||||
|
||||
events.push(record);
|
||||
allGroups.set(record.fingerprint, events);
|
||||
}
|
||||
|
||||
const releases = [...new Set(result.records.map((r) => r.release))].sort();
|
||||
const queried = queryErrorGroups(result.records, params);
|
||||
const groups = new Map(
|
||||
[...allGroups.entries()].filter(([, events]) => {
|
||||
const latest = summarizeErrorGroup(events).latest;
|
||||
|
||||
return (
|
||||
latest &&
|
||||
(!params.source || latest.source === params.source) &&
|
||||
(params.status !== "open" || !latest.resolved) &&
|
||||
(params.status !== "resolved" || latest.resolved) &&
|
||||
(!query ||
|
||||
events.some((record) =>
|
||||
JSON.stringify(record).toLowerCase().includes(query),
|
||||
))
|
||||
);
|
||||
}),
|
||||
queried.map((group) => [group.fingerprint, group.events]),
|
||||
);
|
||||
const latestByGroup = new Map(
|
||||
queried.map((group) => [group.fingerprint, group.latest]),
|
||||
);
|
||||
|
||||
const filtered = [...groups.values()].flat();
|
||||
@@ -72,14 +54,14 @@ export default async function CmsErrorsPage({
|
||||
1,
|
||||
|
||||
Math.min(
|
||||
Number(params.page) || 1,
|
||||
Math.floor(Number(params.page)) || 1,
|
||||
|
||||
Math.max(1, Math.ceil(groups.size / 25)),
|
||||
),
|
||||
);
|
||||
|
||||
const pageHref = (next: number) =>
|
||||
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", page: String(next) })}`;
|
||||
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", release: params.release ?? "", period: params.period ?? "", sort: params.sort ?? "", page: String(next) })}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -126,13 +108,46 @@ export default async function CmsErrorsPage({
|
||||
<option value="open">{t("open")}</option>
|
||||
<option value="resolved">{t("resolved")}</option>
|
||||
</select>
|
||||
<select
|
||||
name="release"
|
||||
aria-label={t("releaseFilter")}
|
||||
defaultValue={params.release ?? ""}
|
||||
className="input max-w-full"
|
||||
>
|
||||
<option value="">{t("allReleases")}</option>
|
||||
{releases.map((release) => (
|
||||
<option key={release} value={release}>
|
||||
{release}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="period"
|
||||
aria-label={t("period")}
|
||||
defaultValue={params.period ?? ""}
|
||||
className="input"
|
||||
>
|
||||
<option value="">{t("allRetained")}</option>
|
||||
<option value="24h">{t("last24h")}</option>
|
||||
<option value="7d">{t("last7d")}</option>
|
||||
</select>
|
||||
<select
|
||||
name="sort"
|
||||
aria-label={t("sort")}
|
||||
defaultValue={params.sort ?? ""}
|
||||
className="input"
|
||||
>
|
||||
<option value="">{t("recentFirst")}</option>
|
||||
<option value="frequency">{t("frequentFirst")}</option>
|
||||
</select>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("search")}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("counts", { count: filtered.length, groups: groups.size })}{" "}
|
||||
{result.truncated ? t("truncated") : ""} {t("retention")}
|
||||
{result.truncated ? t("truncated") : ""} {t("retention")}{" "}
|
||||
{t("filterScope")}
|
||||
</p>
|
||||
{groups.size === 0 ? (
|
||||
<div className="admin-card">{t("empty")}</div>
|
||||
@@ -144,7 +159,7 @@ export default async function CmsErrorsPage({
|
||||
.map(([fingerprint, events]) => {
|
||||
const metrics = summarizeErrorGroup(events);
|
||||
|
||||
const r = metrics.latest;
|
||||
const r = latestByGroup.get(fingerprint);
|
||||
|
||||
if (!r) return null;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HistoryRestore } from "@/features/history/history-restore";
|
||||
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
import { AuditFilters } from "./audit-filters";
|
||||
@@ -102,7 +103,14 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
{
|
||||
key: "diff",
|
||||
label: t("audit.colDetails"),
|
||||
render: (_value, row) => <DiffCell row={row} />,
|
||||
render: (_value, row) => (
|
||||
<div className="space-y-2">
|
||||
<DiffCell row={row} />
|
||||
{["history_update", "history_restore"].includes(row.action) && (
|
||||
<HistoryRestore id={row.id} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { OperationsCenter } from "@/components/admin/operations-center";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
export default async function OperationsPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank))
|
||||
redirect("/admin");
|
||||
return <OperationsCenter />;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
||||
@@ -16,6 +18,7 @@ export default async function PermissionsPage({
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.permissionPreview");
|
||||
const sp = (await searchParams) ?? {};
|
||||
const fromHousekeeping = sp.from === "housekeeping";
|
||||
|
||||
@@ -36,9 +39,14 @@ export default async function PermissionsPage({
|
||||
}));
|
||||
|
||||
return (
|
||||
<PermissionsList
|
||||
ranks={ranksWithCounts}
|
||||
fromHousekeeping={fromHousekeeping}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<Link href="/admin/permissions/preview" className="btn btn-outline">
|
||||
{t("title")}
|
||||
</Link>
|
||||
<PermissionsList
|
||||
ranks={ranksWithCounts}
|
||||
fromHousekeeping={fromHousekeeping}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ access: vi.fn(), execute: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: state.execute } }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { PERMISSIONS_MANAGE: "admin.permissions.manage" },
|
||||
getAdminContext: async () => ({
|
||||
session: { user: { rank: 7 } },
|
||||
permissions: {},
|
||||
}),
|
||||
canAccess: state.access,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: () => {
|
||||
throw Error("redirect");
|
||||
},
|
||||
}));
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("@/components/link", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
import Page from "./page";
|
||||
|
||||
beforeEach(() => {
|
||||
state.access.mockReset().mockReturnValue(true);
|
||||
state.execute.mockReset();
|
||||
});
|
||||
it("rejects before querying role details", async () => {
|
||||
state.access.mockReturnValue(false);
|
||||
await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow(
|
||||
"redirect",
|
||||
);
|
||||
expect(state.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not silently substitute an invalid requested role", async () => {
|
||||
state.execute.mockResolvedValueOnce([
|
||||
[{ id: 1, slug: "rank_1", title: "User" }],
|
||||
]);
|
||||
const html = renderToStaticMarkup(
|
||||
await Page({ searchParams: Promise.resolve({ role: "99" }) }),
|
||||
);
|
||||
expect(html).toContain("missing");
|
||||
expect(state.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("simulates highest-rank override from live rank data", async () => {
|
||||
state.execute
|
||||
.mockResolvedValueOnce([[{ id: 10, slug: "rank_8", title: "Owner" }]])
|
||||
.mockResolvedValueOnce([[]])
|
||||
.mockResolvedValueOnce([[{ highest_rank: 8 }]])
|
||||
.mockResolvedValueOnce([[]]);
|
||||
const html = renderToStaticMarkup(
|
||||
await Page({ searchParams: Promise.resolve({ role: "10" }) }),
|
||||
);
|
||||
expect(html).toContain("superAdmin");
|
||||
expect(html).toContain("entryAllowed");
|
||||
});
|
||||
it("does not infer owner privileges from a title", async () => {
|
||||
state.execute
|
||||
.mockResolvedValueOnce([[{ id: 10, slug: "owner", title: "Owner" }]])
|
||||
.mockResolvedValueOnce([[]])
|
||||
.mockResolvedValueOnce([[{ highest_rank: 8 }]])
|
||||
.mockResolvedValueOnce([[]]);
|
||||
const html = renderToStaticMarkup(
|
||||
await Page({ searchParams: Promise.resolve({ role: "10" }) }),
|
||||
);
|
||||
expect(html).not.toContain("superAdmin");
|
||||
expect(html).toContain("entryDenied");
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
|
||||
import { previewRoleAccess } from "@/lib/admin/permission-preview";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function PermissionPreview({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ role?: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank))
|
||||
redirect("/admin");
|
||||
const t = await getTranslations("pages.admin.permissionPreview");
|
||||
const nav = await getTranslations("pages.admin.nav");
|
||||
const [roleRows] = await db.execute(
|
||||
sql`SELECT id, slug, title FROM acl_roles ORDER BY title, id`,
|
||||
);
|
||||
const roles = roleRows as unknown as {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
}[];
|
||||
const params = await searchParams;
|
||||
const selected =
|
||||
roles.find((r) => String(r.id) === params.role) ??
|
||||
(params.role ? undefined : roles[0]);
|
||||
let preview: ReturnType<typeof previewRoleAccess> | undefined;
|
||||
let superAdmin = false;
|
||||
let permissionTitles: Record<string, string> = {};
|
||||
if (selected) {
|
||||
const [grantRows] = await db.execute(
|
||||
sql`SELECT p.slug, p.title FROM acl_model_permissions mp JOIN acl_permissions p ON p.id = mp.permission_id WHERE mp.model_type = 'Role' AND mp.model_id = ${selected.id}`,
|
||||
);
|
||||
const grants = grantRows as unknown as { slug: string; title: string }[];
|
||||
const [highestRows] = await db.execute(
|
||||
sql`SELECT COALESCE((SELECT MAX(u.\`rank\`) FROM users u INNER JOIN permission_ranks pr ON pr.id = u.\`rank\`), (SELECT MAX(id) FROM permission_ranks)) AS highest_rank`,
|
||||
);
|
||||
const highest = (
|
||||
highestRows as unknown as { highest_rank: number | null }[]
|
||||
)[0]?.highest_rank;
|
||||
const rankMatch = /^rank_([1-9]\d*)$/.exec(selected.slug);
|
||||
superAdmin =
|
||||
!!rankMatch &&
|
||||
isDynamicSuperAdmin(
|
||||
Number(rankMatch[1]),
|
||||
highest == null ? null : Number(highest),
|
||||
);
|
||||
preview = previewRoleAccess(
|
||||
grants.map((g) => g.slug),
|
||||
superAdmin,
|
||||
);
|
||||
const [allRows] = await db.execute(
|
||||
sql`SELECT slug, title FROM acl_permissions ORDER BY slug`,
|
||||
);
|
||||
permissionTitles = Object.fromEntries(
|
||||
(allRows as unknown as { slug: string; title: string }[]).map((p) => [
|
||||
p.slug,
|
||||
p.title,
|
||||
]),
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<Link className="btn btn-outline" href="/admin/permissions">
|
||||
{t("back")}
|
||||
</Link>
|
||||
<form className="admin-card flex flex-wrap items-end gap-3">
|
||||
<label className="grid min-w-0 max-w-full flex-1 gap-1 text-sm">
|
||||
{t("role")}
|
||||
<select
|
||||
name="role"
|
||||
defaultValue={selected?.id ?? ""}
|
||||
className="input w-full min-w-0 max-w-full"
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.title} ({role.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="submit"
|
||||
disabled={!roles.length}
|
||||
>
|
||||
{t("show")}
|
||||
</button>
|
||||
</form>
|
||||
{!preview ? (
|
||||
<p role="status">{t("missing")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-card space-y-2">
|
||||
<p className="font-semibold">
|
||||
{t(preview.canEnter ? "entryAllowed" : "entryDenied")}
|
||||
</p>
|
||||
{superAdmin && <p>{t("superAdmin")}</p>}
|
||||
<p className="text-sm text-muted-foreground">{t("scope")}</p>
|
||||
</div>
|
||||
<section className="admin-card">
|
||||
<h2 className="mb-3 font-semibold">{t("sections")}</h2>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{preview.sections.map((item) => (
|
||||
<div key={item.href} className="min-w-0 rounded border p-3">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>{nav(item.labelKey)}</span>
|
||||
<span className="text-xs">
|
||||
{t(item.allowed ? "allowed" : "denied")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{item.href}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<details className="admin-card">
|
||||
<summary className="cursor-pointer font-semibold">
|
||||
{t("actions")}
|
||||
</summary>
|
||||
<p className="my-3 text-sm text-muted-foreground">
|
||||
{t("actionScope")}
|
||||
</p>
|
||||
<ul className="divide-y">
|
||||
{preview.actions.map((item) => (
|
||||
<li
|
||||
key={item.slug}
|
||||
className="flex items-start justify-between gap-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="break-words">
|
||||
{permissionTitles[item.slug] || item.slug}
|
||||
</p>
|
||||
<code className="break-all text-xs text-muted-foreground">
|
||||
{item.slug}
|
||||
</code>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs">
|
||||
{t(item.granted ? "granted" : "notGranted")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import {
|
||||
BOOLEAN_KEYS,
|
||||
type ManagedField,
|
||||
@@ -45,7 +46,13 @@ export function CmsSettingsForm({
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const [values, setValues] = useState(initialValues);
|
||||
const { isPending, run } = useServerAction();
|
||||
const [savedValues, setSavedValues] = useState(initialValues);
|
||||
const tAction = useTranslations("pages.admin.actions");
|
||||
const dirty = Object.keys({ ...savedValues, ...values }).some(
|
||||
(key) => savedValues[key] !== values[key],
|
||||
);
|
||||
useUnsavedChanges(canEdit && dirty, tAction("leaveUnsaved"));
|
||||
const { isPending, run, fieldErrors } = useServerAction();
|
||||
const t = useTranslations("pages.admin.settings");
|
||||
|
||||
function handleChange(key: string, value: string) {
|
||||
@@ -55,6 +62,7 @@ export function CmsSettingsForm({
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canEdit) return;
|
||||
const submitted = { ...values };
|
||||
const normalized = { ...values };
|
||||
for (const key of BOOLEAN_KEYS) {
|
||||
normalized[key] = values[key] === "1" ? "1" : "0";
|
||||
@@ -62,24 +70,34 @@ export function CmsSettingsForm({
|
||||
run(() => saveManagedSettings({ settings: normalized }), {
|
||||
successMessage: t("saved"),
|
||||
errorMessage: t("saveFailed"),
|
||||
onSuccess: () => setSavedValues(submitted),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{Object.keys(fieldErrors).length > 0 && (
|
||||
<div role="alert" className="admin-card text-sm text-destructive">
|
||||
{Object.values(fieldErrors).flat().join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
{SETTINGS_GROUPS.map((group) => (
|
||||
<GroupSection
|
||||
key={group.id}
|
||||
group={group}
|
||||
values={values}
|
||||
canEdit={canEdit}
|
||||
canEdit={canEdit && !isPending}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
))}
|
||||
|
||||
{canEdit ? (
|
||||
<div className="sticky bottom-3 z-10 flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="shadow-lg">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !dirty}
|
||||
className="shadow-lg"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
@@ -20,9 +21,13 @@ type ExportStatus = {
|
||||
|
||||
export function CatalogExportClient({
|
||||
canConfigure = false,
|
||||
monitorOnly = false,
|
||||
}: {
|
||||
canConfigure?: boolean;
|
||||
monitorOnly?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.operationsExport");
|
||||
const submitting = useRef(false);
|
||||
const [status, setStatus] = useState<ExportStatus | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -34,37 +39,37 @@ export function CatalogExportClient({
|
||||
const response = await adminFetch("/api/admin/catalog-export", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error("Unable to load export status");
|
||||
if (!response.ok) throw new Error(t("loadError"));
|
||||
setStatus(await response.json());
|
||||
setError("");
|
||||
} catch {
|
||||
setError("Unable to load export status");
|
||||
setError(t("loadError"));
|
||||
} finally {
|
||||
refreshing.current = false;
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
useVisiblePolling(refresh, 15000);
|
||||
async function retry() {
|
||||
if (submitting.current) return;
|
||||
submitting.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/catalog-export", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) throw new Error("Unable to queue export");
|
||||
if (!response.ok) throw new Error(t("queueError"));
|
||||
await refresh();
|
||||
} catch {
|
||||
setError("Unable to queue export");
|
||||
setError(t("queueError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
submitting.current = false;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
|
||||
<h2 className="text-lg font-semibold">Catalog repository export</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically publishes bundles, icons, gamedata, translations and
|
||||
catalog SQL to your configured Gitea repository.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("hint")}</p>
|
||||
{canConfigure && (
|
||||
<CatalogGitConfiguration onSaved={() => void refresh()} />
|
||||
)}
|
||||
@@ -75,19 +80,19 @@ export function CatalogExportClient({
|
||||
)}
|
||||
<div role="status" className="text-sm" aria-live="polite">
|
||||
{!status
|
||||
? "Loading…"
|
||||
? t("loading")
|
||||
: !status.enabled
|
||||
? "Export disabled. Configure the Gitea connection below."
|
||||
? t("disabled")
|
||||
: status.active
|
||||
? `Waiting for ${status.active} operation(s) to finish.`
|
||||
? t("active", { count: status.active })
|
||||
: status.running
|
||||
? "Export in progress…"
|
||||
? t("running")
|
||||
: status.pending
|
||||
? `${status.pending} update(s) queued for export.`
|
||||
: "No pending updates."}
|
||||
? t("pending", { count: status.pending })
|
||||
: t("idle")}
|
||||
{status?.commit && (
|
||||
<p>
|
||||
Last published commit:{" "}
|
||||
{t("lastCommit")}{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href={`${status.remote?.replace(/\.git$/, "")}/commit/${status.commit}`}
|
||||
@@ -98,21 +103,30 @@ export function CatalogExportClient({
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{status?.finishedAt && <p>Last attempt: {status.finishedAt}</p>}
|
||||
{status?.finishedAt && (
|
||||
<p>{t("lastAttempt", { date: status.finishedAt })}</p>
|
||||
)}
|
||||
</div>
|
||||
{(error || status?.error) && (
|
||||
<p role="alert" className="text-sm">
|
||||
{error || status?.error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!status?.enabled || busy}
|
||||
onClick={retry}
|
||||
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Queuing…" : "Export now / retry"}
|
||||
</button>
|
||||
{!monitorOnly && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!status?.enabled ||
|
||||
busy ||
|
||||
Boolean(status.running) ||
|
||||
status.active > 0
|
||||
}
|
||||
onClick={retry}
|
||||
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Queuing…" : "Export now / retry"}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { updateUser } from "@/actions/users";
|
||||
import { useFormErrorFocus } from "@/components/admin/form-feedback";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import { type UpdateUserInput, updateUserSchema } from "@/lib/validators/user";
|
||||
|
||||
interface UserEditFormProps {
|
||||
@@ -46,13 +51,18 @@ const FALLBACK_RANKS: Array<{ id: number; name: string }> = [
|
||||
|
||||
export function UserEditForm({ user, ranks }: UserEditFormProps) {
|
||||
const rankOptions = ranks && ranks.length > 0 ? ranks : FALLBACK_RANKS;
|
||||
const { isPending, run } = useServerAction();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const tAction = useTranslations("pages.admin.actions");
|
||||
const router = useRouter();
|
||||
const { isPending, run, fieldErrors } = useServerAction();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
setError,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<UpdateUserInput>({
|
||||
resolver: zodResolver(updateUserSchema),
|
||||
defaultValues: {
|
||||
@@ -67,9 +77,36 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
|
||||
},
|
||||
});
|
||||
|
||||
useUnsavedChanges(isDirty, tAction("leaveUnsaved"));
|
||||
useFormErrorFocus(formRef, fieldErrors, isPending);
|
||||
useEffect(() => {
|
||||
for (const [field, messages] of Object.entries(fieldErrors)) {
|
||||
if (
|
||||
[
|
||||
"username",
|
||||
"mail",
|
||||
"rank",
|
||||
"motto",
|
||||
"credits",
|
||||
"pixels",
|
||||
"diamonds",
|
||||
"duckets",
|
||||
].includes(field)
|
||||
)
|
||||
setError(
|
||||
field as keyof UpdateUserInput,
|
||||
{ type: "server", message: messages.join(" · ") },
|
||||
{ shouldFocus: true },
|
||||
);
|
||||
}
|
||||
}, [fieldErrors, setError]);
|
||||
function onSubmit(data: UpdateUserInput) {
|
||||
run(() => updateUser({ id: user.id, ...data }), {
|
||||
successMessage: "User updated successfully.",
|
||||
successMessage: tAction("saved"),
|
||||
onSuccess: () => {
|
||||
reset(data);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,91 +116,127 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
|
||||
<CardTitle>Edit User</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" {...register("username")} />
|
||||
{errors.username && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.username.message}
|
||||
</p>
|
||||
)}
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<fieldset disabled={isPending} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" {...register("username")} />
|
||||
{errors.username && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.username.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mail">Email</Label>
|
||||
<Input id="mail" type="email" {...register("mail")} />
|
||||
{errors.mail && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.mail.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mail">Email</Label>
|
||||
<Input id="mail" type="email" {...register("mail")} />
|
||||
{errors.mail && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.mail.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank">Rank</Label>
|
||||
<Select
|
||||
defaultValue={String(user.rank)}
|
||||
onValueChange={(v) => setValue("rank", parseInt(v, 10))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select rank" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rankOptions.map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>
|
||||
{r.id} — {r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank">Rank</Label>
|
||||
<Select
|
||||
defaultValue={String(user.rank)}
|
||||
onValueChange={(v) =>
|
||||
setValue("rank", parseInt(v, 10), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select rank" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rankOptions.map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>
|
||||
{r.id} — {r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motto">Motto</Label>
|
||||
<Input id="motto" {...register("motto")} />
|
||||
{errors.motto && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.motto.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motto">Motto</Label>
|
||||
<Input id="motto" {...register("motto")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="credits">Credits</Label>
|
||||
<Input
|
||||
id="credits"
|
||||
type="number"
|
||||
{...register("credits", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="credits">Credits</Label>
|
||||
<Input
|
||||
id="credits"
|
||||
type="number"
|
||||
{...register("credits", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.credits && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.credits.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pixels">Pixels</Label>
|
||||
<Input
|
||||
id="pixels"
|
||||
type="number"
|
||||
{...register("pixels", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.pixels && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.pixels.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="diamonds">Diamonds</Label>
|
||||
<Input
|
||||
id="diamonds"
|
||||
type="number"
|
||||
{...register("diamonds", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.diamonds && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.diamonds.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duckets">Duckets</Label>
|
||||
<Input
|
||||
id="duckets"
|
||||
type="number"
|
||||
{...register("duckets", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.duckets && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.duckets.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pixels">Pixels</Label>
|
||||
<Input
|
||||
id="pixels"
|
||||
type="number"
|
||||
{...register("pixels", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="diamonds">Diamonds</Label>
|
||||
<Input
|
||||
id="diamonds"
|
||||
type="number"
|
||||
{...register("diamonds", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duckets">Duckets</Label>
|
||||
<Input
|
||||
id="duckets"
|
||||
type="number"
|
||||
{...register("duckets", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
create: vi.fn(),
|
||||
list: vi.fn(),
|
||||
page: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
after: vi.fn(),
|
||||
ping: vi.fn(),
|
||||
@@ -31,6 +33,8 @@ vi.mock("@/lib/services/furni-job-store", async (original) => ({
|
||||
ImportJobStore: class {
|
||||
create = mocks.create;
|
||||
list = mocks.list;
|
||||
page = mocks.page;
|
||||
retry = mocks.retry;
|
||||
requestCancellation = mocks.cancel;
|
||||
},
|
||||
}));
|
||||
@@ -129,3 +133,49 @@ it("does not reveal other owners' jobs", async () => {
|
||||
it("rejects unsafe cancellation IDs", async () => {
|
||||
expect((await PATCH(request({ id: "../other" }), ctx)).status).toBe(400);
|
||||
});
|
||||
|
||||
it("delegates retry to owner-scoped server selection, ignoring client items", async () => {
|
||||
const id = randomUUID();
|
||||
mocks.retry.mockResolvedValue({ id });
|
||||
const response = await PATCH(
|
||||
request({ id, action: "retry", items: [item] }),
|
||||
ctx,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.retry).toHaveBeenCalledWith(id, 7);
|
||||
});
|
||||
it("rejects retries while the queue is offline", async () => {
|
||||
mocks.ping.mockResolvedValue(null);
|
||||
mocks.retry.mockClear();
|
||||
expect(
|
||||
(await PATCH(request({ id: randomUUID(), action: "retry" }), ctx)).status,
|
||||
).toBe(503);
|
||||
expect(mocks.retry).not.toHaveBeenCalled();
|
||||
});
|
||||
it("loads a page only for the authenticated operator", async () => {
|
||||
mocks.page.mockResolvedValue({ jobs: [], nextCursor: null });
|
||||
const response = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/studio/import-jobs?page=1&userId=99",
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.page).toHaveBeenCalledWith({
|
||||
userId: 7,
|
||||
limit: 20,
|
||||
before: undefined,
|
||||
});
|
||||
});
|
||||
it("rejects malformed history cursors", async () => {
|
||||
expect(
|
||||
(
|
||||
await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/studio/import-jobs?page=1&before=broken",
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
});
|
||||
@@ -36,7 +36,26 @@ const schema = z.object({
|
||||
});
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (_request, ctx) => {
|
||||
async (request, ctx) => {
|
||||
const query = new URL(request.url).searchParams;
|
||||
if (query.has("page")) {
|
||||
const before = query.get("before") || undefined;
|
||||
if (
|
||||
before &&
|
||||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i.test(
|
||||
before,
|
||||
)
|
||||
)
|
||||
return apiError("Invalid history cursor", 400);
|
||||
after(drainFurnitureImports);
|
||||
return apiOk(
|
||||
await new ImportJobStore().page({
|
||||
userId: ctx.session.user.id,
|
||||
limit: 20,
|
||||
before,
|
||||
}),
|
||||
);
|
||||
}
|
||||
after(drainFurnitureImports);
|
||||
const jobs = await new ImportJobStore().list({
|
||||
userId: ctx.session.user.id,
|
||||
@@ -88,6 +107,26 @@ export const PATCH = withAdmin(
|
||||
async (request, ctx) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!validJobId(body?.id)) return apiError("Invalid import ID", 400);
|
||||
if (body.action === "retry") {
|
||||
if (!redis || (await redis.ping().catch(() => null)) !== "PONG")
|
||||
return apiError(
|
||||
"Background import queue is temporarily unavailable",
|
||||
503,
|
||||
);
|
||||
const job = await new ImportJobStore().retry(
|
||||
body.id,
|
||||
ctx.session.user.id,
|
||||
);
|
||||
if (!job)
|
||||
return apiError(
|
||||
"No safe remaining items to retry, or import job not found",
|
||||
404,
|
||||
);
|
||||
after(drainFurnitureImports);
|
||||
return apiOk({ job });
|
||||
}
|
||||
if (body.action && body.action !== "cancel")
|
||||
return apiError("Invalid job action", 400);
|
||||
const job = await new ImportJobStore().requestCancellation(
|
||||
body.id,
|
||||
ctx.session.user.id,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||
import { useArticleRecovery } from "./article-recovery";
|
||||
import { FormFieldError, useFormErrorFocus } from "./form-feedback";
|
||||
import { MediaPicker } from "./media-picker";
|
||||
import { RichText } from "./rich-text";
|
||||
|
||||
@@ -36,6 +37,7 @@ export function ArticleForm({
|
||||
const tAction = useTranslations("pages.admin.actions");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const saving = useRef(false);
|
||||
@@ -86,6 +88,7 @@ export function ArticleForm({
|
||||
dirty,
|
||||
restoreDraft,
|
||||
);
|
||||
useFormErrorFocus(formRef, fieldErrors, pending);
|
||||
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||
|
||||
@@ -105,12 +108,14 @@ export function ArticleForm({
|
||||
const submittedVersion = editVersion.current;
|
||||
saving.current = true;
|
||||
setSaveError(null);
|
||||
setFieldErrors({});
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await recovery.wait();
|
||||
const result = await action(data);
|
||||
if (result && !result.ok) {
|
||||
setSaveError(result.error);
|
||||
setFieldErrors(result.fieldErrors ?? {});
|
||||
return;
|
||||
}
|
||||
if (editVersion.current === submittedVersion) setDirty(false);
|
||||
@@ -151,6 +156,10 @@ export function ArticleForm({
|
||||
</span>
|
||||
<input
|
||||
name="title"
|
||||
aria-invalid={!!fieldErrors.title?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.title?.length ? "error-title" : undefined
|
||||
}
|
||||
maxLength={255}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
@@ -158,6 +167,7 @@ export function ArticleForm({
|
||||
required
|
||||
className="input input-bordered mt-1 w-full"
|
||||
/>
|
||||
<FormFieldError name="title" errors={fieldErrors} />
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
@@ -186,6 +196,10 @@ export function ArticleForm({
|
||||
<input
|
||||
aria-label={t("image")}
|
||||
name="image"
|
||||
aria-invalid={!!fieldErrors.image?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.image?.length ? "error-image" : undefined
|
||||
}
|
||||
maxLength={255}
|
||||
value={image}
|
||||
onChange={(e) => {
|
||||
@@ -195,6 +209,7 @@ export function ArticleForm({
|
||||
placeholder={t("imagePlaceholder")}
|
||||
className="input input-bordered min-w-0 flex-1"
|
||||
/>
|
||||
<FormFieldError name="image" errors={fieldErrors} />
|
||||
<MediaPicker
|
||||
onSelect={(url) => {
|
||||
setImage(url);
|
||||
@@ -227,6 +242,10 @@ export function ArticleForm({
|
||||
</span>
|
||||
<textarea
|
||||
name="shortStory"
|
||||
aria-invalid={!!fieldErrors.shortStory?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.shortStory?.length ? "error-shortStory" : undefined
|
||||
}
|
||||
maxLength={255}
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
@@ -234,6 +253,7 @@ export function ArticleForm({
|
||||
rows={2}
|
||||
className="input input-bordered mt-1 w-full resize-y"
|
||||
/>
|
||||
<FormFieldError name="shortStory" errors={fieldErrors} />
|
||||
</label>
|
||||
|
||||
<div className="block">
|
||||
@@ -257,6 +277,10 @@ export function ArticleForm({
|
||||
</span>
|
||||
<select
|
||||
name="status"
|
||||
aria-invalid={!!fieldErrors.status?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.status?.length ? "error-status" : undefined
|
||||
}
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="input input-bordered mt-1 w-full"
|
||||
@@ -265,6 +289,7 @@ export function ArticleForm({
|
||||
<option value="scheduled">{t("statusScheduled")}</option>
|
||||
<option value="published">{t("statusPublished")}</option>
|
||||
</select>
|
||||
<FormFieldError name="status" errors={fieldErrors} />
|
||||
</label>
|
||||
|
||||
{status === "scheduled" ? (
|
||||
@@ -275,11 +300,16 @@ export function ArticleForm({
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="publishAt"
|
||||
aria-invalid={!!fieldErrors.publishAt?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.publishAt?.length ? "error-publishAt" : undefined
|
||||
}
|
||||
value={publishAt}
|
||||
onChange={(e) => setPublishAt(e.target.value)}
|
||||
required
|
||||
className="input input-bordered mt-1 w-full"
|
||||
/>
|
||||
<FormFieldError name="publishAt" errors={fieldErrors} />
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
applyCatalogIntegrityRepairAction,
|
||||
inspectCatalogIntegrityAction,
|
||||
previewCatalogIntegrityRepairAction,
|
||||
} from "@/actions/catalog-integrity";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type {
|
||||
IntegrityCatalog,
|
||||
inspectCatalogIntegrity,
|
||||
} from "@/lib/services/catalog-integrity";
|
||||
import type { CatalogParentRepairPreview } from "@/lib/services/catalog-repair";
|
||||
|
||||
type Report = ReturnType<typeof inspectCatalogIntegrity>;
|
||||
export function CatalogIntegrityPanel({ canRepair }: { canRepair: boolean }) {
|
||||
const t = useTranslations("pages.admin.catalog.integrity");
|
||||
const [catalog, setCatalog] = useState<IntegrityCatalog>("normal");
|
||||
const [report, setReport] = useState<Report | null>(null);
|
||||
const [preview, setPreview] = useState<CatalogParentRepairPreview | null>(
|
||||
null,
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<"failed" | "stalePreview" | null>(null);
|
||||
const [applied, setApplied] = useState<number | null>(null);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const request = useRef(0);
|
||||
const applying = useRef(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: A requested refresh must start a new read-only scan.
|
||||
useEffect(() => {
|
||||
const version = ++request.current;
|
||||
setBusy(true);
|
||||
setReport(null);
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
void inspectCatalogIntegrityAction(catalog)
|
||||
.then((result) => {
|
||||
if (version !== request.current) return;
|
||||
if (result.ok && result.data) setReport(result.data);
|
||||
else setError("failed");
|
||||
})
|
||||
.catch(() => {
|
||||
if (version === request.current) setError("failed");
|
||||
})
|
||||
.finally(() => {
|
||||
if (version === request.current) setBusy(false);
|
||||
});
|
||||
return () => {
|
||||
request.current++;
|
||||
};
|
||||
}, [catalog, refresh]);
|
||||
async function prepare() {
|
||||
setBusy(true);
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
setApplied(null);
|
||||
try {
|
||||
const result = await previewCatalogIntegrityRepairAction(catalog);
|
||||
if (result.ok && result.data) setPreview(result.data);
|
||||
else setError("failed");
|
||||
} catch {
|
||||
setError("failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function apply() {
|
||||
if (!preview || applying.current) return;
|
||||
applying.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await applyCatalogIntegrityRepairAction(
|
||||
preview.catalog,
|
||||
preview.fingerprint,
|
||||
);
|
||||
setPreview(null);
|
||||
if (result.ok && result.data) {
|
||||
setApplied(result.data.applied);
|
||||
setRefresh((value) => value + 1);
|
||||
} else if (!result.ok)
|
||||
setError(result.error === "stalePreview" ? "stalePreview" : "failed");
|
||||
} catch {
|
||||
setError("failed");
|
||||
setPreview(null);
|
||||
} finally {
|
||||
applying.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
<CardDescription>{t("description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm" htmlFor="integrity-catalog">
|
||||
{t("catalog")}
|
||||
</label>
|
||||
<select
|
||||
id="integrity-catalog"
|
||||
value={catalog}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setCatalog(event.target.value as IntegrityCatalog);
|
||||
setApplied(null);
|
||||
}}
|
||||
className="rounded-md border bg-[var(--admin-surface)] p-2 text-sm"
|
||||
>
|
||||
<option value="normal">{t("normal")}</option>
|
||||
<option value="bc">{t("bc")}</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => setRefresh((value) => value + 1)}
|
||||
>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
{busy && <p role="status">{t("working")}</p>}
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{t(error)}
|
||||
</p>
|
||||
)}
|
||||
{applied !== null && (
|
||||
<p role="status">{t("applied", { count: applied })}</p>
|
||||
)}
|
||||
{report && (
|
||||
<>
|
||||
{!report.iconsAvailable && (
|
||||
<p role="status" className="text-sm">
|
||||
{t("iconsUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
{report.total === 0
|
||||
? t("healthy")
|
||||
: t("total", {
|
||||
count: report.total,
|
||||
shown: report.issues.length,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(report.counts).map(([code, count]) => (
|
||||
<span
|
||||
key={code}
|
||||
className="rounded-md border px-2 py-1 text-xs"
|
||||
>
|
||||
{t(`issues.${code}`)}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{report.issues.length > 0 && (
|
||||
<ul className="m-0 max-h-80 list-none space-y-2 overflow-y-auto p-0">
|
||||
{report.issues.map((issue) => (
|
||||
<li
|
||||
key={`${issue.code}-${issue.id}`}
|
||||
className="rounded-md border p-3 text-sm"
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<span className="min-w-0 break-all font-medium">
|
||||
{t(`issues.${issue.code}`)} · #{issue.id} {issue.name}
|
||||
</span>
|
||||
<Link href={issue.href}>{t("open")}</Link>
|
||||
</div>
|
||||
<p className="m-0 break-all text-xs text-muted-foreground">
|
||||
{t("reference", { value: issue.detail })}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canRepair && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<p className="m-0 text-sm">{t("repairHint")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || !report}
|
||||
onClick={() => void prepare()}
|
||||
>
|
||||
{t("preview")}
|
||||
</Button>
|
||||
{preview && (
|
||||
<>
|
||||
<p className="m-0 text-sm">
|
||||
{t("planned", { count: preview.changes.length })}
|
||||
</p>
|
||||
<ul className="m-0 max-h-60 overflow-y-auto pl-5 text-sm">
|
||||
{preview.changes.map((change) => (
|
||||
<li key={change.id} className="break-all">
|
||||
#{change.id} {change.caption}:{" "}
|
||||
{t("parentChange", { from: change.from })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
disabled={busy || preview.changes.length === 0}
|
||||
onClick={() => void apply()}
|
||||
>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
import { type RefObject, useEffect } from "react";
|
||||
export function useFormErrorFocus(
|
||||
form: RefObject<HTMLFormElement | null>,
|
||||
errors: Record<string, string[]>,
|
||||
pending: boolean,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (pending) return;
|
||||
for (const field of Object.keys(errors)) {
|
||||
const control = form.current?.elements.namedItem(field);
|
||||
if (control instanceof HTMLElement && !control.hasAttribute("disabled")) {
|
||||
control.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [form, errors, pending]);
|
||||
}
|
||||
export function FormFieldError({
|
||||
name,
|
||||
errors,
|
||||
}: {
|
||||
name: string;
|
||||
errors: Record<string, string[]>;
|
||||
}) {
|
||||
const messages = errors[name];
|
||||
return messages?.length ? (
|
||||
<p id={`error-${name}`} role="alert" className="text-sm text-destructive">
|
||||
{messages.join(" · ")}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CatalogExportClient } from "@/app/admin/studio/sync/catalog-export-client";
|
||||
import Link from "@/components/link";
|
||||
import { FurnitureJobHistory } from "./studio/furniture-jobs";
|
||||
import { useFurnitureJobs } from "./studio/use-furniture-jobs";
|
||||
export function OperationsCenter() {
|
||||
const t = useTranslations("pages.admin.operationsCenter");
|
||||
const jobs = useFurnitureJobs(() => {}, true);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-[var(--admin-text-muted)]">{t("hint")}</p>
|
||||
<FurnitureJobHistory state={jobs} expanded />
|
||||
<CatalogExportClient monitorOnly />
|
||||
<section className="admin-card space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t("syncTitle")}</h2>
|
||||
<p>{t("syncHint")}</p>
|
||||
<Link href="/admin/studio/sync" className="btn btn-outline">
|
||||
{t("openSync")}
|
||||
</Link>
|
||||
<Link href="/admin/studio/furni" className="btn btn-outline">
|
||||
{t("openImports")}
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export async function OperationsInbox() {
|
||||
source: "imports",
|
||||
count: total,
|
||||
severity: 2,
|
||||
href: "/admin/studio/furni",
|
||||
href: "/admin/operations",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -7,14 +7,19 @@ import { retryableJobItems } from "@/lib/furni/import-job-retry";
|
||||
import type { useFurnitureJobs } from "./use-furniture-jobs";
|
||||
export function FurnitureJobHistory({
|
||||
state,
|
||||
expanded = false,
|
||||
}: {
|
||||
state: ReturnType<typeof useFurnitureJobs>;
|
||||
expanded?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const format = useFormatter();
|
||||
const [limits, setLimits] = useState<Record<string, number>>({});
|
||||
return (
|
||||
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
|
||||
<details
|
||||
open={expanded || undefined}
|
||||
className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm"
|
||||
>
|
||||
<summary className="cursor-pointer">
|
||||
{t("summary", {
|
||||
count: state.jobs.filter(
|
||||
@@ -22,15 +27,40 @@ export function FurnitureJobHistory({
|
||||
).length,
|
||||
})}
|
||||
</summary>
|
||||
<div className="max-h-72 space-y-2 overflow-auto py-2">
|
||||
<div
|
||||
className={
|
||||
expanded ? "space-y-2 py-2" : "max-h-72 space-y-2 overflow-auto py-2"
|
||||
}
|
||||
>
|
||||
{state.error && <p role="alert">{state.error}</p>}
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t("historyLimit")}
|
||||
{state.paginated ? t("ownerHistory") : t("historyLimit")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void state.refresh()}>
|
||||
{t("refreshHistory")}
|
||||
</Button>
|
||||
{!state.jobs.length && <p>{t("empty")}</p>}
|
||||
{state.paginated && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.loading || state.page === 1}
|
||||
onClick={() => void state.navigate("previous")}
|
||||
>
|
||||
{t("previousPage")}
|
||||
</Button>
|
||||
<span>{t("page", { page: state.page })}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.loading || !state.nextCursor}
|
||||
onClick={() => void state.navigate("next")}
|
||||
>
|
||||
{t("nextPage")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!state.jobs.length && !state.loading && !state.error && (
|
||||
<p>{t("empty")}</p>
|
||||
)}
|
||||
{state.jobs.map((job) => {
|
||||
const retryItems = retryableJobItems(job);
|
||||
const done = job.items.filter((i) => i.state === "done"),
|
||||
@@ -123,14 +153,7 @@ export function FurnitureJobHistory({
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.busy}
|
||||
onClick={() =>
|
||||
void state.submit(retryItems, {
|
||||
sourceId: job.sourceId,
|
||||
mode: job.mode,
|
||||
translate: job.translate,
|
||||
langs: job.langs,
|
||||
})
|
||||
}
|
||||
onClick={() => void state.retry(job.id)}
|
||||
>
|
||||
{t(
|
||||
job.state === "interrupted" || job.state === "cancelled"
|
||||
|
||||
@@ -5,8 +5,13 @@ import { toast } from "sonner";
|
||||
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
|
||||
export function useFurnitureJobs(onComplete: () => void) {
|
||||
export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const cursor = useRef<string | null>(null);
|
||||
const previous = useRef<Array<string | null>>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<string | null>(null);
|
||||
const cancelInFlight = useRef(false);
|
||||
const [jobs, setJobs] = useState<ImportJob[]>([]),
|
||||
@@ -21,14 +26,18 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
const refresh = useCallback(async () => {
|
||||
if (refreshing.current) return;
|
||||
refreshing.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs");
|
||||
const response = await adminFetch(
|
||||
`/api/admin/studio/import-jobs${paginated ? `?page=1${cursor.current ? `&before=${encodeURIComponent(cursor.current)}` : ""}` : ""}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw Error(
|
||||
data.error || `Import history unavailable (${response.status})`,
|
||||
);
|
||||
setJobs(data.jobs);
|
||||
setNextCursor(data.nextCursor ?? null);
|
||||
setError("");
|
||||
let changed = false;
|
||||
for (const job of data.jobs as ImportJob[])
|
||||
@@ -48,8 +57,45 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
);
|
||||
} finally {
|
||||
refreshing.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [paginated]);
|
||||
const navigate = async (direction: "next" | "previous") => {
|
||||
if (refreshing.current) return;
|
||||
if (direction === "next" && nextCursor) {
|
||||
previous.current.push(cursor.current);
|
||||
cursor.current = nextCursor;
|
||||
} else if (direction === "previous" && previous.current.length)
|
||||
cursor.current = previous.current.pop() ?? null;
|
||||
else return;
|
||||
setPage(previous.current.length + 1);
|
||||
setJobs([]);
|
||||
setNextCursor(null);
|
||||
await refresh();
|
||||
};
|
||||
const retry = async (id: string) => {
|
||||
if (submitting.current) return;
|
||||
submitting.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, action: "retry" }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(data.error || t("cancelFailed"));
|
||||
cursor.current = null;
|
||||
previous.current = [];
|
||||
setPage(1);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const active = jobs.some(
|
||||
(job) => job.state === "queued" || job.state === "running",
|
||||
);
|
||||
@@ -120,5 +166,19 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return { jobs, busy, error, submit, refresh, cancel, cancelling };
|
||||
return {
|
||||
jobs,
|
||||
busy,
|
||||
error,
|
||||
submit,
|
||||
refresh,
|
||||
cancel,
|
||||
cancelling,
|
||||
retry,
|
||||
navigate,
|
||||
page,
|
||||
nextCursor,
|
||||
loading,
|
||||
paginated,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -1159,8 +1159,8 @@ export const AdminAuditLog = mysqlTable("admin_audit_log", {
|
||||
target: varchar("target", { length: 191 }).notNull().default(""),
|
||||
targetId: int("target_id"),
|
||||
details: text("details"),
|
||||
before: text("before"),
|
||||
after: text("after"),
|
||||
before: mediumtext("before"),
|
||||
after: mediumtext("after"),
|
||||
diff: text("diff"),
|
||||
ipAddress: varchar("ip_address", { length: 45 }),
|
||||
createdAt: varchar("created_at", { length: 64 }).notNull().default(""),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { recordHistory } from "@/features/history/server";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
type BulkOfferInput,
|
||||
@@ -108,6 +109,7 @@ export async function previewBulkOffersCommand(
|
||||
export async function applyBulkOffersCommand(
|
||||
value: BulkOfferInput,
|
||||
fingerprint: string,
|
||||
userId?: number,
|
||||
) {
|
||||
const input = bulkOfferInputSchema.parse(value);
|
||||
if (!/^[a-f0-9]{64}$/.test(fingerprint))
|
||||
@@ -144,6 +146,12 @@ export async function applyBulkOffersCommand(
|
||||
sql`, `,
|
||||
)} WHERE id=${row.id}`,
|
||||
);
|
||||
if (userId)
|
||||
await recordHistory(tx, "prices", row.id, userId, {
|
||||
costCredits: row.before.costCredits,
|
||||
costPoints: row.before.costPoints,
|
||||
pointsType: row.before.pointsType,
|
||||
});
|
||||
}
|
||||
return { changedCount: result.changedCount };
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import { getTableColumns, type SQL, sql } from "drizzle-orm";
|
||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
|
||||
import {
|
||||
distinctOfferIds,
|
||||
@@ -79,7 +80,10 @@ function assignments(
|
||||
return sql`${sql.identifier(column.name)}=${key === "pageId" ? String(value) : value}`;
|
||||
});
|
||||
}
|
||||
export async function updateOfferCommand(input: UpdateOfferInput) {
|
||||
export async function updateOfferCommand(
|
||||
input: UpdateOfferInput,
|
||||
userId?: number,
|
||||
) {
|
||||
offerIdSchema.parse(input.id);
|
||||
const fields = offerPatchSchema.parse(input.catalogFields);
|
||||
const baseFields = input.baseItem
|
||||
@@ -95,6 +99,9 @@ export async function updateOfferCommand(input: UpdateOfferInput) {
|
||||
// Page locks precede offer locks, matching category deletion's lock order.
|
||||
if (fields.pageId !== undefined) await lockPage(tx, fields.pageId);
|
||||
const [offer] = await lockedOffers(tx, [input.id]);
|
||||
const before = userId
|
||||
? await historySnapshot(tx, "prices", input.id)
|
||||
: null;
|
||||
if (input.baseItem) {
|
||||
const currentIds = String(offer.itemIds).split(";").map(Number);
|
||||
const nextIds =
|
||||
@@ -121,6 +128,8 @@ export async function updateOfferCommand(input: UpdateOfferInput) {
|
||||
await tx.execute(
|
||||
sql`UPDATE ${ItemsBase} SET ${sql.join(baseAssignments, sql`, `)} WHERE id=${input.baseItem.id}`,
|
||||
);
|
||||
if (before && userId)
|
||||
await recordHistory(tx, "prices", input.id, userId, before);
|
||||
});
|
||||
}
|
||||
export async function reorderOffersCommand(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
||||
import { remapIncludes } from "../domain/duplicate";
|
||||
@@ -261,6 +262,7 @@ async function updateRow(
|
||||
}
|
||||
async function publishCatalogPackageAttempt(
|
||||
raw: PublishCatalogPackageInput,
|
||||
userId?: number,
|
||||
): Promise<PackagePublication> {
|
||||
const input = publishPackageSchema.parse(raw);
|
||||
const initial = await getCatalogPackageCommand(input.id);
|
||||
@@ -292,21 +294,32 @@ async function publishCatalogPackageAttempt(
|
||||
const pageIds: number[] = [];
|
||||
if (p.mode === "update") {
|
||||
for (const page of p.draft.pages) {
|
||||
const historyBefore = userId
|
||||
? await historySnapshot(tx, "category", page.id)
|
||||
: null;
|
||||
await updateRow(
|
||||
tx,
|
||||
"catalog_pages",
|
||||
page.id,
|
||||
columns(page, pageColumns),
|
||||
);
|
||||
if (historyBefore && userId)
|
||||
await recordHistory(tx, "category", page.id, userId, historyBefore);
|
||||
pageIds.push(page.id);
|
||||
}
|
||||
for (const offer of p.draft.offers)
|
||||
for (const offer of p.draft.offers) {
|
||||
const historyBefore = userId
|
||||
? await historySnapshot(tx, "prices", offer.id)
|
||||
: null;
|
||||
await updateRow(
|
||||
tx,
|
||||
"catalog_items",
|
||||
offer.id,
|
||||
columns(offer, offerColumns),
|
||||
);
|
||||
if (historyBefore && userId)
|
||||
await recordHistory(tx, "prices", offer.id, userId, historyBefore);
|
||||
}
|
||||
} else {
|
||||
if (reserved.length !== source.offers.length)
|
||||
throw new CatalogConflict(
|
||||
@@ -399,10 +412,11 @@ async function publishCatalogPackageAttempt(
|
||||
/** An ID reserved by another process can race our process-local allocator. A collision rolls back the entire copy before retry. */
|
||||
export async function publishCatalogPackageCommand(
|
||||
input: PublishCatalogPackageInput,
|
||||
userId?: number,
|
||||
): Promise<PackagePublication> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await publishCatalogPackageAttempt(input);
|
||||
return await publishCatalogPackageAttempt(input, userId);
|
||||
} catch (error) {
|
||||
let cause: unknown = error,
|
||||
duplicate = false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import { getTableColumns, type SQL, sql } from "drizzle-orm";
|
||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||
import { CatalogPages, CatalogPagesBc, db } from "@/lib/db";
|
||||
import {
|
||||
assertParent,
|
||||
@@ -53,6 +54,7 @@ export async function updatePageCommand(
|
||||
id: number,
|
||||
fields: Record<string, unknown>,
|
||||
expected?: Record<string, unknown>,
|
||||
userId?: number,
|
||||
) {
|
||||
positiveId(id);
|
||||
const data = pagePatchSchema.parse(fields);
|
||||
@@ -76,6 +78,8 @@ export async function updatePageCommand(
|
||||
);
|
||||
}
|
||||
}
|
||||
const historyKind = kind === "bc" ? "category_bc" : "category";
|
||||
const before = userId ? await historySnapshot(tx, historyKind, id) : null;
|
||||
if (data.parentId !== undefined) assertParent(rows, id, data.parentId);
|
||||
const assignments = fieldsSql(kind, data);
|
||||
if (!assignments.length)
|
||||
@@ -83,6 +87,8 @@ export async function updatePageCommand(
|
||||
await tx.execute(
|
||||
sql`UPDATE ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)} WHERE id=${id}`,
|
||||
);
|
||||
if (before && userId)
|
||||
await recordHistory(tx, historyKind, id, userId, before);
|
||||
});
|
||||
}
|
||||
export async function createPageCommand(
|
||||
@@ -153,6 +159,7 @@ export async function togglePageCommand(
|
||||
kind: CatalogKind,
|
||||
id: number,
|
||||
field: "enabled" | "visible",
|
||||
userId?: number,
|
||||
) {
|
||||
positiveId(id);
|
||||
if (field !== "enabled" && field !== "visible")
|
||||
@@ -161,9 +168,13 @@ export async function togglePageCommand(
|
||||
const rows = await lockedPages(tx, kind);
|
||||
if (!rows.some((row) => row.id === id))
|
||||
throw new CatalogNotFound("Catalog page not found");
|
||||
const historyKind = kind === "bc" ? "category_bc" : "category";
|
||||
const before = userId ? await historySnapshot(tx, historyKind, id) : null;
|
||||
const column = sql.identifier(field);
|
||||
await tx.execute(
|
||||
sql`UPDATE ${tables(kind).pages} SET ${column}=CASE WHEN ${column}='1' THEN '0' ELSE '1' END WHERE id=${id}`,
|
||||
);
|
||||
if (before && userId)
|
||||
await recordHistory(tx, historyKind, id, userId, before);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import { previewHistoryRestore, restoreHistory } from "@/actions/history";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
export function HistoryRestore({ id }: { id: number }) {
|
||||
const t = useTranslations("pages.admin.logs.history");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pending = useRef<"preview" | "restore" | null>(null);
|
||||
const [busy, setBusy] = useState<"preview" | "restore" | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [preview, setPreview] = useState<Awaited<
|
||||
ReturnType<typeof previewHistoryRestore>
|
||||
> | null>(null);
|
||||
async function load() {
|
||||
if (pending.current) return;
|
||||
pending.current = "preview";
|
||||
setOpen(true);
|
||||
setBusy("preview");
|
||||
setError("");
|
||||
setPreview(null);
|
||||
try {
|
||||
setPreview(await previewHistoryRestore(id));
|
||||
} catch {
|
||||
setError(t("unavailable"));
|
||||
} finally {
|
||||
pending.current = null;
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
async function restore() {
|
||||
if (pending.current || !preview?.canRestore || !preview.changes.length)
|
||||
return;
|
||||
pending.current = "restore";
|
||||
setBusy("restore");
|
||||
setError("");
|
||||
try {
|
||||
const result = await restoreHistory(id);
|
||||
if (!result.ok) {
|
||||
setError(t(result.code));
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError(t("failed"));
|
||||
} finally {
|
||||
pending.current = null;
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={load}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{t("preview")}
|
||||
</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen, details) => {
|
||||
if (!nextOpen && pending.current === "restore") {
|
||||
details.cancel();
|
||||
return;
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="w-[calc(100%-2rem)] min-w-0 overflow-x-hidden sm:max-w-3xl"
|
||||
showCloseButton={busy !== "restore"}
|
||||
>
|
||||
<DialogTitle className="pr-6 leading-snug">{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
{busy && (
|
||||
<p role="status">
|
||||
{t(busy === "restore" ? "restoring" : "loading")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{preview && (
|
||||
<>
|
||||
{!preview.canRestore && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{t("conflict")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="max-h-[50dvh] min-w-0 space-y-4 overflow-y-auto pr-1">
|
||||
{preview.changes.map((change) => (
|
||||
<section
|
||||
key={change.key}
|
||||
className="min-w-0 rounded-lg border p-3"
|
||||
>
|
||||
<h3 className="mb-2 text-sm font-semibold">
|
||||
{t.has(`fields.${change.key}`)
|
||||
? t(`fields.${change.key}`)
|
||||
: t("field")}
|
||||
</h3>
|
||||
<dl className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<dt className="mb-1 text-xs text-muted-foreground">
|
||||
{t("current")}
|
||||
</dt>
|
||||
<dd className="min-w-0">
|
||||
<pre className="max-h-52 overflow-y-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted/40 p-2 text-xs">
|
||||
{String(change.current ?? "—")}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="mb-1 text-xs text-muted-foreground">
|
||||
{t("restore")}
|
||||
</dt>
|
||||
<dd className="min-w-0">
|
||||
<pre className="max-h-52 overflow-y-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted/40 p-2 text-xs">
|
||||
{String(change.restore ?? "—")}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={
|
||||
busy !== null ||
|
||||
!preview.canRestore ||
|
||||
!preview.changes.length
|
||||
}
|
||||
onClick={restore}
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", async () => ({ ...(await import("@/db/schema")), db: {} }));
|
||||
|
||||
import { historyChanges, sameSnapshot } from "./model";
|
||||
import {
|
||||
applyHistory,
|
||||
type HistoryTransaction,
|
||||
historySnapshot,
|
||||
} from "./server";
|
||||
|
||||
describe("history restore safety", () => {
|
||||
const before = { costCredits: 10, costPoints: 0, pointsType: 0 };
|
||||
const after = { costCredits: 20, costPoints: 0, pointsType: 0 };
|
||||
function transaction(current = after) {
|
||||
const writes: string[] = [];
|
||||
const audit = vi.fn();
|
||||
let snapshot = current;
|
||||
const tx = {
|
||||
execute: vi.fn(async (query: SQL) => {
|
||||
const text = new MySqlDialect().sqlToQuery(query).sql;
|
||||
if (text.startsWith("SELECT")) return [[snapshot]];
|
||||
writes.push(text);
|
||||
snapshot = before;
|
||||
return [{}];
|
||||
}),
|
||||
insert: () => ({ values: audit }),
|
||||
} as unknown as HistoryTransaction;
|
||||
return { tx, writes, audit };
|
||||
}
|
||||
it("compares all recorded fields and does not confuse null with empty", () => {
|
||||
expect(sameSnapshot({ a: null }, { a: "" })).toBe(false);
|
||||
expect(sameSnapshot(after, { ...after, extra: 0 })).toBe(false);
|
||||
expect(historyChanges(after, before)).toEqual([
|
||||
{ key: "costCredits", current: 20, restore: 10 },
|
||||
]);
|
||||
});
|
||||
it("rejects concurrent edits without any writes or audit", async () => {
|
||||
const { tx, writes, audit } = transaction({ ...after, costPoints: 5 });
|
||||
await expect(
|
||||
applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7),
|
||||
).rejects.toThrow("conflict");
|
||||
expect(writes).toEqual([]);
|
||||
expect(audit).not.toHaveBeenCalled();
|
||||
});
|
||||
it("restores only the price fields and audits in the same transaction", async () => {
|
||||
const { tx, writes, audit } = transaction();
|
||||
await applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]).not.toContain("limited_sells");
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
action: "history_restore",
|
||||
before: JSON.stringify(after),
|
||||
after: JSON.stringify(before),
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("propagates audit failure so the caller transaction rolls back", async () => {
|
||||
const { tx, audit } = transaction();
|
||||
audit.mockRejectedValue(Error("audit unavailable"));
|
||||
await expect(
|
||||
applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7),
|
||||
).rejects.toThrow("audit unavailable");
|
||||
});
|
||||
it("rejects a second restore of the same revision", async () => {
|
||||
const { tx, writes, audit } = transaction();
|
||||
const entry = { kind: "prices" as const, targetId: 1, before, after };
|
||||
await applyHistory(tx, entry, 7);
|
||||
await expect(applyHistory(tx, entry, 7)).rejects.toThrow("conflict");
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(audit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("reads only columns present in the smaller builder club schema", async () => {
|
||||
const { tx } = transaction();
|
||||
const snapshot = await historySnapshot(tx, "category_bc", 1);
|
||||
expect(snapshot).not.toHaveProperty("captionSave");
|
||||
expect(snapshot).not.toHaveProperty("includes");
|
||||
expect(snapshot).toHaveProperty("caption");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export type HistorySnapshot = Record<string, string | number | null>;
|
||||
export function sameSnapshot(a: HistorySnapshot, b: HistorySnapshot): boolean {
|
||||
const keys = Object.keys(a);
|
||||
return (
|
||||
keys.length === Object.keys(b).length &&
|
||||
keys.every((key) => Object.hasOwn(b, key) && a[key] === b[key])
|
||||
);
|
||||
}
|
||||
export function historyChanges(
|
||||
current: HistorySnapshot,
|
||||
restore: HistorySnapshot,
|
||||
) {
|
||||
return Object.keys(restore)
|
||||
.filter((key) => current[key] !== restore[key])
|
||||
.map((key) => ({ key, current: current[key], restore: restore[key] }));
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import "server-only";
|
||||
import { eq, getTableColumns, sql } from "drizzle-orm";
|
||||
import {
|
||||
AdminAuditLog,
|
||||
CatalogItems,
|
||||
CatalogPages,
|
||||
CatalogPagesBc,
|
||||
WebsiteArticles,
|
||||
} from "@/db/schema";
|
||||
import type { db } from "@/lib/db";
|
||||
import { type HistorySnapshot, sameSnapshot } from "./model";
|
||||
export type HistoryKind = "category" | "category_bc" | "prices" | "news";
|
||||
export type HistoryTransaction = Parameters<
|
||||
Parameters<typeof db.transaction>[0]
|
||||
>[0];
|
||||
const categoryFields = [
|
||||
"caption",
|
||||
"captionSave",
|
||||
"pageLayout",
|
||||
"enabled",
|
||||
"visible",
|
||||
"clubOnly",
|
||||
"vipOnly",
|
||||
"minRank",
|
||||
"iconImage",
|
||||
"iconColor",
|
||||
"pageHeadline",
|
||||
"pageTeaser",
|
||||
"pageSpecial",
|
||||
"pageText1",
|
||||
"pageText2",
|
||||
"pageTextDetails",
|
||||
"pageTextTeaser",
|
||||
"includes",
|
||||
];
|
||||
const configs = {
|
||||
category: { table: CatalogPages, fields: categoryFields },
|
||||
category_bc: { table: CatalogPagesBc, fields: categoryFields },
|
||||
prices: {
|
||||
table: CatalogItems,
|
||||
fields: ["costCredits", "costPoints", "pointsType"],
|
||||
},
|
||||
news: {
|
||||
table: WebsiteArticles,
|
||||
fields: [
|
||||
"title",
|
||||
"slug",
|
||||
"image",
|
||||
"shortStory",
|
||||
"fullStory",
|
||||
"status",
|
||||
"publishAt",
|
||||
"publishedAt",
|
||||
],
|
||||
},
|
||||
};
|
||||
export function historyConfig(kind: string) {
|
||||
if (!Object.hasOwn(configs, kind)) throw Error("unavailable");
|
||||
const config = configs[kind as HistoryKind];
|
||||
const columns = getTableColumns(config.table);
|
||||
return {
|
||||
...config,
|
||||
fields: config.fields.filter((key) => Object.hasOwn(columns, key)),
|
||||
};
|
||||
}
|
||||
export async function historySnapshot(
|
||||
tx: HistoryTransaction,
|
||||
kind: HistoryKind,
|
||||
id: number | string,
|
||||
): Promise<HistorySnapshot> {
|
||||
const { table, fields } = historyConfig(kind);
|
||||
const columns = getTableColumns(table);
|
||||
const selected = fields.map(
|
||||
(key) =>
|
||||
sql`${sql.identifier(columns[key as keyof typeof columns].name)} AS ${sql.identifier(key)}`,
|
||||
);
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT ${sql.join(selected, sql`, `)} FROM ${table} WHERE id=${id} FOR UPDATE`,
|
||||
);
|
||||
const row = (rows as unknown as Record<string, unknown>[])[0];
|
||||
if (!row) throw Error("unavailable");
|
||||
return Object.fromEntries(
|
||||
fields.map((key) => {
|
||||
const value = row[key];
|
||||
return [
|
||||
key,
|
||||
value instanceof Date
|
||||
? value.toISOString()
|
||||
: value == null
|
||||
? null
|
||||
: typeof value === "number"
|
||||
? value
|
||||
: String(value),
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
export async function recordHistory(
|
||||
tx: HistoryTransaction,
|
||||
kind: HistoryKind,
|
||||
id: number | string,
|
||||
userId: number,
|
||||
before: HistorySnapshot,
|
||||
action = "history_update",
|
||||
) {
|
||||
const after = await historySnapshot(tx, kind, id);
|
||||
if (sameSnapshot(before, after)) return;
|
||||
const serializedBefore = JSON.stringify(before);
|
||||
const serializedAfter = JSON.stringify(after);
|
||||
if (
|
||||
Buffer.byteLength(serializedBefore, "utf8") > 16_000_000 ||
|
||||
Buffer.byteLength(serializedAfter, "utf8") > 16_000_000
|
||||
)
|
||||
throw Error("History snapshot is too large");
|
||||
await tx.insert(AdminAuditLog).values({
|
||||
userId,
|
||||
action,
|
||||
target: kind,
|
||||
targetId: Number(id) <= 2147483647 ? Number(id) : null,
|
||||
details:
|
||||
Number(id) > 2147483647 ? JSON.stringify({ targetId: String(id) }) : null,
|
||||
before: serializedBefore,
|
||||
after: serializedAfter,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
export async function readHistory(tx: HistoryTransaction, id: number) {
|
||||
const [entry] = await tx
|
||||
.select()
|
||||
.from(AdminAuditLog)
|
||||
.where(eq(AdminAuditLog.id, id))
|
||||
.limit(1);
|
||||
if (!entry || !["history_update", "history_restore"].includes(entry.action))
|
||||
throw Error("unavailable");
|
||||
let targetId: number | string | null = entry.targetId;
|
||||
if (targetId === null && entry.target === "news") {
|
||||
const details = JSON.parse(entry.details ?? "null");
|
||||
if (
|
||||
typeof details?.targetId === "string" &&
|
||||
/^[1-9]\d{0,19}$/.test(details.targetId)
|
||||
)
|
||||
targetId = details.targetId;
|
||||
}
|
||||
if (targetId === null) throw Error("unavailable");
|
||||
const config = historyConfig(entry.target);
|
||||
const parse = (raw: string | null): HistorySnapshot => {
|
||||
const value: unknown = JSON.parse(raw ?? "null");
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
throw Error("unavailable");
|
||||
const data = value as HistorySnapshot;
|
||||
if (
|
||||
Object.keys(data).length !== config.fields.length ||
|
||||
!config.fields.every(
|
||||
(key) =>
|
||||
Object.hasOwn(data, key) &&
|
||||
(data[key] === null ||
|
||||
typeof data[key] === "string" ||
|
||||
typeof data[key] === "number"),
|
||||
)
|
||||
)
|
||||
throw Error("unavailable");
|
||||
return data;
|
||||
};
|
||||
return {
|
||||
kind: entry.target as HistoryKind,
|
||||
targetId,
|
||||
before: parse(entry.before),
|
||||
after: parse(entry.after),
|
||||
};
|
||||
}
|
||||
export async function applyHistory(
|
||||
tx: HistoryTransaction,
|
||||
entry: Awaited<ReturnType<typeof readHistory>>,
|
||||
userId: number,
|
||||
) {
|
||||
const current = await historySnapshot(tx, entry.kind, entry.targetId);
|
||||
if (!sameSnapshot(current, entry.after)) throw Error("conflict");
|
||||
const { table } = historyConfig(entry.kind);
|
||||
const columns = getTableColumns(table);
|
||||
const assignments = Object.entries(entry.before).map(
|
||||
([key, value]) =>
|
||||
sql`${sql.identifier(columns[key as keyof typeof columns].name)}=${entry.kind === "news" && (key === "publishAt" || key === "publishedAt") && value ? new Date(String(value)) : value}`,
|
||||
);
|
||||
if (entry.kind === "news") assignments.push(sql`updated_at=${new Date()}`);
|
||||
await tx.execute(
|
||||
sql`UPDATE ${table} SET ${sql.join(assignments, sql`, `)} WHERE id=${entry.targetId}`,
|
||||
);
|
||||
await recordHistory(
|
||||
tx,
|
||||
entry.kind,
|
||||
entry.targetId,
|
||||
userId,
|
||||
current,
|
||||
"history_restore",
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export const operationsManifest = {
|
||||
descriptionKey: "pages.housekeeping.domains.operations.description",
|
||||
iconId: "inbox",
|
||||
previewHref: "/admin-next/operations",
|
||||
capability: anyCapability(PERMS.ADMIN_DASHBOARD),
|
||||
capability: anyCapability(PERMS.ADMIN_DASHBOARD, PERMS.ASSETS_IMPORT),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
|
||||
@@ -605,15 +605,15 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
expect(html).toContain(`>${sentinel}</button>`);
|
||||
});
|
||||
|
||||
it("validates the complete 139-row migration matrix without issues", () => {
|
||||
it("validates the complete 141-row migration matrix without issues", () => {
|
||||
const discovered = discoverLegacyPages();
|
||||
const issues = validateMigrationEntries(
|
||||
discovered,
|
||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||
);
|
||||
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
|
||||
expect(discovered).toHaveLength(139);
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(141);
|
||||
expect(discovered).toHaveLength(141);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ const expectedManifests = [
|
||||
previewHref: "/admin-next/operations",
|
||||
labelKey: "pages.housekeeping.domains.operations.title",
|
||||
descriptionKey: "pages.housekeeping.domains.operations.description",
|
||||
slugs: [PERMS.ADMIN_DASHBOARD],
|
||||
slugs: [PERMS.ADMIN_DASHBOARD, PERMS.ASSETS_IMPORT],
|
||||
},
|
||||
{
|
||||
id: "people",
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => {
|
||||
it("discovers the exact legacy administration inventory", () => {
|
||||
const pages = discoverLegacyPages();
|
||||
|
||||
expect(pages).toHaveLength(139);
|
||||
expect(pages).toHaveLength(141);
|
||||
expect(pages).toContainEqual({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/users/:id/edit",
|
||||
|
||||
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
||||
import { validateMigrationEntries } from "./validate-matrix";
|
||||
|
||||
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
||||
it("covers all 139 legacy pages exactly once", () => {
|
||||
it("covers all 141 legacy pages exactly once", () => {
|
||||
const discovered = discoverLegacyPages();
|
||||
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(141);
|
||||
expect(
|
||||
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
||||
).toEqual([]);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { operationsMigrationEntries } from "./operations";
|
||||
|
||||
describe("operationsMigrationEntries", () => {
|
||||
it("covers the legacy dashboard once", () => {
|
||||
expect(operationsMigrationEntries).toHaveLength(1);
|
||||
expect(operationsMigrationEntries).toHaveLength(2);
|
||||
expect(operationsMigrationEntries[0]).toMatchObject({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin",
|
||||
|
||||
@@ -24,4 +24,27 @@ export const operationsMigrationEntries: readonly MigrationEntry[] = [
|
||||
"Replace metric dashboard with capability-derived operational home",
|
||||
],
|
||||
},
|
||||
{
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/operations",
|
||||
sourceFile: "src/app/admin/operations/page.tsx",
|
||||
targetDomain: "operations",
|
||||
targetPath: "/admin/work/jobs",
|
||||
decision: "REHOST",
|
||||
capabilities: {
|
||||
read: [PERMS.ASSETS_IMPORT],
|
||||
mutate: [PERMS.ASSETS_IMPORT],
|
||||
},
|
||||
dependencies: {
|
||||
queries: ["ImportJobStore", "catalogExportStatus"],
|
||||
mutations: ["retry furniture import", "cancel furniture import"],
|
||||
},
|
||||
auditRequirement: "MUTATION",
|
||||
localization: "PARTIAL",
|
||||
accessibility: "PARTIAL",
|
||||
requiredTests: ["unit", "integration", "e2e", "visual"],
|
||||
parityEvidence: [],
|
||||
status: "PLANNED",
|
||||
notes: ["Owner-scoped import history and shared export status"],
|
||||
},
|
||||
];
|
||||
@@ -53,6 +53,7 @@ describe("peopleMigrationEntries", () => {
|
||||
const expected = discoverLegacyPages().filter(
|
||||
(page) =>
|
||||
page.legacyPath === "/admin" ||
|
||||
page.legacyPath === "/admin/operations" ||
|
||||
PEOPLE_PREFIXES.some(
|
||||
(prefix) =>
|
||||
page.legacyPath === prefix ||
|
||||
|
||||
@@ -18,8 +18,8 @@ const SYSTEM_PREFIXES = [
|
||||
] as const;
|
||||
|
||||
describe("systemMigrationEntries", () => {
|
||||
it("covers all 20 System pages exactly once", () => {
|
||||
expect(systemMigrationEntries).toHaveLength(20);
|
||||
it("covers all 21 System pages exactly once", () => {
|
||||
expect(systemMigrationEntries).toHaveLength(21);
|
||||
expect(
|
||||
validateMigrationEntries(
|
||||
ownedLegacyPages(SYSTEM_PREFIXES),
|
||||
|
||||
@@ -21,6 +21,28 @@ function plannedSystemEntry(entry: PlannedSystemEntry): MigrationEntry {
|
||||
}
|
||||
|
||||
export const systemMigrationEntries: readonly MigrationEntry[] = [
|
||||
plannedSystemEntry({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/permissions/preview",
|
||||
sourceFile: "src/app/admin/permissions/preview/page.tsx",
|
||||
targetPath: "/admin/system/access/preview",
|
||||
decision: "REHOST",
|
||||
capabilities: { read: [PERMS.PERMISSIONS_MANAGE], mutate: [] },
|
||||
dependencies: {
|
||||
queries: [
|
||||
"acl_roles",
|
||||
"acl_permissions",
|
||||
"acl_model_permissions",
|
||||
"permission_ranks",
|
||||
"users highest rank",
|
||||
],
|
||||
mutations: [],
|
||||
},
|
||||
auditRequirement: "NONE",
|
||||
localization: "PARTIAL",
|
||||
accessibility: "PARTIAL",
|
||||
notes: ["Read-only role simulation; never changes sessions or grants"],
|
||||
}),
|
||||
plannedSystemEntry({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/devops/installation",
|
||||
|
||||
@@ -251,6 +251,12 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
icon: LayoutDashboard,
|
||||
permission: PERMS.ADMIN_DASHBOARD,
|
||||
},
|
||||
{
|
||||
href: "/admin/operations",
|
||||
labelKey: "operationsCenter",
|
||||
icon: Activity,
|
||||
permission: PERMS.ASSETS_IMPORT,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { PERMS } from "../permission-slugs";
|
||||
import { previewRoleAccess } from "./permission-preview";
|
||||
|
||||
it("requires dashboard access before reporting a module usable", () => {
|
||||
const p = previewRoleAccess([PERMS.USERS_VIEW], false);
|
||||
expect(p.canEnter).toBe(false);
|
||||
expect(p.sections.find((s) => s.href === "/admin/users")?.allowed).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("shows grants and denies without combining another role", () => {
|
||||
const p = previewRoleAccess([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW], false);
|
||||
expect(p.sections.find((s) => s.href === "/admin/users")?.allowed).toBe(true);
|
||||
expect(p.actions.find((s) => s.slug === PERMS.USERS_EDIT)?.granted).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("applies the existing highest-rank override", () => {
|
||||
const p = previewRoleAccess([], true);
|
||||
expect(p.canEnter).toBe(true);
|
||||
expect(p.actions.every((a) => a.granted)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ADMIN_NAV_GROUPS } from "../admin-nav";
|
||||
import { PERMS } from "../permission-slugs";
|
||||
/** Role-only simulation; never used to authorize a request. */
|
||||
export function previewRoleAccess(
|
||||
slugs: readonly string[],
|
||||
superAdmin: boolean,
|
||||
) {
|
||||
const grants = new Set(slugs);
|
||||
const has = (slug: string) => superAdmin || grants.has(slug);
|
||||
const canEnter = has(PERMS.ADMIN_DASHBOARD);
|
||||
return {
|
||||
canEnter,
|
||||
sections: ADMIN_NAV_GROUPS.flatMap((group) =>
|
||||
group.items.map((item) => ({
|
||||
href: item.href,
|
||||
labelKey: item.labelKey,
|
||||
required:
|
||||
typeof item.permission === "string"
|
||||
? [item.permission]
|
||||
: [...(item.permission ?? [])],
|
||||
allowed:
|
||||
canEnter &&
|
||||
(!item.permission ||
|
||||
(typeof item.permission === "string"
|
||||
? has(item.permission)
|
||||
: item.permission.some(has))),
|
||||
})),
|
||||
),
|
||||
actions: [...new Set(Object.values(PERMS))]
|
||||
.sort()
|
||||
.map((slug) => ({ slug, granted: has(slug) })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { articleInputField } from "./article-input";
|
||||
|
||||
it("maps correctable article failures and keeps conflicts global", () => {
|
||||
expect(articleInputField("titleRequired")).toBe("title");
|
||||
expect(articleInputField("summaryTooLong")).toBe("shortStory");
|
||||
expect(articleInputField("publishDateRequired")).toBe("publishAt");
|
||||
expect(articleInputField("editConflict")).toBeUndefined();
|
||||
expect(articleInputField("articleNotFound")).toBeUndefined();
|
||||
});
|
||||
@@ -47,3 +47,18 @@ export function readArticleInput(form: FormData) {
|
||||
export function articleSlug(value: string | string[]): string {
|
||||
return Array.isArray(value) ? value.join("/") : value;
|
||||
}
|
||||
|
||||
/** Associate validation failures with the field the editor can correct. */
|
||||
export function articleInputField(
|
||||
code: ArticleInputError["code"],
|
||||
): string | undefined {
|
||||
const fields: Partial<Record<ArticleInputError["code"], string>> = {
|
||||
titleRequired: "title",
|
||||
titleTooLong: "title",
|
||||
summaryTooLong: "shortStory",
|
||||
imageTooLong: "image",
|
||||
invalidStatus: "status",
|
||||
publishDateRequired: "publishAt",
|
||||
};
|
||||
return fields[code];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { queryErrorGroups } from "./error-group-query";
|
||||
import { createErrorRecord } from "./error-monitor";
|
||||
|
||||
const now = Date.parse("2026-09-09T12:00:00Z");
|
||||
const base = createErrorRecord("server", "save", "failed");
|
||||
it("filters occurrences by exact release before aggregating", () => {
|
||||
const old = { ...base, release: "old", at: "2026-09-09T10:00:00Z" };
|
||||
const fresh = { ...base, release: "new", at: "2026-09-09T11:00:00Z" };
|
||||
expect(
|
||||
queryErrorGroups([old, fresh], { release: "old" }, now)[0].events,
|
||||
).toEqual([old]);
|
||||
});
|
||||
it("filters finite recent windows and sorts frequent groups first", () => {
|
||||
const a = { ...base, fingerprint: "a", at: "2026-09-09T10:00:00Z" };
|
||||
const b = { ...a, fingerprint: "b", at: "2026-09-09T11:00:00Z" };
|
||||
const old = { ...a, at: "2026-09-01T10:00:00Z" };
|
||||
expect(
|
||||
queryErrorGroups(
|
||||
[b, a, a, old],
|
||||
{ period: "24h", sort: "frequency" },
|
||||
now,
|
||||
).map((g) => [g.fingerprint, g.events.length]),
|
||||
).toEqual([
|
||||
["a", 2],
|
||||
["b", 1],
|
||||
]);
|
||||
});
|
||||
it("uses latest group resolution while filtering historical releases", () => {
|
||||
const old = {
|
||||
...base,
|
||||
release: "old",
|
||||
at: "2026-09-08T10:00:00Z",
|
||||
resolved: false,
|
||||
};
|
||||
const latest = {
|
||||
...base,
|
||||
release: "new",
|
||||
at: "2026-09-09T11:00:00Z",
|
||||
resolved: true,
|
||||
};
|
||||
expect(
|
||||
queryErrorGroups([old, latest], { release: "old", status: "open" }, now),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { CmsErrorRecord } from "./error-monitor";
|
||||
export function queryErrorGroups(
|
||||
records: CmsErrorRecord[],
|
||||
params: Record<string, string | undefined>,
|
||||
now = Date.now(),
|
||||
) {
|
||||
const groups = new Map<string, CmsErrorRecord[]>();
|
||||
for (const record of records) {
|
||||
const events = groups.get(record.fingerprint) ?? [];
|
||||
events.push(record);
|
||||
groups.set(record.fingerprint, events);
|
||||
}
|
||||
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
|
||||
const duration =
|
||||
params.period === "24h"
|
||||
? 86400000
|
||||
: params.period === "7d"
|
||||
? 604800000
|
||||
: null;
|
||||
return [...groups]
|
||||
.flatMap(([fingerprint, events]) => {
|
||||
const latest = [...events].sort((a, b) => b.at.localeCompare(a.at))[0];
|
||||
if (
|
||||
(params.status === "open" && latest.resolved) ||
|
||||
(params.status === "resolved" && !latest.resolved)
|
||||
)
|
||||
return [];
|
||||
const matched = events.filter(
|
||||
(r) =>
|
||||
(!params.release || r.release === params.release) &&
|
||||
(!params.source || r.source === params.source) &&
|
||||
(!duration ||
|
||||
(Date.parse(r.at) >= now - duration && Date.parse(r.at) <= now)) &&
|
||||
(!query || JSON.stringify(r).toLowerCase().includes(query)),
|
||||
);
|
||||
if (!matched.length) return [];
|
||||
matched.sort((a, b) => b.at.localeCompare(a.at));
|
||||
return [{ fingerprint, events: matched, latest }];
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(params.sort === "frequency" ? b.events.length - a.events.length : 0) ||
|
||||
b.events[0].at.localeCompare(a.events[0].at) ||
|
||||
a.fingerprint.localeCompare(b.fingerprint),
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface ImportJobItem {
|
||||
}
|
||||
export interface ImportJob {
|
||||
mode?: "repair";
|
||||
retryOf?: string;
|
||||
id: string;
|
||||
userId: number;
|
||||
createdAt: string;
|
||||
|
||||
@@ -957,3 +957,50 @@ export async function runCatalogAudit(
|
||||
summary,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read-only integrity report: never invokes import, repair, or directory creation. */
|
||||
export async function inspectLiveCatalogIntegrity(catalog: "normal" | "bc") {
|
||||
const { integrityCatalog, inspectCatalogIntegrity } = await import(
|
||||
"./catalog-integrity"
|
||||
);
|
||||
integrityCatalog(catalog);
|
||||
const pagesTable = sql.raw(
|
||||
catalog === "bc" ? "catalog_pages_bc" : "catalog_pages",
|
||||
);
|
||||
const offersTable = sql.raw(
|
||||
catalog === "bc" ? "catalog_items_bc" : "catalog_items",
|
||||
);
|
||||
const [pageResult, itemResult, offerResult] = await Promise.all([
|
||||
db.execute(
|
||||
sql`SELECT id, parent_id AS parentId, caption FROM ${pagesTable} ORDER BY id`,
|
||||
),
|
||||
db.execute(
|
||||
sql`SELECT id, item_name, public_name, type, interaction_type FROM items_base ORDER BY id`,
|
||||
),
|
||||
db.execute(
|
||||
sql`SELECT id, page_id AS pageId, item_ids AS itemIds, catalog_name AS name, ${catalog === "bc" ? sql`NULL` : sql`offer_id`} AS offerId FROM ${offersTable} ORDER BY id`,
|
||||
),
|
||||
]);
|
||||
let icons: Set<string> | null = null;
|
||||
try {
|
||||
const dirs = await getFurniAssetDirs();
|
||||
icons = new Set(await readdir(dirs.iconDir));
|
||||
} catch {
|
||||
/* Unknown asset state is surfaced separately from missing icons. */
|
||||
}
|
||||
return inspectCatalogIntegrity({
|
||||
catalog,
|
||||
pages:
|
||||
pageResult[0] as unknown as import("./catalog-integrity").IntegrityPage[],
|
||||
items:
|
||||
itemResult[0] as unknown as import("./catalog-integrity").IntegrityItem[],
|
||||
offers: (
|
||||
offerResult[0] as unknown as import("./catalog-integrity").IntegrityOffer[]
|
||||
).map((row) => ({
|
||||
...row,
|
||||
pageId: Number(row.pageId),
|
||||
offerId: row.offerId === null ? null : Number(row.offerId),
|
||||
})),
|
||||
icons,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
ensureDirectories: vi.fn(),
|
||||
repair: vi.fn(),
|
||||
}));
|
||||
vi.mock("node:fs/promises", () => ({ readdir: mocks.readdir }));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } }));
|
||||
vi.mock("@/lib/services/clone-import", () => ({}));
|
||||
vi.mock("@/lib/services/clone-sources", () => ({}));
|
||||
vi.mock("@/lib/services/furni-asset-dirs", () => ({
|
||||
getFurniAssetDirs: async () => ({ iconDir: "/icons" }),
|
||||
}));
|
||||
vi.mock("@/lib/utils/runtime-path", () => ({}));
|
||||
vi.mock("./catalog-repair", () => ({}));
|
||||
vi.mock("./furni-data", () => ({}));
|
||||
vi.mock("./furni-import", () => ({
|
||||
ensureDirectories: mocks.ensureDirectories,
|
||||
}));
|
||||
vi.mock("./repair-icons", () => ({ repairMissingIcons: mocks.repair }));
|
||||
vi.mock("./repair-nitros", () => ({}));
|
||||
|
||||
import { inspectLiveCatalogIntegrity } from "./catalog-audit";
|
||||
|
||||
const dialect = new MySqlDialect();
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.readdir.mockResolvedValue([]);
|
||||
mocks.execute.mockImplementation(async (query) => {
|
||||
const text = dialect.sqlToQuery(query).sql;
|
||||
if (text.includes("FROM items_base"))
|
||||
return [
|
||||
[{ id: 1, item_name: "chair", public_name: "Chair", type: "s" }],
|
||||
[],
|
||||
];
|
||||
if (text.includes("FROM catalog_pages"))
|
||||
return [[{ id: 1, parentId: -1, caption: "Root" }], []];
|
||||
return [
|
||||
[{ id: 8, pageId: "1", itemIds: "1", name: "Chair", offerId: null }],
|
||||
[],
|
||||
];
|
||||
});
|
||||
});
|
||||
describe("read-only integrity audit", () => {
|
||||
it("uses BC-specific pages and offers and never invokes directory creation or repair", async () => {
|
||||
const result = await inspectLiveCatalogIntegrity("bc");
|
||||
const queries = mocks.execute.mock.calls.map(
|
||||
([query]) => dialect.sqlToQuery(query).sql,
|
||||
);
|
||||
expect(queries.every((query) => query.startsWith("SELECT"))).toBe(true);
|
||||
expect(queries[0]).toContain("FROM catalog_pages_bc");
|
||||
expect(queries[2]).toContain("NULL AS offerId FROM catalog_items_bc");
|
||||
expect(result.counts.missing_icon).toBe(1);
|
||||
expect(result.counts.missing_page).toBeUndefined();
|
||||
expect(mocks.ensureDirectories).not.toHaveBeenCalled();
|
||||
expect(mocks.repair).not.toHaveBeenCalled();
|
||||
});
|
||||
it("distinguishes unavailable icon storage from confirmed missing files", async () => {
|
||||
mocks.readdir.mockRejectedValue(new Error("permission denied"));
|
||||
const result = await inspectLiveCatalogIntegrity("normal");
|
||||
expect(result.iconsAvailable).toBe(false);
|
||||
expect(result.counts.missing_icon).toBeUndefined();
|
||||
});
|
||||
it("does not turn a database failure into a healthy report", async () => {
|
||||
mocks.execute.mockRejectedValue(new Error("database unavailable"));
|
||||
await expect(inspectLiveCatalogIntegrity("normal")).rejects.toThrow(
|
||||
"database unavailable",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type IntegrityPage,
|
||||
inspectCatalogIntegrity,
|
||||
planParentRepairs,
|
||||
} from "./catalog-integrity";
|
||||
|
||||
const pages: IntegrityPage[] = [
|
||||
{ id: 1, parentId: -1, caption: "Root" },
|
||||
{ id: 2, parentId: 999, caption: "Missing parent" },
|
||||
{ id: 3, parentId: 2, caption: "Child" },
|
||||
{ id: 4, parentId: 5, caption: "Cycle A" },
|
||||
{ id: 5, parentId: 4, caption: "Cycle B" },
|
||||
{ id: 6, parentId: 0, caption: "Root zero" },
|
||||
];
|
||||
describe("catalog integrity", () => {
|
||||
it("distinguishes missing-parent descendants, cycles and valid roots without proposing destructive fixes", () => {
|
||||
const result = inspectCatalogIntegrity({
|
||||
catalog: "normal",
|
||||
pages,
|
||||
items: [],
|
||||
offers: [],
|
||||
icons: new Set(),
|
||||
});
|
||||
expect(result.counts).toEqual({ unreachable_page: 2, category_cycle: 2 });
|
||||
expect(planParentRepairs(pages)).toEqual([
|
||||
{ id: 2, caption: "Missing parent", from: 999, to: -1 },
|
||||
]);
|
||||
expect(pages[1].parentId).toBe(999);
|
||||
});
|
||||
it("reports empty/malformed item lists, missing furniture and repeated offer IDs", () => {
|
||||
const result = inspectCatalogIntegrity({
|
||||
catalog: "normal",
|
||||
pages,
|
||||
items: [],
|
||||
icons: null,
|
||||
offers: [
|
||||
{
|
||||
id: 10,
|
||||
pageId: 1,
|
||||
itemIds: "2;garbage;0;3.5",
|
||||
name: "Bundle",
|
||||
offerId: 8,
|
||||
},
|
||||
{ id: 11, pageId: 999, itemIds: "", name: "Broken", offerId: 8 },
|
||||
],
|
||||
});
|
||||
expect(result.counts.missing_furniture).toBe(1);
|
||||
expect(result.counts.invalid_item_ids).toBe(2);
|
||||
expect(result.counts.duplicate_offer_id).toBe(2);
|
||||
expect(result.counts.missing_page).toBe(1);
|
||||
expect(result.iconsAvailable).toBe(false);
|
||||
expect(
|
||||
result.issues.find((issue) => issue.code === "missing_furniture")?.detail,
|
||||
).toBe("2");
|
||||
});
|
||||
it("checks actual icon candidates, excludes special item kinds and preserves BC detail paths", () => {
|
||||
const result = inspectCatalogIntegrity({
|
||||
catalog: "bc",
|
||||
pages: [pages[1]],
|
||||
offers: [],
|
||||
icons: new Set(["chair_icon.png"]),
|
||||
items: [
|
||||
{ id: 1, item_name: "chair", public_name: "Chair", type: "s" },
|
||||
{ id: 2, item_name: "table", public_name: "Table", type: "s" },
|
||||
{ id: 3, item_name: "badge", public_name: "Badge", type: "b" },
|
||||
],
|
||||
});
|
||||
expect(result.counts.missing_icon).toBe(1);
|
||||
expect(result.issues[0].href).toBe("/admin/catalog/builder-club/2");
|
||||
});
|
||||
it("preserves default and special offer IDs plus intentionally unlisted placements", () => {
|
||||
const result = inspectCatalogIntegrity({
|
||||
catalog: "normal",
|
||||
pages: [],
|
||||
icons: null,
|
||||
items: [{ id: 1, item_name: "chair", public_name: "Chair", type: "s" }],
|
||||
offers: [-1, 0, -10, -2147483648].map((offerId, index) => ({
|
||||
id: index + 1,
|
||||
pageId: index % 2 ? 0 : -1,
|
||||
itemIds: "001",
|
||||
name: "System offer",
|
||||
offerId,
|
||||
})),
|
||||
});
|
||||
expect(result.issues).toEqual([]);
|
||||
expect(result.counts.invalid_offer_id).toBeUndefined();
|
||||
expect(result.counts.duplicate_offer_id).toBeUndefined();
|
||||
expect(result.counts.missing_page).toBeUndefined();
|
||||
});
|
||||
it("still detects malformed offer IDs and missing positive category references", () => {
|
||||
const result = inspectCatalogIntegrity({
|
||||
catalog: "normal",
|
||||
pages: [],
|
||||
icons: null,
|
||||
items: [{ id: 1, item_name: "chair", public_name: "Chair", type: "s" }],
|
||||
offers: [1.5, 2147483648].map((offerId, index) => ({
|
||||
id: index + 1,
|
||||
pageId: 999,
|
||||
itemIds: "1",
|
||||
name: "Broken",
|
||||
offerId,
|
||||
})),
|
||||
});
|
||||
expect(result.counts.invalid_offer_id).toBe(2);
|
||||
expect(result.counts.missing_page).toBe(2);
|
||||
});
|
||||
|
||||
it("handles deep catalog paths iteratively and caps the returned issue detail", () => {
|
||||
const deep = Array.from({ length: 15000 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
caption: "Page",
|
||||
parentId: i === 14999 ? 99999 : i + 2,
|
||||
}));
|
||||
const result = inspectCatalogIntegrity({
|
||||
catalog: "normal",
|
||||
pages: deep,
|
||||
items: [],
|
||||
offers: [],
|
||||
icons: null,
|
||||
});
|
||||
expect(result.total).toBe(15000);
|
||||
expect(result.issues).toHaveLength(200);
|
||||
expect(planParentRepairs(deep)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
offerInteger,
|
||||
parseFurnitureIds,
|
||||
} from "@/features/catalog/domain/offer-input";
|
||||
import {
|
||||
assetNameCandidates,
|
||||
classifyCatalogItem,
|
||||
NO_ASSET_KINDS,
|
||||
} from "./item-kind";
|
||||
|
||||
export type IntegrityCatalog = "normal" | "bc";
|
||||
export interface IntegrityPage {
|
||||
id: number;
|
||||
parentId: number;
|
||||
caption: string;
|
||||
}
|
||||
export interface IntegrityItem {
|
||||
id: number;
|
||||
item_name: string;
|
||||
public_name: string;
|
||||
type: string;
|
||||
interaction_type?: string | null;
|
||||
}
|
||||
export interface IntegrityOffer {
|
||||
id: number;
|
||||
pageId: number;
|
||||
itemIds: string;
|
||||
name: string;
|
||||
offerId: number | null;
|
||||
}
|
||||
export type IntegrityCode =
|
||||
| "missing_furniture"
|
||||
| "invalid_item_ids"
|
||||
| "missing_page"
|
||||
| "unreachable_page"
|
||||
| "category_cycle"
|
||||
| "missing_icon"
|
||||
| "invalid_offer_id"
|
||||
| "duplicate_offer_id";
|
||||
export interface IntegrityIssue {
|
||||
code: IntegrityCode;
|
||||
id: number;
|
||||
name: string;
|
||||
detail: string;
|
||||
href: string;
|
||||
}
|
||||
export interface ParentRepair {
|
||||
id: number;
|
||||
caption: string;
|
||||
from: number;
|
||||
to: -1;
|
||||
}
|
||||
|
||||
export function integrityCatalog(value: unknown): IntegrityCatalog {
|
||||
if (value !== "normal" && value !== "bc") throw new Error("Invalid catalog");
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Only missing parents have an unambiguous, record-preserving repair. */
|
||||
export function planParentRepairs(pages: IntegrityPage[]): ParentRepair[] {
|
||||
const ids = new Set(pages.map((page) => page.id));
|
||||
return pages
|
||||
.filter((page) => page.parentId > 0 && !ids.has(page.parentId))
|
||||
.map((page) => ({
|
||||
id: page.id,
|
||||
caption: page.caption,
|
||||
from: page.parentId,
|
||||
to: -1 as const,
|
||||
}))
|
||||
.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
export function inspectCatalogIntegrity(input: {
|
||||
catalog: IntegrityCatalog;
|
||||
pages: IntegrityPage[];
|
||||
items: IntegrityItem[];
|
||||
offers: IntegrityOffer[];
|
||||
icons: Set<string> | null;
|
||||
}): {
|
||||
issues: IntegrityIssue[];
|
||||
counts: Partial<Record<IntegrityCode, number>>;
|
||||
total: number;
|
||||
iconsAvailable: boolean;
|
||||
} {
|
||||
const issues: IntegrityIssue[] = [];
|
||||
const pageMap = new Map(input.pages.map((page) => [page.id, page]));
|
||||
const items = new Set(input.items.map((item) => item.id));
|
||||
const base =
|
||||
input.catalog === "bc" ? "/admin/catalog/builder-club" : "/admin/catalog";
|
||||
const state = new Map<
|
||||
number,
|
||||
"reachable" | "unreachable_page" | "category_cycle"
|
||||
>();
|
||||
// Iterative traversal avoids stack overflow on deep imported catalogs.
|
||||
for (const page of input.pages) {
|
||||
const chain: number[] = [];
|
||||
const visiting = new Set<number>();
|
||||
let current = page.id;
|
||||
let result: "reachable" | "unreachable_page" | "category_cycle" =
|
||||
"reachable";
|
||||
while (current > 0) {
|
||||
const known = state.get(current);
|
||||
if (known) {
|
||||
result = known;
|
||||
break;
|
||||
}
|
||||
if (visiting.has(current)) {
|
||||
result = "category_cycle";
|
||||
break;
|
||||
}
|
||||
const node = pageMap.get(current);
|
||||
if (!node) {
|
||||
result = "unreachable_page";
|
||||
break;
|
||||
}
|
||||
visiting.add(current);
|
||||
chain.push(current);
|
||||
current = node.parentId;
|
||||
}
|
||||
for (const id of chain) state.set(id, result);
|
||||
}
|
||||
for (const page of input.pages) {
|
||||
const code = state.get(page.id);
|
||||
if (code && code !== "reachable")
|
||||
issues.push({
|
||||
code,
|
||||
id: page.id,
|
||||
name: page.caption,
|
||||
detail: String(page.parentId),
|
||||
href: `${base}/${page.id}`,
|
||||
});
|
||||
}
|
||||
const offerIds = new Map<number, number>();
|
||||
for (const offer of input.offers)
|
||||
if (offer.offerId !== null && offer.offerId > 0)
|
||||
offerIds.set(offer.offerId, (offerIds.get(offer.offerId) ?? 0) + 1);
|
||||
for (const offer of input.offers) {
|
||||
const href = pageMap.has(offer.pageId)
|
||||
? `${base}/${offer.pageId}`
|
||||
: `${base}?view=table&search=${offer.id}`;
|
||||
const add = (code: IntegrityCode, detail: string) => {
|
||||
issues.push({ code, id: offer.id, name: offer.name, detail, href });
|
||||
};
|
||||
// Non-positive placement IDs may represent unlisted/system offers. Only missing positive category references are actionable.
|
||||
if (offer.pageId > 0 && !pageMap.has(offer.pageId))
|
||||
add("missing_page", String(offer.pageId));
|
||||
const tokens = String(offer.itemIds ?? "")
|
||||
.split(";")
|
||||
.map((token) => token.trim());
|
||||
try {
|
||||
parseFurnitureIds(String(offer.itemIds ?? ""));
|
||||
} catch {
|
||||
add("invalid_item_ids", String(offer.itemIds ?? ""));
|
||||
}
|
||||
|
||||
const missing = [
|
||||
...new Set(
|
||||
tokens.filter(
|
||||
(token) =>
|
||||
/^\d+$/.test(token) &&
|
||||
Number(token) > 0 &&
|
||||
Number.isSafeInteger(Number(token)) &&
|
||||
!items.has(Number(token)),
|
||||
),
|
||||
),
|
||||
];
|
||||
if (missing.length) add("missing_furniture", missing.join(";"));
|
||||
// Match the editor contract: zero and signed special values (including default -1) survive unchanged.
|
||||
if (
|
||||
offer.offerId !== null &&
|
||||
!offerInteger.safeParse(offer.offerId).success
|
||||
)
|
||||
add("invalid_offer_id", String(offer.offerId));
|
||||
else if (offer.offerId !== null && (offerIds.get(offer.offerId) ?? 0) > 1)
|
||||
add("duplicate_offer_id", String(offer.offerId));
|
||||
}
|
||||
if (input.icons)
|
||||
for (const item of input.items) {
|
||||
if (NO_ASSET_KINDS.has(classifyCatalogItem(item))) continue;
|
||||
if (
|
||||
!assetNameCandidates(item.item_name, item.public_name).icon.some(
|
||||
(name) => input.icons?.has(name),
|
||||
)
|
||||
)
|
||||
issues.push({
|
||||
code: "missing_icon",
|
||||
id: item.id,
|
||||
name: item.public_name || item.item_name,
|
||||
detail: item.item_name,
|
||||
href: `/admin/items/${item.id}`,
|
||||
});
|
||||
}
|
||||
const counts: Partial<Record<IntegrityCode, number>> = {};
|
||||
for (const issue of issues)
|
||||
counts[issue.code] = (counts[issue.code] ?? 0) + 1;
|
||||
return {
|
||||
issues: issues.slice(0, 200),
|
||||
counts,
|
||||
total: issues.length,
|
||||
iconsAvailable: input.icons !== null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
values: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
AdminAuditLog: "audit",
|
||||
db: { execute: mocks.execute, transaction: mocks.transaction },
|
||||
}));
|
||||
vi.mock("@/lib/services/furni-data", () => ({}));
|
||||
vi.mock("@/lib/services/furni-import", () => ({}));
|
||||
|
||||
import {
|
||||
applyCatalogParentRepair,
|
||||
previewCatalogParentRepair,
|
||||
} from "./catalog-repair";
|
||||
|
||||
const dialect = new MySqlDialect();
|
||||
let pages: { id: number; parentId: number; caption: string }[];
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pages = [
|
||||
{ id: 1, parentId: 999, caption: "Orphan" },
|
||||
{ id: 2, parentId: -1, caption: "Root" },
|
||||
];
|
||||
mocks.execute.mockImplementation(async (query) =>
|
||||
dialect.sqlToQuery(query).sql.startsWith("SELECT")
|
||||
? [pages, []]
|
||||
: [{ affectedRows: 1 }, []],
|
||||
);
|
||||
mocks.insert.mockReturnValue({ values: mocks.values });
|
||||
mocks.values.mockResolvedValue(undefined);
|
||||
mocks.transaction.mockImplementation((callback) =>
|
||||
callback({ execute: mocks.execute, insert: mocks.insert }),
|
||||
);
|
||||
});
|
||||
describe("catalog parent repair", () => {
|
||||
it("previews without writes, locks the graph and records each change inside the transaction", async () => {
|
||||
const preview = await previewCatalogParentRepair("normal");
|
||||
expect(preview.changes).toEqual([
|
||||
{ id: 1, caption: "Orphan", from: 999, to: -1 },
|
||||
]);
|
||||
expect(mocks.transaction).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await applyCatalogParentRepair("normal", preview.fingerprint, 7),
|
||||
).toEqual({ applied: 1 });
|
||||
const queries = mocks.execute.mock.calls.map(([query]) =>
|
||||
dialect.sqlToQuery(query),
|
||||
);
|
||||
expect(queries[1].sql).toContain("FOR UPDATE");
|
||||
expect(queries[2].sql).toContain("UPDATE catalog_pages SET parent_id");
|
||||
expect(queries[2].params).toEqual([-1, 1, 999]);
|
||||
expect(queries.some((query) => /DELETE|INSERT/.test(query.sql))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(mocks.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
targetId: 1,
|
||||
action: "catalog_parent_repair",
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("rejects a stale preview before any mutation when the graph changed", async () => {
|
||||
const preview = await previewCatalogParentRepair("normal");
|
||||
pages = [...pages, { id: 999, parentId: -1, caption: "Restored" }];
|
||||
await expect(
|
||||
applyCatalogParentRepair("normal", preview.fingerprint, 7),
|
||||
).rejects.toMatchObject({ name: "CatalogPreviewConflict" });
|
||||
expect(mocks.insert).not.toHaveBeenCalled();
|
||||
expect(mocks.execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("binds previews to the selected catalog and uses BC tables for BC repairs", async () => {
|
||||
const preview = await previewCatalogParentRepair("bc");
|
||||
await expect(
|
||||
applyCatalogParentRepair("normal", preview.fingerprint, 7),
|
||||
).rejects.toMatchObject({ name: "CatalogPreviewConflict" });
|
||||
await applyCatalogParentRepair("bc", preview.fingerprint, 7);
|
||||
expect(
|
||||
dialect.sqlToQuery(mocks.execute.mock.calls.at(-1)?.[0]).sql,
|
||||
).toContain("UPDATE catalog_pages_bc");
|
||||
});
|
||||
it("propagates audit failure so the transaction rolls back, instead of claiming success", async () => {
|
||||
const preview = await previewCatalogParentRepair("normal");
|
||||
mocks.values.mockRejectedValue(new Error("Audit unavailable"));
|
||||
await expect(
|
||||
applyCatalogParentRepair("normal", preview.fingerprint, 7),
|
||||
).rejects.toThrow("Audit unavailable");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { AdminAuditLog, db } from "@/lib/db";
|
||||
import {
|
||||
readFurniData,
|
||||
withFurniDataLock,
|
||||
@@ -784,3 +785,89 @@ export async function repairFurniData(
|
||||
return { added: added.length, removedDuplicates, removedIdConflicts };
|
||||
});
|
||||
}
|
||||
|
||||
export interface CatalogParentRepairPreview {
|
||||
fingerprint: string;
|
||||
catalog: "normal" | "bc";
|
||||
changes: import("./catalog-integrity").ParentRepair[];
|
||||
}
|
||||
|
||||
function parentStateFingerprint(
|
||||
catalog: "normal" | "bc",
|
||||
pages: import("./catalog-integrity").IntegrityPage[],
|
||||
) {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify({ catalog, pages }))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export async function previewCatalogParentRepair(
|
||||
catalog: "normal" | "bc",
|
||||
): Promise<CatalogParentRepairPreview> {
|
||||
const { integrityCatalog, planParentRepairs } = await import(
|
||||
"./catalog-integrity"
|
||||
);
|
||||
integrityCatalog(catalog);
|
||||
const table = sql.raw(
|
||||
catalog === "bc" ? "catalog_pages_bc" : "catalog_pages",
|
||||
);
|
||||
const [rows] = await db.execute(
|
||||
sql`SELECT id, parent_id AS parentId, caption FROM ${table} ORDER BY id`,
|
||||
);
|
||||
const pages =
|
||||
rows as unknown as import("./catalog-integrity").IntegrityPage[];
|
||||
return {
|
||||
catalog,
|
||||
fingerprint: parentStateFingerprint(catalog, pages),
|
||||
changes: planParentRepairs(pages),
|
||||
};
|
||||
}
|
||||
|
||||
/** Locks and revalidates the complete parent graph before making the previewed changes. */
|
||||
export async function applyCatalogParentRepair(
|
||||
catalog: "normal" | "bc",
|
||||
fingerprint: string,
|
||||
userId: number,
|
||||
): Promise<{ applied: number }> {
|
||||
const { integrityCatalog, planParentRepairs } = await import(
|
||||
"./catalog-integrity"
|
||||
);
|
||||
integrityCatalog(catalog);
|
||||
if (!/^[a-f0-9]{64}$/.test(fingerprint))
|
||||
throw new Error("Invalid repair preview");
|
||||
const tableName = catalog === "bc" ? "catalog_pages_bc" : "catalog_pages";
|
||||
const table = sql.raw(tableName);
|
||||
return db.transaction(async (tx) => {
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT id, parent_id AS parentId, caption FROM ${table} ORDER BY id FOR UPDATE`,
|
||||
);
|
||||
const pages =
|
||||
rows as unknown as import("./catalog-integrity").IntegrityPage[];
|
||||
if (parentStateFingerprint(catalog, pages) !== fingerprint) {
|
||||
const error = new Error("Catalog changed since preview");
|
||||
error.name = "CatalogPreviewConflict";
|
||||
throw error;
|
||||
}
|
||||
const changes = planParentRepairs(pages);
|
||||
for (const change of changes) {
|
||||
await tx.execute(
|
||||
sql`UPDATE ${table} SET parent_id = ${change.to} WHERE id = ${change.id} AND parent_id = ${change.from}`,
|
||||
);
|
||||
await tx
|
||||
.insert(AdminAuditLog)
|
||||
.values({
|
||||
userId,
|
||||
action: "catalog_parent_repair",
|
||||
target: tableName,
|
||||
targetId: change.id,
|
||||
before: JSON.stringify({ parentId: change.from }),
|
||||
after: JSON.stringify({ parentId: change.to }),
|
||||
diff: JSON.stringify({
|
||||
parentId: { from: change.from, to: change.to },
|
||||
}),
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return { applied: changes.length };
|
||||
});
|
||||
}
|
||||
@@ -100,3 +100,104 @@ it("loads bounded history for the requesting owner", async () => {
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((job) => job.userId === 4)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps history pages stable while an older job changes", async () => {
|
||||
const s = await store();
|
||||
const older = { ...job(), createdAt: "2026-01-01T00:00:00.000Z" };
|
||||
const newer = { ...job(), createdAt: "2026-01-02T00:00:00.000Z" };
|
||||
await s.create(older);
|
||||
await s.create(newer);
|
||||
const first = await s.page({ userId: 4, limit: 1 });
|
||||
expect(first.jobs.map((j) => j.id)).toEqual([newer.id]);
|
||||
await s.save({ ...older, state: "completed" });
|
||||
const second = await s.page({
|
||||
userId: 4,
|
||||
limit: 1,
|
||||
before: first.nextCursor ?? undefined,
|
||||
});
|
||||
expect(second.jobs.map((j) => j.id)).toEqual([older.id]);
|
||||
expect(second.nextCursor).toBeNull();
|
||||
});
|
||||
it("creates a single retry across concurrent requests and excludes successful items", async () => {
|
||||
const s = await store();
|
||||
const original = {
|
||||
...job(),
|
||||
state: "completed" as const,
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
classname: "chair",
|
||||
name: "Chair",
|
||||
description: "",
|
||||
type: "flooritem",
|
||||
revision: 1,
|
||||
category: "",
|
||||
state: "failed" as const,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
classname: "lamp",
|
||||
name: "Lamp",
|
||||
description: "",
|
||||
type: "flooritem",
|
||||
revision: 1,
|
||||
category: "",
|
||||
state: "done" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
await s.create(original);
|
||||
const [a, b] = await Promise.all([
|
||||
s.retry(original.id, 4),
|
||||
s.retry(original.id, 4),
|
||||
]);
|
||||
expect(a?.id).toBe(b?.id);
|
||||
expect(a?.items.map((i) => i.classname)).toEqual(["chair"]);
|
||||
expect(await s.list()).toHaveLength(2);
|
||||
expect(await s.retry(original.id, 99)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not retry active jobs or uncertain outcomes", async () => {
|
||||
const s = await store();
|
||||
const original = {
|
||||
...job(),
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
classname: "chair",
|
||||
name: "Chair",
|
||||
description: "",
|
||||
type: "flooritem",
|
||||
revision: 1,
|
||||
category: "",
|
||||
state: "interrupted" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
await s.create(original);
|
||||
expect(await s.retry(original.id, 4)).toBeNull();
|
||||
await s.save({ ...original, state: "interrupted" });
|
||||
expect(await s.retry(original.id, 4)).toBeNull();
|
||||
expect(await s.list()).toHaveLength(1);
|
||||
});
|
||||
it("paginates timestamp ties without duplicates and excludes other owners", async () => {
|
||||
const s = await store();
|
||||
const createdAt = "2026-01-01T00:00:00.000Z";
|
||||
const own = [
|
||||
{ ...job(), createdAt },
|
||||
{ ...job(), createdAt },
|
||||
{ ...job(), createdAt },
|
||||
];
|
||||
for (const entry of [...own, { ...job(), createdAt, userId: 99 }])
|
||||
await s.create(entry);
|
||||
const first = await s.page({ userId: 4, limit: 2 });
|
||||
const second = await s.page({
|
||||
userId: 4,
|
||||
limit: 2,
|
||||
before: first.nextCursor ?? undefined,
|
||||
});
|
||||
expect(new Set([...first.jobs, ...second.jobs].map((j) => j.id))).toEqual(
|
||||
new Set(own.map((j) => j.id)),
|
||||
);
|
||||
expect(first.jobs.length + second.jobs.length).toBe(3);
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ImportJob } from "@/lib/furni/import-job";
|
||||
import { retryableJobItems } from "@/lib/furni/import-job-retry";
|
||||
export const importRoot = () =>
|
||||
path.join(process.cwd(), "storage", "furniture-imports");
|
||||
export const validJobId = (value: unknown): value is string =>
|
||||
@@ -73,6 +74,51 @@ export class ImportJobStore {
|
||||
});
|
||||
return this.read(id);
|
||||
}
|
||||
async retry(id: string, userId: number): Promise<ImportJob | null> {
|
||||
const original = await this.read(id).catch((error) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!original || original.userId !== userId) return null;
|
||||
const items = retryableJobItems(original);
|
||||
if (!items.length) return null;
|
||||
// A parent produces one durable child, including concurrent and uncertain responses.
|
||||
const hex = createHash("sha256").update(`retry:${id}`).digest("hex");
|
||||
const retryId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
||||
const now = new Date().toISOString();
|
||||
return this.create({
|
||||
...original,
|
||||
id: retryId,
|
||||
retryOf: id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
state: "queued",
|
||||
cancelRequested: false,
|
||||
items: items.map(
|
||||
({ error: _error, warnings: _warnings, itemId: _itemId, ...item }) => ({
|
||||
...item,
|
||||
state: "pending",
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
async page(options: { userId: number; limit: number; before?: string }) {
|
||||
const limit = Math.min(30, Math.max(1, options.limit));
|
||||
const key = (job: ImportJob) => `${job.createdAt}|${job.id}`;
|
||||
const jobs = (await this.list())
|
||||
.filter(
|
||||
(job) =>
|
||||
job.userId === options.userId &&
|
||||
(!options.before || key(job) < options.before),
|
||||
)
|
||||
.sort((a, b) => key(b).localeCompare(key(a)));
|
||||
const page = jobs.slice(0, limit);
|
||||
return {
|
||||
jobs: page,
|
||||
nextCursor: jobs.length > limit ? key(page[page.length - 1]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async list(options?: {
|
||||
userId: number;
|
||||
limit: number;
|
||||
|
||||
+140
-3
@@ -6,7 +6,8 @@
|
||||
"pages": {
|
||||
"admin": {
|
||||
"nav": {
|
||||
"studio": "Studio"
|
||||
"studio": "Studio",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"logs": {
|
||||
"audit": {
|
||||
@@ -22,6 +23,51 @@
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cmsErrors": {
|
||||
@@ -36,7 +82,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -76,7 +132,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"articles": {
|
||||
"form": {
|
||||
@@ -217,6 +277,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"catalogPackages": {
|
||||
@@ -371,6 +465,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
|
||||
+140
-3
@@ -916,7 +916,8 @@
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu"
|
||||
"adminMenu": "Admin menu",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Табло за управление",
|
||||
@@ -1597,6 +1598,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2303,6 +2338,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3200,7 +3280,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3240,7 +3330,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3325,6 +3419,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -916,7 +916,8 @@
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu"
|
||||
"adminMenu": "Admin menu",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1597,6 +1598,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2303,6 +2338,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3200,7 +3280,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3240,7 +3330,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3325,6 +3419,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -916,7 +916,8 @@
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu"
|
||||
"adminMenu": "Admin menu",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1597,6 +1598,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2303,6 +2338,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3200,7 +3280,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3240,7 +3330,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3325,6 +3419,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -887,7 +887,8 @@
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu",
|
||||
"studio": "Studio"
|
||||
"studio": "Studio",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1568,6 +1569,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2274,6 +2309,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3171,7 +3251,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3211,7 +3301,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3296,6 +3390,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
+140
-3
@@ -916,7 +916,8 @@
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu"
|
||||
"adminMenu": "Admin menu",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Ταμπλό",
|
||||
@@ -1597,6 +1598,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2303,6 +2338,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3200,7 +3280,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3240,7 +3330,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3325,6 +3419,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -1103,7 +1103,8 @@
|
||||
"cmsErrors": "CMS errors",
|
||||
"themeBuilder": "Theme builder",
|
||||
"openSidebar": "Open menu",
|
||||
"closeSidebar": "Close menu"
|
||||
"closeSidebar": "Close menu",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -2257,6 +2258,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -3217,6 +3252,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hubs": {
|
||||
@@ -3991,7 +4071,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"importHistory": {
|
||||
"summary": "Import history · {count} active",
|
||||
@@ -4016,7 +4106,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -4119,6 +4213,49 @@
|
||||
"specialPricing": "Special pricing: {credits} credits, {points} points (type {type})",
|
||||
"restrictedOffers": "{count} offers in this category require Club membership.",
|
||||
"clubOffer": "Club offer"
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"mod": {
|
||||
|
||||
+140
-3
@@ -887,7 +887,8 @@
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu",
|
||||
"studio": "Studio"
|
||||
"studio": "Studio",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
@@ -1568,6 +1569,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2274,6 +2309,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3171,7 +3251,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3211,7 +3301,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3296,6 +3390,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
+140
-3
@@ -6,7 +6,8 @@
|
||||
"pages": {
|
||||
"admin": {
|
||||
"nav": {
|
||||
"studio": "Studio"
|
||||
"studio": "Studio",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"logs": {
|
||||
"audit": {
|
||||
@@ -22,6 +23,51 @@
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cmsErrors": {
|
||||
@@ -36,7 +82,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -76,7 +132,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"articles": {
|
||||
"form": {
|
||||
@@ -217,6 +277,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"catalogPackages": {
|
||||
@@ -371,6 +465,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
|
||||
+140
-3
@@ -887,7 +887,8 @@
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu",
|
||||
"studio": "Studio"
|
||||
"studio": "Studio",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -1568,6 +1569,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2274,6 +2309,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3171,7 +3251,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3211,7 +3301,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3296,6 +3390,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
+140
-3
@@ -916,7 +916,8 @@
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu"
|
||||
"adminMenu": "Admin menu",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Nadzorna ploča",
|
||||
@@ -1597,6 +1598,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2303,6 +2338,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3200,7 +3280,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3240,7 +3330,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3325,6 +3419,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -916,7 +916,8 @@
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu"
|
||||
"adminMenu": "Admin menu",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Irányítópult",
|
||||
@@ -1597,6 +1598,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2303,6 +2338,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3200,7 +3280,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3240,7 +3330,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3325,6 +3419,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -1077,7 +1077,8 @@
|
||||
"guilds": "Gruppi",
|
||||
"shop": "Negozio",
|
||||
"hint": "Inizia a scrivere per cercare tra utenti, articoli, stanze, gruppi e articoli del negozio"
|
||||
}
|
||||
},
|
||||
"operationsCenter": "Centro operazioni"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -2176,6 +2177,40 @@
|
||||
"acceptBulk": "Accetta prezzi nella bozza",
|
||||
"previous": "Precedente",
|
||||
"next": "Successiva"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Integrità del catalogo",
|
||||
"description": "Controlla riferimenti, percorsi delle categorie, identificativi e icone dei furni. Il controllo non modifica i record.",
|
||||
"catalog": "Catalogo",
|
||||
"normal": "Catalogo normale",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Ripeti controllo",
|
||||
"working": "Controllo in corso…",
|
||||
"failed": "Non è stato possibile completare l’operazione. Riprova.",
|
||||
"stalePreview": "Il catalogo è cambiato dopo l’anteprima. Genera una nuova anteprima prima di applicare le correzioni.",
|
||||
"applied": "{count} categorie ricollegate alla radice del catalogo.",
|
||||
"iconsUnavailable": "Impossibile leggere la cartella delle icone. La disponibilità delle icone non è stata verificata.",
|
||||
"healthy": "Nessun problema rilevato nei controlli completati.",
|
||||
"total": "Rilevati {count} problemi. Visualizzati {shown}.",
|
||||
"open": "Apri",
|
||||
"reference": "Riferimento: {value}",
|
||||
"repairHint": "Visualizza le categorie il cui genitore non esiste più. L’applicazione sposta solo queste categorie alla radice e conserva tutti i record. Cicli, furni mancanti, icone e conflitti di identificativi richiedono una verifica individuale.",
|
||||
"preview": "Anteprima correzioni sicure",
|
||||
"planned": "Verranno spostate {count} categorie:",
|
||||
"parentChange": "genitore #{from} → radice del catalogo",
|
||||
"apply": "Applica le correzioni mostrate",
|
||||
"legacyTitle": "Azioni di manutenzione avanzata",
|
||||
"legacyHint": "Questi strumenti di manutenzione esistenti possono riscrivere identificativi e unire o rimuovere record duplicati. Sono separati dal controllo di integrità non distruttivo e dalla correzione dei genitori mostrata in anteprima.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offerta con furni mancanti",
|
||||
"invalid_item_ids": "Identificativi furni non validi",
|
||||
"missing_page": "Offerta con categoria mancante",
|
||||
"unreachable_page": "Categoria non raggiungibile",
|
||||
"category_cycle": "Percorso categoria con ciclo",
|
||||
"missing_icon": "Icona furni mancante",
|
||||
"invalid_offer_id": "Identificativo offerta non valido",
|
||||
"duplicate_offer_id": "Identificativo offerta ripetuto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -3136,6 +3171,51 @@
|
||||
"colStatus": "Stato",
|
||||
"statusUnknown": "sconosciuto",
|
||||
"searchPlaceholder": "Cerca per username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Anteprima ripristino",
|
||||
"title": "Ripristina una modifica registrata",
|
||||
"description": "Confronta i valori attuali con quelli precedenti prima di confermare. Vengono ripristinati solo impostazioni della categoria, prezzi dell’offerta o contenuto della notizia. Record eliminati e gerarchia delle categorie sono esclusi.",
|
||||
"loading": "Caricamento…",
|
||||
"unavailable": "La revisione non è disponibile oppure non hai il permesso di ripristinarla.",
|
||||
"conflict": "Questo record è cambiato dopo la modifica selezionata. Aggiorna e controlla lo storico più recente prima di ripristinare.",
|
||||
"failed": "Ripristino non completato. Ricarica la pagina e riprova.",
|
||||
"field": "Campo",
|
||||
"current": "Valore attuale",
|
||||
"restore": "Valore da ripristinare",
|
||||
"confirm": "Conferma ripristino",
|
||||
"restoring": "Ripristino in corso…",
|
||||
"fields": {
|
||||
"caption": "Nome categoria",
|
||||
"captionSave": "Nome interno categoria",
|
||||
"pageLayout": "Layout categoria",
|
||||
"enabled": "Abilitata",
|
||||
"visible": "Visibile",
|
||||
"clubOnly": "Solo membri Club",
|
||||
"vipOnly": "Solo membri VIP",
|
||||
"minRank": "Grado minimo",
|
||||
"iconImage": "Icona categoria",
|
||||
"iconColor": "Colore icona",
|
||||
"pageHeadline": "Immagine intestazione",
|
||||
"pageTeaser": "Immagine anteprima",
|
||||
"pageSpecial": "Immagine speciale",
|
||||
"pageText1": "Testo principale",
|
||||
"pageText2": "Testo secondario",
|
||||
"pageTextDetails": "Testo dettagli",
|
||||
"pageTextTeaser": "Testo anteprima",
|
||||
"includes": "Categorie incluse",
|
||||
"costCredits": "Prezzo in crediti",
|
||||
"costPoints": "Prezzo in punti",
|
||||
"pointsType": "Valuta dei punti",
|
||||
"title": "Titolo notizia",
|
||||
"slug": "Indirizzo notizia",
|
||||
"image": "Immagine notizia",
|
||||
"shortStory": "Sommario",
|
||||
"fullStory": "Contenuto articolo",
|
||||
"status": "Stato pubblicazione",
|
||||
"publishAt": "Pubblicazione programmata",
|
||||
"publishedAt": "Data pubblicazione"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3966,7 +4046,17 @@
|
||||
"clearAssignment": "Rimuovi assegnazione",
|
||||
"openOperation": "Apri area collegata",
|
||||
"releaseOccurrences": "Occorrenze per release",
|
||||
"releaseScope": "Queste release compaiono nei dati conservati. La prima occorrenza disponibile non dimostra quale release abbia introdotto l’errore."
|
||||
"releaseScope": "Queste release compaiono nei dati conservati. La prima occorrenza disponibile non dimostra quale release abbia introdotto l’errore.",
|
||||
"releaseFilter": "Versione",
|
||||
"allReleases": "Tutte le versioni",
|
||||
"period": "Periodo",
|
||||
"allRetained": "Tutti gli eventi conservati",
|
||||
"last24h": "Ultime 24 ore",
|
||||
"last7d": "Ultimi 7 giorni",
|
||||
"sort": "Ordine",
|
||||
"recentFirst": "Prima i più recenti",
|
||||
"frequentFirst": "Prima i più frequenti",
|
||||
"filterScope": "I conteggi riguardano gli eventi conservati che corrispondono ai filtri. Stato e dettagli più recenti del gruppo restano aggiornati tra le versioni."
|
||||
},
|
||||
"importHistory": {
|
||||
"summary": "Storico importazioni · {count} attive",
|
||||
@@ -3991,7 +4081,11 @@
|
||||
"refreshHistory": "Aggiorna cronologia",
|
||||
"showMoreItems": "Mostra altri 50 elementi",
|
||||
"uncertainOutcome": "Un elemento interrotto potrebbe avere già modificato i dati locali. È escluso dai tentativi successivi.",
|
||||
"reviewLocalData": "Controlla i dati locali"
|
||||
"reviewLocalData": "Controlla i dati locali",
|
||||
"ownerHistory": "Le tue importazioni e riparazioni, dalla più recente. Il retry non ripete elementi completati o dall’esito incerto.",
|
||||
"previousPage": "Precedente",
|
||||
"nextPage": "Successiva",
|
||||
"page": "Pagina {page}"
|
||||
},
|
||||
"installation": {
|
||||
"title": "Diagnostica installazione",
|
||||
@@ -4094,6 +4188,49 @@
|
||||
"specialPricing": "Prezzo speciale: {credits} crediti, {points} punti (tipo {type})",
|
||||
"restrictedOffers": "{count} offerte in questa categoria richiedono l’iscrizione al Club.",
|
||||
"clubOffer": "Offerta Club"
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Anteprima accessi del ruolo",
|
||||
"description": "Verifica gli accessi CMS senza modificare sessioni o impersonare utenti.",
|
||||
"back": "Torna ai permessi",
|
||||
"role": "Ruolo",
|
||||
"show": "Mostra accessi",
|
||||
"missing": "Seleziona un ruolo esistente.",
|
||||
"entryAllowed": "Accesso HK consentito",
|
||||
"entryDenied": "Accesso HK negato: manca il permesso dashboard",
|
||||
"superAdmin": "Questo grado riceve attualmente i privilegi del grado più alto.",
|
||||
"scope": "Questa anteprima considera un solo ruolo. Un utente può avere altri ruoli. La navigazione personalizzata può nascondere sezioni consentite; singoli dati e azioni possono richiedere ulteriori controlli.",
|
||||
"sections": "Sezioni",
|
||||
"allowed": "Consentito",
|
||||
"denied": "Negato",
|
||||
"actions": "Permessi CMS",
|
||||
"actionScope": "Un permesso concesso non supera i controlli di accesso HK, gerarchia dei gradi, proprietà dei dati o validazione della singola azione.",
|
||||
"granted": "Concesso",
|
||||
"notGranted": "Non concesso"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Centro operazioni",
|
||||
"hint": "Segui le tue importazioni e riparazioni persistenti e la coda condivisa di esportazione catalogo.",
|
||||
"syncTitle": "Sincronizzazione sorgenti",
|
||||
"syncHint": "La sincronizzazione delle sorgenti avviene nella sua pagina. Qui non dispone di cronologia persistente o ripetizione automatica degli elementi falliti. L’esportazione catalogo mostra coda condivisa e ultimo esito; non supporta retry dei singoli elementi.",
|
||||
"openSync": "Apri sincronizzazione ed esportazione",
|
||||
"openImports": "Apri importazioni furni"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Esportazione repository catalogo",
|
||||
"hint": "Esportazione automatica di bundle, icone, gamedata, traduzioni e SQL catalogo. Coda e ultimo esito sono condivisi tra operatori.",
|
||||
"loadError": "Impossibile caricare lo stato esportazione",
|
||||
"queueError": "Impossibile accodare l’esportazione",
|
||||
"loading": "Caricamento…",
|
||||
"disabled": "Esportazione disabilitata. Apri sincronizzazione per verificarne la configurazione.",
|
||||
"active": "In attesa del completamento di {count} operazioni.",
|
||||
"running": "Esportazione in corso…",
|
||||
"pending": "{count} aggiornamenti in coda per esportazione.",
|
||||
"idle": "Nessun aggiornamento in attesa.",
|
||||
"lastCommit": "Ultimo commit pubblicato:",
|
||||
"lastAttempt": "Ultimo tentativo: {date}",
|
||||
"queuing": "Accodamento…",
|
||||
"queue": "Esporta ora / riprova"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
+140
-3
@@ -6,7 +6,8 @@
|
||||
"pages": {
|
||||
"admin": {
|
||||
"nav": {
|
||||
"studio": "Studio"
|
||||
"studio": "Studio",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"logs": {
|
||||
"audit": {
|
||||
@@ -22,6 +23,51 @@
|
||||
"beforeValue": "Before",
|
||||
"afterValue": "After",
|
||||
"exportHint": "CSV exports the current filtered page, up to 100 records."
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cmsErrors": {
|
||||
@@ -36,7 +82,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -76,7 +132,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"articles": {
|
||||
"form": {
|
||||
@@ -217,6 +277,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"catalogPackages": {
|
||||
@@ -371,6 +465,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"myDashboard": {
|
||||
|
||||
+140
-3
@@ -1090,7 +1090,8 @@
|
||||
"guilds": "Groepen",
|
||||
"shop": "Winkel",
|
||||
"hint": "Typ om te zoeken naar gebruikers, artikelen, kamers, groepen en winkelartikelen."
|
||||
}
|
||||
},
|
||||
"operationsCenter": "Operatiecentrum"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -2033,6 +2034,40 @@
|
||||
"acceptBulk": "Prijzen overnemen in concept",
|
||||
"previous": "Vorige",
|
||||
"next": "Volgende"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalogusintegriteit",
|
||||
"description": "Controleer verwijzingen, categoriepaden, identificaties en meubeliconen. Een controle wijzigt geen records.",
|
||||
"catalog": "Catalogus",
|
||||
"normal": "Normale catalogus",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Opnieuw controleren",
|
||||
"working": "Controleren…",
|
||||
"failed": "De bewerking kon niet worden voltooid. Probeer opnieuw.",
|
||||
"stalePreview": "De catalogus is sinds het voorbeeld gewijzigd. Maak een nieuw voorbeeld voordat je reparaties toepast.",
|
||||
"applied": "{count} categorieën opnieuw aan de catalogushoofdmap gekoppeld.",
|
||||
"iconsUnavailable": "De map met iconen kon niet worden gelezen. De beschikbaarheid van iconen is niet gecontroleerd.",
|
||||
"healthy": "Geen problemen gevonden in de voltooide controles.",
|
||||
"total": "{count} problemen gevonden. {shown} weergegeven.",
|
||||
"open": "Openen",
|
||||
"reference": "Verwijzing: {value}",
|
||||
"repairHint": "Bekijk categorieën waarvan de bovenliggende categorie niet meer bestaat. Alleen deze categorieën worden naar de hoofdmap verplaatst; alle records blijven behouden. Cycli, ontbrekende meubels, iconen en conflicterende identificaties vereisen afzonderlijke controle.",
|
||||
"preview": "Veilige reparaties bekijken",
|
||||
"planned": "{count} categorieën worden verplaatst:",
|
||||
"parentChange": "bovenliggende categorie #{from} → catalogushoofdmap",
|
||||
"apply": "Getoonde reparaties toepassen",
|
||||
"legacyTitle": "Geavanceerd onderhoud",
|
||||
"legacyHint": "Deze bestaande onderhoudstools kunnen identificaties herschrijven en dubbele records samenvoegen of verwijderen. Ze staan los van de integriteitscontrole die niets verwijdert en de vooraf getoonde reparatie van bovenliggende categorieën.",
|
||||
"issues": {
|
||||
"missing_furniture": "Aanbieding verwijst naar ontbrekende meubels",
|
||||
"invalid_item_ids": "Ongeldige meubelidentificaties",
|
||||
"missing_page": "Aanbieding verwijst naar ontbrekende categorie",
|
||||
"unreachable_page": "Onbereikbare categorie",
|
||||
"category_cycle": "Categoriepad bevat een cyclus",
|
||||
"missing_icon": "Ontbrekend meubelicoon",
|
||||
"invalid_offer_id": "Ongeldige aanbiedingsidentificatie",
|
||||
"duplicate_offer_id": "Herhaalde aanbiedingsidentificatie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2782,6 +2817,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "onbekend",
|
||||
"searchPlaceholder": "Zoek op gebruikersnaam…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Herstel bekijken",
|
||||
"title": "Een opgeslagen wijziging herstellen",
|
||||
"description": "Vergelijk de huidige waarden met de vorige waarden voordat je bevestigt. Alleen categorie-instellingen, aanbiedingsprijzen of nieuwsinhoud worden hersteld. Verwijderde gegevens en de categoriehiërarchie kunnen hier niet worden hersteld.",
|
||||
"loading": "Laden…",
|
||||
"unavailable": "Deze revisie is niet beschikbaar of je hebt geen toestemming om deze te herstellen.",
|
||||
"conflict": "Dit record is na de geselecteerde wijziging aangepast. Vernieuw en bekijk de nieuwste geschiedenis voordat je herstelt.",
|
||||
"failed": "Het herstellen is mislukt. Vernieuw de pagina en probeer het opnieuw.",
|
||||
"field": "Veld",
|
||||
"current": "Huidige waarde",
|
||||
"restore": "Te herstellen waarde",
|
||||
"confirm": "Herstel bevestigen",
|
||||
"restoring": "Herstellen…",
|
||||
"fields": {
|
||||
"caption": "Categorienaam",
|
||||
"captionSave": "Interne categorienaam",
|
||||
"pageLayout": "Categorie-indeling",
|
||||
"enabled": "Ingeschakeld",
|
||||
"visible": "Zichtbaar",
|
||||
"clubOnly": "Alleen clubleden",
|
||||
"vipOnly": "Alleen VIP-leden",
|
||||
"minRank": "Minimale rang",
|
||||
"iconImage": "Categoriepictogram",
|
||||
"iconColor": "Pictogramkleur",
|
||||
"pageHeadline": "Kopafbeelding",
|
||||
"pageTeaser": "Voorbeeldafbeelding",
|
||||
"pageSpecial": "Speciale afbeelding",
|
||||
"pageText1": "Primaire tekst",
|
||||
"pageText2": "Secundaire tekst",
|
||||
"pageTextDetails": "Detailtekst",
|
||||
"pageTextTeaser": "Voorbeeldtekst",
|
||||
"includes": "Inbegrepen categorieën",
|
||||
"costCredits": "Prijs in credits",
|
||||
"costPoints": "Prijs in punten",
|
||||
"pointsType": "Puntenvaluta",
|
||||
"title": "Nieuwstitel",
|
||||
"slug": "Nieuwsadres",
|
||||
"image": "Nieuwsafbeelding",
|
||||
"shortStory": "Samenvatting",
|
||||
"fullStory": "Artikelinhoud",
|
||||
"status": "Publicatiestatus",
|
||||
"publishAt": "Geplande publicatie",
|
||||
"publishedAt": "Publicatiedatum"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3574,7 +3654,17 @@
|
||||
"clearAssignment": "Toewijzing verwijderen",
|
||||
"openOperation": "Gerelateerd onderdeel openen",
|
||||
"releaseOccurrences": "Gebeurtenissen per release",
|
||||
"releaseScope": "Deze releases komen voor in de bewaarde gegevens. De eerste beschikbare gebeurtenis bewijst niet welke release de fout heeft geïntroduceerd."
|
||||
"releaseScope": "Deze releases komen voor in de bewaarde gegevens. De eerste beschikbare gebeurtenis bewijst niet welke release de fout heeft geïntroduceerd.",
|
||||
"releaseFilter": "Versie",
|
||||
"allReleases": "Alle versies",
|
||||
"period": "Periode",
|
||||
"allRetained": "Alle bewaarde gebeurtenissen",
|
||||
"last24h": "Afgelopen 24 uur",
|
||||
"last7d": "Afgelopen 7 dagen",
|
||||
"sort": "Volgorde",
|
||||
"recentFirst": "Nieuwste eerst",
|
||||
"frequentFirst": "Meest voorkomend eerst",
|
||||
"filterScope": "Aantallen gelden voor passende bewaarde gebeurtenissen. Groepsstatus en nieuwste details blijven actueel over alle versies."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installatiediagnose",
|
||||
@@ -3614,7 +3704,11 @@
|
||||
"refreshHistory": "Geschiedenis vernieuwen",
|
||||
"showMoreItems": "Nog 50 items tonen",
|
||||
"uncertainOutcome": "Een onderbroken item heeft mogelijk al lokale gegevens gewijzigd. Het wordt niet opnieuw geprobeerd.",
|
||||
"reviewLocalData": "Lokale gegevens controleren"
|
||||
"reviewLocalData": "Lokale gegevens controleren",
|
||||
"ownerHistory": "Je imports en reparaties, nieuwste eerst. Herhalen voert voltooide of onzekere onderdelen nooit opnieuw uit.",
|
||||
"previousPage": "Vorige",
|
||||
"nextPage": "Volgende",
|
||||
"page": "Pagina {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Aandacht nodig",
|
||||
@@ -3708,6 +3802,49 @@
|
||||
"retry": "Opnieuw proberen",
|
||||
"description": "Zoek pagina’s, gebruikers, meubels, cataloguscategorieën, artikelen en supporttickets.",
|
||||
"partialError": "Sommige resultaten konden niet worden geladen. Probeer opnieuw."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Voorbeeld van roltoegang",
|
||||
"description": "Bekijk CMS-toegang zonder een sessie te wijzigen of een gebruiker over te nemen.",
|
||||
"back": "Terug naar rechten",
|
||||
"role": "Rol",
|
||||
"show": "Toegang bekijken",
|
||||
"missing": "Selecteer een bestaande rol.",
|
||||
"entryAllowed": "Toegang tot housekeeping toegestaan",
|
||||
"entryDenied": "Geen toegang tot housekeeping: dashboardrecht ontbreekt",
|
||||
"superAdmin": "Deze rang krijgt momenteel de rechten van de hoogste rang.",
|
||||
"scope": "Dit voorbeeld toont één rol. Een gebruiker kan extra rollen hebben. Aangepaste navigatie kan toegestane onderdelen verbergen; specifieke gegevens en acties kunnen extra controles vereisen.",
|
||||
"sections": "Onderdelen",
|
||||
"allowed": "Toegestaan",
|
||||
"denied": "Niet toegestaan",
|
||||
"actions": "CMS-rechten",
|
||||
"actionScope": "Een toegekend recht omzeilt geen controles op toegang, ranghiërarchie, eigendom of de specifieke actie.",
|
||||
"granted": "Toegekend",
|
||||
"notGranted": "Niet toegekend"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operatiecentrum",
|
||||
"hint": "Volg je opgeslagen imports en reparaties en de gedeelde wachtrij voor catalogusexport.",
|
||||
"syncTitle": "Bronsynchronisatie",
|
||||
"syncHint": "Bronsynchronisatie wordt op de eigen pagina uitgevoerd. Er is hier geen opgeslagen geschiedenis of automatische herhaling van mislukte onderdelen. Catalogusexport toont de gedeelde wachtrij en het laatste resultaat; herhalen per onderdeel wordt niet ondersteund.",
|
||||
"openSync": "Synchronisatie en export openen",
|
||||
"openImports": "Meubelimports openen"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalogusrepository export",
|
||||
"hint": "Automatische export van bundels, pictogrammen, gamedata, vertalingen en catalogus-SQL. De wachtrij en het laatste resultaat worden gedeeld tussen medewerkers.",
|
||||
"loadError": "Exportstatus kon niet worden geladen",
|
||||
"queueError": "Export kon niet worden ingepland",
|
||||
"loading": "Laden…",
|
||||
"disabled": "Export is uitgeschakeld. Open synchronisatie om de configuratie te bekijken.",
|
||||
"active": "Wachten totdat {count} bewerking(en) voltooid zijn.",
|
||||
"running": "Export wordt uitgevoerd…",
|
||||
"pending": "{count} update(s) in de exportwachtrij.",
|
||||
"idle": "Geen updates in de wachtrij.",
|
||||
"lastCommit": "Laatst gepubliceerde commit:",
|
||||
"lastAttempt": "Laatste poging: {date}",
|
||||
"queuing": "Inplannen…",
|
||||
"queue": "Nu exporteren / opnieuw proberen"
|
||||
}
|
||||
},
|
||||
"mod": {
|
||||
|
||||
+140
-3
@@ -916,7 +916,8 @@
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu"
|
||||
"adminMenu": "Admin menu",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashbord",
|
||||
@@ -1597,6 +1598,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2303,6 +2338,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3200,7 +3280,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3240,7 +3330,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3325,6 +3419,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -917,7 +917,8 @@
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu",
|
||||
"studio": "Studio"
|
||||
"studio": "Studio",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Pulpit nawigacyjny",
|
||||
@@ -1598,6 +1599,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2304,6 +2339,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3201,7 +3281,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3241,7 +3331,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3326,6 +3420,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -917,7 +917,8 @@
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu",
|
||||
"studio": "Studio"
|
||||
"studio": "Studio",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Painel",
|
||||
@@ -1598,6 +1599,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2304,6 +2339,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3201,7 +3281,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3241,7 +3331,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3326,6 +3420,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -916,7 +916,8 @@
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu"
|
||||
"adminMenu": "Admin menu",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tabloul de bord",
|
||||
@@ -1597,6 +1598,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2303,6 +2338,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3200,7 +3280,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3240,7 +3330,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3325,6 +3419,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -915,7 +915,8 @@
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu",
|
||||
"studio": "Studio"
|
||||
"studio": "Studio",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Панель управления",
|
||||
@@ -1596,6 +1597,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2302,6 +2337,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3199,7 +3279,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3239,7 +3329,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3324,6 +3418,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -916,7 +916,8 @@
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu"
|
||||
"adminMenu": "Admin menu",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1597,6 +1598,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2303,6 +2338,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3200,7 +3280,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3240,7 +3330,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3325,6 +3419,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -916,7 +916,8 @@
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu"
|
||||
"adminMenu": "Admin menu",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Дасхбоард",
|
||||
@@ -1597,6 +1598,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2303,6 +2338,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3200,7 +3280,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3240,7 +3330,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3325,6 +3419,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -916,7 +916,8 @@
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu"
|
||||
"adminMenu": "Admin menu",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Instrumentbräda",
|
||||
@@ -1597,6 +1598,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2303,6 +2338,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3200,7 +3280,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3240,7 +3330,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3325,6 +3419,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -917,7 +917,8 @@
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu",
|
||||
"studio": "Studio"
|
||||
"studio": "Studio",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Kontrol Paneli",
|
||||
@@ -1598,6 +1599,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2304,6 +2339,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3201,7 +3281,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3241,7 +3331,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3326,6 +3420,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+140
-3
@@ -916,7 +916,8 @@
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds",
|
||||
"itemsBase": "Items base",
|
||||
"adminMenu": "Admin menu"
|
||||
"adminMenu": "Admin menu",
|
||||
"operationsCenter": "Operations center"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Приладова панель",
|
||||
@@ -1597,6 +1598,40 @@
|
||||
"acceptBulk": "Accept prices into draft",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"integrity": {
|
||||
"title": "Catalog integrity",
|
||||
"description": "Check references, category paths, identifiers and furniture icons. Scanning does not change records.",
|
||||
"catalog": "Catalog",
|
||||
"normal": "Normal catalog",
|
||||
"bc": "Builders Club",
|
||||
"refresh": "Scan again",
|
||||
"working": "Checking…",
|
||||
"failed": "The operation could not be completed. Try again.",
|
||||
"stalePreview": "The catalog changed after the preview. Create a new preview before applying repairs.",
|
||||
"applied": "{count} categories reattached to the catalog root.",
|
||||
"iconsUnavailable": "The icon folder could not be read. Icon availability has not been verified.",
|
||||
"healthy": "No issues found in the completed checks.",
|
||||
"total": "{count} issues found. Showing {shown}.",
|
||||
"open": "Open",
|
||||
"reference": "Reference: {value}",
|
||||
"repairHint": "Preview categories whose parent no longer exists. Applying moves only these categories to the root and preserves every record. Cycles, missing furniture, icons and identifier conflicts require individual review.",
|
||||
"preview": "Preview safe repairs",
|
||||
"planned": "{count} categories will be moved:",
|
||||
"parentChange": "parent #{from} → catalog root",
|
||||
"apply": "Apply previewed repairs",
|
||||
"legacyTitle": "Advanced maintenance actions",
|
||||
"legacyHint": "These existing maintenance tools can rewrite identifiers and merge or remove duplicate records. They are separate from the non-destructive integrity check and its previewed parent repair.",
|
||||
"issues": {
|
||||
"missing_furniture": "Offer references missing furniture",
|
||||
"invalid_item_ids": "Invalid furniture identifiers",
|
||||
"missing_page": "Offer references a missing category",
|
||||
"unreachable_page": "Unreachable category",
|
||||
"category_cycle": "Category path contains a cycle",
|
||||
"missing_icon": "Missing furniture icon",
|
||||
"invalid_offer_id": "Invalid offer identifier",
|
||||
"duplicate_offer_id": "Repeated offer identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
@@ -2303,6 +2338,51 @@
|
||||
"colStatus": "Status",
|
||||
"statusUnknown": "unknown",
|
||||
"searchPlaceholder": "Search by username…"
|
||||
},
|
||||
"history": {
|
||||
"preview": "Preview restore",
|
||||
"title": "Restore a recorded edit",
|
||||
"description": "Compare current values with the previous values before confirming. Only category settings, offer prices or news content are restored. Deleted records and category hierarchy cannot be restored here.",
|
||||
"loading": "Loading…",
|
||||
"unavailable": "This revision is unavailable or you do not have permission to restore it.",
|
||||
"conflict": "This record changed after the selected edit. Refresh and review its latest history before restoring.",
|
||||
"failed": "The restore could not be completed. Please reload and try again.",
|
||||
"field": "Field",
|
||||
"current": "Current value",
|
||||
"restore": "Value to restore",
|
||||
"confirm": "Confirm restore",
|
||||
"restoring": "Restoring…",
|
||||
"fields": {
|
||||
"caption": "Category name",
|
||||
"captionSave": "Internal category name",
|
||||
"pageLayout": "Category layout",
|
||||
"enabled": "Enabled",
|
||||
"visible": "Visible",
|
||||
"clubOnly": "Club members only",
|
||||
"vipOnly": "VIP members only",
|
||||
"minRank": "Minimum rank",
|
||||
"iconImage": "Category icon",
|
||||
"iconColor": "Icon colour",
|
||||
"pageHeadline": "Header image",
|
||||
"pageTeaser": "Preview image",
|
||||
"pageSpecial": "Special image",
|
||||
"pageText1": "Primary text",
|
||||
"pageText2": "Secondary text",
|
||||
"pageTextDetails": "Details text",
|
||||
"pageTextTeaser": "Preview text",
|
||||
"includes": "Included categories",
|
||||
"costCredits": "Credit price",
|
||||
"costPoints": "Points price",
|
||||
"pointsType": "Points currency",
|
||||
"title": "News title",
|
||||
"slug": "News address",
|
||||
"image": "News image",
|
||||
"shortStory": "Summary",
|
||||
"fullStory": "Article content",
|
||||
"status": "Publication status",
|
||||
"publishAt": "Scheduled publication",
|
||||
"publishedAt": "Publication date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wordfilter": {
|
||||
@@ -3200,7 +3280,17 @@
|
||||
"clearAssignment": "Clear assignment",
|
||||
"openOperation": "Open related area",
|
||||
"releaseOccurrences": "Occurrences by release",
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
|
||||
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error.",
|
||||
"releaseFilter": "Version",
|
||||
"allReleases": "All versions",
|
||||
"period": "Period",
|
||||
"allRetained": "All retained events",
|
||||
"last24h": "Last 24 hours",
|
||||
"last7d": "Last 7 days",
|
||||
"sort": "Order",
|
||||
"recentFirst": "Most recent first",
|
||||
"frequentFirst": "Most frequent first",
|
||||
"filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions."
|
||||
},
|
||||
"installation": {
|
||||
"title": "Installation diagnostics",
|
||||
@@ -3240,7 +3330,11 @@
|
||||
"refreshHistory": "Refresh history",
|
||||
"showMoreItems": "Show 50 more items",
|
||||
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
|
||||
"reviewLocalData": "Review local data"
|
||||
"reviewLocalData": "Review local data",
|
||||
"ownerHistory": "Your imports and repairs, newest first. Retries never repeat completed or uncertain items.",
|
||||
"previousPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"page": "Page {page}"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Needs attention",
|
||||
@@ -3325,6 +3419,49 @@
|
||||
"retry": "Retry",
|
||||
"description": "Search pages, users, furniture, catalog categories, articles and support tickets.",
|
||||
"partialError": "Some results could not be loaded. Try again."
|
||||
},
|
||||
"permissionPreview": {
|
||||
"title": "Role access preview",
|
||||
"description": "Inspect CMS access without changing a session or impersonating a user.",
|
||||
"back": "Back to permissions",
|
||||
"role": "Role",
|
||||
"show": "Show access",
|
||||
"missing": "Select an existing role.",
|
||||
"entryAllowed": "Housekeeping entry allowed",
|
||||
"entryDenied": "Housekeeping entry denied: dashboard permission missing",
|
||||
"superAdmin": "This rank currently receives the highest-rank override.",
|
||||
"scope": "This preview shows one role only. A user may receive additional roles. Navigation customization can hide allowed sections; specific records and actions can require further checks.",
|
||||
"sections": "Sections",
|
||||
"allowed": "Allowed",
|
||||
"denied": "Denied",
|
||||
"actions": "CMS permissions",
|
||||
"actionScope": "A granted permission alone does not bypass dashboard entry, rank hierarchy, record ownership or action-specific validation.",
|
||||
"granted": "Granted",
|
||||
"notGranted": "Not granted"
|
||||
},
|
||||
"operationsCenter": {
|
||||
"title": "Operations center",
|
||||
"hint": "Track your durable imports and repairs, and the shared catalog export queue.",
|
||||
"syncTitle": "Source synchronization",
|
||||
"syncHint": "Source synchronization shows live progress on its own page. It has no durable history or automatic retry of failed items here. Catalog export reports the shared queue and latest result; item-level retry is not supported.",
|
||||
"openSync": "Open synchronization and export",
|
||||
"openImports": "Open furniture imports"
|
||||
},
|
||||
"operationsExport": {
|
||||
"title": "Catalog repository export",
|
||||
"hint": "Automatic export of bundles, icons, gamedata, translations and catalog SQL. The queue and latest result are shared across operators.",
|
||||
"loadError": "Unable to load export status",
|
||||
"queueError": "Unable to queue export",
|
||||
"loading": "Loading…",
|
||||
"disabled": "Export is disabled. Open synchronization to review its configuration.",
|
||||
"active": "Waiting for {count} operation(s) to finish.",
|
||||
"running": "Export in progress…",
|
||||
"pending": "{count} update(s) queued for export.",
|
||||
"idle": "No pending updates.",
|
||||
"lastCommit": "Last published commit:",
|
||||
"lastAttempt": "Last attempt: {date}",
|
||||
"queuing": "Queuing…",
|
||||
"queue": "Export now / retry"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
Reference in new issue
Block a user