feat(cms): add recovery history and operational reliability tools
CI / check (push) Failing after 22s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-09 21:57:56 +02:00
1 parent 89526af344
commit 1ef405be0a
92 files changed
+6384 -267

No files matched your search

+4
View File
@@ -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" }),
}));
+13 -2
View File
@@ -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");
+3 -3
View File
@@ -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 };
+1 -1
View File
@@ -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,
+72
View File
@@ -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();
});
});
+60
View File
@@ -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);
}
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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) {
+3 -2
View File
@@ -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");
+74
View File
@@ -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",
});
});
+69
View File
@@ -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 };
}
+24 -1
View File
@@ -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>
);
}
+44 -29
View File
@@ -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;
+9 -1
View File
@@ -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",
+9
View File
@@ -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 />;
}
+12 -4
View File
@@ -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");
});
+160
View File
@@ -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>
);
}
+21 -3
View File
@@ -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>
);
}
+155 -82
View File
@@ -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);
});
+40 -1
View File
@@ -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,
+30
View File
@@ -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>
);
}
+32
View File
@@ -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>
);
}
+1 -1
View File
@@ -58,7 +58,7 @@ export async function OperationsInbox() {
source: "imports",
count: total,
severity: 2,
href: "/admin/studio/furni",
href: "/admin/operations",
},
];
},
+35 -12
View File
@@ -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
View File
@@ -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 };
});
+10 -1
View File
@@ -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(
+16 -2
View File
@@ -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);
});
}
+158
View File
@@ -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>
</>
);
}
+84
View File
@@ -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");
});
});
+16
View File
@@ -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] }));
}
+196
View File
@@ -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",
+6
View File
@@ -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,
},
],
},
{
+23
View File
@@ -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);
});
+33
View File
@@ -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) })),
};
}
+10
View File
@@ -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();
});
+15
View File
@@ -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];
}
+45
View File
@@ -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);
});
+46
View File
@@ -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),
);
}
+1
View File
@@ -10,6 +10,7 @@ export interface ImportJobItem {
}
export interface ImportJob {
mode?: "repair";
retryOf?: string;
id: string;
userId: number;
createdAt: string;
+47
View File
@@ -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",
);
});
});
+126
View File
@@ -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);
});
});
+202
View File
@@ -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");
});
});
+88 -1
View File
@@ -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 };
});
}
+101
View File
@@ -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);
});
+47 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": {