feat(cms): add recovery history and operational reliability tools

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

+13
View File
@@ -101,3 +101,16 @@ other locales receive English fallback strings. This is a focused editorial pass
not a full translation of every CMS page. No database migration or dependency
change is required. Browser checks use real components with simulated data at
1280 and 390 pixels; production database behavior still needs deployment validation.
## Operational reliability and recovery
- Audit history now records category settings (normal/Club), individual/bulk offer prices, update-mode package publication and news edits inside the write transaction. The audit screen previews and restores individual changes after locking and comparing the current recorded fields. Deleted records, hierarchy changes, LTD counters, imports and historical entries without complete snapshots cannot be restored. Restores create their own history entry. Apply migration `0028_history_snapshots.sql` before running this version: it widens audit snapshots to MEDIUMTEXT without deleting existing data.
- `/admin/operations` reuses durable import jobs, stable history pagination and owner-scoped failed-item retries. A deterministic child ID prevents duplicate retries. The shared Git export queue displays actual pending/running state and latest result; synchronous/SSE synchronization remains linked rather than represented as a durable job history.
- Catalog maintenance includes a read-only integrity report for normal/Club categories, offers, furniture references and local icons. Known sentinel IDs are preserved. Only categories pointing to a missing positive parent have an automated repair: preview lists every affected category and apply compares the locked graph before reattaching those categories at the root. No records are deleted. Other issues require an explicit manual edit. Reports display up to 200 issues with complete counts; unavailable icon storage is distinguished from missing assets.
- CMS errors support exact release and time filters plus frequency sorting. Counts refer to retained matching events, while group resolution remains current across releases.
- User/settings forms now protect unsaved edits and preserve failed submissions. User/news validation errors appear at the affected fields; settings show returned validation errors inline. Existing submission locking is retained and tested in a browser.
- `/admin/permissions/preview` shows one role's section access and known CMS grants using live ACL and the existing highest-rank policy. It never changes sessions. Additional user roles, navigation customization and record-specific authorization remain explicit limits of the preview.
New UI copy is supplied in English, Italian and Dutch; other locales receive English fallback strings. No new runtime dependencies. Browser fixtures use real UI components with simulated server responses; the database migration and production behavior have not been exercised on the live hotel.
Validation for this increment: 1,589 tests passed, six skipped; TypeScript, Biome, translation contracts and fixture production build passed. Browser checks covered user/settings/news forms, permission preview, CMS errors, integrity preview and history restore at 1280 and 390 pixels. Double submission, stale preview, blocked navigation, field focus and horizontal overflow were checked with simulated server actions. No live database writes or deployment were performed.
@@ -0,0 +1,2 @@
-- Preserve complete before/after snapshots for long news articles.
ALTER TABLE admin_audit_log MODIFY COLUMN `before` MEDIUMTEXT NULL, MODIFY COLUMN `after` MEDIUMTEXT NULL;
+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": {