diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index ce0cda90..f0809c21 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -58,6 +58,24 @@ jobs: time pnpm test:coverage --maxWorkers=4 fi + # One-time Linux capture, replaced by comparison after artifact review. + - name: Install UI test browser + run: pnpm exec playwright install chromium + + - name: Capture Linux UI references + run: pnpm test:ui:update + + - name: Upload UI results + if: always() + uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2 + with: + name: ui-results + path: | + e2e/ui/__screenshots__/linux/ + playwright-report/ui/ + test-results/ui/ + retention-days: 14 + # ───────────────────────────────────────────── # Docker build & deploy # Draait op de host (self-hosted) zodat Docker diff --git a/e2e/ui/README.md b/e2e/ui/README.md new file mode 100644 index 00000000..c8aa8051 --- /dev/null +++ b/e2e/ui/README.md @@ -0,0 +1,10 @@ +# Isolated UI regression checks + +Run `pnpm test:ui` after `pnpm install --frozen-lockfile` and `pnpm exec playwright install chromium`. +The fixture server bundles real CMS components with local HTTP/server-action fixtures. It does not use the database or production credentials. Production smoke tests remain in the separate `pnpm test:e2e` command. + +The suite covers accessible settings switches, news editor/preview, support-table filters, import history, attachment recovery and news/event recovery. Axe checks WCAG A/AA rules. The sandboxed preview cannot execute Axe; its accessible name and keyboard exit are checked separately. Automated checks do not replace a full accessibility audit. + +Visual references are committed under `__screenshots__//`. Normal runs compare references and must not update them. For an intentional UI change, use `pnpm test:ui:update`, inspect every changed PNG and commit only accepted references. Keep the Playwright browser version aligned with the lockfile. Linux references must be captured on the Linux runner; Windows references are not interchangeable. + +Fixtures contain synthetic data only. Do not add production requests, environment secrets, ignored local documents or database access to tests. CI retains test reports and failures as the `ui-results` artifact. diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-form.png b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-form.png new file mode 100644 index 00000000..18455262 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-form.png differ diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-preview.png b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-preview.png new file mode 100644 index 00000000..bd5fac9b Binary files /dev/null and b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-preview.png differ diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/settings-switches.png b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/settings-switches.png new file mode 100644 index 00000000..2ce354f0 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/settings-switches.png differ diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/sync-history.png b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/sync-history.png new file mode 100644 index 00000000..951772c4 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/sync-history.png differ diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-saved-views.png b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-saved-views.png new file mode 100644 index 00000000..0143a4cf Binary files /dev/null and b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-saved-views.png differ diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-table.png b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-table.png new file mode 100644 index 00000000..dbe09286 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-table.png differ diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-form.png b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-form.png new file mode 100644 index 00000000..ea28e6d2 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-form.png differ diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-preview.png b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-preview.png new file mode 100644 index 00000000..4cb66cd8 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-preview.png differ diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/settings-switches.png b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/settings-switches.png new file mode 100644 index 00000000..20761e17 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/settings-switches.png differ diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/sync-history.png b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/sync-history.png new file mode 100644 index 00000000..af2dcf71 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/sync-history.png differ diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-saved-views.png b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-saved-views.png new file mode 100644 index 00000000..96b93af5 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-saved-views.png differ diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-table.png b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-table.png new file mode 100644 index 00000000..c803f758 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-table.png differ diff --git a/e2e/ui/attachment-recovery.spec.ts b/e2e/ui/attachment-recovery.spec.ts new file mode 100644 index 00000000..a52bcadd --- /dev/null +++ b/e2e/ui/attachment-recovery.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from "@playwright/test"; + +const attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const file = { + name: "fixture_chair.nitro", + mimeType: "application/octet-stream", + buffer: Buffer.from("isolated upload fixture"), +}; + +test.beforeEach(async ({ page }) => { + await page.goto("/admin/attachment-harness"); + await page + .getByText("Attach original file and resume", { exact: true }) + .click(); +}); + +test("attachment upload and double-click resume send one retry and refresh history", async ({ + page, +}) => { + let retries = 0; + await page.route("**/api/admin/studio/import-attachment", async (route) => { + expect(route.request().method()).toBe("POST"); + expect(route.request().postData()).toContain('name="classname"'); + expect(route.request().postData()).toContain("fixture_chair"); + expect(route.request().postData()).toContain( + 'filename="fixture_chair.nitro"', + ); + await route.fulfill({ json: { ok: true, attachmentId } }); + }); + await page.route("**/api/admin/studio/import-jobs", async (route) => { + retries++; + expect(route.request().method()).toBe("PATCH"); + expect(route.request().postDataJSON()).toEqual({ + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + action: "retry", + attachments: { fixture_chair: attachmentId }, + }); + await new Promise((resolve) => setTimeout(resolve, 150)); + await route.fulfill({ json: { ok: true } }); + }); + await page.getByLabel("Choose the original .nitro file").setInputFiles(file); + await expect( + page.getByText("Matching original file attached", { exact: true }), + ).toBeVisible(); + await page + .getByRole("button", { + name: "Resume failed imports with this file", + exact: true, + }) + .dblclick(); + await expect(page.getByLabel("Resumed count")).toHaveText("1"); + expect(retries).toBe(1); + await expect( + page.getByRole("button", { + name: "Resume failed imports with this file", + exact: true, + }), + ).toHaveCount(0); +}); + +for (const scenario of [ + { + name: "owner rejection", + status: 403, + code: "forbidden", + message: "Could not attach the file.", + }, + { + name: "mismatched furniture", + status: 400, + code: "mismatchedFile", + message: "This file belongs to different furniture.", + }, + { + name: "network failure", + status: 0, + code: "", + message: "Network connection failed", + }, +]) + test(`attachment ${scenario.name} shows readable error without enabling resume`, async ({ + page, + }) => { + let retries = 0; + await page.route("**/api/admin/studio/import-jobs", async (route) => { + retries++; + await route.fulfill({ json: { ok: true } }); + }); + await page.route("**/api/admin/studio/import-attachment", (route) => + scenario.status + ? route.fulfill({ + status: scenario.status, + json: { ok: false, code: scenario.code }, + }) + : route.abort("failed"), + ); + await page + .getByLabel("Choose the original .nitro file") + .setInputFiles(file); + await expect(page.getByRole("alert")).toContainText(scenario.message); + await expect( + page.getByRole("button", { + name: "Resume failed imports with this file", + exact: true, + }), + ).toHaveCount(0); + await expect(page.getByLabel("Resumed count")).toHaveText("0"); + expect(retries).toBe(0); + }); diff --git a/e2e/ui/draft-recovery.spec.ts b/e2e/ui/draft-recovery.spec.ts new file mode 100644 index 00000000..90784b07 --- /dev/null +++ b/e2e/ui/draft-recovery.spec.ts @@ -0,0 +1,169 @@ +import { expect, test } from "@playwright/test"; + +const key = "cms:draft:v1:7:article:new"; +test("browser draft survives session failure and reload, restores explicitly and clears only after save", async ({ + page, +}) => { + await page.route("**/fixture/actions/load-recovery", (route) => + route.fulfill({ status: 401, body: "expired" }), + ); + await page.route("**/fixture/actions/article", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ ok: false, error: "Session expired" }), + }), + ); + await page.goto("/admin/articles/recovery"); + const title = page.locator('input[name="title"]'); + await title.fill("Keep this unfinished article"); + await expect + .poll(() => page.evaluate((k) => localStorage.getItem(k), key)) + .toContain("Keep this unfinished article"); + await page.getByRole("button", { name: "Save article", exact: true }).click(); + await expect(page.getByRole("alert")).toContainText("Session expired"); + expect(await page.evaluate((k) => localStorage.getItem(k), key)).toContain( + "Keep this unfinished article", + ); + page.on("dialog", (dialog) => dialog.accept()); + await page.reload(); + await expect(title).toHaveValue("Community update"); + await page + .getByRole("button", { name: "Restore browser copy", exact: true }) + .click(); + await expect(title).toHaveValue("Keep this unfinished article"); + await page.unroute("**/fixture/actions/article"); + await page.route("**/fixture/actions/article", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ ok: true }), + }), + ); + await page.getByRole("button", { name: "Save article", exact: true }).click(); + await expect + .poll(() => page.evaluate((k) => localStorage.getItem(k), key)) + .toBeNull(); + await page.reload(); + await expect( + page.getByRole("button", { name: "Restore browser copy", exact: true }), + ).toHaveCount(0); +}); +test("pending recovery does not overwrite current edits and discard preserves them", async ({ + page, +}) => { + await page.goto("/admin/articles/recovery"); + const title = page.locator('input[name="title"]'); + await title.fill("Older draft"); + await expect + .poll(() => page.evaluate((k) => localStorage.getItem(k), key)) + .toContain("Older draft"); + page.on("dialog", (dialog) => dialog.accept()); + await page.reload(); + await title.fill("New current edits"); + await page + .getByRole("button", { name: "Discard browser copy", exact: true }) + .click(); + await expect(title).toHaveValue("New current edits"); + await expect + .poll(() => page.evaluate((k) => localStorage.getItem(k), key)) + .toContain("New current edits"); +}); +test("unavailable browser storage leaves the form editable", async ({ + page, +}) => { + await page.addInitScript(() => { + Storage.prototype.setItem = () => { + throw Error("quota"); + }; + }); + await page.goto("/admin/articles/recovery"); + await page.locator('input[name="title"]').fill("Still editable"); + await expect( + page.getByText("Browser recovery is unavailable.", { exact: false }), + ).toBeVisible(); + await expect(page.locator('input[name="title"]')).toHaveValue( + "Still editable", + ); +}); +test("saving while recovery is pending enables recovery for subsequent edits", async ({ + page, +}) => { + await page.route("**/fixture/actions/article", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ ok: true }), + }), + ); + await page.goto("/admin/articles/recovery"); + const title = page.locator('input[name="title"]'); + await title.fill("Previous browser draft"); + await expect + .poll(() => page.evaluate((k) => localStorage.getItem(k), key)) + .toContain("Previous browser draft"); + page.on("dialog", (dialog) => dialog.accept()); + await page.reload(); + await expect( + page.getByRole("button", { name: "Restore browser copy", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Save article", exact: true }).click(); + await expect + .poll(() => page.evaluate((k) => localStorage.getItem(k), key)) + .toBeNull(); + await title.fill("Edits after successful save"); + await expect + .poll(() => page.evaluate((k) => localStorage.getItem(k), key)) + .toContain("Edits after successful save"); +}); +test("unmount flush retains an edit before the autosave interval", async ({ + page, +}) => { + await page.goto("/admin/articles/recovery"); + await page.clock.install(); + await page.clock.pauseAt(new Date()); + await page.locator('input[name="title"]').fill("Immediate final edit"); + await page + .getByRole("button", { name: "Close editor fixture", exact: true }) + .click(); + expect(await page.evaluate((k) => localStorage.getItem(k), key)).toContain( + "Immediate final edit", + ); +}); +test("event title and dates recover after reload and clear after successful update", async ({ + page, +}) => { + const eventKey = "cms:draft:v1:7:event:12"; + await page.route("**/fixture/actions/update-event", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ ok: true, data: { id: 12 } }), + }), + ); + await page.goto("/admin/events/recovery"); + const title = page.locator("#title"); + const dates = page.locator('input[type="datetime-local"]'); + await title.fill("Recovered community event"); + await dates.nth(0).fill("2030-02-03T14:30"); + await dates.nth(1).fill("2030-02-03T16:00"); + await expect + .poll(() => page.evaluate((k) => localStorage.getItem(k), eventKey)) + .toContain("2030-02-03T14:30:00.000Z"); + page.on("dialog", (dialog) => dialog.accept()); + await page.reload(); + await expect(title).toHaveValue("Original event"); + await page + .getByRole("button", { name: "Restore browser copy", exact: true }) + .click(); + await expect(title).toHaveValue("Recovered community event"); + await expect(dates.nth(0)).toHaveValue("2030-02-03T14:30"); + await expect(dates.nth(1)).toHaveValue("2030-02-03T16:00"); + const save = page.waitForRequest("**/fixture/actions/update-event"); + await page.locator('button[type="submit"]').click(); + expect((await save).postDataJSON()).toMatchObject({ + id: 12, + title: "Recovered community event", + startsAt: "2030-02-03T14:30:00.000Z", + endsAt: "2030-02-03T16:00:00.000Z", + }); + await expect + .poll(() => page.evaluate((k) => localStorage.getItem(k), eventKey)) + .toBeNull(); +}); diff --git a/e2e/ui/fixture-server.mjs b/e2e/ui/fixture-server.mjs new file mode 100644 index 00000000..714f61a8 --- /dev/null +++ b/e2e/ui/fixture-server.mjs @@ -0,0 +1,224 @@ +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import tailwind from "@tailwindcss/postcss"; +import { build } from "esbuild"; +import postcss from "postcss"; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); +const fixtures = path.join(root, "e2e/ui/fixtures"); +const port = Number(process.env.UI_TEST_PORT || 3107); +if (!Number.isInteger(port) || port < 1024 || port > 65535) + throw Error("Invalid UI_TEST_PORT"); +const actions = new Set([ + "@/actions/events", + "@/actions/admin-settings", + "@/actions/admin-table-views", + "@/actions/article-recovery", + "@/actions/admin-media", +]); +const bundle = await build({ + entryPoints: [path.join(fixtures, "entry.tsx")], + bundle: true, + write: false, + outdir: path.join(root, "test-results/ui-fixture"), + format: "iife", + jsx: "automatic", + alias: { "@": path.join(root, "src") }, + define: { "process.env.NODE_ENV": '"production"' }, + plugins: [ + { + name: "server-contracts", + setup(build) { + build.onResolve( + { filter: /^next\/navigation$|^next\/link$|^@\/actions\// }, + (args) => { + if (args.path === "next/navigation") + return { path: path.join(fixtures, "navigation.ts") }; + if (args.path === "next/link") + return { path: path.join(fixtures, "next-link.tsx") }; + if (actions.has(args.path)) + return { path: path.join(fixtures, "server-actions.ts") }; + throw Error(`Unexpected server action in UI fixture: ${args.path}`); + }, + ); + build.onResolve( + { filter: /^@\/lib\/db$|^mysql2|^next\/headers$/ }, + (args) => { + throw Error( + `Database or server dependency forbidden in UI fixture: ${args.path}`, + ); + }, + ); + }, + }, + ], +}); +const globalCss = ( + await postcss([tailwind({ base: root })]).process( + await readFile(path.join(root, "src/app/globals.css"), "utf8"), + { from: path.join(root, "src/app/globals.css") }, + ) +).css; +const css = + globalCss + + "\n" + + bundle.outputFiles + .filter((file) => file.path.endsWith(".css")) + .map((file) => file.text) + .join("\n"); +const js = bundle.outputFiles.find((file) => file.path.endsWith(".js")).text; +const html = + 'UI verification fixture'; +const cover = + ''; +const jobs = new Map(); +const views = []; +const json = (res, value, status = 200) => { + res.writeHead(status, { + "content-type": "application/json", + "cache-control": "no-store", + }); + res.end(JSON.stringify(value)); +}; +const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://127.0.0.1"); + if (url.pathname === "/fixture/reset") { + jobs.clear(); + views.length = 0; + return json(res, { ok: true }); + } + if (url.pathname === "/health") return json(res, { ready: true }); + if (url.pathname === "/fixture/styles.css") { + res.setHeader("content-type", "text/css"); + return res.end(css); + } + if (url.pathname === "/fixture/app.js") { + res.setHeader("content-type", "application/javascript"); + return res.end(js); + } + if (url.pathname === "/fixture/cover.svg") { + res.setHeader("content-type", "image/svg+xml"); + return res.end(cover); + } + if (url.pathname.startsWith("/vendor/tinymce/")) { + const assetRoot = path.join(root, "node_modules/tinymce"); + const asset = path.resolve( + assetRoot, + url.pathname.slice("/vendor/tinymce/".length), + ); + if (!asset.startsWith(assetRoot + path.sep)) { + res.writeHead(400); + return res.end(); + } + res.setHeader( + "content-type", + asset.endsWith(".css") + ? "text/css" + : asset.endsWith(".js") + ? "application/javascript" + : asset.endsWith(".woff") + ? "font/woff" + : "application/octet-stream", + ); + return res.end(await readFile(asset)); + } + let body = {}; + if (req.method === "POST" || req.method === "PATCH") { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > 1000000) throw Error("Fixture payload too large"); + chunks.push(chunk); + } + body = JSON.parse(Buffer.concat(chunks).toString() || "{}"); + } + if (url.pathname === "/api/admin/studio/import-jobs") { + if (req.method === "PATCH") { + const job = jobs.get(body.id); + if (job) job.state = "cancelled"; + return json(res, { ok: true, job }); + } + return json(res, { + ok: true, + jobs: [...jobs.values()], + nextCursor: null, + }); + } + if (url.pathname === "/api/admin/import/official/sync-all") { + if (!jobs.has(body.id)) + jobs.set(body.id, { + id: body.id, + userId: 7, + createdAt: "2030-01-01T12:00:00.000Z", + updatedAt: "2030-01-01T12:00:00.000Z", + state: "queued", + translate: false, + syncKind: "official", + items: [ + { + id: 0, + classname: "fixture_chair", + name: "Fixture chair", + description: "", + type: "flooritem", + category: "other", + revision: 1, + state: "pending", + }, + ], + }); + await new Promise((resolve) => setTimeout(resolve, 150)); + return json(res, { ok: true, job: jobs.get(body.id) }); + } + if (url.pathname === "/api/media") return json(res, { files: [] }); + if (url.pathname.startsWith("/fixture/actions/")) { + const name = url.pathname.split("/").at(-1); + if (name === "load-recovery") + return json(res, { version: 0, draft: null, revisions: [] }); + if (name === "autosave" || name === "clear-recovery") + return json(res, { ok: true, version: 1 }); + if (name === "list-views") + return json(res, { ok: true, data: { views } }); + if (name === "save-view") { + views.push(body.view ?? body); + return json(res, { ok: true }); + } + return json(res, { ok: true, data: {} }); + } + if ( + [ + "/admin/attachment-harness", + "/admin/jobs-harness", + "/admin/settings", + "/admin/articles/new", + "/admin/articles/recovery", + "/admin/events/recovery", + "/admin/tickets/desk", + "/admin/import/official", + ].includes(url.pathname) + ) { + res.setHeader("content-type", "text/html"); + return res.end(html); + } + res.writeHead(404); + res.end("Not found"); + } catch (error) { + console.error(error); + res.writeHead(500); + res.end("Fixture error"); + } +}); +server.listen(port, "127.0.0.1", () => + console.log(`Isolated UI fixture ready at http://127.0.0.1:${port}`), +); +for (const signal of ["SIGINT", "SIGTERM"]) + process.on(signal, () => server.close(() => process.exit(0))); diff --git a/e2e/ui/fixtures/attachment-harness.tsx b/e2e/ui/fixtures/attachment-harness.tsx new file mode 100644 index 00000000..a8cfcb21 --- /dev/null +++ b/e2e/ui/fixtures/attachment-harness.tsx @@ -0,0 +1,18 @@ +import { useState } from "react"; +import { ImportAttachmentRecovery } from "@/components/admin/studio/import-attachment-recovery"; +export function AttachmentHarness() { + const [resumed, setResumed] = useState(0); + return ( + <> + { + setResumed((value) => value + 1); + }} + /> + {resumed} + > + ); +} diff --git a/e2e/ui/fixtures/entry.tsx b/e2e/ui/fixtures/entry.tsx new file mode 100644 index 00000000..d4ea3c6f --- /dev/null +++ b/e2e/ui/fixtures/entry.tsx @@ -0,0 +1,143 @@ +import { NextIntlClientProvider } from "next-intl"; +import { useState } from "react"; +import { createRoot } from "react-dom/client"; +import { Toaster } from "sonner"; +import { EventForm } from "@/app/admin/events/event-form"; +import { CmsSettingsForm } from "@/app/admin/settings/cms-settings-form"; +import { TicketsTable } from "@/app/admin/tickets/tickets-table"; +import { ArticleForm } from "@/components/admin/article-form"; +import { FurnitureSyncQueue } from "@/components/admin/studio/furniture-sync-queue"; +import type { ArticleSaveResult } from "@/lib/article-input"; +import { themePaletteCss } from "@/lib/theme-css"; +import { PRESETS } from "@/lib/theme-presets"; +import messages from "@/messages/en.json"; +import { AttachmentHarness } from "./attachment-harness"; +import { FurnitureJobsHarness } from "./furniture-jobs-harness"; +import { fixtureAction } from "./server-actions"; + +const theme = document.createElement("style"); +theme.textContent = themePaletteCss( + ":root.dark", + PRESETS["Atom (golden)"].dark, +); +document.head.appendChild(theme); +const route = window.location.pathname; +const root = document.getElementById("root"); +if (!root) throw Error("Fixture root missing"); +const rows = [ + { + id: 1, + subject: "Account assistance", + creator: "SampleMember", + creatorId: 7, + category: "general", + messages: 2, + status: "open", + priority: "normal", + assignee: "Moderator", + date: "01/01/2030", + awaitingStaff: true, + replyAgeMinutes: 90, + }, + { + id: 2, + subject: "Event question", + creator: "AnotherMember", + creatorId: 8, + category: "events", + messages: 1, + status: "waiting", + priority: "high", + assignee: "—", + date: "01/01/2030", + awaitingStaff: false, + replyAgeMinutes: 15, + }, +]; +const saveArticle = async (form: FormData): Promise => + fixtureAction("article", Object.fromEntries(form)); + +function ArticleFixture() { + const [visible, setVisible] = useState(true); + return ( + <> + {route.includes("recovery") && ( + setVisible(false)}> + Close editor fixture + + )} + {visible && ( + Join the upcoming event and explore the hotel together.
- {form.formState.errors.title.message} -
+ {form.formState.errors.title.message} +
@@ -282,6 +309,7 @@ export function ArticleForm({ {t("body")} { markDirty(); diff --git a/src/components/admin/article-recovery.tsx b/src/components/admin/article-recovery.tsx index 619c9504..f4553a5a 100644 --- a/src/components/admin/article-recovery.tsx +++ b/src/components/admin/article-recovery.tsx @@ -82,6 +82,7 @@ export function useArticleRecovery( }; }, [key, form, saving, reload]); return { + draft: recovery?.draft?.payload, wait: () => request.current ?? Promise.resolve(), clear: () => clearArticleRecovery(key, version.current), panel: ( diff --git a/src/components/admin/data-table.tsx b/src/components/admin/data-table.tsx index ee1bdba9..f39974a1 100644 --- a/src/components/admin/data-table.tsx +++ b/src/components/admin/data-table.tsx @@ -395,7 +395,10 @@ export function DataTable({ value={currentValue} onValueChange={(v) => handleFilterChange(col, v)} > - + @@ -479,7 +482,10 @@ export function DataTable({ value={String(data.perPage)} onValueChange={handlePerPageChange} > - + diff --git a/src/components/admin/rich-text.tsx b/src/components/admin/rich-text.tsx index fdc74077..87f5ae5e 100644 --- a/src/components/admin/rich-text.tsx +++ b/src/components/admin/rich-text.tsx @@ -56,12 +56,14 @@ export function RichText({ name, defaultValue = "", placeholder, + ariaLabel, rows = 10, onChange, }: { name: string; defaultValue?: string; placeholder?: string; + ariaLabel?: string; rows?: number; onChange?: () => void; }) { @@ -83,6 +85,7 @@ export function RichText({ .init({ selector, menubar: false, + iframe_attrs: { title: ariaLabel || name }, height: Math.max(rows * 22, 240), plugins: "link lists code autolink", toolbar: @@ -127,13 +130,14 @@ export function RichText({ /* noop */ } }; - }, [editorId, rows]); + }, [editorId, rows, ariaLabel, name]); return ( [] = []; +function client() { + const value = createFurnitureJobsClient(); + clients.push(value); + return value; +} +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(async () => { + await Promise.all( + clients.map(async (value) => { + await value.cancelQueries(); + value.clear(); + }), + ); + clients.length = 0; + server.resetHandlers(); +}); +afterAll(() => server.close()); +describe("furniture history HTTP query", () => { + it("deduplicates simultaneous refreshes and caches their validated result", async () => { + let calls = 0; + let release: (() => void) | undefined; + server.use( + http.get(endpoint, async () => { + calls++; + await new Promise((resolve) => { + release = resolve; + }); + return HttpResponse.json({ + ok: true, + jobs: [furnitureJobFixture], + nextCursor: furnitureCursorFixture, + }); + }), + ); + const cache = client(); + const options = furnitureJobsQueryOptions(true, null); + const first = cache.fetchQuery(options); + const second = cache.fetchQuery(options); + await vi.waitFor(() => expect(calls).toBe(1)); + release?.(); + const [a, b] = await Promise.all([first, second]); + expect(a).toEqual(b); + expect(a.jobs[0].id).toBe(furnitureJobFixture.id); + expect(cache.getQueryData(options.queryKey)).toEqual(a); + }); + it("keeps different pages and mounted history clients isolated", async () => { + const urls: string[] = []; + server.use( + http.get(endpoint, ({ request }) => { + urls.push(request.url); + return HttpResponse.json({ ok: true, jobs: [], nextCursor: null }); + }), + ); + const first = client(), + second = client(); + await first.fetchQuery(furnitureJobsQueryOptions(true, null)); + await first.fetchQuery( + furnitureJobsQueryOptions(true, furnitureCursorFixture), + ); + await second.fetchQuery(furnitureJobsQueryOptions(true, null)); + expect(urls).toHaveLength(3); + expect(new URL(urls[1]).searchParams.get("before")).toBe( + furnitureCursorFixture, + ); + first.clear(); + expect( + second.getQueryData(furnitureJobsQueryOptions(true, null).queryKey), + ).toEqual({ jobs: [], nextCursor: null }); + }); + it.each([403, 500])( + "surfaces HTTP %i without automatic retries or a false empty result", + async (status) => { + let calls = 0; + server.use( + http.get(endpoint, () => { + calls++; + return HttpResponse.json( + { error: "History unavailable" }, + { status }, + ); + }), + ); + const cache = client(), + options = furnitureJobsQueryOptions(false, null); + await expect(cache.fetchQuery(options)).rejects.toMatchObject({ + status, + message: "History unavailable", + }); + expect(calls).toBe(1); + expect(cache.getQueryData(options.queryKey)).toBeUndefined(); + }, + ); + it.each([ + { ok: true, jobs: null }, + { ok: true, jobs: [{ ...furnitureJobFixture, state: "invented" }] }, + { + ok: true, + jobs: [ + { + ...furnitureJobFixture, + items: [{ ...furnitureJobFixture.items[0], state: "unknown" }], + }, + ], + }, + { ok: true, jobs: [{ ...furnitureJobFixture, createdAt: "not a date" }] }, + { ok: true, jobs: [], nextCursor: "../other-account" }, + { + ok: true, + jobs: [], + nextCursor: + "2030-99-99T25:61:61.000Z|aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }, + ])("rejects malformed successful payloads", async (payload) => { + server.use(http.get(endpoint, () => HttpResponse.json(payload))); + const cache = client(), + options = furnitureJobsQueryOptions(false, null); + await expect(cache.fetchQuery(options)).rejects.toThrow( + "Invalid import history response", + ); + expect(cache.getQueryData(options.queryKey)).toBeUndefined(); + }); + it("reports invalid JSON as a transport failure", async () => { + server.use( + http.get( + endpoint, + () => new HttpResponse("unexpected", { status: 200 }), + ), + ); + await expect( + client().fetchQuery(furnitureJobsQueryOptions(false, null)), + ).rejects.toThrow("Invalid import history response"); + }); + it("times out a stalled HTTP request without retries", async () => { + let calls = 0; + server.use( + http.get(endpoint, async () => { + calls++; + await delay(100); + return HttpResponse.json({ ok: true, jobs: [] }); + }), + ); + await expect( + client().fetchQuery(furnitureJobsQueryOptions(false, null, 10)), + ).rejects.toThrow("Import history request timed out"); + expect(calls).toBe(1); + }); + it("aborts a cancelled request without replacing cache data with an empty result", async () => { + let entered = false; + let transportAborted = false; + server.use( + http.get(endpoint, async ({ request }) => { + request.signal.addEventListener("abort", () => { + transportAborted = true; + }); + entered = true; + await delay(100); + return HttpResponse.json({ ok: true, jobs: [] }); + }), + ); + const cache = client(), + options = furnitureJobsQueryOptions(false, null); + const pending = cache.fetchQuery(options); + const rejection = expect(pending).rejects.toThrow(); + await vi.waitFor(() => expect(entered).toBe(true)); + await cache.cancelQueries({ queryKey: options.queryKey }); + await rejection; + await vi.waitFor(() => expect(transportAborted).toBe(true)); + expect(cache.getQueryData(options.queryKey)).toBeUndefined(); + }); +}); + +describe("history refresh and mutation response integrity", () => { + it("retains the last valid page while a refresh fails", async () => { + server.use( + http.get(endpoint, () => + HttpResponse.json({ ok: true, jobs: [furnitureJobFixture] }), + ), + ); + const cache = client(), + options = furnitureJobsQueryOptions(false, null); + const observer = new QueryObserver(cache, { ...options, enabled: false }); + const unsubscribe = observer.subscribe(() => {}); + try { + await cache.fetchQuery(options); + server.use( + http.get(endpoint, () => + HttpResponse.json({ error: "offline" }, { status: 500 }), + ), + ); + await expect(cache.fetchQuery(options)).rejects.toThrow("offline"); + expect(observer.getCurrentResult().data?.jobs[0].id).toBe( + furnitureJobFixture.id, + ); + expect(observer.getCurrentResult().error?.message).toBe("offline"); + } finally { + unsubscribe(); + } + }); + it("rejects malformed successful mutation responses rather than storing an undefined job", async () => { + server.use( + http.patch(endpoint, () => + HttpResponse.json({ ok: true, job: { id: furnitureJobFixture.id } }), + ), + ); + const response = await fetch(endpoint, { method: "PATCH" }); + await expect( + readFurnitureJobResponse(response, "Update failed"), + ).rejects.toThrow("Invalid import job response"); + }); + it("reports mutation HTTP failure without issuing a second write", async () => { + let calls = 0; + server.use( + http.post(endpoint, () => { + calls++; + return HttpResponse.json( + { error: "Queue unavailable" }, + { status: 500 }, + ); + }), + ); + const response = await fetch(endpoint, { method: "POST" }); + await expect( + readFurnitureJobResponse(response, "Queue failed"), + ).rejects.toMatchObject({ status: 500, message: "Queue unavailable" }); + expect(calls).toBe(1); + }); +}); + +it("does not park manual refresh indefinitely behind a global offline signal", async () => { + server.use( + http.get(endpoint, () => HttpResponse.json({ ok: true, jobs: [] })), + ); + onlineManager.setOnline(false); + try { + await expect( + client().fetchQuery(furnitureJobsQueryOptions(false, null)), + ).resolves.toEqual({ jobs: [], nextCursor: null }); + } finally { + onlineManager.setOnline(true); + } +}); diff --git a/src/components/admin/studio/furniture-jobs-query.ts b/src/components/admin/studio/furniture-jobs-query.ts new file mode 100644 index 00000000..bb80b144 --- /dev/null +++ b/src/components/admin/studio/furniture-jobs-query.ts @@ -0,0 +1,164 @@ +import { QueryClient, queryOptions } from "@tanstack/react-query"; +import { z } from "zod/mini"; +import { adminFetch } from "@/lib/admin-fetch"; +import type { ImportJob } from "@/lib/furni/import-job"; + +const endpoint = "/api/admin/studio/import-jobs"; +const historyTimeoutMs = 15_000; +const jobSchema = z.looseObject({ + id: z.uuid(), + userId: z.int().check(z.positive()), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + state: z.enum(["queued", "running", "completed", "interrupted", "cancelled"]), + translate: z.boolean(), + mode: z.optional(z.literal("repair")), + syncKind: z.optional(z.enum(["official", "clone"])), + operationId: z.optional(z.string()), + retryOf: z.optional(z.string()), + sourceId: z.optional(z.string()), + cancelRequested: z.optional(z.boolean()), + langs: z.optional(z.array(z.string())), + items: z.array( + z.looseObject({ + id: z.int().check(z.nonnegative()), + classname: z.string(), + name: z.string(), + description: z.string(), + type: z.string(), + revision: z.number(), + category: z.string(), + state: z.enum([ + "pending", + "running", + "done", + "failed", + "cancelled", + "interrupted", + ]), + error: z.optional(z.string()), + warnings: z.optional(z.array(z.string())), + itemId: z.optional(z.number()), + }), + ), +}); +const historySchema = z.object({ + ok: z.literal(true), + jobs: z.array(jobSchema).check(z.maxLength(30)), + nextCursor: z.optional( + z.nullable( + z.string().check( + z.regex( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i, + ), + z.refine((value) => { + const [timestamp, id] = value.split("|"); + return ( + z.iso.datetime({ precision: 3 }).safeParse(timestamp).success && + z.uuid().safeParse(id).success + ); + }), + ), + ), + ), +}); +export interface FurnitureJobsPage { + jobs: ImportJob[]; + nextCursor: string | null; +} +export class FurnitureJobsHttpError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} +async function responseData( + response: Response, + fallback: string, +): Promise { + const data: unknown = await response.json().catch(() => null); + if (!response.ok) { + const message = + data && + typeof data === "object" && + "error" in data && + typeof data.error === "string" + ? data.error + : `${fallback} (${response.status})`; + throw new FurnitureJobsHttpError(message, response.status); + } + return data; +} +export async function readFurnitureJobResponse( + response: Response, + fallback: string, +): Promise { + const data = await responseData(response, fallback); + const parsed = z + .object({ ok: z.literal(true), job: jobSchema }) + .safeParse(data); + if (!parsed.success) throw Error("Invalid import job response"); + return parsed.data.job as ImportJob; +} +/** Each mounted history owns this client; no persisted or cross-page account cache. */ +export function createFurnitureJobsClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + networkMode: "always", + staleTime: 0, + gcTime: 0, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + mutations: { retry: false }, + }, + }); +} +export function furnitureJobsQueryOptions( + paginated: boolean, + cursor: string | null, + timeoutMs = historyTimeoutMs, +) { + return queryOptions({ + queryKey: [ + "furniture-import-history", + paginated, + paginated ? cursor : null, + ] as const, + queryFn: async ({ signal }): Promise => { + const timeout = AbortSignal.timeout(timeoutMs); + const combined = AbortSignal.any([signal, timeout]); + const url = new URL( + endpoint, + typeof window === "undefined" + ? "http://localhost" + : window.location.origin, + ); + if (paginated) { + url.searchParams.set("page", "1"); + if (cursor) url.searchParams.set("before", cursor); + } + try { + const response = await adminFetch(url, { + signal: combined, + cache: "no-store", + }); + const data = await responseData(response, "Import history unavailable"); + const parsed = historySchema.safeParse(data); + if (!parsed.success) throw Error("Invalid import history response"); + return { + jobs: parsed.data.jobs as ImportJob[], + nextCursor: parsed.data.nextCursor ?? null, + }; + } catch (error) { + if (timeout.aborted && !signal.aborted) + throw Error("Import history request timed out"); + throw error; + } + }, + }); +} diff --git a/src/components/admin/studio/furniture-jobs.tsx b/src/components/admin/studio/furniture-jobs.tsx index 4aa0c27a..0439a2d7 100644 --- a/src/components/admin/studio/furniture-jobs.tsx +++ b/src/components/admin/studio/furniture-jobs.tsx @@ -4,6 +4,7 @@ import { useFormatter, useTranslations } from "next-intl"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { retryableJobItems } from "@/lib/furni/import-job-retry"; +import { ImportAttachmentRecovery } from "./import-attachment-recovery"; import type { useFurnitureJobs } from "./use-furniture-jobs"; export function FurnitureJobHistory({ state, @@ -98,6 +99,18 @@ export function FurnitureJobHistory({ {item.error}
Promise; +}) { + const t = useTranslations("admin.studio.importDiagnostics"); + const lock = useRef(false); + const [busy, setBusy] = useState(false); + const [attachmentId, setAttachmentId] = useState(""); + const [error, setError] = useState(""); + async function attach(file: File) { + if (disabled || lock.current) return; + lock.current = true; + setBusy(true); + setError(""); + setAttachmentId(""); + try { + const form = new FormData(); + form.set("classname", classname); + form.set("file", file); + const response = await adminFetch("/api/admin/studio/import-attachment", { + method: "POST", + body: form, + }); + const data = await response.json(); + if (!response.ok || typeof data.attachmentId !== "string") { + setError( + t(data.code === "mismatchedFile" ? "mismatchedFile" : "invalidFile"), + ); + return; + } + setAttachmentId(data.attachmentId); + } catch { + setError(t("network")); + } finally { + lock.current = false; + setBusy(false); + } + } + async function resume() { + if (disabled || lock.current || !attachmentId) return; + lock.current = true; + setBusy(true); + setError(""); + try { + const response = await adminFetch("/api/admin/studio/import-jobs", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: jobId, + action: "retry", + attachments: { [classname]: attachmentId }, + }), + }); + if (!response.ok) { + const data = await response.json(); + const code = + data.code === "alreadyStarted" + ? "alreadyStarted" + : data.code === "notEligible" + ? "notEligible" + : data.code === "invalidFile" + ? "invalidFile" + : "resumeError"; + setError(t(code)); + return; + } + setAttachmentId(""); + await onResumed(); + } catch { + setError(t("network")); + } finally { + lock.current = false; + setBusy(false); + } + } + return ( + + {t("recover")} + + {t("recoveryHint", { file: `${classname.split("*")[0]}.nitro` })} + + + {t("attach")} + { + const file = event.target.files?.[0]; + if (file) void attach(file); + event.target.value = ""; + }} + /> + + {busy && {t("working")}} + {attachmentId && ( + <> + {t("attached")} + void resume()} + > + {t("resume")} + + > + )} + {error && ( + + {error} + + )} + + ); +} diff --git a/src/components/admin/studio/import-review.tsx b/src/components/admin/studio/import-review.tsx index 1d6f852e..c1361636 100644 --- a/src/components/admin/studio/import-review.tsx +++ b/src/components/admin/studio/import-review.tsx @@ -17,6 +17,7 @@ import { adminFetch } from "@/lib/admin-fetch"; import { previewAutoCatalog } from "@/lib/furni/auto-catalog"; import { compareFurniture } from "@/lib/furni/studio-inspection"; import { FurnitureComparison } from "./furniture-inspector"; +import { ImportSourceDiagnostics } from "./import-source-diagnostics"; import type { FurniItem } from "./studio-types"; import { useFurnitureInspection } from "./use-furniture-inspection"; import { useSourceAssetChecks } from "./use-source-asset-checks"; @@ -38,6 +39,7 @@ export function ImportReview({ busy?: boolean; onConfirm: (items: FurniItem[]) => void; }) { + const diagnosticsT = useTranslations("admin.studio.importDiagnostics"); const completenessT = useTranslations("admin.studio.completeness"); const [attachments, setAttachments] = useState>({}); const [uploading, setUploading] = useState(null); @@ -57,7 +59,7 @@ export function ImportReview({ ...current, [item.classname]: data.attachmentId, })); - toast.success("Matching .nitro attached to this import"); + toast.success(diagnosticsT("attached")); } catch (error) { toast.error(error instanceof Error ? error.message : "Upload failed"); } finally { @@ -260,8 +262,8 @@ export function ImportReview({ {local.nitro.exists !== true && ( {attachments[item.classname] - ? "Matching .nitro attached" - : "Attach the original .nitro"} + ? diagnosticsT("attached") + : diagnosticsT("attach")} )} + {assets.items.find( + (check) => check.classname === item.classname, + ) && ( + check.classname === item.classname, + )} + /> + )} )} diff --git a/src/components/admin/studio/import-source-diagnostics.test.tsx b/src/components/admin/studio/import-source-diagnostics.test.tsx new file mode 100644 index 00000000..b37b8b25 --- /dev/null +++ b/src/components/admin/studio/import-source-diagnostics.test.tsx @@ -0,0 +1,54 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { expect, it, vi } from "vitest"; +import { ImportSourceDiagnostics } from "./import-source-diagnostics"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, args?: Record) => + `${key}${args?.status ?? ""}`, +})); +it("shows filenames, attempted addresses and distinct reasons inline", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("chair.swf"); + expect(html).toContain("http404"); + expect(html).toContain("timeout"); + expect(html).toContain("https://source.test/1/chair.swf"); + expect(html).not.toContain("href="); +}); +it("does not invent diagnostics for earlier responses", () => { + expect( + renderToStaticMarkup( + , + ), + ).toBe(""); +}); diff --git a/src/components/admin/studio/import-source-diagnostics.tsx b/src/components/admin/studio/import-source-diagnostics.tsx new file mode 100644 index 00000000..5f75074f --- /dev/null +++ b/src/components/admin/studio/import-source-diagnostics.tsx @@ -0,0 +1,32 @@ +"use client"; +import { useTranslations } from "next-intl"; +import type { SourceAssetCheck } from "@/lib/furni/source-assets"; +export function ImportSourceDiagnostics({ + check, +}: { + check?: SourceAssetCheck; +}) { + const t = useTranslations("admin.studio.importDiagnostics"); + if (!check?.attempts?.length) return null; + return ( + + {t("attempts")} + {t("preliminary")} + + {[ + ...new Map( + check.attempts.map((attempt) => [attempt.url, attempt]), + ).values(), + ].map((attempt) => ( + + {attempt.file} ·{" "} + {attempt.reason === "http" + ? t("http", { status: attempt.status ?? 0 }) + : t(attempt.reason)} + {attempt.url} + + ))} + + + ); +} diff --git a/src/components/admin/studio/use-furniture-jobs.ts b/src/components/admin/studio/use-furniture-jobs.ts index 0a050b14..60e45b4a 100644 --- a/src/components/admin/studio/use-furniture-jobs.ts +++ b/src/components/admin/studio/use-furniture-jobs.ts @@ -1,76 +1,87 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; 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"; +import type { ImportJobItem } from "@/lib/furni/import-job"; +import { + createFurnitureJobsClient, + FurnitureJobsHttpError, + type FurnitureJobsPage, + furnitureJobsQueryOptions, + readFurnitureJobResponse, +} from "./furniture-jobs-query"; + export function useFurnitureJobs(onComplete: () => void, paginated = false) { const t = useTranslations("pages.admin.importHistory"); + const [client] = useState(createFurnitureJobsClient); const cursor = useRef(null); const previous = useRef>([]); - const [nextCursor, setNextCursor] = useState(null); + const [pageCursor, setPageCursor] = useState(null); const [page, setPage] = useState(1); - const [loading, setLoading] = useState(false); + const query = useQuery( + { ...furnitureJobsQueryOptions(paginated, pageCursor), enabled: false }, + client, + ); + const unauthorized = + query.error instanceof FurnitureJobsHttpError && + [401, 403].includes(query.error.status); + const jobs = unauthorized ? [] : (query.data?.jobs ?? []); + const nextCursor = unauthorized ? null : (query.data?.nextCursor ?? null); + const loading = query.isFetching; + const error = query.error?.message ?? ""; const [cancelling, setCancelling] = useState(null); const cancelInFlight = useRef(false); - const [jobs, setJobs] = useState([]), - [busy, setBusy] = useState(false), - [error, setError] = useState(""); - const completed = useRef(new Set()), - callback = useRef(onComplete); + const [busy, setBusy] = useState(false); + const completed = useRef(new Set()); + const callback = useRef(onComplete); callback.current = onComplete; const request = useRef<{ payload: string; id: string } | null>(null); const submitting = useRef(false); - const refreshing = useRef(false); - const refresh = useCallback(async () => { - if (refreshing.current) return; - refreshing.current = true; - setLoading(true); - try { - 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[]) - if ( - (job.state === "completed" || - job.state === "interrupted" || - job.state === "cancelled") && - !completed.current.has(job.id) - ) { - completed.current.add(job.id); - changed = true; - } - if (changed) callback.current(); - } catch (error) { - setError( - error instanceof Error ? error.message : "Cannot load import history", - ); - } finally { - refreshing.current = false; - setLoading(false); + useEffect( + () => () => { + client.clear(); + }, + [client], + ); + useEffect(() => { + if (!query.data || unauthorized) return; + let changed = false; + for (const job of query.data.jobs) { + if ( + ["completed", "interrupted", "cancelled"].includes(job.state) && + !completed.current.has(job.id) + ) { + completed.current.add(job.id); + changed = true; + } } - }, [paginated]); + if (changed) callback.current(); + }, [query.data, unauthorized]); + const refresh = useCallback(async () => { + // fetchQuery shares an in-flight GET. Query owns data, errors and loading. + await client + .fetchQuery(furnitureJobsQueryOptions(paginated, cursor.current)) + .catch(() => undefined); + }, [client, paginated]); + const resetPage = () => { + cursor.current = null; + previous.current = []; + setPageCursor(null); + setPage(1); + }; const navigate = async (direction: "next" | "previous") => { - if (refreshing.current) return; + if (client.isFetching()) 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; + setPageCursor(cursor.current); setPage(previous.current.length + 1); - setJobs([]); - setNextCursor(null); await refresh(); }; const retry = async (id: string) => { @@ -83,11 +94,9 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) { 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 readFurnitureJobResponse(response, t("cancelFailed")); + await client.cancelQueries(); + resetPage(); await refresh(); } catch (error) { toast.error(error instanceof Error ? error.message : t("cancelFailed")); @@ -99,6 +108,7 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) { const active = jobs.some( (job) => job.state === "queued" || job.state === "running", ); + // Retain settle-then-poll timing and visibility behavior without global focus state. useVisiblePolling(refresh, active ? 5000 : 30000); const cancel = async (id: string) => { if (cancelInFlight.current) return; @@ -110,10 +120,17 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id }), }); - const data = await response.json(); - if (!response.ok) throw Error(data.error || t("cancelFailed")); - setJobs((current) => - current.map((job) => (job.id === id ? data.job : job)), + const job = await readFurnitureJobResponse(response, t("cancelFailed")); + await client.cancelQueries(); + client.setQueriesData( + { queryKey: ["furniture-import-history"] }, + (current) => + current + ? { + ...current, + jobs: current.jobs.map((item) => (item.id === id ? job : item)), + } + : current, ); await refresh(); } catch (error) { @@ -145,12 +162,10 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...body, id: request.current.id }), }); - const data = await response.json(); - if (!response.ok) - throw Error( - data.error || `Could not queue import (${response.status})`, - ); + await readFurnitureJobResponse(response, "Could not queue import"); request.current = null; + await client.cancelQueries(); + resetPage(); await refresh(); toast.success( "Import queued. You can leave this page and return to its history.", diff --git a/src/hooks/use-form-recovery.tsx b/src/hooks/use-form-recovery.tsx new file mode 100644 index 00000000..b3cb989e --- /dev/null +++ b/src/hooks/use-form-recovery.tsx @@ -0,0 +1,202 @@ +"use client"; +import { useTranslations } from "next-intl"; +import { useEffect, useRef, useState } from "react"; +import { + decodeRecovery, + encodeRecovery, + type RecoveryKind, + recoveryKey, +} from "@/lib/form-recovery"; + +/** Local fallback complements server autosave and survives an expired session. */ +export function useFormRecovery({ + userId, + kind, + record, + dirty, + snapshot, + restore, + serverDraft, +}: { + userId?: string; + kind: RecoveryKind; + record: string; + dirty: boolean; + serverDraft?: unknown; + snapshot: () => unknown; + restore: (payload: Record) => void; +}) { + const t = useTranslations("draftRecovery"); + const key = userId ? recoveryKey(userId, kind, record) : null; + const current = useRef({ dirty, snapshot, restore, key }); + current.current = { dirty, snapshot, restore, key }; + const [found, setFound] = useState | null>( + null, + ); + const [state, setState] = useState<"ready" | "saved" | "unavailable">( + "ready", + ); + const blocked = useRef(true); + const last = useRef(null); + const suppressed = useRef(false); + const lastPayload = useRef(""); + const savedSnapshot = useRef(""); + const flush = useRef(() => {}); + useEffect(() => { + blocked.current = true; + suppressed.current = false; + lastPayload.current = ""; + setFound(null); + setState("ready"); + if (!key) return; + try { + const raw = localStorage.getItem(key); + last.current = raw; + if (raw) { + try { + setFound(decodeRecovery(kind, raw)); + } catch { + localStorage.removeItem(key); + last.current = null; + blocked.current = false; + } + } else blocked.current = false; + } catch { + setState("unavailable"); + blocked.current = false; + } + function persist() { + if ( + current.current.key !== key || + blocked.current || + !current.current.dirty + ) + return; + if ( + suppressed.current && + JSON.stringify(current.current.snapshot()) === savedSnapshot.current + ) + return; + suppressed.current = false; + try { + // Do not overwrite a draft written by another tab without offering recovery. + const existing = localStorage.getItem(key as string); + if (existing !== last.current && existing) { + setFound(decodeRecovery(kind, existing)); + last.current = existing; + blocked.current = true; + return; + } + const payload = encodeRecovery(kind, current.current.snapshot(), 0); + if (payload === lastPayload.current && existing === last.current) + return; + const raw = encodeRecovery(kind, current.current.snapshot()); + localStorage.setItem(key as string, raw); + lastPayload.current = payload; + last.current = raw; + setState("saved"); + } catch { + setState("unavailable"); + } + } + flush.current = persist; + const timer = window.setInterval(persist, 750); + const visibility = () => { + if (document.visibilityState === "hidden") persist(); + }; + window.addEventListener("pagehide", persist); + window.addEventListener("beforeunload", persist); + document.addEventListener("visibilitychange", visibility); + return () => { + persist(); + clearInterval(timer); + window.removeEventListener("pagehide", persist); + window.removeEventListener("beforeunload", persist); + document.removeEventListener("visibilitychange", visibility); + flush.current = () => {}; + }; + }, [key, kind]); + function clear() { + blocked.current = false; + lastPayload.current = ""; + suppressed.current = true; + savedSnapshot.current = JSON.stringify(current.current.snapshot()); + setFound(null); + if (!key) return; + try { + if (localStorage.getItem(key) === last.current) + localStorage.removeItem(key); + last.current = null; + setState("ready"); + } catch { + setState("unavailable"); + } + } + useEffect(() => { + if (!found || !serverDraft) return; + try { + if ( + encodeRecovery(kind, found.payload, 0) === + encodeRecovery(kind, serverDraft, 0) + ) { + setFound(null); + blocked.current = false; + } + } catch { + /* The server keeps its own validation and recovery flow. */ + } + }, [found, serverDraft, kind]); + // A new edit after successful save enables the next recovery snapshot. + function changed() { + suppressed.current = false; + } + return { + clear, + changed, + flush: () => flush.current(), + panel: !key ? null : ( + + + {t(found ? "found" : state)} + {found && ( + <> + {" "} + + {new Date(found.savedAt).toLocaleString()} + + > + )} + + {found && ( + + { + if (!window.confirm(t("confirmRestore"))) return; + current.current.restore(found.payload); + blocked.current = false; + suppressed.current = false; + setFound(null); + }} + > + {t("restore")} + + { + if (!window.confirm(t("confirmDiscard"))) return; + clear(); + blocked.current = false; + suppressed.current = false; + }} + > + {t("discard")} + + + )} + + ), + }; +} diff --git a/src/lib/form-recovery.test.ts b/src/lib/form-recovery.test.ts new file mode 100644 index 00000000..975f46f9 --- /dev/null +++ b/src/lib/form-recovery.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + DRAFT_TTL, + decodeRecovery, + encodeRecovery, + recoveryKey, +} from "./form-recovery"; + +const article = { + title: "Draft", + slug: "draft", + image: "", + shortStory: "", + fullStory: "Unsaved", + status: "draft", + publishAt: "", +}; +describe("form recovery storage contract", () => { + it("isolates account, form and record, including separator characters", () => { + expect( + new Set([ + recoveryKey("1", "article", "new"), + recoveryKey("2", "article", "new"), + recoveryKey("1", "event", "new"), + recoveryKey("1", "article", "2"), + recoveryKey("1:article", "article", "new"), + ]).size, + ).toBe(5); + }); + it("round trips unfinished content and only retains allowlisted fields", () => { + const raw = encodeRecovery( + "article", + { + ...article, + password: "private", + accessToken: "secret", + baseToken: "version", + }, + 1000, + ); + expect(decodeRecovery("article", raw, 1001).payload).toEqual(article); + expect(raw).not.toMatch(/private|secret|baseToken/); + }); + it.each([ + "{", + JSON.stringify({ version: 2, savedAt: 1000, payload: article }), + JSON.stringify({ + version: 1, + savedAt: 1000, + payload: { ...article, title: 42 }, + }), + ])("rejects malformed or unsupported records", (raw) => { + expect(() => decodeRecovery("article", raw, 1001)).toThrow(); + }); + it("expires old records and rejects future timestamps", () => { + expect(() => + decodeRecovery( + "article", + encodeRecovery("article", article, 1000), + 1001 + DRAFT_TTL, + ), + ).toThrow(); + expect(() => + decodeRecovery( + "article", + encodeRecovery("article", article, 100000), + 1000, + ), + ).toThrow(); + }); + it("limits content size", () => { + expect(() => + encodeRecovery("article", { ...article, fullStory: "x".repeat(500001) }), + ).toThrow(); + }); + it("preserves optional event dates without coercing absent dates", () => { + const event = { + title: "", + description: "", + typeId: 1, + status: "draft", + isRecurring: 0, + startsAt: "2030-01-01T12:00:00.000Z", + endsAt: null, + roomId: "", + }; + expect( + decodeRecovery("event", encodeRecovery("event", event)).payload, + ).toEqual(event); + }); +}); diff --git a/src/lib/form-recovery.ts b/src/lib/form-recovery.ts new file mode 100644 index 00000000..e481f51c --- /dev/null +++ b/src/lib/form-recovery.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; + +export const DRAFT_TTL = 7 * 24 * 60 * 60 * 1000; +const text = z.string().max(500_000); +export const articleRecoverySchema = z.object({ + title: z.string().max(255), + slug: z.string().max(255), + image: z.string().max(255), + shortStory: z.string().max(255), + fullStory: text, + status: z.enum(["draft", "scheduled", "published"]), + publishAt: z.string().max(255).default(""), +}); +const optionalNumber = z + .union([z.string().max(30), z.number().finite(), z.null()]) + .optional(); +const date = z.union([z.string().datetime(), z.null()]).optional(); +export const eventRecoverySchema = z.object({ + title: z.string().max(255), + description: text, + image: z.string().max(500).nullable().optional(), + typeId: z.number().int().nonnegative(), + status: z.enum(["draft", "published", "cancelled", "completed"]), + isRecurring: z.number().int().min(0).max(1), + recurrenceRule: z.string().max(255).nullable().optional(), + maxPlayers: optionalNumber, + roomId: optionalNumber, + startsAt: date, + endsAt: date, +}); +export type RecoveryKind = "article" | "event"; +export function recoveryKey( + userId: string, + kind: RecoveryKind, + record: string, +) { + return `cms:draft:v1:${encodeURIComponent(userId)}:${kind}:${encodeURIComponent(record)}`; +} +export function validateRecovery( + kind: RecoveryKind, + value: unknown, +): Record { + return ( + kind === "article" ? articleRecoverySchema : eventRecoverySchema + ).parse(value); +} +export function encodeRecovery( + kind: RecoveryKind, + value: unknown, + now = Date.now(), +) { + const raw = JSON.stringify({ + version: 1, + savedAt: now, + payload: validateRecovery(kind, value), + }); + if (raw.length > 600_000) throw new Error("draftTooLarge"); + return raw; +} +export function decodeRecovery( + kind: RecoveryKind, + raw: string, + now = Date.now(), +) { + if (raw.length > 600_000) throw new Error("draftTooLarge"); + const envelope = z + .object({ + version: z.literal(1), + savedAt: z.number().int(), + payload: z.unknown(), + }) + .parse(JSON.parse(raw)); + if (envelope.savedAt > now + 60_000 || now - envelope.savedAt > DRAFT_TTL) + throw new Error("draftExpired"); + return { + savedAt: envelope.savedAt, + payload: validateRecovery(kind, envelope.payload), + }; +} diff --git a/src/lib/furni/source-assets.ts b/src/lib/furni/source-assets.ts index 4453c6fc..bb4c5c7d 100644 --- a/src/lib/furni/source-assets.ts +++ b/src/lib/furni/source-assets.ts @@ -8,7 +8,15 @@ export interface SourceAssetItem { category: string; } export type AssetAvailability = "available" | "missing" | "unknown"; +export interface SourceAssetAttempt { + url: string; + file: string; + state: AssetAvailability; + reason: "available" | "http" | "network" | "timeout" | "invalidResponse"; + status?: number; +} export interface SourceAssetCheck { + attempts?: SourceAssetAttempt[]; classname: string; state: AssetAvailability; revision: number; diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index e1dfcf41..b2dcaa08 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -20,6 +20,7 @@ import { getFurnitureDataWritePaths, removeFurniEntry, } from "@/lib/services/furni-data"; +import { diagnosticAssetUrl } from "@/lib/services/furniture-source-assets"; import { lookupOfficialHabboFurni, type OfficialHabboFurniEntry, @@ -707,6 +708,7 @@ export async function importSingleFurni(params: { ? Promise.resolve(true) : tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => { iconFailure = detail; + warnings.push(`Icon download failed: ${detail}`); }), params.providedNitro || (params.preserveAssets && existsSync(nitroPath)) ? Promise.resolve(false) @@ -714,6 +716,7 @@ export async function importSingleFurni(params: { ? Promise.resolve(true) : tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => { swfFailure = detail; + warnings.push(`SWF download failed: ${detail}`); }), ]); let iconOk = iconOkResult; @@ -729,6 +732,10 @@ export async function importSingleFurni(params: { const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`; const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" }); if (dl.ok) nitroDownloadOk = true; + else + warnings.push( + `Nitro download failed: ${diagnosticAssetUrl(nitroUrl)}: ${dl.error ?? "unknown cause"}`, + ); } // ── Icon fallback: extract from SWF if download failed ──────────── diff --git a/src/lib/services/furni-job-store.test.ts b/src/lib/services/furni-job-store.test.ts index f9c737ee..7283066e 100644 --- a/src/lib/services/furni-job-store.test.ts +++ b/src/lib/services/furni-job-store.test.ts @@ -201,3 +201,44 @@ it("paginates timestamp ties without duplicates and excludes other owners", asyn ); expect(first.jobs.length + second.jobs.length).toBe(3); }); +it("attaches recovery files only to safe failed items and preserves retry history", async () => { + const s = await store(); + const original: ImportJob = { + ...job(), + state: "completed", + items: [ + { + id: 1, + classname: "chair", + name: "Chair", + description: "", + type: "flooritem", + revision: 1, + category: "", + state: "failed", + }, + { + id: 2, + classname: "lamp", + name: "Lamp", + description: "", + type: "flooritem", + revision: 1, + category: "", + state: "done", + }, + ], + }; + await s.create(original); + await expect(s.retry(original.id, 4, { lamp: randomUUID() })).rejects.toThrow( + "not eligible", + ); + const attachmentId = randomUUID(); + const child = await s.retry(original.id, 4, { chair: attachmentId }); + expect(child?.items).toHaveLength(1); + expect(child?.items[0].attachmentId).toBe(attachmentId); + expect((await s.read(original.id)).items[0].attachmentId).toBeUndefined(); + await expect( + s.retry(original.id, 4, { chair: randomUUID() }), + ).rejects.toThrow("latest retry"); +}); diff --git a/src/lib/services/furni-job-store.ts b/src/lib/services/furni-job-store.ts index ee1956ec..addab578 100644 --- a/src/lib/services/furni-job-store.ts +++ b/src/lib/services/furni-job-store.ts @@ -74,7 +74,11 @@ export class ImportJobStore { }); return this.read(id); } - async retry(id: string, userId: number): Promise { + async retry( + id: string, + userId: number, + attachments: Record = {}, + ): Promise { const original = await this.read(id).catch((error) => { if (error.code === "ENOENT") return null; throw error; @@ -82,11 +86,23 @@ export class ImportJobStore { if (!original || original.userId !== userId) return null; const items = retryableJobItems(original); if (!items.length) return null; + const entries = Object.entries(attachments); + if ( + entries.length && + (original.syncKind || + entries.some( + ([classname]) => + !items.some( + (item) => item.classname === classname && item.state === "failed", + ), + )) + ) + throw Error("Furniture is not eligible for attachment recovery"); // 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({ + const child = await this.create({ ...original, id: retryId, retryOf: id, @@ -97,10 +113,24 @@ export class ImportJobStore { items: items.map( ({ error: _error, warnings: _warnings, itemId: _itemId, ...item }) => ({ ...item, + ...(Object.hasOwn(attachments, item.classname) + ? { attachmentId: attachments[item.classname] } + : {}), state: "pending", }), ), }); + if ( + entries.some( + ([classname, attachmentId]) => + child.items.find((item) => item.classname === classname) + ?.attachmentId !== attachmentId, + ) + ) + throw Error( + "Recovery already started. Use the latest retry in the history.", + ); + return child; } async page(options: { userId: number; limit: number; before?: string }) { const limit = Math.min(30, Math.max(1, options.limit)); diff --git a/src/lib/services/furniture-source-assets.test.ts b/src/lib/services/furniture-source-assets.test.ts index 1cf05ed9..6d4914d5 100644 --- a/src/lib/services/furniture-source-assets.test.ts +++ b/src/lib/services/furniture-source-assets.test.ts @@ -74,3 +74,54 @@ describe("source asset preflight", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); }); + +const item = { + id: 1, + classname: "chair", + name: "Chair", + description: "", + type: "flooritem", + revision: 123, + category: "other", +}; + +it("reports every missing filename and HTTP reason without credentials", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 404 })), + ); + const result = await inspectSourceAssets(item, [], { + nitroBaseUrl: "https://user:secret@assets.test/private?token=secret", + } as never); + expect(result.state).toBe("missing"); + expect(result.attempts).toHaveLength(2); + expect(result.attempts?.[1]).toMatchObject({ + file: "chair.swf", + reason: "http", + status: 404, + }); + expect(JSON.stringify(result)).not.toContain("secret"); +}); +it("keeps network and blocked sources unknown rather than calling them missing", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("https://secret.test/?token=secret")), + ); + const result = await inspectSourceAssets({ ...item, revision: 124 }, []); + expect(result.state).toBe("unknown"); + expect(result.attempts?.[0].reason).toBe("network"); + expect(JSON.stringify(result)).not.toContain("secret"); +}); +it("distinguishes HTML responses from valid assets", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response(null, { headers: { "content-type": "text/html" } }), + ), + ); + const result = await inspectSourceAssets({ ...item, revision: 125 }, []); + expect(result.attempts?.[0].reason).toBe("invalidResponse"); + expect(result.state).toBe("unknown"); +}); diff --git a/src/lib/services/furniture-source-assets.ts b/src/lib/services/furniture-source-assets.ts index 864bc440..89e0c4f6 100644 --- a/src/lib/services/furniture-source-assets.ts +++ b/src/lib/services/furniture-source-assets.ts @@ -1,35 +1,63 @@ import type { AssetAvailability, + SourceAssetAttempt, SourceAssetCheck, SourceAssetItem, } from "@/lib/furni/source-assets"; import type { CloneSource } from "./clone-sources"; import { browserHeaders } from "./import/core/browser-headers"; -const cache = new Map(); -export async function probeAsset(url: string): Promise { +const cache = new Map< + string, + { expires: number; attempt: SourceAssetAttempt } +>(); +/** Only expose the address path; credentials, signed queries and fragments stay server-side. */ +export function diagnosticAssetUrl(raw: string): string { + const url = new URL(raw); + return `${url.protocol}//${url.host}${url.pathname}`; +} +async function probeAssetAttempt(url: string): Promise { const cached = cache.get(url); - if (cached && cached.expires > Date.now()) return cached.state; - let state: AssetAvailability = "unknown"; + if (cached && cached.expires > Date.now()) return cached.attempt; + const safeUrl = diagnosticAssetUrl(url); + const attempt: SourceAssetAttempt = { + url: safeUrl, + file: new URL(url).pathname.split("/").pop() || "", + state: "unknown", + reason: "network", + }; try { const response = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(6000), headers: browserHeaders(), }); + attempt.status = response.status; if ( response.ok && !response.headers.get("content-type")?.includes("text/html") - ) - state = "available"; - else if (response.status === 404 || response.status === 410) - state = "missing"; - } catch { - /* A network problem is not proof the asset is absent. */ + ) { + attempt.state = "available"; + attempt.reason = "available"; + } else if (response.ok) attempt.reason = "invalidResponse"; + else { + attempt.reason = "http"; + if (response.status === 404 || response.status === 410) + attempt.state = "missing"; + } + } catch (error) { + attempt.reason = + error instanceof Error && + (error.name === "TimeoutError" || error.name === "AbortError") + ? "timeout" + : "network"; } if (cache.size >= 2000) cache.clear(); - cache.set(url, { expires: Date.now() + 60000, state }); - return state; + cache.set(url, { expires: Date.now() + 60000, attempt }); + return attempt; +} +export async function probeAsset(url: string): Promise { + return (await probeAssetAttempt(url)).state; } export function furnitureAssetUrls( item: SourceAssetItem, @@ -51,19 +79,6 @@ export function furnitureAssetUrls( ); return urls; } -async function available( - item: SourceAssetItem, - source?: CloneSource | null, -): Promise { - const states = await Promise.all( - furnitureAssetUrls(item, source).map(probeAsset), - ); - return states.includes("available") - ? "available" - : states.every((state) => state === "missing") - ? "missing" - : "unknown"; -} export async function inspectSourceAssets( item: SourceAssetItem, official: SourceAssetItem[], @@ -76,12 +91,20 @@ export async function inspectSourceAssets( ...item, revision: item.revision || canonical?.revision || 0, }; - const state = await available(effective, source); + const attempts = await Promise.all( + furnitureAssetUrls(effective, source).map(probeAssetAttempt), + ); + const state = attempts.some((attempt) => attempt.state === "available") + ? "available" + : attempts.every((attempt) => attempt.state === "missing") + ? "missing" + : "unknown"; return { classname: item.classname, revision: effective.revision, state, alternatives: [], + attempts, }; } diff --git a/src/messages/ar.json b/src/messages/ar.json index db313f98..067530ae 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "ستُحفظ نسخة للاستعادة في هذا المتصفح لمدة 7 أيام.", + "saved": "تم حفظ نسخة للاستعادة في هذا المتصفح لمدة 7 أيام.", + "unavailable": "الاستعادة في المتصفح غير متاحة. أبقِ هذه الصفحة مفتوحة حتى يتم حفظ تغييراتك.", + "found": "توجد نسخة للاستعادة في هذا المتصفح، تم حفظها في", + "restore": "استعادة نسخة المتصفح", + "discard": "حذف نسخة المتصفح", + "confirmRestore": "هل تريد استبدال النموذج الحالي بنسخة الاستعادة من المتصفح؟ راجع المحتوى قبل الحفظ.", + "confirmDiscard": "هل تريد حذف نسخة الاستعادة هذه من المتصفح؟ سيبقى النموذج الحالي دون تغيير." + }, "nav": {}, "header": {}, "footer": {}, @@ -539,6 +549,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "تشخيص الملفات", + "preliminary": "فحص أولي للعناوين؛ سيتم التحقق من المحتوى عند الاستيراد.", + "http": "HTTP {status}", + "network": "تعذر الاتصال", + "timeout": "انتهت مهلة المصدر", + "invalidResponse": "أعاد المصدر صفحة ويب بدلاً من ملف", + "available": "تم العثور على الملف؛ ينتظر التحقق", + "recover": "إرفاق الأصل والاستئناف", + "recoveryHint": "اختر {file} (حتى 50 MB). تُفحص هويته. تُعاد العناصر الفاشلة فقط وتُحفظ الواردات المكتملة.", + "attach": "اختر ملف .nitro الأصلي", + "working": "جارٍ التحقق أو الاستئناف...", + "attached": "أُرفق الملف المطابق", + "resume": "استئناف العناصر الفاشلة بهذا الملف", + "invalidFile": "اختر ملف .nitro أصلياً مطابقاً، حتى 50 MB، ثم أعد المحاولة.", + "resumeError": "حدّث السجل واستخدم أحدث محاولة؛ تحقق من الملف وأعد المحاولة.", + "mismatchedFile": "هذا الملف يخص أثاثاً مختلفاً.", + "alreadyStarted": "بدأ الاستئناف بالفعل. حدّث السجل واستخدم أحدث محاولة.", + "notEligible": "هذا العنصر غير مؤهل للاسترداد بالمرفقات." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/bg.json b/src/messages/bg.json index 75405a77..44d17933 100644 --- a/src/messages/bg.json +++ b/src/messages/bg.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Копие за възстановяване ще се пази в този браузър 7 дни.", + "saved": "Копие за възстановяване е запазено в този браузър за 7 дни.", + "unavailable": "Възстановяването в браузъра не е достъпно. Остави тази страница отворена, докато промените бъдат запазени.", + "found": "В този браузър има копие за възстановяване, запазено на", + "restore": "Възстанови копието от браузъра", + "discard": "Изтрий копието от браузъра", + "confirmRestore": "Да се замени ли текущият формуляр с копието от браузъра? Провери съдържанието, преди да запазиш.", + "confirmDiscard": "Да се изтрие ли това копие от браузъра? Текущият формуляр ще остане непроменен." + }, "nav": { "home": "Начало", "community": "Общност", @@ -3783,6 +3793,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Диагностика на файловете", + "preliminary": "Предварителна проверка на адресите; съдържанието се проверява при импортиране.", + "http": "HTTP {status}", + "network": "Неуспешна връзка", + "timeout": "Изтекло време за отговор", + "invalidResponse": "Източникът върна уеб страница вместо файл", + "available": "Файлът е намерен; предстои проверка", + "recover": "Прикачване на оригинала и продължаване", + "recoveryHint": "Изберете {file} (до 50 MB). Проверява се самоличността му. Повтарят се неуспешните елементи; завършените се запазват.", + "attach": "Изберете оригиналния .nitro файл", + "working": "Проверка или продължаване...", + "attached": "Съответстващият файл е прикачен", + "resume": "Повторете неуспешните импортирания с файла", + "invalidFile": "Изберете съответстващ оригинален .nitro файл до 50 MB и опитайте отново.", + "resumeError": "Обновете историята и използвайте последния опит; проверете файла и опитайте отново.", + "mismatchedFile": "Този файл принадлежи на друга мебел.", + "alreadyStarted": "Възстановяването вече е започнало. Обновете историята и използвайте последния опит.", + "notEligible": "Този елемент не допуска възстановяване с прикачен файл." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/cs.json b/src/messages/cs.json index 60553865..8ea7ffd7 100644 --- a/src/messages/cs.json +++ b/src/messages/cs.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Kopie pro obnovení bude v tomto prohlížeči uchována po dobu 7 dnů.", + "saved": "Kopie pro obnovení byla v tomto prohlížeči uložena na 7 dnů.", + "unavailable": "Obnovení v prohlížeči není dostupné. Nech tuto stránku otevřenou, dokud se změny neuloží.", + "found": "V tomto prohlížeči je dostupná kopie pro obnovení, uložená", + "restore": "Obnovit kopii z prohlížeče", + "discard": "Smazat kopii z prohlížeče", + "confirmRestore": "Nahradit aktuální formulář kopií z prohlížeče? Před uložením zkontroluj obsah.", + "confirmDiscard": "Smazat tuto kopii z prohlížeče? Aktuální formulář zůstane beze změn." + }, "nav": { "home": "Domů", "community": "Společenství", @@ -3783,6 +3793,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Diagnostika souborů", + "preliminary": "Předběžná kontrola adres; obsah se ověří při importu.", + "http": "HTTP {status}", + "network": "Připojení selhalo", + "timeout": "Zdroj neodpověděl včas", + "invalidResponse": "Zdroj vrátil webovou stránku místo souboru", + "available": "Soubor nalezen; čeká na ověření", + "recover": "Přiložit originál a pokračovat", + "recoveryHint": "Vyberte {file} (do 50 MB). Ověří se jeho identita. Opakují se neúspěšné položky; dokončené importy zůstanou zachovány.", + "attach": "Vyberte původní soubor .nitro", + "working": "Ověřování nebo pokračování...", + "attached": "Odpovídající soubor přiložen", + "resume": "Opakovat neúspěšné importy s tímto souborem", + "invalidFile": "Vyberte odpovídající původní .nitro do 50 MB a zkuste to znovu.", + "resumeError": "Obnovte historii a použijte poslední pokus; zkontrolujte soubor a zkuste to znovu.", + "mismatchedFile": "Tento soubor patří jinému nábytku.", + "alreadyStarted": "Obnova již začala. Obnovte historii a použijte poslední pokus.", + "notEligible": "Tuto položku nelze obnovit přílohou." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/da.json b/src/messages/da.json index 8205c053..31c248d2 100644 --- a/src/messages/da.json +++ b/src/messages/da.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "En gendannelseskopi gemmes i denne browser i 7 dage.", + "saved": "En gendannelseskopi er gemt i denne browser i 7 dage.", + "unavailable": "Gendannelse i browseren er ikke tilgængelig. Hold denne side åben, indtil dine ændringer er gemt.", + "found": "Der er en gendannelseskopi i denne browser, gemt den", + "restore": "Gendan browserkopi", + "discard": "Slet browserkopi", + "confirmRestore": "Erstat den aktuelle formular med browserens gendannelseskopi? Kontrollér indholdet, før du gemmer.", + "confirmDiscard": "Slet denne gendannelseskopi fra browseren? Den aktuelle formular forbliver uændret." + }, "nav": { "home": "Hjem", "community": "Fællesskab", @@ -3783,6 +3793,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Fildiagnostik", + "preliminary": "Foreløbig kontrol af adresser; indholdet valideres under import.", + "http": "HTTP {status}", + "network": "Forbindelsen mislykkedes", + "timeout": "Kilden svarede ikke i tide", + "invalidResponse": "Kilden returnerede en webside i stedet for en fil", + "available": "Fil fundet; afventer validering", + "recover": "Vedhæft originalen og fortsæt", + "recoveryHint": "Vælg {file} (højst 50 MB). Identiteten kontrolleres. Mislykkede elementer prøves igen; færdige importer bevares.", + "attach": "Vælg den originale .nitro-fil", + "working": "Validerer eller genoptager...", + "attached": "Matchende fil vedhæftet", + "resume": "Genoptag mislykkede importer med filen", + "invalidFile": "Vælg en matchende original .nitro-fil på højst 50 MB og prøv igen.", + "resumeError": "Opdater historikken og brug seneste forsøg; kontroller filen og prøv igen.", + "mismatchedFile": "Denne fil tilhører et andet møbel.", + "alreadyStarted": "Genoptagelsen er startet. Opdater historikken og brug seneste forsøg.", + "notEligible": "Dette element kan ikke gendannes med en vedhæftet fil." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/de.json b/src/messages/de.json index 7c588f59..9bdc51c7 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Eine Wiederherstellungskopie wird 7 Tage in diesem Browser aufbewahrt.", + "saved": "Die Wiederherstellungskopie wurde für 7 Tage in diesem Browser gespeichert.", + "unavailable": "Die Wiederherstellung im Browser ist nicht verfügbar. Lass diese Seite geöffnet, bis deine Änderungen gespeichert sind.", + "found": "In diesem Browser ist eine Wiederherstellungskopie verfügbar, gespeichert am", + "restore": "Browserkopie wiederherstellen", + "discard": "Browserkopie löschen", + "confirmRestore": "Das aktuelle Formular durch die Wiederherstellungskopie im Browser ersetzen? Prüfe den Inhalt vor dem Speichern.", + "confirmDiscard": "Diese Wiederherstellungskopie im Browser löschen? Das aktuelle Formular bleibt unverändert." + }, "nav": { "home": "Start", "community": "Community", @@ -3784,6 +3794,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Dateidiagnose", + "preliminary": "Vorprüfung der Adressen; der Inhalt wird beim Import geprüft.", + "http": "HTTP {status}", + "network": "Verbindung fehlgeschlagen", + "timeout": "Zeitüberschreitung der Quelle", + "invalidResponse": "Die Quelle lieferte eine Webseite statt einer Datei", + "available": "Datei gefunden; Prüfung steht aus", + "recover": "Original anhängen und fortsetzen", + "recoveryHint": "Wähle {file} (maximal 50 MB). Die Identität wird geprüft. Fehlgeschlagene Einträge werden wiederholt; abgeschlossene Importe bleiben erhalten.", + "attach": "Originale .nitro-Datei wählen", + "working": "Datei wird geprüft oder Import fortgesetzt...", + "attached": "Passende Originaldatei angehängt", + "resume": "Fehlgeschlagene Importe mit dieser Datei fortsetzen", + "invalidFile": "Wähle eine passende originale .nitro-Datei bis 50 MB und versuche es erneut.", + "resumeError": "Aktualisiere den Verlauf und nutze den neuesten Versuch; prüfe die Datei und versuche es erneut.", + "mismatchedFile": "Diese Datei gehört zu einem anderen Möbelstück.", + "alreadyStarted": "Die Wiederherstellung läuft bereits. Aktualisiere den Verlauf und nutze den neuesten Versuch.", + "notEligible": "Dieser Eintrag kann nicht mit einem Anhang wiederhergestellt werden." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/el.json b/src/messages/el.json index 154e773e..9894466b 100644 --- a/src/messages/el.json +++ b/src/messages/el.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Ένα αντίγραφο ανάκτησης θα διατηρηθεί σε αυτό το πρόγραμμα περιήγησης για 7 ημέρες.", + "saved": "Το αντίγραφο ανάκτησης αποθηκεύτηκε σε αυτό το πρόγραμμα περιήγησης για 7 ημέρες.", + "unavailable": "Η ανάκτηση στο πρόγραμμα περιήγησης δεν είναι διαθέσιμη. Κράτησε αυτή τη σελίδα ανοιχτή μέχρι να αποθηκευτούν οι αλλαγές σου.", + "found": "Υπάρχει αντίγραφο ανάκτησης σε αυτό το πρόγραμμα περιήγησης, αποθηκευμένο στις", + "restore": "Επαναφορά αντιγράφου περιήγησης", + "discard": "Διαγραφή αντιγράφου περιήγησης", + "confirmRestore": "Να αντικατασταθεί η τρέχουσα φόρμα με το αντίγραφο ανάκτησης του προγράμματος περιήγησης; Έλεγξε το περιεχόμενο πριν από την αποθήκευση.", + "confirmDiscard": "Να διαγραφεί αυτό το αντίγραφο ανάκτησης από το πρόγραμμα περιήγησης; Η τρέχουσα φόρμα θα παραμείνει αμετάβλητη." + }, "nav": { "home": "Σπίτι", "community": "Κοινότητα", @@ -3783,6 +3793,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Διαγνωστικός έλεγχος αρχείων", + "preliminary": "Προκαταρκτικός έλεγχος διευθύνσεων· το περιεχόμενο ελέγχεται στην εισαγωγή.", + "http": "HTTP {status}", + "network": "Αποτυχία σύνδεσης", + "timeout": "Η πηγή δεν απάντησε εγκαίρως", + "invalidResponse": "Η πηγή επέστρεψε ιστοσελίδα αντί αρχείου", + "available": "Βρέθηκε αρχείο· εκκρεμεί έλεγχος", + "recover": "Επισύναψη πρωτοτύπου και συνέχεια", + "recoveryHint": "Επιλέξτε {file} (έως 50 MB). Ελέγχεται η ταυτότητά του. Επαναλαμβάνονται οι αποτυχίες· οι ολοκληρωμένες εισαγωγές διατηρούνται.", + "attach": "Επιλέξτε το αρχικό αρχείο .nitro", + "working": "Έλεγχος ή συνέχιση...", + "attached": "Επισυνάφθηκε αντίστοιχο αρχείο", + "resume": "Συνέχιση αποτυχημένων εισαγωγών με το αρχείο", + "invalidFile": "Επιλέξτε αντίστοιχο αρχικό .nitro έως 50 MB και δοκιμάστε ξανά.", + "resumeError": "Ανανεώστε το ιστορικό και χρησιμοποιήστε την τελευταία προσπάθεια· ελέγξτε το αρχείο και ξαναδοκιμάστε.", + "mismatchedFile": "Αυτό το αρχείο ανήκει σε άλλο έπιπλο.", + "alreadyStarted": "Η ανάκτηση ξεκίνησε ήδη. Ανανεώστε το ιστορικό και χρησιμοποιήστε την τελευταία προσπάθεια.", + "notEligible": "Αυτό το στοιχείο δεν επιτρέπει ανάκτηση με συνημμένο." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/en.json b/src/messages/en.json index 7c81858b..536ab934 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "A recovery copy will be kept in this browser for 7 days.", + "saved": "Recovery copy saved in this browser for 7 days.", + "unavailable": "Browser recovery is unavailable. Keep this page open until your changes are saved.", + "found": "A recovery copy is available in this browser, saved on", + "restore": "Restore browser copy", + "discard": "Discard browser copy", + "confirmRestore": "Replace the current form with the browser recovery copy? Review the contents before saving.", + "confirmDiscard": "Delete this browser recovery copy? Your current form will remain unchanged." + }, "nav": { "home": "Home", "community": "Community", @@ -4661,6 +4671,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Source file diagnostics", + "preliminary": "Preliminary check: these addresses were checked without downloading the files. The import will validate the original files.", + "http": "HTTP {status}", + "network": "Network connection failed", + "timeout": "The source did not respond in time", + "invalidResponse": "The source returned a web page instead of a file", + "available": "File found; content will be verified during import", + "recover": "Attach original file and resume", + "recoveryHint": "Choose {file} for this furniture (maximum 50 MB). Its identity is checked before use. Resuming retries the failed items and preserves completed imports.", + "attach": "Choose the original .nitro file", + "working": "Validating file or resuming import...", + "attached": "Matching original file attached", + "resume": "Resume failed imports with this file", + "invalidFile": "Could not attach the file. Select a valid original .nitro for this furniture, up to 50 MB, and try again.", + "resumeError": "Could not resume. Refresh the history and use the latest retry; check the original file and try again.", + "mismatchedFile": "This file belongs to different furniture.", + "alreadyStarted": "Recovery already started. Refresh the history and use the latest retry.", + "notEligible": "This item is not eligible for attachment recovery." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/es.json b/src/messages/es.json index 80b76852..269f0a78 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Se conservará una copia de recuperación en este navegador durante 7 días.", + "saved": "Copia de recuperación guardada en este navegador durante 7 días.", + "unavailable": "La recuperación en el navegador no está disponible. Mantén esta página abierta hasta que se guarden los cambios.", + "found": "Hay una copia de recuperación disponible en este navegador, guardada el", + "restore": "Restaurar copia del navegador", + "discard": "Eliminar copia del navegador", + "confirmRestore": "¿Reemplazar el formulario actual con la copia de recuperación del navegador? Revisa el contenido antes de guardar.", + "confirmDiscard": "¿Eliminar esta copia de recuperación del navegador? El formulario actual no cambiará." + }, "nav": { "home": "Inicio", "community": "Comunidad", @@ -3784,6 +3794,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Diagnóstico de archivos", + "preliminary": "Comprobación preliminar de direcciones; el contenido se valida al importar.", + "http": "HTTP {status}", + "network": "Falló la conexión", + "timeout": "La fuente no respondió a tiempo", + "invalidResponse": "La fuente devolvió una página web en lugar de un archivo", + "available": "Archivo encontrado; pendiente de validación", + "recover": "Adjuntar original y reanudar", + "recoveryHint": "Elige {file} (máximo 50 MB). Se verifica su identidad. Se reintentan los elementos fallidos y se conservan los importados.", + "attach": "Elige el archivo .nitro original", + "working": "Validando o reanudando...", + "attached": "Archivo original correspondiente adjuntado", + "resume": "Reanudar importaciones fallidas con este archivo", + "invalidFile": "Elige un .nitro original correspondiente de hasta 50 MB y vuelve a intentarlo.", + "resumeError": "Actualiza el historial y usa el último intento; comprueba el archivo y vuelve a intentarlo.", + "mismatchedFile": "Este archivo pertenece a otro mueble.", + "alreadyStarted": "La recuperación ya comenzó. Actualiza el historial y usa el último intento.", + "notEligible": "Este elemento no admite recuperación mediante archivo adjunto." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/fi.json b/src/messages/fi.json index 770a2ca2..02287db2 100644 --- a/src/messages/fi.json +++ b/src/messages/fi.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Palautuskopio säilytetään tässä selaimessa 7 päivän ajan.", + "saved": "Palautuskopio on tallennettu tähän selaimeen 7 päiväksi.", + "unavailable": "Selaimen palautustoiminto ei ole käytettävissä. Pidä tämä sivu auki, kunnes muutoksesi on tallennettu.", + "found": "Tässä selaimessa on palautuskopio, joka on tallennettu", + "restore": "Palauta selaimen kopio", + "discard": "Poista selaimen kopio", + "confirmRestore": "Korvataanko nykyinen lomake selaimen palautuskopiolla? Tarkista sisältö ennen tallentamista.", + "confirmDiscard": "Poistetaanko tämä palautuskopio selaimesta? Nykyinen lomake säilyy ennallaan." + }, "nav": {}, "header": {}, "footer": {}, @@ -539,6 +549,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Tiedostojen diagnostiikka", + "preliminary": "Osoitteiden alustava tarkistus; sisältö tarkistetaan tuonnissa.", + "http": "HTTP {status}", + "network": "Yhteys epäonnistui", + "timeout": "Lähde ei vastannut ajoissa", + "invalidResponse": "Lähde palautti verkkosivun tiedoston sijaan", + "available": "Tiedosto löytyi; odottaa tarkistusta", + "recover": "Liitä alkuperäinen ja jatka", + "recoveryHint": "Valitse {file} (enintään 50 MB). Sen tunniste tarkistetaan. Epäonnistuneet kohteet yritetään uudelleen; valmiit tuonnit säilytetään.", + "attach": "Valitse alkuperäinen .nitro-tiedosto", + "working": "Tarkistetaan tai jatketaan...", + "attached": "Vastaava tiedosto liitetty", + "resume": "Jatka epäonnistuneita tuonteja tällä tiedostolla", + "invalidFile": "Valitse vastaava alkuperäinen .nitro, enintään 50 MB, ja yritä uudelleen.", + "resumeError": "Päivitä historia ja käytä uusinta yritystä; tarkista tiedosto ja yritä uudelleen.", + "mismatchedFile": "Tämä tiedosto kuuluu toiselle huonekalulle.", + "alreadyStarted": "Palautus on jo alkanut. Päivitä historia ja käytä uusinta yritystä.", + "notEligible": "Tätä kohdetta ei voi palauttaa liitteellä." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/fr.json b/src/messages/fr.json index 6e2f1c9b..4811e7cd 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Une copie de récupération sera conservée dans ce navigateur pendant 7 jours.", + "saved": "Une copie de récupération a été enregistrée dans ce navigateur pour 7 jours.", + "unavailable": "La récupération dans le navigateur est indisponible. Garde cette page ouverte jusqu’à l’enregistrement de tes modifications.", + "found": "Une copie de récupération est disponible dans ce navigateur, enregistrée le", + "restore": "Restaurer la copie du navigateur", + "discard": "Supprimer la copie du navigateur", + "confirmRestore": "Remplacer le formulaire actuel par la copie de récupération du navigateur ? Vérifie le contenu avant d’enregistrer.", + "confirmDiscard": "Supprimer cette copie de récupération du navigateur ? Le formulaire actuel restera inchangé." + }, "nav": { "home": "Accueil", "community": "Communauté", @@ -3784,6 +3794,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Diagnostic des fichiers", + "preliminary": "Vérification préliminaire des adresses ; le contenu sera validé à l’importation.", + "http": "HTTP {status}", + "network": "Échec de la connexion", + "timeout": "La source n’a pas répondu à temps", + "invalidResponse": "La source a renvoyé une page web au lieu d’un fichier", + "available": "Fichier trouvé ; validation en attente", + "recover": "Joindre l’original et reprendre", + "recoveryHint": "Choisissez {file} (50 MB maximum). Son identité est vérifiée. Les éléments échoués sont relancés ; les imports terminés sont conservés.", + "attach": "Choisir le fichier .nitro original", + "working": "Validation ou reprise en cours...", + "attached": "Fichier original correspondant joint", + "resume": "Reprendre les imports échoués avec ce fichier", + "invalidFile": "Choisissez un .nitro original correspondant de 50 MB maximum et réessayez.", + "resumeError": "Actualisez l’historique et utilisez la dernière tentative ; vérifiez le fichier et réessayez.", + "mismatchedFile": "Ce fichier appartient à un autre meuble.", + "alreadyStarted": "La reprise a déjà commencé. Actualisez l’historique et utilisez la dernière tentative.", + "notEligible": "Cet élément ne permet pas la récupération par pièce jointe." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/hr.json b/src/messages/hr.json index 3763fb3b..3ca70581 100644 --- a/src/messages/hr.json +++ b/src/messages/hr.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Kopija za oporavak čuvat će se u ovom pregledniku 7 dana.", + "saved": "Kopija za oporavak spremljena je u ovom pregledniku na 7 dana.", + "unavailable": "Oporavak u pregledniku nije dostupan. Ostavi ovu stranicu otvorenom dok se promjene ne spreme.", + "found": "U ovom pregledniku dostupna je kopija za oporavak, spremljena", + "restore": "Vrati kopiju iz preglednika", + "discard": "Izbriši kopiju iz preglednika", + "confirmRestore": "Zamijeniti trenutačni obrazac kopijom iz preglednika? Provjeri sadržaj prije spremanja.", + "confirmDiscard": "Izbrisati ovu kopiju iz preglednika? Trenutačni obrazac ostat će nepromijenjen." + }, "nav": { "home": "Dom", "community": "zajednica", @@ -3783,6 +3793,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Dijagnostika datoteka", + "preliminary": "Preliminarna provjera adresa; sadržaj se provjerava pri uvozu.", + "http": "HTTP {status}", + "network": "Povezivanje nije uspjelo", + "timeout": "Izvor nije odgovorio na vrijeme", + "invalidResponse": "Izvor je vratio web-stranicu umjesto datoteke", + "available": "Datoteka pronađena; čeka provjeru", + "recover": "Priloži izvornik i nastavi", + "recoveryHint": "Odaberite {file} (do 50 MB). Provjerava se identitet. Ponavljaju se neuspjele stavke; dovršeni uvozi ostaju sačuvani.", + "attach": "Odaberite izvornu .nitro datoteku", + "working": "Provjera ili nastavak...", + "attached": "Odgovarajuća datoteka priložena", + "resume": "Nastavi neuspjele uvoze s datotekom", + "invalidFile": "Odaberite odgovarajuću izvornu .nitro datoteku do 50 MB i pokušajte ponovno.", + "resumeError": "Osvježite povijest i koristite posljednji pokušaj; provjerite datoteku i pokušajte ponovno.", + "mismatchedFile": "Ova datoteka pripada drugom komadu namještaja.", + "alreadyStarted": "Oporavak je već pokrenut. Osvježite povijest i koristite posljednji pokušaj.", + "notEligible": "Ova stavka ne podržava oporavak prilogom." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/hu.json b/src/messages/hu.json index aa17ab11..e6b017cb 100644 --- a/src/messages/hu.json +++ b/src/messages/hu.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "A helyreállítási másolat 7 napig megmarad ebben a böngészőben.", + "saved": "A helyreállítási másolat 7 napra elmentve ebben a böngészőben.", + "unavailable": "A böngészőből történő helyreállítás nem érhető el. Tartsd nyitva ezt az oldalt a módosítások mentéséig.", + "found": "Ebben a böngészőben elérhető egy helyreállítási másolat. Mentés ideje:", + "restore": "Böngészőmásolat visszaállítása", + "discard": "Böngészőmásolat törlése", + "confirmRestore": "Lecseréled a jelenlegi űrlapot a böngészőben tárolt helyreállítási másolatra? Mentés előtt ellenőrizd a tartalmát.", + "confirmDiscard": "Törlöd ezt a helyreállítási másolatot a böngészőből? A jelenlegi űrlap változatlan marad." + }, "nav": { "home": "Otthon", "community": "közösség", @@ -3783,6 +3793,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Fájldiagnosztika", + "preliminary": "A címek előzetes ellenőrzése; a tartalmat az importálás ellenőrzi.", + "http": "HTTP {status}", + "network": "Sikertelen kapcsolódás", + "timeout": "A forrás nem válaszolt időben", + "invalidResponse": "A forrás fájl helyett weboldalt adott vissza", + "available": "Fájl megtalálva; ellenőrzésre vár", + "recover": "Eredeti csatolása és folytatás", + "recoveryHint": "Válassza ki: {file} (legfeljebb 50 MB). Az azonosságát ellenőrizzük. A sikertelen elemek újrapróbálhatók; a kész importok megmaradnak.", + "attach": "Válassza ki az eredeti .nitro fájlt", + "working": "Ellenőrzés vagy folytatás...", + "attached": "Megfelelő eredeti fájl csatolva", + "resume": "Sikertelen importok folytatása ezzel a fájllal", + "invalidFile": "Válasszon megfelelő eredeti .nitro fájlt legfeljebb 50 MB méretben, és próbálja újra.", + "resumeError": "Frissítse az előzményeket, és használja a legutóbbi próbálkozást; ellenőrizze a fájlt, és próbálja újra.", + "mismatchedFile": "Ez a fájl másik bútorhoz tartozik.", + "alreadyStarted": "A helyreállítás már elindult. Frissítse az előzményeket, és használja a legutóbbi próbálkozást.", + "notEligible": "Ez az elem nem állítható helyre csatolmánnyal." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/it.json b/src/messages/it.json index bf1ae33d..f7737f31 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Una copia di recupero sarà conservata in questo browser per 7 giorni.", + "saved": "Copia di recupero salvata in questo browser per 7 giorni.", + "unavailable": "Recupero nel browser non disponibile. Tieni aperta questa pagina fino al salvataggio delle modifiche.", + "found": "È disponibile una copia di recupero in questo browser, salvata il", + "restore": "Ripristina copia del browser", + "discard": "Elimina copia del browser", + "confirmRestore": "Sostituire il modulo attuale con la copia di recupero del browser? Controlla il contenuto prima di salvare.", + "confirmDiscard": "Eliminare questa copia di recupero del browser? Il modulo attuale resterà invariato." + }, "nav": { "home": "Home", "community": "Community", @@ -4666,6 +4676,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Diagnostica file sorgente", + "preliminary": "Controllo preliminare: questi indirizzi sono stati verificati senza scaricare i file. L'importazione validerà i file originali.", + "http": "HTTP {status}", + "network": "Connessione alla sorgente non riuscita", + "timeout": "La sorgente non ha risposto in tempo", + "invalidResponse": "La sorgente ha restituito una pagina web invece di un file", + "available": "File trovato; il contenuto sarà verificato durante l'importazione", + "recover": "Carica il file originale e riprendi", + "recoveryHint": "Scegli {file} per questo furni (massimo 50 MB). L'identità viene verificata prima dell'uso. La ripresa ritenta gli elementi falliti e preserva le importazioni completate.", + "attach": "Scegli il file .nitro originale", + "working": "Validazione del file o ripresa dell'importazione...", + "attached": "File originale corrispondente allegato", + "resume": "Riprendi gli import falliti con questo file", + "invalidFile": "Impossibile allegare il file. Scegli un .nitro originale valido per questo furni, fino a 50 MB, e riprova.", + "resumeError": "Impossibile riprendere. Aggiorna la cronologia e usa l'ultimo tentativo; controlla il file originale e riprova.", + "mismatchedFile": "Questo file appartiene a un altro furni.", + "alreadyStarted": "Ripresa già avviata. Aggiorna la cronologia e usa l’ultimo tentativo.", + "notEligible": "Questo elemento non consente il recupero tramite allegato." + }, "completeness": { "title": "Completezza e prossimi passi", "parts": { diff --git a/src/messages/ja.json b/src/messages/ja.json index 11ff8000..b60bdb41 100644 --- a/src/messages/ja.json +++ b/src/messages/ja.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "復元用のコピーをこのブラウザーに7日間保存します。", + "saved": "復元用のコピーをこのブラウザーに7日間保存しました。", + "unavailable": "ブラウザーでの復元を利用できません。変更が保存されるまで、このページを開いたままにしてください。", + "found": "このブラウザーに復元用のコピーがあります。保存日時:", + "restore": "ブラウザーのコピーを復元", + "discard": "ブラウザーのコピーを削除", + "confirmRestore": "現在のフォームをブラウザー内の復元用コピーで置き換えますか?保存する前に内容を確認してください。", + "confirmDiscard": "このブラウザー内の復元用コピーを削除しますか?現在のフォームは変更されません。" + }, "nav": {}, "header": {}, "footer": {}, @@ -539,6 +549,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "ファイル診断", + "preliminary": "アドレスの事前確認です。内容はインポート時に検証します。", + "http": "HTTP {status}", + "network": "接続に失敗しました", + "timeout": "ソースの応答がタイムアウトしました", + "invalidResponse": "ソースがファイルではなくウェブページを返しました", + "available": "ファイルを検出しました。検証待ちです", + "recover": "元のファイルを添付して再開", + "recoveryHint": "{file}(最大50 MB)を選択してください。一致を確認し、失敗した項目を再試行します。完了したインポートは保持されます。", + "attach": "元の .nitro ファイルを選択", + "working": "検証または再開中...", + "attached": "一致する元のファイルを添付しました", + "resume": "このファイルで失敗したインポートを再開", + "invalidFile": "対応する元の .nitro ファイル(最大50 MB)を選択して再試行してください。", + "resumeError": "履歴を更新し、最新の試行を使用してください。ファイルを確認して再試行してください。", + "mismatchedFile": "このファイルは別の家具のものです。", + "alreadyStarted": "再開は既に開始されています。履歴を更新し、最新の試行を使用してください。", + "notEligible": "この項目は添付ファイルで復旧できません。" + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/nl.json b/src/messages/nl.json index f492a7fb..76c79b09 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Een herstelkopie wordt 7 dagen in deze browser bewaard.", + "saved": "De herstelkopie is voor 7 dagen in deze browser opgeslagen.", + "unavailable": "Herstel via de browser is niet beschikbaar. Houd deze pagina open totdat je wijzigingen zijn opgeslagen.", + "found": "Er is een herstelkopie beschikbaar in deze browser, opgeslagen op", + "restore": "Browserkopie herstellen", + "discard": "Browserkopie verwijderen", + "confirmRestore": "Het huidige formulier vervangen door de herstelkopie uit de browser? Controleer de inhoud voordat je opslaat.", + "confirmDiscard": "Deze herstelkopie uit de browser verwijderen? Het huidige formulier blijft ongewijzigd." + }, "nav": { "home": "Home", "community": "Community", @@ -4137,6 +4147,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Bestandsdiagnostiek", + "preliminary": "Voorafgaande controle van adressen; inhoud wordt tijdens import gevalideerd.", + "http": "HTTP {status}", + "network": "Verbinding mislukt", + "timeout": "De bron antwoordde niet op tijd", + "invalidResponse": "De bron gaf een webpagina in plaats van een bestand", + "available": "Bestand gevonden; wacht op validatie", + "recover": "Origineel toevoegen en hervatten", + "recoveryHint": "Kies {file} (maximaal 50 MB). De identiteit wordt gecontroleerd. Mislukte items worden opnieuw geprobeerd; voltooide imports blijven behouden.", + "attach": "Kies het originele .nitro-bestand", + "working": "Valideren of hervatten...", + "attached": "Overeenkomend origineel toegevoegd", + "resume": "Mislukte imports hervatten met dit bestand", + "invalidFile": "Kies een overeenkomend origineel .nitro-bestand van maximaal 50 MB en probeer opnieuw.", + "resumeError": "Vernieuw de geschiedenis en gebruik de laatste poging; controleer het bestand en probeer opnieuw.", + "mismatchedFile": "Dit bestand hoort bij een ander meubel.", + "alreadyStarted": "Herstel is al gestart. Vernieuw de geschiedenis en gebruik de laatste poging.", + "notEligible": "Dit item kan niet met een bijlage worden hersteld." + }, "completeness": { "title": "Volledigheid en vervolgstappen", "parts": { diff --git a/src/messages/no.json b/src/messages/no.json index ef6eec17..53ccaa77 100644 --- a/src/messages/no.json +++ b/src/messages/no.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "En gjenopprettingskopi lagres i denne nettleseren i 7 dager.", + "saved": "En gjenopprettingskopi er lagret i denne nettleseren i 7 dager.", + "unavailable": "Gjenoppretting i nettleseren er ikke tilgjengelig. Hold denne siden åpen til endringene dine er lagret.", + "found": "En gjenopprettingskopi er tilgjengelig i denne nettleseren, lagret", + "restore": "Gjenopprett nettleserkopi", + "discard": "Slett nettleserkopi", + "confirmRestore": "Erstatte det gjeldende skjemaet med gjenopprettingskopien fra nettleseren? Kontroller innholdet før du lagrer.", + "confirmDiscard": "Slette denne gjenopprettingskopien fra nettleseren? Det gjeldende skjemaet forblir uendret." + }, "nav": { "home": "Hjem", "community": "Fellesskap", @@ -3783,6 +3793,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Fildiagnostikk", + "preliminary": "Foreløpig kontroll av adresser; innholdet valideres ved import.", + "http": "HTTP {status}", + "network": "Tilkoblingen mislyktes", + "timeout": "Kilden svarte ikke i tide", + "invalidResponse": "Kilden returnerte en nettside i stedet for en fil", + "available": "Fil funnet; venter på validering", + "recover": "Legg ved originalen og fortsett", + "recoveryHint": "Velg {file} (maksimalt 50 MB). Identiteten kontrolleres. Mislykkede elementer prøves på nytt; fullførte importer bevares.", + "attach": "Velg den originale .nitro-filen", + "working": "Validerer eller gjenopptar...", + "attached": "Tilsvarende originalfil vedlagt", + "resume": "Gjenoppta mislykkede importer med filen", + "invalidFile": "Velg en tilsvarende original .nitro-fil på opptil 50 MB og prøv igjen.", + "resumeError": "Oppdater historikken og bruk siste forsøk; kontroller filen og prøv igjen.", + "mismatchedFile": "Denne filen tilhører et annet møbel.", + "alreadyStarted": "Gjenopprettingen har startet. Oppdater historikken og bruk siste forsøk.", + "notEligible": "Dette elementet kan ikke gjenopprettes med et vedlegg." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/pl.json b/src/messages/pl.json index 9f2ce292..5bcfdfbf 100644 --- a/src/messages/pl.json +++ b/src/messages/pl.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Kopia do odzyskania będzie przechowywana w tej przeglądarce przez 7 dni.", + "saved": "Kopia do odzyskania została zapisana w tej przeglądarce na 7 dni.", + "unavailable": "Odzyskiwanie w przeglądarce jest niedostępne. Nie zamykaj tej strony, dopóki zmiany nie zostaną zapisane.", + "found": "W tej przeglądarce jest dostępna kopia do odzyskania, zapisana", + "restore": "Przywróć kopię z przeglądarki", + "discard": "Usuń kopię z przeglądarki", + "confirmRestore": "Zastąpić bieżący formularz kopią z przeglądarki? Sprawdź zawartość przed zapisaniem.", + "confirmDiscard": "Usunąć tę kopię z przeglądarki? Bieżący formularz pozostanie bez zmian." + }, "nav": { "home": "Dom", "community": "Społeczność", @@ -3784,6 +3794,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Diagnostyka plików", + "preliminary": "Wstępna kontrola adresów; zawartość zostanie sprawdzona podczas importu.", + "http": "HTTP {status}", + "network": "Połączenie nie powiodło się", + "timeout": "Źródło nie odpowiedziało na czas", + "invalidResponse": "Źródło zwróciło stronę zamiast pliku", + "available": "Plik znaleziony; oczekuje na weryfikację", + "recover": "Dołącz oryginał i wznów", + "recoveryHint": "Wybierz {file} (do 50 MB). Tożsamość pliku jest sprawdzana. Ponawiane są nieudane pozycje; ukończone importy zostają zachowane.", + "attach": "Wybierz oryginalny plik .nitro", + "working": "Sprawdzanie lub wznawianie...", + "attached": "Dołączono pasujący oryginał", + "resume": "Wznów nieudane importy z tym plikiem", + "invalidFile": "Wybierz pasujący oryginalny plik .nitro do 50 MB i spróbuj ponownie.", + "resumeError": "Odśwież historię i użyj ostatniej próby; sprawdź plik i spróbuj ponownie.", + "mismatchedFile": "Ten plik należy do innego mebla.", + "alreadyStarted": "Odzyskiwanie już rozpoczęto. Odśwież historię i użyj ostatniej próby.", + "notEligible": "Tej pozycji nie można odzyskać za pomocą załącznika." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/pt.json b/src/messages/pt.json index 4c83450c..4ea40e6f 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Uma cópia de recuperação será mantida neste navegador durante 7 dias.", + "saved": "Cópia de recuperação guardada neste navegador durante 7 dias.", + "unavailable": "A recuperação no navegador não está disponível. Mantém esta página aberta até que as alterações sejam guardadas.", + "found": "Existe uma cópia de recuperação neste navegador, guardada em", + "restore": "Restaurar cópia do navegador", + "discard": "Eliminar cópia do navegador", + "confirmRestore": "Substituir o formulário atual pela cópia de recuperação do navegador? Verifica o conteúdo antes de guardar.", + "confirmDiscard": "Eliminar esta cópia de recuperação do navegador? O formulário atual permanecerá inalterado." + }, "nav": { "home": "Página inicial", "community": "Comunidade", @@ -3784,6 +3794,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Diagnóstico dos ficheiros", + "preliminary": "Verificação preliminar dos endereços; o conteúdo será validado na importação.", + "http": "HTTP {status}", + "network": "Falha na ligação", + "timeout": "A fonte não respondeu a tempo", + "invalidResponse": "A fonte devolveu uma página em vez de um ficheiro", + "available": "Ficheiro encontrado; aguarda validação", + "recover": "Anexar original e retomar", + "recoveryHint": "Escolha {file} (até 50 MB). A identidade é verificada. Os itens com falha são repetidos; as importações concluídas são preservadas.", + "attach": "Escolher o ficheiro .nitro original", + "working": "A validar ou retomar...", + "attached": "Original correspondente anexado", + "resume": "Retomar importações com falha usando este ficheiro", + "invalidFile": "Escolha um .nitro original correspondente de até 50 MB e tente novamente.", + "resumeError": "Atualize o histórico e use a última tentativa; verifique o ficheiro e tente novamente.", + "mismatchedFile": "Este ficheiro pertence a outro móvel.", + "alreadyStarted": "A recuperação já começou. Atualize o histórico e use a última tentativa.", + "notEligible": "Este item não permite recuperação por anexo." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/ro.json b/src/messages/ro.json index 04f5a21a..23b535bf 100644 --- a/src/messages/ro.json +++ b/src/messages/ro.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "O copie de recuperare va fi păstrată în acest browser timp de 7 zile.", + "saved": "Copia de recuperare a fost salvată în acest browser pentru 7 zile.", + "unavailable": "Recuperarea în browser nu este disponibilă. Păstrează această pagină deschisă până când modificările sunt salvate.", + "found": "În acest browser este disponibilă o copie de recuperare, salvată la", + "restore": "Restaurează copia din browser", + "discard": "Șterge copia din browser", + "confirmRestore": "Înlocuiești formularul actual cu copia de recuperare din browser? Verifică conținutul înainte de salvare.", + "confirmDiscard": "Ștergi această copie de recuperare din browser? Formularul actual va rămâne neschimbat." + }, "nav": { "home": "Acasă", "community": "comunitate", @@ -3783,6 +3793,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Diagnosticarea fișierelor", + "preliminary": "Verificare preliminară a adreselor; conținutul se validează la import.", + "http": "HTTP {status}", + "network": "Conexiunea a eșuat", + "timeout": "Sursa nu a răspuns la timp", + "invalidResponse": "Sursa a returnat o pagină web în loc de fișier", + "available": "Fișier găsit; așteaptă validarea", + "recover": "Atașează originalul și reia", + "recoveryHint": "Alege {file} (maximum 50 MB). Identitatea se verifică. Elementele eșuate se reîncearcă; importurile finalizate se păstrează.", + "attach": "Alege fișierul .nitro original", + "working": "Validare sau reluare...", + "attached": "Fișierul original corespunzător a fost atașat", + "resume": "Reia importurile eșuate cu acest fișier", + "invalidFile": "Alege un .nitro original corespunzător de maximum 50 MB și încearcă din nou.", + "resumeError": "Actualizează istoricul și folosește ultima încercare; verifică fișierul și încearcă din nou.", + "mismatchedFile": "Acest fișier aparține altui obiect de mobilier.", + "alreadyStarted": "Recuperarea a început deja. Actualizează istoricul și folosește ultima încercare.", + "notEligible": "Acest element nu permite recuperarea prin atașament." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/ru.json b/src/messages/ru.json index f290a4e5..a82387ec 100644 --- a/src/messages/ru.json +++ b/src/messages/ru.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Копия для восстановления будет храниться в этом браузере 7 дней.", + "saved": "Копия для восстановления сохранена в этом браузере на 7 дней.", + "unavailable": "Восстановление в браузере недоступно. Не закрывай эту страницу, пока изменения не будут сохранены.", + "found": "В этом браузере доступна копия для восстановления. Дата сохранения:", + "restore": "Восстановить копию из браузера", + "discard": "Удалить копию из браузера", + "confirmRestore": "Заменить текущую форму копией из браузера? Проверь содержимое перед сохранением.", + "confirmDiscard": "Удалить эту копию из браузера? Текущая форма останется без изменений." + }, "nav": { "home": "Главная", "community": "Сообщество", @@ -3782,6 +3792,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Диагностика файлов", + "preliminary": "Предварительная проверка адресов; содержимое проверяется при импорте.", + "http": "HTTP {status}", + "network": "Не удалось подключиться", + "timeout": "Источник не ответил вовремя", + "invalidResponse": "Источник вернул веб-страницу вместо файла", + "available": "Файл найден; ожидает проверки", + "recover": "Прикрепить оригинал и продолжить", + "recoveryHint": "Выберите {file} (до 50 MB). Подлинность соответствия проверяется. Повторяются неудачные элементы; завершённые импорты сохраняются.", + "attach": "Выберите исходный файл .nitro", + "working": "Проверка или возобновление...", + "attached": "Соответствующий оригинал прикреплён", + "resume": "Повторить неудачные импорты с этим файлом", + "invalidFile": "Выберите соответствующий исходный .nitro до 50 MB и повторите попытку.", + "resumeError": "Обновите историю и используйте последнюю попытку; проверьте файл и попробуйте снова.", + "mismatchedFile": "Этот файл относится к другой мебели.", + "alreadyStarted": "Восстановление уже началось. Обновите историю и используйте последнюю попытку.", + "notEligible": "Этот элемент нельзя восстановить вложением." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/sk.json b/src/messages/sk.json index f550f378..69e5d60a 100644 --- a/src/messages/sk.json +++ b/src/messages/sk.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Kópia na obnovenie bude v tomto prehliadači uchovaná 7 dní.", + "saved": "Kópia na obnovenie bola v tomto prehliadači uložená na 7 dní.", + "unavailable": "Obnovenie v prehliadači nie je dostupné. Nechaj túto stránku otvorenú, kým sa zmeny neuložia.", + "found": "V tomto prehliadači je dostupná kópia na obnovenie, uložená", + "restore": "Obnoviť kópiu z prehliadača", + "discard": "Odstrániť kópiu z prehliadača", + "confirmRestore": "Nahradiť aktuálny formulár kópiou z prehliadača? Pred uložením skontroluj obsah.", + "confirmDiscard": "Odstrániť túto kópiu z prehliadača? Aktuálny formulár zostane nezmenený." + }, "nav": { "home": "Domov", "community": "Spoločenstva", @@ -3783,6 +3793,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Diagnostika súborov", + "preliminary": "Predbežná kontrola adries; obsah sa overí pri importe.", + "http": "HTTP {status}", + "network": "Pripojenie zlyhalo", + "timeout": "Zdroj neodpovedal včas", + "invalidResponse": "Zdroj vrátil webovú stránku namiesto súboru", + "available": "Súbor nájdený; čaká na overenie", + "recover": "Priložiť originál a pokračovať", + "recoveryHint": "Vyberte {file} (do 50 MB). Overí sa jeho identita. Opakujú sa neúspešné položky; dokončené importy zostanú zachované.", + "attach": "Vyberte pôvodný súbor .nitro", + "working": "Overovanie alebo pokračovanie...", + "attached": "Zodpovedajúci súbor priložený", + "resume": "Obnoviť neúspešné importy s týmto súborom", + "invalidFile": "Vyberte zodpovedajúci pôvodný .nitro do 50 MB a skúste to znova.", + "resumeError": "Obnovte históriu a použite posledný pokus; skontrolujte súbor a skúste to znova.", + "mismatchedFile": "Tento súbor patrí inému nábytku.", + "alreadyStarted": "Obnova už začala. Obnovte históriu a použite posledný pokus.", + "notEligible": "Túto položku nemožno obnoviť prílohou." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/sr.json b/src/messages/sr.json index 46139329..44dcc99a 100644 --- a/src/messages/sr.json +++ b/src/messages/sr.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Копија за опоравак чуваће се у овом прегледачу 7 дана.", + "saved": "Копија за опоравак сачувана је у овом прегледачу на 7 дана.", + "unavailable": "Опоравак у прегледачу није доступан. Остави ову страницу отвореном док се промене не сачувају.", + "found": "У овом прегледачу доступна је копија за опоравак, сачувана", + "restore": "Врати копију из прегледача", + "discard": "Избриши копију из прегледача", + "confirmRestore": "Заменити тренутни образац копијом из прегледача? Провери садржај пре чувања.", + "confirmDiscard": "Избрисати ову копију из прегледача? Тренутни образац остаће непромењен." + }, "nav": { "home": "Хоме", "community": "Заједница", @@ -3783,6 +3793,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Дијагностика датотека", + "preliminary": "Прелиминарна провера адреса; садржај се проверава при увозу.", + "http": "HTTP {status}", + "network": "Повезивање није успело", + "timeout": "Извор није одговорио на време", + "invalidResponse": "Извор је вратио веб-страницу уместо датотеке", + "available": "Датотека пронађена; чека проверу", + "recover": "Приложи оригинал и настави", + "recoveryHint": "Изаберите {file} (до 50 MB). Проверава се идентитет. Понављају се неуспеле ставке; завршени увози се чувају.", + "attach": "Изаберите изворну .nitro датотеку", + "working": "Провера или наставак...", + "attached": "Одговарајућа датотека је приложена", + "resume": "Настави неуспеле увозе са датотеком", + "invalidFile": "Изаберите одговарајућу изворну .nitro датотеку до 50 MB и покушајте поново.", + "resumeError": "Освежите историју и користите последњи покушај; проверите датотеку и покушајте поново.", + "mismatchedFile": "Ова датотека припада другом комаду намештаја.", + "alreadyStarted": "Опоравак је већ покренут. Освежите историју и користите последњи покушај.", + "notEligible": "Ова ставка не подржава опоравак прилогом." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/sv.json b/src/messages/sv.json index a9072434..f151929b 100644 --- a/src/messages/sv.json +++ b/src/messages/sv.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "En återställningskopia sparas i den här webbläsaren i 7 dagar.", + "saved": "En återställningskopia har sparats i den här webbläsaren i 7 dagar.", + "unavailable": "Återställning i webbläsaren är inte tillgänglig. Låt sidan vara öppen tills dina ändringar har sparats.", + "found": "En återställningskopia finns i den här webbläsaren, sparad", + "restore": "Återställ webbläsarkopia", + "discard": "Ta bort webbläsarkopia", + "confirmRestore": "Ersätta det aktuella formuläret med webbläsarens återställningskopia? Kontrollera innehållet innan du sparar.", + "confirmDiscard": "Ta bort den här återställningskopian från webbläsaren? Det aktuella formuläret förblir oförändrat." + }, "nav": { "home": "Hem", "community": "gemenskapen", @@ -3783,6 +3793,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Fildiagnostik", + "preliminary": "Förhandskontroll av adresser; innehållet valideras vid import.", + "http": "HTTP {status}", + "network": "Anslutningen misslyckades", + "timeout": "Källan svarade inte i tid", + "invalidResponse": "Källan returnerade en webbsida i stället för en fil", + "available": "Fil hittad; väntar på validering", + "recover": "Bifoga originalet och fortsätt", + "recoveryHint": "Välj {file} (högst 50 MB). Identiteten kontrolleras. Misslyckade objekt försöks igen; slutförda importer bevaras.", + "attach": "Välj den ursprungliga .nitro-filen", + "working": "Validerar eller återupptar...", + "attached": "Matchande originalfil bifogad", + "resume": "Återuppta misslyckade importer med filen", + "invalidFile": "Välj en matchande originalfil i .nitro-format på högst 50 MB och försök igen.", + "resumeError": "Uppdatera historiken och använd senaste försöket; kontrollera filen och försök igen.", + "mismatchedFile": "Den här filen tillhör en annan möbel.", + "alreadyStarted": "Återställningen har redan startat. Uppdatera historiken och använd senaste försöket.", + "notEligible": "Det här objektet kan inte återställas med en bilaga." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/tr.json b/src/messages/tr.json index 1d3ad533..332ba6e7 100644 --- a/src/messages/tr.json +++ b/src/messages/tr.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Kurtarma kopyası bu tarayıcıda 7 gün saklanacak.", + "saved": "Kurtarma kopyası bu tarayıcıya 7 gün süreyle kaydedildi.", + "unavailable": "Tarayıcıda kurtarma kullanılamıyor. Değişikliklerin kaydedilene kadar bu sayfayı açık tut.", + "found": "Bu tarayıcıda bir kurtarma kopyası mevcut. Kaydedilme tarihi:", + "restore": "Tarayıcı kopyasını geri yükle", + "discard": "Tarayıcı kopyasını sil", + "confirmRestore": "Mevcut form tarayıcıdaki kurtarma kopyasıyla değiştirilsin mi? Kaydetmeden önce içeriği kontrol et.", + "confirmDiscard": "Bu kurtarma kopyası tarayıcıdan silinsin mi? Mevcut form değişmeden kalacak." + }, "nav": { "home": "Ana sayfa", "community": "Topluluk", @@ -3784,6 +3794,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Dosya tanılaması", + "preliminary": "Adreslerin ön kontrolü; içerik içe aktarma sırasında doğrulanır.", + "http": "HTTP {status}", + "network": "Bağlantı başarısız", + "timeout": "Kaynak zamanında yanıt vermedi", + "invalidResponse": "Kaynak dosya yerine web sayfası döndürdü", + "available": "Dosya bulundu; doğrulama bekleniyor", + "recover": "Özgün dosyayı ekle ve sürdür", + "recoveryHint": "{file} dosyasını seçin (en fazla 50 MB). Kimliği doğrulanır. Başarısız öğeler yeniden denenir; tamamlanan aktarımlar korunur.", + "attach": "Özgün .nitro dosyasını seçin", + "working": "Doğrulanıyor veya sürdürülüyor...", + "attached": "Eşleşen özgün dosya eklendi", + "resume": "Başarısız aktarımları bu dosyayla sürdür", + "invalidFile": "En fazla 50 MB olan eşleşen özgün .nitro dosyasını seçip yeniden deneyin.", + "resumeError": "Geçmişi yenileyip son denemeyi kullanın; dosyayı kontrol edip yeniden deneyin.", + "mismatchedFile": "Bu dosya başka bir mobilyaya ait.", + "alreadyStarted": "Kurtarma zaten başladı. Geçmişi yenileyip son denemeyi kullanın.", + "notEligible": "Bu öğe ek dosyayla kurtarılamaz." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/messages/uk.json b/src/messages/uk.json index ac65f725..f3b17331 100644 --- a/src/messages/uk.json +++ b/src/messages/uk.json @@ -1,4 +1,14 @@ { + "draftRecovery": { + "ready": "Копія для відновлення зберігатиметься в цьому браузері 7 днів.", + "saved": "Копію для відновлення збережено в цьому браузері на 7 днів.", + "unavailable": "Відновлення в браузері недоступне. Не закривай цю сторінку, доки зміни не буде збережено.", + "found": "У цьому браузері доступна копія для відновлення. Дата збереження:", + "restore": "Відновити копію з браузера", + "discard": "Видалити копію з браузера", + "confirmRestore": "Замінити поточну форму копією з браузера? Перевір вміст перед збереженням.", + "confirmDiscard": "Видалити цю копію з браузера? Поточна форма залишиться без змін." + }, "nav": { "home": "додому", "community": "Спільнота", @@ -3783,6 +3793,26 @@ }, "admin": { "studio": { + "importDiagnostics": { + "attempts": "Діагностика файлів", + "preliminary": "Попередня перевірка адрес; вміст перевіряється під час імпорту.", + "http": "HTTP {status}", + "network": "Не вдалося підключитися", + "timeout": "Джерело не відповіло вчасно", + "invalidResponse": "Джерело повернуло вебсторінку замість файлу", + "available": "Файл знайдено; очікує перевірки", + "recover": "Прикріпити оригінал і продовжити", + "recoveryHint": "Виберіть {file} (до 50 MB). Його відповідність перевіряється. Повторюються невдалі елементи; завершені імпорти зберігаються.", + "attach": "Виберіть оригінальний файл .nitro", + "working": "Перевірка або відновлення...", + "attached": "Відповідний оригінал прикріплено", + "resume": "Повторити невдалі імпорти з цим файлом", + "invalidFile": "Виберіть відповідний оригінальний .nitro до 50 MB і повторіть спробу.", + "resumeError": "Оновіть історію й використайте останню спробу; перевірте файл і спробуйте знову.", + "mismatchedFile": "Цей файл належить іншому предмету меблів.", + "alreadyStarted": "Відновлення вже розпочато. Оновіть історію й використайте останню спробу.", + "notEligible": "Цей елемент не можна відновити вкладенням." + }, "completeness": { "title": "Completeness and next steps", "parts": { diff --git a/src/test/furniture-jobs-fixtures.ts b/src/test/furniture-jobs-fixtures.ts new file mode 100644 index 00000000..f5fb11aa --- /dev/null +++ b/src/test/furniture-jobs-fixtures.ts @@ -0,0 +1,23 @@ +import type { ImportJob } from "@/lib/furni/import-job"; + +export const furnitureJobFixture: ImportJob = { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + userId: 42, + createdAt: "2030-01-01T12:00:00.000Z", + updatedAt: "2030-01-01T12:00:00.000Z", + state: "queued", + translate: false, + items: [ + { + id: 1, + classname: "chair", + name: "Chair", + description: "A chair", + type: "flooritem", + revision: 1, + category: "chair", + state: "pending", + }, + ], +}; +export const furnitureCursorFixture = `${furnitureJobFixture.createdAt}|${furnitureJobFixture.id}`;
+ {t("recoveryHint", { file: `${classname.split("*")[0]}.nitro` })} +
{t("working")}
{t("attached")}
+ {error} +
{t("preliminary")}
{attempt.url}
+ {t(found ? "found" : state)} + {found && ( + <> + {" "} + + {new Date(found.savedAt).toLocaleString()} + + > + )} +
Unsaved