feat(cms): recover drafts and failed imports with verified UI workflows
CI / check (push) Successful in 2m3s
CI / deploy (push) Successful in 1m31s
CI / publish-container (push) Successful in 1m41s

This commit is contained in:
Simo committed 2026-09-11 08:58:10 +02:00
1 parent 410a1e466c
commit 88790d1a0d
84 files changed
+3952 -288

No files matched your search

+18
View File
@@ -58,6 +58,24 @@ jobs:
time pnpm test:coverage --maxWorkers=4 time pnpm test:coverage --maxWorkers=4
fi 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 # Docker build & deploy
# Draait op de host (self-hosted) zodat Docker # Draait op de host (self-hosted) zodat Docker
+10
View File
@@ -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__/<platform>/<viewport>`. 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+18
View File
@@ -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 (
<>
<ImportAttachmentRecovery
jobId="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
classname="fixture_chair"
disabled={false}
onResumed={async () => {
setResumed((value) => value + 1);
}}
/>
<output aria-label="Resumed count">{resumed}</output>
</>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,35 @@
import { useState } from "react";
import { useFurnitureJobs } from "@/components/admin/studio/use-furniture-jobs";
export function FurnitureJobsHarness() {
const [completions, setCompletions] = useState(0);
const history = useFurnitureJobs(
() => setCompletions((value) => value + 1),
true,
);
return (
<section aria-label="Import history hook">
<button
type="button"
onClick={() => {
void history.refresh();
void history.refresh();
}}
>
Refresh twice
</button>
<button type="button" onClick={() => void history.navigate("next")}>
Next page
</button>
<button type="button" onClick={() => void history.navigate("previous")}>
Previous page
</button>
<output aria-label="Page">{history.page}</output>
<output aria-label="Completions">{completions}</output>
<output aria-label="Loading">{String(history.loading)}</output>
<output aria-label="Jobs">
{history.jobs.map((job) => `${job.id}:${job.state}`).join(",")}
</output>
</section>
);
}
+13
View File
@@ -0,0 +1,13 @@
const router = {
push: (url: string) => {
window.location.href = url;
},
replace: (url: string) => {
window.location.replace(url);
},
refresh: () => {},
};
export const useRouter = () => router;
export const usePathname = () => window.location.pathname;
export const useSearchParams = () =>
new URLSearchParams(window.location.search);
+7
View File
@@ -0,0 +1,7 @@
import type { AnchorHTMLAttributes } from "react";
export default function FixtureLink({
prefetch: _prefetch,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & { prefetch?: boolean }) {
return <a {...props} />;
}
+29
View File
@@ -0,0 +1,29 @@
export async function fixtureAction(name: string, input: unknown = {}) {
const response = await fetch(`/fixture/actions/${name}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) throw Error(`Fixture action failed: ${response.status}`);
return response.json();
}
export const saveManagedSettings = (input: unknown) =>
fixtureAction("settings", input);
export const listTableViews = (input: unknown) =>
fixtureAction("list-views", input);
export const saveTableView = (input: unknown) =>
fixtureAction("save-view", input);
export const deleteTableView = (input: unknown) =>
fixtureAction("delete-view", input);
export const loadArticleRecovery = (key: unknown) =>
fixtureAction("load-recovery", { key });
export const autosaveArticle = (...input: unknown[]) =>
fixtureAction("autosave", input);
export const clearArticleRecovery = (...input: unknown[]) =>
fixtureAction("clear-recovery", input);
export const uploadMediaAndReturn = () => fixtureAction("upload");
export const createEvent = (input: unknown) =>
fixtureAction("create-event", input);
export const updateEvent = (input: unknown) =>
fixtureAction("update-event", input);
+99
View File
@@ -0,0 +1,99 @@
import { expect, test } from "@playwright/test";
import {
furnitureCursorFixture,
furnitureJobFixture,
} from "../../src/test/furniture-jobs-fixtures";
test("history hook deduplicates refreshes, pages and reports completion once", async ({
page,
}) => {
let requests = 0;
let completed = false;
await page.route("**/api/admin/studio/import-jobs?*", async (route) => {
requests++;
const secondPage = new URL(route.request().url()).searchParams.has(
"before",
);
await new Promise((resolve) => setTimeout(resolve, 100));
await route.fulfill({
json: {
ok: true,
jobs: [
{
...furnitureJobFixture,
id: secondPage
? "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
: furnitureJobFixture.id,
state: completed ? "completed" : "queued",
},
],
nextCursor: secondPage ? null : furnitureCursorFixture,
},
});
});
await page.goto("/admin/jobs-harness");
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
furnitureJobFixture.id,
);
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
const initial = requests;
await page.getByRole("button", { name: "Refresh twice" }).click();
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("true");
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
expect(requests).toBe(initial + 1);
await page.getByRole("button", { name: "Next page" }).click();
await expect(page.getByLabel("Page", { exact: true })).toHaveText("2");
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
);
await page.getByRole("button", { name: "Previous page" }).click();
await expect(page.getByLabel("Page", { exact: true })).toHaveText("1");
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
furnitureJobFixture.id,
);
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
completed = true;
await page.getByRole("button", { name: "Refresh twice" }).click();
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("true");
await expect(page.getByLabel("Completions", { exact: true })).toHaveText("1");
await page.getByRole("button", { name: "Refresh twice" }).click();
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("true");
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
await expect(page.getByLabel("Completions", { exact: true })).toHaveText("1");
});
test("history polling pauses while hidden and refreshes on visibility return", async ({
page,
}) => {
let requests = 0;
await page.clock.install();
await page.route("**/api/admin/studio/import-jobs?*", (route) => {
requests++;
return route.fulfill({
json: { ok: true, jobs: [furnitureJobFixture], nextCursor: null },
});
});
await page.goto("/admin/jobs-harness");
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
furnitureJobFixture.id,
);
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
await page.evaluate(() => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
});
const hiddenCount = requests;
await page.clock.fastForward(60000);
expect(requests).toBe(hiddenCount);
await page.evaluate(() => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
document.dispatchEvent(new Event("visibilitychange"));
});
await expect.poll(() => requests).toBe(hiddenCount + 1);
});
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -36,7 +36,10 @@
"analyze": "next experimental-analyze", "analyze": "next experimental-analyze",
"i18n:check": "node scripts/audit-cms-translations.mjs --check", "i18n:check": "node scripts/audit-cms-translations.mjs --check",
"i18n:audit": "node scripts/audit-cms-translations.mjs", "i18n:audit": "node scripts/audit-cms-translations.mjs",
"test:e2e": "playwright test" "test:e2e": "playwright test",
"test:ui": "playwright test --config playwright.ui.config.ts",
"test:ui:update": "playwright test --config playwright.ui.config.ts --update-snapshots",
"performance:report": "node scripts/performance-report.mjs"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "1.8.0", "@base-ui/react": "1.8.0",
@@ -45,6 +48,7 @@
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",
"@formatjs/icu-messageformat-parser": "3.5.17", "@formatjs/icu-messageformat-parser": "3.5.17",
"@hookform/resolvers": "5.9.1", "@hookform/resolvers": "5.9.1",
"@tanstack/react-query": "5.102.8",
"@tanstack/react-virtual": "3.14.10", "@tanstack/react-virtual": "3.14.10",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -79,6 +83,7 @@
"zod": "4.5.4" "zod": "4.5.4"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.13.0",
"@babel/parser": "7.29.8", "@babel/parser": "7.29.8",
"@biomejs/biome": "2.5.12", "@biomejs/biome": "2.5.12",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
@@ -90,6 +95,8 @@
"@types/react-dom": "19.2.7", "@types/react-dom": "19.2.7",
"@vitest/coverage-v8": "5.0.0", "@vitest/coverage-v8": "5.0.0",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"esbuild": "0.28.2",
"msw": "2.15.0",
"pino-pretty": "13.1.3", "pino-pretty": "13.1.3",
"postcss": "8.5.28", "postcss": "8.5.28",
"tailwindcss": "4.3.3", "tailwindcss": "4.3.3",
+1
View File
@@ -1,6 +1,7 @@
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from "@playwright/test";
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
testIgnore: "**/ui/**",
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
retries: 1, retries: 1,
+63
View File
@@ -0,0 +1,63 @@
import { defineConfig } from "@playwright/test";
const port = Number(process.env.UI_TEST_PORT || 3107);
if (!Number.isInteger(port) || port < 1024 || port > 65535)
throw Error("Invalid UI_TEST_PORT");
const baseURL = `http://127.0.0.1:${port}`;
export default defineConfig({
testDir: "./e2e/ui",
testMatch: "**/*.spec.ts",
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
timeout: 45000,
expect: {
timeout: 10000,
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.002,
},
},
snapshotPathTemplate: `{testDir}/__screenshots__/${process.platform}/{projectName}/{testFilePath}/{arg}{ext}`,
reporter: [
["list"],
["html", { outputFolder: "playwright-report/ui", open: "never" }],
],
outputDir: "test-results/ui",
use: {
baseURL,
locale: "en-US",
timezoneId: "UTC",
colorScheme: "dark",
contextOptions: { reducedMotion: "reduce" },
trace: "retain-on-failure",
screenshot: "only-on-failure",
launchOptions: process.env.UI_TEST_BROWSER_PATH
? { executablePath: process.env.UI_TEST_BROWSER_PATH }
: {},
},
projects: [
{
name: "desktop",
use: { browserName: "chromium", viewport: { width: 1280, height: 900 } },
},
{
name: "mobile",
use: {
browserName: "chromium",
viewport: { width: 390, height: 844 },
isMobile: true,
deviceScaleFactor: 1,
hasTouch: true,
},
},
],
webServer: {
command: "node e2e/ui/fixture-server.mjs",
url: `${baseURL}/health`,
timeout: 60000,
reuseExistingServer: false,
env: { UI_TEST_PORT: String(port) },
},
});
+426 -5
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -3,6 +3,7 @@ allowBuilds:
sharp: true sharp: true
"@parcel/watcher": true "@parcel/watcher": true
"@swc/core": true "@swc/core": true
msw: false
minimumReleaseAge: 1440 minimumReleaseAge: 1440
+1
View File
@@ -63,6 +63,7 @@ export default async function EditArticle({
</p> </p>
) : null} ) : null}
<ArticleForm <ArticleForm
userId={String(session.user.id)}
articleKey={String(article.id)} articleKey={String(article.id)}
baseToken={articleEditToken(article)} baseToken={articleEditToken(article)}
action={async (fd) => { action={async (fd) => {
+1 -1
View File
@@ -39,7 +39,7 @@ export default async function NewArticle({
Error: {error} Error: {error}
</p> </p>
) : null} ) : null}
<ArticleForm action={createArticle} /> <ArticleForm userId={String(session.user.id)} action={createArticle} />
</main> </main>
); );
} }
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function CreateEventPage() {
</p> </p>
</div> </div>
<EventForm eventTypes={eventTypes} /> <EventForm userId={String(session.user.id)} eventTypes={eventTypes} />
</div> </div>
); );
} }
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,4 @@
import { apiError, apiOk } from "@/lib/api"; import { apiError, apiJson, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { validateClassnames } from "@/lib/furni/studio-inspection"; import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
@@ -28,9 +28,16 @@ export const POST = withAdmin(
); );
return apiOk({ attachmentId }); return apiOk({ attachmentId });
} catch (error) { } catch (error) {
return apiError( return apiJson(
error instanceof Error ? error.message : "Invalid .nitro file", {
400, error: "Invalid original .nitro file",
code:
error instanceof Error &&
error.message.startsWith("This bundle does not belong")
? "mismatchedFile"
: "invalidFile",
},
{ status: 400 },
); );
} }
}, },
@@ -4,6 +4,7 @@ import { beforeEach, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
attachment: vi.fn(),
guard: vi.fn(), guard: vi.fn(),
create: vi.fn(), create: vi.fn(),
list: vi.fn(), list: vi.fn(),
@@ -40,6 +41,10 @@ vi.mock("@/lib/services/furni-job-store", async (original) => ({
})); }));
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source })); vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
vi.mock("@/lib/services/furni-attachment", () => ({
readFurnitureAttachment: mocks.attachment,
}));
import { GET, PATCH, POST } from "./route"; import { GET, PATCH, POST } from "./route";
const item = { const item = {
@@ -179,3 +184,53 @@ it("rejects malformed history cursors", async () => {
).status, ).status,
).toBe(400); ).toBe(400);
}); });
it("validates recovery attachment ownership and classname before queueing", async () => {
const id = randomUUID(),
attachmentId = randomUUID();
mocks.attachment.mockResolvedValue(Buffer.from("valid"));
mocks.retry.mockResolvedValue({ id });
expect(
(
await PATCH(
request({ id, action: "retry", attachments: { chair: attachmentId } }),
ctx,
)
).status,
).toBe(200);
expect(mocks.attachment).toHaveBeenCalledWith(attachmentId, "chair", 7);
expect(mocks.retry).toHaveBeenCalledWith(id, 7, { chair: attachmentId });
});
it("rejects foreign or mismatched recovery files without retrying", async () => {
mocks.retry.mockClear();
mocks.attachment.mockRejectedValue(Error("owner mismatch"));
expect(
(
await PATCH(
request({
id: randomUUID(),
action: "retry",
attachments: { chair: randomUUID() },
}),
ctx,
)
).status,
).toBe(400);
expect(mocks.retry).not.toHaveBeenCalled();
});
it("rejects invalid recovery attachment paths", async () => {
mocks.retry.mockClear();
expect(
(
await PATCH(
request({
id: randomUUID(),
action: "retry",
attachments: { "../chair": randomUUID() },
}),
ctx,
)
).status,
).toBe(400);
expect(mocks.retry).not.toHaveBeenCalled();
});
+37 -5
View File
@@ -1,12 +1,13 @@
import { after } from "next/server"; import { after } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { apiError, apiOk } from "@/lib/api"; import { apiError, apiJson, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { getRequestId } from "@/lib/foundation/request-context"; import { getRequestId } from "@/lib/foundation/request-context";
import { validateClassnames } from "@/lib/furni/studio-inspection"; import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { redis } from "@/lib/redis"; import { redis } from "@/lib/redis";
import { getSource } from "@/lib/services/clone-sources"; import { getSource } from "@/lib/services/clone-sources";
import { readFurnitureAttachment } from "@/lib/services/furni-attachment";
import { ImportJobStore, validJobId } from "@/lib/services/furni-job-store"; import { ImportJobStore, validJobId } from "@/lib/services/furni-job-store";
import { drainFurnitureImports } from "@/lib/services/furni-job-worker"; import { drainFurnitureImports } from "@/lib/services/furni-job-worker";
@@ -115,10 +116,41 @@ export const PATCH = withAdmin(
"Background import queue is temporarily unavailable", "Background import queue is temporarily unavailable",
503, 503,
); );
const job = await new ImportJobStore().retry( const parsed = z
body.id, .record(z.string(), z.uuid())
ctx.session.user.id, .refine(
); (value) =>
Object.keys(value).length <= 500 &&
(!Object.keys(value).length ||
validateClassnames(Object.keys(value))),
)
.safeParse(body.attachments ?? {});
if (!parsed.success) return apiError("Invalid recovery attachments", 400);
const attachments = parsed.data;
let job: Awaited<ReturnType<ImportJobStore["retry"]>>;
try {
for (const [classname, attachmentId] of Object.entries(attachments))
await readFurnitureAttachment(
attachmentId,
classname,
ctx.session.user.id,
);
const store = new ImportJobStore();
job = Object.keys(attachments).length
? await store.retry(body.id, ctx.session.user.id, attachments)
: await store.retry(body.id, ctx.session.user.id);
} catch (error) {
const message = error instanceof Error ? error.message : "";
const code = message.startsWith("Recovery already started")
? "alreadyStarted"
: message.includes("not eligible")
? "notEligible"
: "invalidFile";
return apiJson(
{ error: "Cannot resume with these attachments", code },
{ status: 400 },
);
}
if (!job) if (!job)
return apiError( return apiError(
"No safe remaining items to retry, or import job not found", "No safe remaining items to retry, or import job not found",
+29 -1
View File
@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useMemo, useRef, useState, useTransition } from "react"; import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useFormRecovery } from "@/hooks/use-form-recovery";
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes"; import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import type { ArticleDraft } from "@/lib/article-draft"; import type { ArticleDraft } from "@/lib/article-draft";
import type { ArticleSaveResult } from "@/lib/article-input"; import type { ArticleSaveResult } from "@/lib/article-input";
@@ -17,11 +18,13 @@ import { RichText } from "./rich-text";
export function ArticleForm({ export function ArticleForm({
action, action,
userId,
defaultValues, defaultValues,
articleKey = "new", articleKey = "new",
baseToken = "", baseToken = "",
}: { }: {
action: (formData: FormData) => Promise<ArticleSaveResult | undefined>; action: (formData: FormData) => Promise<ArticleSaveResult | undefined>;
userId?: string;
edit?: boolean; edit?: boolean;
articleKey?: string; articleKey?: string;
baseToken?: string; baseToken?: string;
@@ -45,6 +48,7 @@ export function ArticleForm({
const saving = useRef(false); const saving = useRef(false);
const editVersion = useRef(0); const editVersion = useRef(0);
function markDirty() { function markDirty() {
localRecovery.changed();
editVersion.current++; editVersion.current++;
setDirty(true); setDirty(true);
} }
@@ -90,6 +94,24 @@ export function ArticleForm({
dirty, dirty,
restoreDraft, restoreDraft,
); );
const localRecovery = useFormRecovery({
serverDraft: recovery.draft,
userId,
kind: "article",
record: articleKey,
dirty,
snapshot: () => ({
title,
slug: slugTouched ? slug : slugify(title),
image,
shortStory: summary,
fullStory: body,
status,
publishAt,
}),
restore: (payload) =>
restoreDraft({ ...payload, baseToken } as ArticleDraft, false),
});
useFormErrorFocus(formRef, fieldErrors, pending); useFormErrorFocus(formRef, fieldErrors, pending);
const [preview, setPreview] = useState<ArticlePreviewData | null>(null); const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
const suggestedSlug = useMemo(() => slugify(title), [title]); const suggestedSlug = useMemo(() => slugify(title), [title]);
@@ -126,6 +148,7 @@ export function ArticleForm({
if (!Number.isNaN(date.getTime())) if (!Number.isNaN(date.getTime()))
data.set("publishAt", date.toISOString()); data.set("publishAt", date.toISOString());
} }
localRecovery.flush();
const submittedVersion = editVersion.current; const submittedVersion = editVersion.current;
saving.current = true; saving.current = true;
setSaveError(null); setSaveError(null);
@@ -139,7 +162,10 @@ export function ArticleForm({
setFieldErrors(result.fieldErrors ?? {}); setFieldErrors(result.fieldErrors ?? {});
return; return;
} }
if (editVersion.current === submittedVersion) setDirty(false); if (result?.ok && editVersion.current === submittedVersion) {
setDirty(false);
localRecovery.clear();
}
if (result?.ok) await recovery.clear().catch(() => {}); if (result?.ok) await recovery.clear().catch(() => {});
if (result?.ok && result.data?.redirectTo) if (result?.ok && result.data?.redirectTo)
router.push(result.data.redirectTo); router.push(result.data.redirectTo);
@@ -164,6 +190,7 @@ export function ArticleForm({
> >
<fieldset disabled={pending} className="contents"> <fieldset disabled={pending} className="contents">
<input type="hidden" name="baseToken" value={token} /> <input type="hidden" name="baseToken" value={token} />
{localRecovery.panel}
{recovery.panel} {recovery.panel}
{saveError && ( {saveError && (
<p role="alert" className="text-sm"> <p role="alert" className="text-sm">
@@ -282,6 +309,7 @@ export function ArticleForm({
{t("body")} {t("body")}
</span> </span>
<RichText <RichText
ariaLabel={t("body")}
key={bodyVersion} key={bodyVersion}
onChange={() => { onChange={() => {
markDirty(); markDirty();
@@ -82,6 +82,7 @@ export function useArticleRecovery(
}; };
}, [key, form, saving, reload]); }, [key, form, saving, reload]);
return { return {
draft: recovery?.draft?.payload,
wait: () => request.current ?? Promise.resolve(), wait: () => request.current ?? Promise.resolve(),
clear: () => clearArticleRecovery(key, version.current), clear: () => clearArticleRecovery(key, version.current),
panel: ( panel: (
+8 -2
View File
@@ -395,7 +395,10 @@ export function DataTable<T extends { id: number | string }>({
value={currentValue} value={currentValue}
onValueChange={(v) => handleFilterChange(col, v)} onValueChange={(v) => handleFilterChange(col, v)}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger
aria-label={col.label}
className="h-8 text-xs"
>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -479,7 +482,10 @@ export function DataTable<T extends { id: number | string }>({
value={String(data.perPage)} value={String(data.perPage)}
onValueChange={handlePerPageChange} onValueChange={handlePerPageChange}
> >
<SelectTrigger className="w-[100px]"> <SelectTrigger
aria-label={t("perPage", { count: data.perPage })}
className="w-[100px]"
>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
+5 -1
View File
@@ -56,12 +56,14 @@ export function RichText({
name, name,
defaultValue = "", defaultValue = "",
placeholder, placeholder,
ariaLabel,
rows = 10, rows = 10,
onChange, onChange,
}: { }: {
name: string; name: string;
defaultValue?: string; defaultValue?: string;
placeholder?: string; placeholder?: string;
ariaLabel?: string;
rows?: number; rows?: number;
onChange?: () => void; onChange?: () => void;
}) { }) {
@@ -83,6 +85,7 @@ export function RichText({
.init({ .init({
selector, selector,
menubar: false, menubar: false,
iframe_attrs: { title: ariaLabel || name },
height: Math.max(rows * 22, 240), height: Math.max(rows * 22, 240),
plugins: "link lists code autolink", plugins: "link lists code autolink",
toolbar: toolbar:
@@ -127,13 +130,14 @@ export function RichText({
/* noop */ /* noop */
} }
}; };
}, [editorId, rows]); }, [editorId, rows, ariaLabel, name]);
return ( return (
<textarea <textarea
id={editorId} id={editorId}
ref={textareaRef} ref={textareaRef}
name={name} name={name}
aria-label={ariaLabel || name}
defaultValue={defaultValue} defaultValue={defaultValue}
placeholder={placeholder} placeholder={placeholder}
rows={rows} rows={rows}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -4,6 +4,7 @@ import { useFormatter, useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { retryableJobItems } from "@/lib/furni/import-job-retry"; import { retryableJobItems } from "@/lib/furni/import-job-retry";
import { ImportAttachmentRecovery } from "./import-attachment-recovery";
import type { useFurnitureJobs } from "./use-furniture-jobs"; import type { useFurnitureJobs } from "./use-furniture-jobs";
export function FurnitureJobHistory({ export function FurnitureJobHistory({
state, state,
@@ -98,6 +99,18 @@ export function FurnitureJobHistory({
{item.error} {item.error}
</p> </p>
)} )}
{!job.syncKind &&
item.state === "failed" &&
retryItems.some(
(retryItem) => retryItem.classname === item.classname,
) && (
<ImportAttachmentRecovery
jobId={job.id}
classname={item.classname}
disabled={state.busy}
onResumed={state.refresh}
/>
)}
{[...new Set(item.warnings ?? [])].map((warning) => ( {[...new Set(item.warnings ?? [])].map((warning) => (
<p <p
key={warning} key={warning}
File diff suppressed because it is too large. Load diff
+14 -3
View File
@@ -17,6 +17,7 @@ import { adminFetch } from "@/lib/admin-fetch";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog"; import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { compareFurniture } from "@/lib/furni/studio-inspection"; import { compareFurniture } from "@/lib/furni/studio-inspection";
import { FurnitureComparison } from "./furniture-inspector"; import { FurnitureComparison } from "./furniture-inspector";
import { ImportSourceDiagnostics } from "./import-source-diagnostics";
import type { FurniItem } from "./studio-types"; import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection"; import { useFurnitureInspection } from "./use-furniture-inspection";
import { useSourceAssetChecks } from "./use-source-asset-checks"; import { useSourceAssetChecks } from "./use-source-asset-checks";
@@ -38,6 +39,7 @@ export function ImportReview({
busy?: boolean; busy?: boolean;
onConfirm: (items: FurniItem[]) => void; onConfirm: (items: FurniItem[]) => void;
}) { }) {
const diagnosticsT = useTranslations("admin.studio.importDiagnostics");
const completenessT = useTranslations("admin.studio.completeness"); const completenessT = useTranslations("admin.studio.completeness");
const [attachments, setAttachments] = useState<Record<string, string>>({}); const [attachments, setAttachments] = useState<Record<string, string>>({});
const [uploading, setUploading] = useState<string | null>(null); const [uploading, setUploading] = useState<string | null>(null);
@@ -57,7 +59,7 @@ export function ImportReview({
...current, ...current,
[item.classname]: data.attachmentId, [item.classname]: data.attachmentId,
})); }));
toast.success("Matching .nitro attached to this import"); toast.success(diagnosticsT("attached"));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed"); toast.error(error instanceof Error ? error.message : "Upload failed");
} finally { } finally {
@@ -260,8 +262,8 @@ export function ImportReview({
{local.nitro.exists !== true && ( {local.nitro.exists !== true && (
<label className="mt-3 block text-sm"> <label className="mt-3 block text-sm">
{attachments[item.classname] {attachments[item.classname]
? "Matching .nitro attached" ? diagnosticsT("attached")
: "Attach the original .nitro"} : diagnosticsT("attach")}
<input <input
className="mt-2 block max-w-full" className="mt-2 block max-w-full"
type="file" type="file"
@@ -278,6 +280,15 @@ export function ImportReview({
)} )}
</label> </label>
)} )}
{assets.items.find(
(check) => check.classname === item.classname,
) && (
<ImportSourceDiagnostics
check={assets.items.find(
(check) => check.classname === item.classname,
)}
/>
)}
<FurnitureComparison item={item} local={local} /> <FurnitureComparison item={item} local={local} />
</div> </div>
)} )}
@@ -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<string, unknown>) =>
`${key}${args?.status ?? ""}`,
}));
it("shows filenames, attempted addresses and distinct reasons inline", () => {
const html = renderToStaticMarkup(
<ImportSourceDiagnostics
check={{
classname: "chair",
revision: 1,
state: "missing",
alternatives: [],
attempts: [
{
file: "chair.swf",
url: "https://source.test/1/chair.swf",
state: "missing",
reason: "http",
status: 404,
},
{
file: "chair.nitro",
url: "https://source.test/chair.nitro",
state: "unknown",
reason: "timeout",
},
],
}}
/>,
);
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(
<ImportSourceDiagnostics
check={{
classname: "chair",
revision: 1,
state: "missing",
alternatives: [],
}}
/>,
),
).toBe("");
});
@@ -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 (
<details className="mt-2 text-xs">
<summary className="cursor-pointer">{t("attempts")}</summary>
<p className="mt-2 text-[var(--admin-text-muted)]">{t("preliminary")}</p>
<ul className="space-y-2 py-2">
{[
...new Map(
check.attempts.map((attempt) => [attempt.url, attempt]),
).values(),
].map((attempt) => (
<li key={attempt.url}>
<strong>{attempt.file}</strong> ·{" "}
{attempt.reason === "http"
? t("http", { status: attempt.status ?? 0 })
: t(attempt.reason)}
<code className="block break-all">{attempt.url}</code>
</li>
))}
</ul>
</details>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+91
View File
@@ -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: "<p>Unsaved</p>",
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);
});
});
+79
View File
@@ -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<string, unknown> {
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),
};
}
+8
View File
@@ -8,7 +8,15 @@ export interface SourceAssetItem {
category: string; category: string;
} }
export type AssetAvailability = "available" | "missing" | "unknown"; 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 { export interface SourceAssetCheck {
attempts?: SourceAssetAttempt[];
classname: string; classname: string;
state: AssetAvailability; state: AssetAvailability;
revision: number; revision: number;
+7
View File
@@ -20,6 +20,7 @@ import {
getFurnitureDataWritePaths, getFurnitureDataWritePaths,
removeFurniEntry, removeFurniEntry,
} from "@/lib/services/furni-data"; } from "@/lib/services/furni-data";
import { diagnosticAssetUrl } from "@/lib/services/furniture-source-assets";
import { import {
lookupOfficialHabboFurni, lookupOfficialHabboFurni,
type OfficialHabboFurniEntry, type OfficialHabboFurniEntry,
@@ -707,6 +708,7 @@ export async function importSingleFurni(params: {
? Promise.resolve(true) ? Promise.resolve(true)
: tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => { : tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => {
iconFailure = detail; iconFailure = detail;
warnings.push(`Icon download failed: ${detail}`);
}), }),
params.providedNitro || (params.preserveAssets && existsSync(nitroPath)) params.providedNitro || (params.preserveAssets && existsSync(nitroPath))
? Promise.resolve(false) ? Promise.resolve(false)
@@ -714,6 +716,7 @@ export async function importSingleFurni(params: {
? Promise.resolve(true) ? Promise.resolve(true)
: tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => { : tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => {
swfFailure = detail; swfFailure = detail;
warnings.push(`SWF download failed: ${detail}`);
}), }),
]); ]);
let iconOk = iconOkResult; let iconOk = iconOkResult;
@@ -729,6 +732,10 @@ export async function importSingleFurni(params: {
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`; const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" }); const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" });
if (dl.ok) nitroDownloadOk = true; 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 ──────────── // ── Icon fallback: extract from SWF if download failed ────────────
+41
View File
@@ -201,3 +201,44 @@ it("paginates timestamp ties without duplicates and excludes other owners", asyn
); );
expect(first.jobs.length + second.jobs.length).toBe(3); 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");
});
+32 -2
View File
@@ -74,7 +74,11 @@ export class ImportJobStore {
}); });
return this.read(id); return this.read(id);
} }
async retry(id: string, userId: number): Promise<ImportJob | null> { async retry(
id: string,
userId: number,
attachments: Record<string, string> = {},
): Promise<ImportJob | null> {
const original = await this.read(id).catch((error) => { const original = await this.read(id).catch((error) => {
if (error.code === "ENOENT") return null; if (error.code === "ENOENT") return null;
throw error; throw error;
@@ -82,11 +86,23 @@ export class ImportJobStore {
if (!original || original.userId !== userId) return null; if (!original || original.userId !== userId) return null;
const items = retryableJobItems(original); const items = retryableJobItems(original);
if (!items.length) return null; 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. // A parent produces one durable child, including concurrent and uncertain responses.
const hex = createHash("sha256").update(`retry:${id}`).digest("hex"); 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 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(); const now = new Date().toISOString();
return this.create({ const child = await this.create({
...original, ...original,
id: retryId, id: retryId,
retryOf: id, retryOf: id,
@@ -97,10 +113,24 @@ export class ImportJobStore {
items: items.map( items: items.map(
({ error: _error, warnings: _warnings, itemId: _itemId, ...item }) => ({ ({ error: _error, warnings: _warnings, itemId: _itemId, ...item }) => ({
...item, ...item,
...(Object.hasOwn(attachments, item.classname)
? { attachmentId: attachments[item.classname] }
: {}),
state: "pending", 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 }) { async page(options: { userId: number; limit: number; before?: string }) {
const limit = Math.min(30, Math.max(1, options.limit)); const limit = Math.min(30, Math.max(1, options.limit));
@@ -74,3 +74,54 @@ describe("source asset preflight", () => {
expect(fetchMock).toHaveBeenCalledTimes(1); 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:[email protected]/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");
});
File diff suppressed because it is too large. Load diff
+30
View File
@@ -1,4 +1,14 @@
{ {
"draftRecovery": {
"ready": "ستُحفظ نسخة للاستعادة في هذا المتصفح لمدة 7 أيام.",
"saved": "تم حفظ نسخة للاستعادة في هذا المتصفح لمدة 7 أيام.",
"unavailable": "الاستعادة في المتصفح غير متاحة. أبقِ هذه الصفحة مفتوحة حتى يتم حفظ تغييراتك.",
"found": "توجد نسخة للاستعادة في هذا المتصفح، تم حفظها في",
"restore": "استعادة نسخة المتصفح",
"discard": "حذف نسخة المتصفح",
"confirmRestore": "هل تريد استبدال النموذج الحالي بنسخة الاستعادة من المتصفح؟ راجع المحتوى قبل الحفظ.",
"confirmDiscard": "هل تريد حذف نسخة الاستعادة هذه من المتصفح؟ سيبقى النموذج الحالي دون تغيير."
},
"nav": {}, "nav": {},
"header": {}, "header": {},
"footer": {}, "footer": {},
@@ -539,6 +549,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -1,4 +1,14 @@
{ {
"draftRecovery": {
"ready": "Копие за възстановяване ще се пази в този браузър 7 дни.",
"saved": "Копие за възстановяване е запазено в този браузър за 7 дни.",
"unavailable": "Възстановяването в браузъра не е достъпно. Остави тази страница отворена, докато промените бъдат запазени.",
"found": "В този браузър има копие за възстановяване, запазено на",
"restore": "Възстанови копието от браузъра",
"discard": "Изтрий копието от браузъра",
"confirmRestore": "Да се замени ли текущият формуляр с копието от браузъра? Провери съдържанието, преди да запазиш.",
"confirmDiscard": "Да се изтрие ли това копие от браузъра? Текущият формуляр ще остане непроменен."
},
"nav": { "nav": {
"home": "Начало", "home": "Начало",
"community": "Общност", "community": "Общност",
@@ -3783,6 +3793,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Domů", "home": "Domů",
"community": "Společenství", "community": "Společenství",
@@ -3783,6 +3793,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Hjem", "home": "Hjem",
"community": "Fællesskab", "community": "Fællesskab",
@@ -3783,6 +3793,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Start", "home": "Start",
"community": "Community", "community": "Community",
@@ -3784,6 +3794,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -1,4 +1,14 @@
{ {
"draftRecovery": {
"ready": "Ένα αντίγραφο ανάκτησης θα διατηρηθεί σε αυτό το πρόγραμμα περιήγησης για 7 ημέρες.",
"saved": "Το αντίγραφο ανάκτησης αποθηκεύτηκε σε αυτό το πρόγραμμα περιήγησης για 7 ημέρες.",
"unavailable": "Η ανάκτηση στο πρόγραμμα περιήγησης δεν είναι διαθέσιμη. Κράτησε αυτή τη σελίδα ανοιχτή μέχρι να αποθηκευτούν οι αλλαγές σου.",
"found": "Υπάρχει αντίγραφο ανάκτησης σε αυτό το πρόγραμμα περιήγησης, αποθηκευμένο στις",
"restore": "Επαναφορά αντιγράφου περιήγησης",
"discard": "Διαγραφή αντιγράφου περιήγησης",
"confirmRestore": "Να αντικατασταθεί η τρέχουσα φόρμα με το αντίγραφο ανάκτησης του προγράμματος περιήγησης; Έλεγξε το περιεχόμενο πριν από την αποθήκευση.",
"confirmDiscard": "Να διαγραφεί αυτό το αντίγραφο ανάκτησης από το πρόγραμμα περιήγησης; Η τρέχουσα φόρμα θα παραμείνει αμετάβλητη."
},
"nav": { "nav": {
"home": "Σπίτι", "home": "Σπίτι",
"community": "Κοινότητα", "community": "Κοινότητα",
@@ -3783,6 +3793,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Home", "home": "Home",
"community": "Community", "community": "Community",
@@ -4661,6 +4671,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Inicio", "home": "Inicio",
"community": "Comunidad", "community": "Comunidad",
@@ -3784,6 +3794,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": {}, "nav": {},
"header": {}, "header": {},
"footer": {}, "footer": {},
@@ -539,6 +549,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Accueil", "home": "Accueil",
"community": "Communauté", "community": "Communauté",
@@ -3784,6 +3794,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Dom", "home": "Dom",
"community": "zajednica", "community": "zajednica",
@@ -3783,6 +3793,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Otthon", "home": "Otthon",
"community": "közösség", "community": "közösség",
@@ -3783,6 +3793,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Home", "home": "Home",
"community": "Community", "community": "Community",
@@ -4666,6 +4676,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completezza e prossimi passi", "title": "Completezza e prossimi passi",
"parts": { "parts": {
+30
View File
@@ -1,4 +1,14 @@
{ {
"draftRecovery": {
"ready": "復元用のコピーをこのブラウザーに7日間保存します。",
"saved": "復元用のコピーをこのブラウザーに7日間保存しました。",
"unavailable": "ブラウザーでの復元を利用できません。変更が保存されるまで、このページを開いたままにしてください。",
"found": "このブラウザーに復元用のコピーがあります。保存日時:",
"restore": "ブラウザーのコピーを復元",
"discard": "ブラウザーのコピーを削除",
"confirmRestore": "現在のフォームをブラウザー内の復元用コピーで置き換えますか?保存する前に内容を確認してください。",
"confirmDiscard": "このブラウザー内の復元用コピーを削除しますか?現在のフォームは変更されません。"
},
"nav": {}, "nav": {},
"header": {}, "header": {},
"footer": {}, "footer": {},
@@ -539,6 +549,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Home", "home": "Home",
"community": "Community", "community": "Community",
@@ -4137,6 +4147,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Volledigheid en vervolgstappen", "title": "Volledigheid en vervolgstappen",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Hjem", "home": "Hjem",
"community": "Fellesskap", "community": "Fellesskap",
@@ -3783,6 +3793,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Dom", "home": "Dom",
"community": "Społeczność", "community": "Społeczność",
@@ -3784,6 +3794,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Página inicial", "home": "Página inicial",
"community": "Comunidade", "community": "Comunidade",
@@ -3784,6 +3794,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Acasă", "home": "Acasă",
"community": "comunitate", "community": "comunitate",
@@ -3783,6 +3793,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -1,4 +1,14 @@
{ {
"draftRecovery": {
"ready": "Копия для восстановления будет храниться в этом браузере 7 дней.",
"saved": "Копия для восстановления сохранена в этом браузере на 7 дней.",
"unavailable": "Восстановление в браузере недоступно. Не закрывай эту страницу, пока изменения не будут сохранены.",
"found": "В этом браузере доступна копия для восстановления. Дата сохранения:",
"restore": "Восстановить копию из браузера",
"discard": "Удалить копию из браузера",
"confirmRestore": "Заменить текущую форму копией из браузера? Проверь содержимое перед сохранением.",
"confirmDiscard": "Удалить эту копию из браузера? Текущая форма останется без изменений."
},
"nav": { "nav": {
"home": "Главная", "home": "Главная",
"community": "Сообщество", "community": "Сообщество",
@@ -3782,6 +3792,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Domov", "home": "Domov",
"community": "Spoločenstva", "community": "Spoločenstva",
@@ -3783,6 +3793,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -1,4 +1,14 @@
{ {
"draftRecovery": {
"ready": "Копија за опоравак чуваће се у овом прегледачу 7 дана.",
"saved": "Копија за опоравак сачувана је у овом прегледачу на 7 дана.",
"unavailable": "Опоравак у прегледачу није доступан. Остави ову страницу отвореном док се промене не сачувају.",
"found": "У овом прегледачу доступна је копија за опоравак, сачувана",
"restore": "Врати копију из прегледача",
"discard": "Избриши копију из прегледача",
"confirmRestore": "Заменити тренутни образац копијом из прегледача? Провери садржај пре чувања.",
"confirmDiscard": "Избрисати ову копију из прегледача? Тренутни образац остаће непромењен."
},
"nav": { "nav": {
"home": "Хоме", "home": "Хоме",
"community": "Заједница", "community": "Заједница",
@@ -3783,6 +3793,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Hem", "home": "Hem",
"community": "gemenskapen", "community": "gemenskapen",
@@ -3783,6 +3793,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -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": { "nav": {
"home": "Ana sayfa", "home": "Ana sayfa",
"community": "Topluluk", "community": "Topluluk",
@@ -3784,6 +3794,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+30
View File
@@ -1,4 +1,14 @@
{ {
"draftRecovery": {
"ready": "Копія для відновлення зберігатиметься в цьому браузері 7 днів.",
"saved": "Копію для відновлення збережено в цьому браузері на 7 днів.",
"unavailable": "Відновлення в браузері недоступне. Не закривай цю сторінку, доки зміни не буде збережено.",
"found": "У цьому браузері доступна копія для відновлення. Дата збереження:",
"restore": "Відновити копію з браузера",
"discard": "Видалити копію з браузера",
"confirmRestore": "Замінити поточну форму копією з браузера? Перевір вміст перед збереженням.",
"confirmDiscard": "Видалити цю копію з браузера? Поточна форма залишиться без змін."
},
"nav": { "nav": {
"home": "додому", "home": "додому",
"community": "Спільнота", "community": "Спільнота",
@@ -3783,6 +3793,26 @@
}, },
"admin": { "admin": {
"studio": { "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": { "completeness": {
"title": "Completeness and next steps", "title": "Completeness and next steps",
"parts": { "parts": {
+23
View File
@@ -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}`;