feat(cms): recover drafts and failed imports with verified UI workflows
No files matched your search
@@ -58,6 +58,24 @@ jobs:
|
||||
time pnpm test:coverage --maxWorkers=4
|
||||
fi
|
||||
|
||||
# One-time Linux capture, replaced by comparison after artifact review.
|
||||
- name: Install UI test browser
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
- name: Capture Linux UI references
|
||||
run: pnpm test:ui:update
|
||||
|
||||
- name: Upload UI results
|
||||
if: always()
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||
with:
|
||||
name: ui-results
|
||||
path: |
|
||||
e2e/ui/__screenshots__/linux/
|
||||
playwright-report/ui/
|
||||
test-results/ui/
|
||||
retention-days: 14
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Docker build & deploy
|
||||
# Draait op de host (self-hosted) zodat Docker
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { AnchorHTMLAttributes } from "react";
|
||||
export default function FixtureLink({
|
||||
prefetch: _prefetch,
|
||||
...props
|
||||
}: AnchorHTMLAttributes<HTMLAnchorElement> & { prefetch?: boolean }) {
|
||||
return <a {...props} />;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -36,7 +36,10 @@
|
||||
"analyze": "next experimental-analyze",
|
||||
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
|
||||
"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": {
|
||||
"@base-ui/react": "1.8.0",
|
||||
@@ -45,6 +48,7 @@
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.17",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@tanstack/react-query": "5.102.8",
|
||||
"@tanstack/react-virtual": "3.14.10",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -79,6 +83,7 @@
|
||||
"zod": "4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@babel/parser": "7.29.8",
|
||||
"@biomejs/biome": "2.5.12",
|
||||
"@playwright/test": "1.62.1",
|
||||
@@ -90,6 +95,8 @@
|
||||
"@types/react-dom": "19.2.7",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.28.2",
|
||||
"msw": "2.15.0",
|
||||
"pino-pretty": "13.1.3",
|
||||
"postcss": "8.5.28",
|
||||
"tailwindcss": "4.3.3",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: "**/ui/**",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 1,
|
||||
|
||||
@@ -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) },
|
||||
},
|
||||
});
|
||||
@@ -3,6 +3,7 @@ allowBuilds:
|
||||
sharp: true
|
||||
"@parcel/watcher": true
|
||||
"@swc/core": true
|
||||
msw: false
|
||||
|
||||
minimumReleaseAge: 1440
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export default async function EditArticle({
|
||||
</p>
|
||||
) : null}
|
||||
<ArticleForm
|
||||
userId={String(session.user.id)}
|
||||
articleKey={String(article.id)}
|
||||
baseToken={articleEditToken(article)}
|
||||
action={async (fd) => {
|
||||
|
||||
@@ -39,7 +39,7 @@ export default async function NewArticle({
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
<ArticleForm action={createArticle} />
|
||||
<ArticleForm userId={String(session.user.id)} action={createArticle} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export default async function CreateEventPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EventForm eventTypes={eventTypes} />
|
||||
<EventForm userId={String(session.user.id)} eventTypes={eventTypes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiError, apiOk } from "@/lib/api";
|
||||
import { apiError, apiJson, apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
@@ -28,9 +28,16 @@ export const POST = withAdmin(
|
||||
);
|
||||
return apiOk({ attachmentId });
|
||||
} catch (error) {
|
||||
return apiError(
|
||||
error instanceof Error ? error.message : "Invalid .nitro file",
|
||||
400,
|
||||
return apiJson(
|
||||
{
|
||||
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";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
attachment: vi.fn(),
|
||||
guard: vi.fn(),
|
||||
create: 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/furni-attachment", () => ({
|
||||
readFurnitureAttachment: mocks.attachment,
|
||||
}));
|
||||
|
||||
import { GET, PATCH, POST } from "./route";
|
||||
|
||||
const item = {
|
||||
@@ -179,3 +184,53 @@ it("rejects malformed history cursors", async () => {
|
||||
).status,
|
||||
).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();
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { after } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiError, apiOk } from "@/lib/api";
|
||||
import { apiError, apiJson, apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { getRequestId } from "@/lib/foundation/request-context";
|
||||
import { validateClassnames } from "@/lib/furni/studio-inspection";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { getSource } from "@/lib/services/clone-sources";
|
||||
import { readFurnitureAttachment } from "@/lib/services/furni-attachment";
|
||||
import { ImportJobStore, validJobId } from "@/lib/services/furni-job-store";
|
||||
import { drainFurnitureImports } from "@/lib/services/furni-job-worker";
|
||||
|
||||
@@ -115,10 +116,41 @@ export const PATCH = withAdmin(
|
||||
"Background import queue is temporarily unavailable",
|
||||
503,
|
||||
);
|
||||
const job = await new ImportJobStore().retry(
|
||||
body.id,
|
||||
ctx.session.user.id,
|
||||
);
|
||||
const parsed = z
|
||||
.record(z.string(), z.uuid())
|
||||
.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)
|
||||
return apiError(
|
||||
"No safe remaining items to retry, or import job not found",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useFormRecovery } from "@/hooks/use-form-recovery";
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import type { ArticleDraft } from "@/lib/article-draft";
|
||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
@@ -17,11 +18,13 @@ import { RichText } from "./rich-text";
|
||||
|
||||
export function ArticleForm({
|
||||
action,
|
||||
userId,
|
||||
defaultValues,
|
||||
articleKey = "new",
|
||||
baseToken = "",
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<ArticleSaveResult | undefined>;
|
||||
userId?: string;
|
||||
edit?: boolean;
|
||||
articleKey?: string;
|
||||
baseToken?: string;
|
||||
@@ -45,6 +48,7 @@ export function ArticleForm({
|
||||
const saving = useRef(false);
|
||||
const editVersion = useRef(0);
|
||||
function markDirty() {
|
||||
localRecovery.changed();
|
||||
editVersion.current++;
|
||||
setDirty(true);
|
||||
}
|
||||
@@ -90,6 +94,24 @@ export function ArticleForm({
|
||||
dirty,
|
||||
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);
|
||||
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||
@@ -126,6 +148,7 @@ export function ArticleForm({
|
||||
if (!Number.isNaN(date.getTime()))
|
||||
data.set("publishAt", date.toISOString());
|
||||
}
|
||||
localRecovery.flush();
|
||||
const submittedVersion = editVersion.current;
|
||||
saving.current = true;
|
||||
setSaveError(null);
|
||||
@@ -139,7 +162,10 @@ export function ArticleForm({
|
||||
setFieldErrors(result.fieldErrors ?? {});
|
||||
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 && result.data?.redirectTo)
|
||||
router.push(result.data.redirectTo);
|
||||
@@ -164,6 +190,7 @@ export function ArticleForm({
|
||||
>
|
||||
<fieldset disabled={pending} className="contents">
|
||||
<input type="hidden" name="baseToken" value={token} />
|
||||
{localRecovery.panel}
|
||||
{recovery.panel}
|
||||
{saveError && (
|
||||
<p role="alert" className="text-sm">
|
||||
@@ -282,6 +309,7 @@ export function ArticleForm({
|
||||
{t("body")}
|
||||
</span>
|
||||
<RichText
|
||||
ariaLabel={t("body")}
|
||||
key={bodyVersion}
|
||||
onChange={() => {
|
||||
markDirty();
|
||||
|
||||
@@ -82,6 +82,7 @@ export function useArticleRecovery(
|
||||
};
|
||||
}, [key, form, saving, reload]);
|
||||
return {
|
||||
draft: recovery?.draft?.payload,
|
||||
wait: () => request.current ?? Promise.resolve(),
|
||||
clear: () => clearArticleRecovery(key, version.current),
|
||||
panel: (
|
||||
|
||||
@@ -395,7 +395,10 @@ export function DataTable<T extends { id: number | string }>({
|
||||
value={currentValue}
|
||||
onValueChange={(v) => handleFilterChange(col, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectTrigger
|
||||
aria-label={col.label}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -479,7 +482,10 @@ export function DataTable<T extends { id: number | string }>({
|
||||
value={String(data.perPage)}
|
||||
onValueChange={handlePerPageChange}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectTrigger
|
||||
aria-label={t("perPage", { count: data.perPage })}
|
||||
className="w-[100px]"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -56,12 +56,14 @@ export function RichText({
|
||||
name,
|
||||
defaultValue = "",
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
rows = 10,
|
||||
onChange,
|
||||
}: {
|
||||
name: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
rows?: number;
|
||||
onChange?: () => void;
|
||||
}) {
|
||||
@@ -83,6 +85,7 @@ export function RichText({
|
||||
.init({
|
||||
selector,
|
||||
menubar: false,
|
||||
iframe_attrs: { title: ariaLabel || name },
|
||||
height: Math.max(rows * 22, 240),
|
||||
plugins: "link lists code autolink",
|
||||
toolbar:
|
||||
@@ -127,13 +130,14 @@ export function RichText({
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
}, [editorId, rows]);
|
||||
}, [editorId, rows, ariaLabel, name]);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
id={editorId}
|
||||
ref={textareaRef}
|
||||
name={name}
|
||||
aria-label={ariaLabel || name}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useFormatter, useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { retryableJobItems } from "@/lib/furni/import-job-retry";
|
||||
import { ImportAttachmentRecovery } from "./import-attachment-recovery";
|
||||
import type { useFurnitureJobs } from "./use-furniture-jobs";
|
||||
export function FurnitureJobHistory({
|
||||
state,
|
||||
@@ -98,6 +99,18 @@ export function FurnitureJobHistory({
|
||||
{item.error}
|
||||
</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) => (
|
||||
<p
|
||||
key={warning}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
|
||||
import { compareFurniture } from "@/lib/furni/studio-inspection";
|
||||
import { FurnitureComparison } from "./furniture-inspector";
|
||||
import { ImportSourceDiagnostics } from "./import-source-diagnostics";
|
||||
import type { FurniItem } from "./studio-types";
|
||||
import { useFurnitureInspection } from "./use-furniture-inspection";
|
||||
import { useSourceAssetChecks } from "./use-source-asset-checks";
|
||||
@@ -38,6 +39,7 @@ export function ImportReview({
|
||||
busy?: boolean;
|
||||
onConfirm: (items: FurniItem[]) => void;
|
||||
}) {
|
||||
const diagnosticsT = useTranslations("admin.studio.importDiagnostics");
|
||||
const completenessT = useTranslations("admin.studio.completeness");
|
||||
const [attachments, setAttachments] = useState<Record<string, string>>({});
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
@@ -57,7 +59,7 @@ export function ImportReview({
|
||||
...current,
|
||||
[item.classname]: data.attachmentId,
|
||||
}));
|
||||
toast.success("Matching .nitro attached to this import");
|
||||
toast.success(diagnosticsT("attached"));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Upload failed");
|
||||
} finally {
|
||||
@@ -260,8 +262,8 @@ export function ImportReview({
|
||||
{local.nitro.exists !== true && (
|
||||
<label className="mt-3 block text-sm">
|
||||
{attachments[item.classname]
|
||||
? "Matching .nitro attached"
|
||||
: "Attach the original .nitro"}
|
||||
? diagnosticsT("attached")
|
||||
: diagnosticsT("attach")}
|
||||
<input
|
||||
className="mt-2 block max-w-full"
|
||||
type="file"
|
||||
@@ -278,6 +280,15 @@ export function ImportReview({
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
{assets.items.find(
|
||||
(check) => check.classname === item.classname,
|
||||
) && (
|
||||
<ImportSourceDiagnostics
|
||||
check={assets.items.find(
|
||||
(check) => check.classname === item.classname,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FurnitureComparison item={item} local={local} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,7 +8,15 @@ export interface SourceAssetItem {
|
||||
category: string;
|
||||
}
|
||||
export type AssetAvailability = "available" | "missing" | "unknown";
|
||||
export interface SourceAssetAttempt {
|
||||
url: string;
|
||||
file: string;
|
||||
state: AssetAvailability;
|
||||
reason: "available" | "http" | "network" | "timeout" | "invalidResponse";
|
||||
status?: number;
|
||||
}
|
||||
export interface SourceAssetCheck {
|
||||
attempts?: SourceAssetAttempt[];
|
||||
classname: string;
|
||||
state: AssetAvailability;
|
||||
revision: number;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getFurnitureDataWritePaths,
|
||||
removeFurniEntry,
|
||||
} from "@/lib/services/furni-data";
|
||||
import { diagnosticAssetUrl } from "@/lib/services/furniture-source-assets";
|
||||
import {
|
||||
lookupOfficialHabboFurni,
|
||||
type OfficialHabboFurniEntry,
|
||||
@@ -707,6 +708,7 @@ export async function importSingleFurni(params: {
|
||||
? Promise.resolve(true)
|
||||
: tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => {
|
||||
iconFailure = detail;
|
||||
warnings.push(`Icon download failed: ${detail}`);
|
||||
}),
|
||||
params.providedNitro || (params.preserveAssets && existsSync(nitroPath))
|
||||
? Promise.resolve(false)
|
||||
@@ -714,6 +716,7 @@ export async function importSingleFurni(params: {
|
||||
? Promise.resolve(true)
|
||||
: tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => {
|
||||
swfFailure = detail;
|
||||
warnings.push(`SWF download failed: ${detail}`);
|
||||
}),
|
||||
]);
|
||||
let iconOk = iconOkResult;
|
||||
@@ -729,6 +732,10 @@ export async function importSingleFurni(params: {
|
||||
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
|
||||
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" });
|
||||
if (dl.ok) nitroDownloadOk = true;
|
||||
else
|
||||
warnings.push(
|
||||
`Nitro download failed: ${diagnosticAssetUrl(nitroUrl)}: ${dl.error ?? "unknown cause"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Icon fallback: extract from SWF if download failed ────────────
|
||||
|
||||
@@ -201,3 +201,44 @@ it("paginates timestamp ties without duplicates and excludes other owners", asyn
|
||||
);
|
||||
expect(first.jobs.length + second.jobs.length).toBe(3);
|
||||
});
|
||||
it("attaches recovery files only to safe failed items and preserves retry history", async () => {
|
||||
const s = await store();
|
||||
const original: ImportJob = {
|
||||
...job(),
|
||||
state: "completed",
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
classname: "chair",
|
||||
name: "Chair",
|
||||
description: "",
|
||||
type: "flooritem",
|
||||
revision: 1,
|
||||
category: "",
|
||||
state: "failed",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
classname: "lamp",
|
||||
name: "Lamp",
|
||||
description: "",
|
||||
type: "flooritem",
|
||||
revision: 1,
|
||||
category: "",
|
||||
state: "done",
|
||||
},
|
||||
],
|
||||
};
|
||||
await s.create(original);
|
||||
await expect(s.retry(original.id, 4, { lamp: randomUUID() })).rejects.toThrow(
|
||||
"not eligible",
|
||||
);
|
||||
const attachmentId = randomUUID();
|
||||
const child = await s.retry(original.id, 4, { chair: attachmentId });
|
||||
expect(child?.items).toHaveLength(1);
|
||||
expect(child?.items[0].attachmentId).toBe(attachmentId);
|
||||
expect((await s.read(original.id)).items[0].attachmentId).toBeUndefined();
|
||||
await expect(
|
||||
s.retry(original.id, 4, { chair: randomUUID() }),
|
||||
).rejects.toThrow("latest retry");
|
||||
});
|
||||
@@ -74,7 +74,11 @@ export class ImportJobStore {
|
||||
});
|
||||
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) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
@@ -82,11 +86,23 @@ export class ImportJobStore {
|
||||
if (!original || original.userId !== userId) return null;
|
||||
const items = retryableJobItems(original);
|
||||
if (!items.length) return null;
|
||||
const entries = Object.entries(attachments);
|
||||
if (
|
||||
entries.length &&
|
||||
(original.syncKind ||
|
||||
entries.some(
|
||||
([classname]) =>
|
||||
!items.some(
|
||||
(item) => item.classname === classname && item.state === "failed",
|
||||
),
|
||||
))
|
||||
)
|
||||
throw Error("Furniture is not eligible for attachment recovery");
|
||||
// A parent produces one durable child, including concurrent and uncertain responses.
|
||||
const hex = createHash("sha256").update(`retry:${id}`).digest("hex");
|
||||
const retryId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
||||
const now = new Date().toISOString();
|
||||
return this.create({
|
||||
const child = await this.create({
|
||||
...original,
|
||||
id: retryId,
|
||||
retryOf: id,
|
||||
@@ -97,10 +113,24 @@ export class ImportJobStore {
|
||||
items: items.map(
|
||||
({ error: _error, warnings: _warnings, itemId: _itemId, ...item }) => ({
|
||||
...item,
|
||||
...(Object.hasOwn(attachments, item.classname)
|
||||
? { attachmentId: attachments[item.classname] }
|
||||
: {}),
|
||||
state: "pending",
|
||||
}),
|
||||
),
|
||||
});
|
||||
if (
|
||||
entries.some(
|
||||
([classname, attachmentId]) =>
|
||||
child.items.find((item) => item.classname === classname)
|
||||
?.attachmentId !== attachmentId,
|
||||
)
|
||||
)
|
||||
throw Error(
|
||||
"Recovery already started. Use the latest retry in the history.",
|
||||
);
|
||||
return child;
|
||||
}
|
||||
async page(options: { userId: number; limit: number; before?: string }) {
|
||||
const limit = Math.min(30, Math.max(1, options.limit));
|
||||
|
||||
@@ -74,3 +74,54 @@ describe("source asset preflight", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
const item = {
|
||||
id: 1,
|
||||
classname: "chair",
|
||||
name: "Chair",
|
||||
description: "",
|
||||
type: "flooritem",
|
||||
revision: 123,
|
||||
category: "other",
|
||||
};
|
||||
|
||||
it("reports every missing filename and HTTP reason without credentials", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(null, { status: 404 })),
|
||||
);
|
||||
const result = await inspectSourceAssets(item, [], {
|
||||
nitroBaseUrl: "https://user:[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");
|
||||
});
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "ستُحفظ نسخة للاستعادة في هذا المتصفح لمدة 7 أيام.",
|
||||
"saved": "تم حفظ نسخة للاستعادة في هذا المتصفح لمدة 7 أيام.",
|
||||
"unavailable": "الاستعادة في المتصفح غير متاحة. أبقِ هذه الصفحة مفتوحة حتى يتم حفظ تغييراتك.",
|
||||
"found": "توجد نسخة للاستعادة في هذا المتصفح، تم حفظها في",
|
||||
"restore": "استعادة نسخة المتصفح",
|
||||
"discard": "حذف نسخة المتصفح",
|
||||
"confirmRestore": "هل تريد استبدال النموذج الحالي بنسخة الاستعادة من المتصفح؟ راجع المحتوى قبل الحفظ.",
|
||||
"confirmDiscard": "هل تريد حذف نسخة الاستعادة هذه من المتصفح؟ سيبقى النموذج الحالي دون تغيير."
|
||||
},
|
||||
"nav": {},
|
||||
"header": {},
|
||||
"footer": {},
|
||||
@@ -539,6 +549,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "تشخيص الملفات",
|
||||
"preliminary": "فحص أولي للعناوين؛ سيتم التحقق من المحتوى عند الاستيراد.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "تعذر الاتصال",
|
||||
"timeout": "انتهت مهلة المصدر",
|
||||
"invalidResponse": "أعاد المصدر صفحة ويب بدلاً من ملف",
|
||||
"available": "تم العثور على الملف؛ ينتظر التحقق",
|
||||
"recover": "إرفاق الأصل والاستئناف",
|
||||
"recoveryHint": "اختر {file} (حتى 50 MB). تُفحص هويته. تُعاد العناصر الفاشلة فقط وتُحفظ الواردات المكتملة.",
|
||||
"attach": "اختر ملف .nitro الأصلي",
|
||||
"working": "جارٍ التحقق أو الاستئناف...",
|
||||
"attached": "أُرفق الملف المطابق",
|
||||
"resume": "استئناف العناصر الفاشلة بهذا الملف",
|
||||
"invalidFile": "اختر ملف .nitro أصلياً مطابقاً، حتى 50 MB، ثم أعد المحاولة.",
|
||||
"resumeError": "حدّث السجل واستخدم أحدث محاولة؛ تحقق من الملف وأعد المحاولة.",
|
||||
"mismatchedFile": "هذا الملف يخص أثاثاً مختلفاً.",
|
||||
"alreadyStarted": "بدأ الاستئناف بالفعل. حدّث السجل واستخدم أحدث محاولة.",
|
||||
"notEligible": "هذا العنصر غير مؤهل للاسترداد بالمرفقات."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Копие за възстановяване ще се пази в този браузър 7 дни.",
|
||||
"saved": "Копие за възстановяване е запазено в този браузър за 7 дни.",
|
||||
"unavailable": "Възстановяването в браузъра не е достъпно. Остави тази страница отворена, докато промените бъдат запазени.",
|
||||
"found": "В този браузър има копие за възстановяване, запазено на",
|
||||
"restore": "Възстанови копието от браузъра",
|
||||
"discard": "Изтрий копието от браузъра",
|
||||
"confirmRestore": "Да се замени ли текущият формуляр с копието от браузъра? Провери съдържанието, преди да запазиш.",
|
||||
"confirmDiscard": "Да се изтрие ли това копие от браузъра? Текущият формуляр ще остане непроменен."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Начало",
|
||||
"community": "Общност",
|
||||
@@ -3783,6 +3793,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Диагностика на файловете",
|
||||
"preliminary": "Предварителна проверка на адресите; съдържанието се проверява при импортиране.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Неуспешна връзка",
|
||||
"timeout": "Изтекло време за отговор",
|
||||
"invalidResponse": "Източникът върна уеб страница вместо файл",
|
||||
"available": "Файлът е намерен; предстои проверка",
|
||||
"recover": "Прикачване на оригинала и продължаване",
|
||||
"recoveryHint": "Изберете {file} (до 50 MB). Проверява се самоличността му. Повтарят се неуспешните елементи; завършените се запазват.",
|
||||
"attach": "Изберете оригиналния .nitro файл",
|
||||
"working": "Проверка или продължаване...",
|
||||
"attached": "Съответстващият файл е прикачен",
|
||||
"resume": "Повторете неуспешните импортирания с файла",
|
||||
"invalidFile": "Изберете съответстващ оригинален .nitro файл до 50 MB и опитайте отново.",
|
||||
"resumeError": "Обновете историята и използвайте последния опит; проверете файла и опитайте отново.",
|
||||
"mismatchedFile": "Този файл принадлежи на друга мебел.",
|
||||
"alreadyStarted": "Възстановяването вече е започнало. Обновете историята и използвайте последния опит.",
|
||||
"notEligible": "Този елемент не допуска възстановяване с прикачен файл."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Kopie pro obnovení bude v tomto prohlížeči uchována po dobu 7 dnů.",
|
||||
"saved": "Kopie pro obnovení byla v tomto prohlížeči uložena na 7 dnů.",
|
||||
"unavailable": "Obnovení v prohlížeči není dostupné. Nech tuto stránku otevřenou, dokud se změny neuloží.",
|
||||
"found": "V tomto prohlížeči je dostupná kopie pro obnovení, uložená",
|
||||
"restore": "Obnovit kopii z prohlížeče",
|
||||
"discard": "Smazat kopii z prohlížeče",
|
||||
"confirmRestore": "Nahradit aktuální formulář kopií z prohlížeče? Před uložením zkontroluj obsah.",
|
||||
"confirmDiscard": "Smazat tuto kopii z prohlížeče? Aktuální formulář zůstane beze změn."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Domů",
|
||||
"community": "Společenství",
|
||||
@@ -3783,6 +3793,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Diagnostika souborů",
|
||||
"preliminary": "Předběžná kontrola adres; obsah se ověří při importu.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Připojení selhalo",
|
||||
"timeout": "Zdroj neodpověděl včas",
|
||||
"invalidResponse": "Zdroj vrátil webovou stránku místo souboru",
|
||||
"available": "Soubor nalezen; čeká na ověření",
|
||||
"recover": "Přiložit originál a pokračovat",
|
||||
"recoveryHint": "Vyberte {file} (do 50 MB). Ověří se jeho identita. Opakují se neúspěšné položky; dokončené importy zůstanou zachovány.",
|
||||
"attach": "Vyberte původní soubor .nitro",
|
||||
"working": "Ověřování nebo pokračování...",
|
||||
"attached": "Odpovídající soubor přiložen",
|
||||
"resume": "Opakovat neúspěšné importy s tímto souborem",
|
||||
"invalidFile": "Vyberte odpovídající původní .nitro do 50 MB a zkuste to znovu.",
|
||||
"resumeError": "Obnovte historii a použijte poslední pokus; zkontrolujte soubor a zkuste to znovu.",
|
||||
"mismatchedFile": "Tento soubor patří jinému nábytku.",
|
||||
"alreadyStarted": "Obnova již začala. Obnovte historii a použijte poslední pokus.",
|
||||
"notEligible": "Tuto položku nelze obnovit přílohou."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "En gendannelseskopi gemmes i denne browser i 7 dage.",
|
||||
"saved": "En gendannelseskopi er gemt i denne browser i 7 dage.",
|
||||
"unavailable": "Gendannelse i browseren er ikke tilgængelig. Hold denne side åben, indtil dine ændringer er gemt.",
|
||||
"found": "Der er en gendannelseskopi i denne browser, gemt den",
|
||||
"restore": "Gendan browserkopi",
|
||||
"discard": "Slet browserkopi",
|
||||
"confirmRestore": "Erstat den aktuelle formular med browserens gendannelseskopi? Kontrollér indholdet, før du gemmer.",
|
||||
"confirmDiscard": "Slet denne gendannelseskopi fra browseren? Den aktuelle formular forbliver uændret."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Hjem",
|
||||
"community": "Fællesskab",
|
||||
@@ -3783,6 +3793,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Fildiagnostik",
|
||||
"preliminary": "Foreløbig kontrol af adresser; indholdet valideres under import.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Forbindelsen mislykkedes",
|
||||
"timeout": "Kilden svarede ikke i tide",
|
||||
"invalidResponse": "Kilden returnerede en webside i stedet for en fil",
|
||||
"available": "Fil fundet; afventer validering",
|
||||
"recover": "Vedhæft originalen og fortsæt",
|
||||
"recoveryHint": "Vælg {file} (højst 50 MB). Identiteten kontrolleres. Mislykkede elementer prøves igen; færdige importer bevares.",
|
||||
"attach": "Vælg den originale .nitro-fil",
|
||||
"working": "Validerer eller genoptager...",
|
||||
"attached": "Matchende fil vedhæftet",
|
||||
"resume": "Genoptag mislykkede importer med filen",
|
||||
"invalidFile": "Vælg en matchende original .nitro-fil på højst 50 MB og prøv igen.",
|
||||
"resumeError": "Opdater historikken og brug seneste forsøg; kontroller filen og prøv igen.",
|
||||
"mismatchedFile": "Denne fil tilhører et andet møbel.",
|
||||
"alreadyStarted": "Genoptagelsen er startet. Opdater historikken og brug seneste forsøg.",
|
||||
"notEligible": "Dette element kan ikke gendannes med en vedhæftet fil."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Eine Wiederherstellungskopie wird 7 Tage in diesem Browser aufbewahrt.",
|
||||
"saved": "Die Wiederherstellungskopie wurde für 7 Tage in diesem Browser gespeichert.",
|
||||
"unavailable": "Die Wiederherstellung im Browser ist nicht verfügbar. Lass diese Seite geöffnet, bis deine Änderungen gespeichert sind.",
|
||||
"found": "In diesem Browser ist eine Wiederherstellungskopie verfügbar, gespeichert am",
|
||||
"restore": "Browserkopie wiederherstellen",
|
||||
"discard": "Browserkopie löschen",
|
||||
"confirmRestore": "Das aktuelle Formular durch die Wiederherstellungskopie im Browser ersetzen? Prüfe den Inhalt vor dem Speichern.",
|
||||
"confirmDiscard": "Diese Wiederherstellungskopie im Browser löschen? Das aktuelle Formular bleibt unverändert."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Start",
|
||||
"community": "Community",
|
||||
@@ -3784,6 +3794,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Dateidiagnose",
|
||||
"preliminary": "Vorprüfung der Adressen; der Inhalt wird beim Import geprüft.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Verbindung fehlgeschlagen",
|
||||
"timeout": "Zeitüberschreitung der Quelle",
|
||||
"invalidResponse": "Die Quelle lieferte eine Webseite statt einer Datei",
|
||||
"available": "Datei gefunden; Prüfung steht aus",
|
||||
"recover": "Original anhängen und fortsetzen",
|
||||
"recoveryHint": "Wähle {file} (maximal 50 MB). Die Identität wird geprüft. Fehlgeschlagene Einträge werden wiederholt; abgeschlossene Importe bleiben erhalten.",
|
||||
"attach": "Originale .nitro-Datei wählen",
|
||||
"working": "Datei wird geprüft oder Import fortgesetzt...",
|
||||
"attached": "Passende Originaldatei angehängt",
|
||||
"resume": "Fehlgeschlagene Importe mit dieser Datei fortsetzen",
|
||||
"invalidFile": "Wähle eine passende originale .nitro-Datei bis 50 MB und versuche es erneut.",
|
||||
"resumeError": "Aktualisiere den Verlauf und nutze den neuesten Versuch; prüfe die Datei und versuche es erneut.",
|
||||
"mismatchedFile": "Diese Datei gehört zu einem anderen Möbelstück.",
|
||||
"alreadyStarted": "Die Wiederherstellung läuft bereits. Aktualisiere den Verlauf und nutze den neuesten Versuch.",
|
||||
"notEligible": "Dieser Eintrag kann nicht mit einem Anhang wiederhergestellt werden."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Ένα αντίγραφο ανάκτησης θα διατηρηθεί σε αυτό το πρόγραμμα περιήγησης για 7 ημέρες.",
|
||||
"saved": "Το αντίγραφο ανάκτησης αποθηκεύτηκε σε αυτό το πρόγραμμα περιήγησης για 7 ημέρες.",
|
||||
"unavailable": "Η ανάκτηση στο πρόγραμμα περιήγησης δεν είναι διαθέσιμη. Κράτησε αυτή τη σελίδα ανοιχτή μέχρι να αποθηκευτούν οι αλλαγές σου.",
|
||||
"found": "Υπάρχει αντίγραφο ανάκτησης σε αυτό το πρόγραμμα περιήγησης, αποθηκευμένο στις",
|
||||
"restore": "Επαναφορά αντιγράφου περιήγησης",
|
||||
"discard": "Διαγραφή αντιγράφου περιήγησης",
|
||||
"confirmRestore": "Να αντικατασταθεί η τρέχουσα φόρμα με το αντίγραφο ανάκτησης του προγράμματος περιήγησης; Έλεγξε το περιεχόμενο πριν από την αποθήκευση.",
|
||||
"confirmDiscard": "Να διαγραφεί αυτό το αντίγραφο ανάκτησης από το πρόγραμμα περιήγησης; Η τρέχουσα φόρμα θα παραμείνει αμετάβλητη."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Σπίτι",
|
||||
"community": "Κοινότητα",
|
||||
@@ -3783,6 +3793,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Διαγνωστικός έλεγχος αρχείων",
|
||||
"preliminary": "Προκαταρκτικός έλεγχος διευθύνσεων· το περιεχόμενο ελέγχεται στην εισαγωγή.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Αποτυχία σύνδεσης",
|
||||
"timeout": "Η πηγή δεν απάντησε εγκαίρως",
|
||||
"invalidResponse": "Η πηγή επέστρεψε ιστοσελίδα αντί αρχείου",
|
||||
"available": "Βρέθηκε αρχείο· εκκρεμεί έλεγχος",
|
||||
"recover": "Επισύναψη πρωτοτύπου και συνέχεια",
|
||||
"recoveryHint": "Επιλέξτε {file} (έως 50 MB). Ελέγχεται η ταυτότητά του. Επαναλαμβάνονται οι αποτυχίες· οι ολοκληρωμένες εισαγωγές διατηρούνται.",
|
||||
"attach": "Επιλέξτε το αρχικό αρχείο .nitro",
|
||||
"working": "Έλεγχος ή συνέχιση...",
|
||||
"attached": "Επισυνάφθηκε αντίστοιχο αρχείο",
|
||||
"resume": "Συνέχιση αποτυχημένων εισαγωγών με το αρχείο",
|
||||
"invalidFile": "Επιλέξτε αντίστοιχο αρχικό .nitro έως 50 MB και δοκιμάστε ξανά.",
|
||||
"resumeError": "Ανανεώστε το ιστορικό και χρησιμοποιήστε την τελευταία προσπάθεια· ελέγξτε το αρχείο και ξαναδοκιμάστε.",
|
||||
"mismatchedFile": "Αυτό το αρχείο ανήκει σε άλλο έπιπλο.",
|
||||
"alreadyStarted": "Η ανάκτηση ξεκίνησε ήδη. Ανανεώστε το ιστορικό και χρησιμοποιήστε την τελευταία προσπάθεια.",
|
||||
"notEligible": "Αυτό το στοιχείο δεν επιτρέπει ανάκτηση με συνημμένο."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "A recovery copy will be kept in this browser for 7 days.",
|
||||
"saved": "Recovery copy saved in this browser for 7 days.",
|
||||
"unavailable": "Browser recovery is unavailable. Keep this page open until your changes are saved.",
|
||||
"found": "A recovery copy is available in this browser, saved on",
|
||||
"restore": "Restore browser copy",
|
||||
"discard": "Discard browser copy",
|
||||
"confirmRestore": "Replace the current form with the browser recovery copy? Review the contents before saving.",
|
||||
"confirmDiscard": "Delete this browser recovery copy? Your current form will remain unchanged."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"community": "Community",
|
||||
@@ -4661,6 +4671,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Source file diagnostics",
|
||||
"preliminary": "Preliminary check: these addresses were checked without downloading the files. The import will validate the original files.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Network connection failed",
|
||||
"timeout": "The source did not respond in time",
|
||||
"invalidResponse": "The source returned a web page instead of a file",
|
||||
"available": "File found; content will be verified during import",
|
||||
"recover": "Attach original file and resume",
|
||||
"recoveryHint": "Choose {file} for this furniture (maximum 50 MB). Its identity is checked before use. Resuming retries the failed items and preserves completed imports.",
|
||||
"attach": "Choose the original .nitro file",
|
||||
"working": "Validating file or resuming import...",
|
||||
"attached": "Matching original file attached",
|
||||
"resume": "Resume failed imports with this file",
|
||||
"invalidFile": "Could not attach the file. Select a valid original .nitro for this furniture, up to 50 MB, and try again.",
|
||||
"resumeError": "Could not resume. Refresh the history and use the latest retry; check the original file and try again.",
|
||||
"mismatchedFile": "This file belongs to different furniture.",
|
||||
"alreadyStarted": "Recovery already started. Refresh the history and use the latest retry.",
|
||||
"notEligible": "This item is not eligible for attachment recovery."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Se conservará una copia de recuperación en este navegador durante 7 días.",
|
||||
"saved": "Copia de recuperación guardada en este navegador durante 7 días.",
|
||||
"unavailable": "La recuperación en el navegador no está disponible. Mantén esta página abierta hasta que se guarden los cambios.",
|
||||
"found": "Hay una copia de recuperación disponible en este navegador, guardada el",
|
||||
"restore": "Restaurar copia del navegador",
|
||||
"discard": "Eliminar copia del navegador",
|
||||
"confirmRestore": "¿Reemplazar el formulario actual con la copia de recuperación del navegador? Revisa el contenido antes de guardar.",
|
||||
"confirmDiscard": "¿Eliminar esta copia de recuperación del navegador? El formulario actual no cambiará."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Inicio",
|
||||
"community": "Comunidad",
|
||||
@@ -3784,6 +3794,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Diagnóstico de archivos",
|
||||
"preliminary": "Comprobación preliminar de direcciones; el contenido se valida al importar.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Falló la conexión",
|
||||
"timeout": "La fuente no respondió a tiempo",
|
||||
"invalidResponse": "La fuente devolvió una página web en lugar de un archivo",
|
||||
"available": "Archivo encontrado; pendiente de validación",
|
||||
"recover": "Adjuntar original y reanudar",
|
||||
"recoveryHint": "Elige {file} (máximo 50 MB). Se verifica su identidad. Se reintentan los elementos fallidos y se conservan los importados.",
|
||||
"attach": "Elige el archivo .nitro original",
|
||||
"working": "Validando o reanudando...",
|
||||
"attached": "Archivo original correspondiente adjuntado",
|
||||
"resume": "Reanudar importaciones fallidas con este archivo",
|
||||
"invalidFile": "Elige un .nitro original correspondiente de hasta 50 MB y vuelve a intentarlo.",
|
||||
"resumeError": "Actualiza el historial y usa el último intento; comprueba el archivo y vuelve a intentarlo.",
|
||||
"mismatchedFile": "Este archivo pertenece a otro mueble.",
|
||||
"alreadyStarted": "La recuperación ya comenzó. Actualiza el historial y usa el último intento.",
|
||||
"notEligible": "Este elemento no admite recuperación mediante archivo adjunto."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Palautuskopio säilytetään tässä selaimessa 7 päivän ajan.",
|
||||
"saved": "Palautuskopio on tallennettu tähän selaimeen 7 päiväksi.",
|
||||
"unavailable": "Selaimen palautustoiminto ei ole käytettävissä. Pidä tämä sivu auki, kunnes muutoksesi on tallennettu.",
|
||||
"found": "Tässä selaimessa on palautuskopio, joka on tallennettu",
|
||||
"restore": "Palauta selaimen kopio",
|
||||
"discard": "Poista selaimen kopio",
|
||||
"confirmRestore": "Korvataanko nykyinen lomake selaimen palautuskopiolla? Tarkista sisältö ennen tallentamista.",
|
||||
"confirmDiscard": "Poistetaanko tämä palautuskopio selaimesta? Nykyinen lomake säilyy ennallaan."
|
||||
},
|
||||
"nav": {},
|
||||
"header": {},
|
||||
"footer": {},
|
||||
@@ -539,6 +549,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Tiedostojen diagnostiikka",
|
||||
"preliminary": "Osoitteiden alustava tarkistus; sisältö tarkistetaan tuonnissa.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Yhteys epäonnistui",
|
||||
"timeout": "Lähde ei vastannut ajoissa",
|
||||
"invalidResponse": "Lähde palautti verkkosivun tiedoston sijaan",
|
||||
"available": "Tiedosto löytyi; odottaa tarkistusta",
|
||||
"recover": "Liitä alkuperäinen ja jatka",
|
||||
"recoveryHint": "Valitse {file} (enintään 50 MB). Sen tunniste tarkistetaan. Epäonnistuneet kohteet yritetään uudelleen; valmiit tuonnit säilytetään.",
|
||||
"attach": "Valitse alkuperäinen .nitro-tiedosto",
|
||||
"working": "Tarkistetaan tai jatketaan...",
|
||||
"attached": "Vastaava tiedosto liitetty",
|
||||
"resume": "Jatka epäonnistuneita tuonteja tällä tiedostolla",
|
||||
"invalidFile": "Valitse vastaava alkuperäinen .nitro, enintään 50 MB, ja yritä uudelleen.",
|
||||
"resumeError": "Päivitä historia ja käytä uusinta yritystä; tarkista tiedosto ja yritä uudelleen.",
|
||||
"mismatchedFile": "Tämä tiedosto kuuluu toiselle huonekalulle.",
|
||||
"alreadyStarted": "Palautus on jo alkanut. Päivitä historia ja käytä uusinta yritystä.",
|
||||
"notEligible": "Tätä kohdetta ei voi palauttaa liitteellä."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Une copie de récupération sera conservée dans ce navigateur pendant 7 jours.",
|
||||
"saved": "Une copie de récupération a été enregistrée dans ce navigateur pour 7 jours.",
|
||||
"unavailable": "La récupération dans le navigateur est indisponible. Garde cette page ouverte jusqu’à l’enregistrement de tes modifications.",
|
||||
"found": "Une copie de récupération est disponible dans ce navigateur, enregistrée le",
|
||||
"restore": "Restaurer la copie du navigateur",
|
||||
"discard": "Supprimer la copie du navigateur",
|
||||
"confirmRestore": "Remplacer le formulaire actuel par la copie de récupération du navigateur ? Vérifie le contenu avant d’enregistrer.",
|
||||
"confirmDiscard": "Supprimer cette copie de récupération du navigateur ? Le formulaire actuel restera inchangé."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Accueil",
|
||||
"community": "Communauté",
|
||||
@@ -3784,6 +3794,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Diagnostic des fichiers",
|
||||
"preliminary": "Vérification préliminaire des adresses ; le contenu sera validé à l’importation.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Échec de la connexion",
|
||||
"timeout": "La source n’a pas répondu à temps",
|
||||
"invalidResponse": "La source a renvoyé une page web au lieu d’un fichier",
|
||||
"available": "Fichier trouvé ; validation en attente",
|
||||
"recover": "Joindre l’original et reprendre",
|
||||
"recoveryHint": "Choisissez {file} (50 MB maximum). Son identité est vérifiée. Les éléments échoués sont relancés ; les imports terminés sont conservés.",
|
||||
"attach": "Choisir le fichier .nitro original",
|
||||
"working": "Validation ou reprise en cours...",
|
||||
"attached": "Fichier original correspondant joint",
|
||||
"resume": "Reprendre les imports échoués avec ce fichier",
|
||||
"invalidFile": "Choisissez un .nitro original correspondant de 50 MB maximum et réessayez.",
|
||||
"resumeError": "Actualisez l’historique et utilisez la dernière tentative ; vérifiez le fichier et réessayez.",
|
||||
"mismatchedFile": "Ce fichier appartient à un autre meuble.",
|
||||
"alreadyStarted": "La reprise a déjà commencé. Actualisez l’historique et utilisez la dernière tentative.",
|
||||
"notEligible": "Cet élément ne permet pas la récupération par pièce jointe."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Kopija za oporavak čuvat će se u ovom pregledniku 7 dana.",
|
||||
"saved": "Kopija za oporavak spremljena je u ovom pregledniku na 7 dana.",
|
||||
"unavailable": "Oporavak u pregledniku nije dostupan. Ostavi ovu stranicu otvorenom dok se promjene ne spreme.",
|
||||
"found": "U ovom pregledniku dostupna je kopija za oporavak, spremljena",
|
||||
"restore": "Vrati kopiju iz preglednika",
|
||||
"discard": "Izbriši kopiju iz preglednika",
|
||||
"confirmRestore": "Zamijeniti trenutačni obrazac kopijom iz preglednika? Provjeri sadržaj prije spremanja.",
|
||||
"confirmDiscard": "Izbrisati ovu kopiju iz preglednika? Trenutačni obrazac ostat će nepromijenjen."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Dom",
|
||||
"community": "zajednica",
|
||||
@@ -3783,6 +3793,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Dijagnostika datoteka",
|
||||
"preliminary": "Preliminarna provjera adresa; sadržaj se provjerava pri uvozu.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Povezivanje nije uspjelo",
|
||||
"timeout": "Izvor nije odgovorio na vrijeme",
|
||||
"invalidResponse": "Izvor je vratio web-stranicu umjesto datoteke",
|
||||
"available": "Datoteka pronađena; čeka provjeru",
|
||||
"recover": "Priloži izvornik i nastavi",
|
||||
"recoveryHint": "Odaberite {file} (do 50 MB). Provjerava se identitet. Ponavljaju se neuspjele stavke; dovršeni uvozi ostaju sačuvani.",
|
||||
"attach": "Odaberite izvornu .nitro datoteku",
|
||||
"working": "Provjera ili nastavak...",
|
||||
"attached": "Odgovarajuća datoteka priložena",
|
||||
"resume": "Nastavi neuspjele uvoze s datotekom",
|
||||
"invalidFile": "Odaberite odgovarajuću izvornu .nitro datoteku do 50 MB i pokušajte ponovno.",
|
||||
"resumeError": "Osvježite povijest i koristite posljednji pokušaj; provjerite datoteku i pokušajte ponovno.",
|
||||
"mismatchedFile": "Ova datoteka pripada drugom komadu namještaja.",
|
||||
"alreadyStarted": "Oporavak je već pokrenut. Osvježite povijest i koristite posljednji pokušaj.",
|
||||
"notEligible": "Ova stavka ne podržava oporavak prilogom."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "A helyreállítási másolat 7 napig megmarad ebben a böngészőben.",
|
||||
"saved": "A helyreállítási másolat 7 napra elmentve ebben a böngészőben.",
|
||||
"unavailable": "A böngészőből történő helyreállítás nem érhető el. Tartsd nyitva ezt az oldalt a módosítások mentéséig.",
|
||||
"found": "Ebben a böngészőben elérhető egy helyreállítási másolat. Mentés ideje:",
|
||||
"restore": "Böngészőmásolat visszaállítása",
|
||||
"discard": "Böngészőmásolat törlése",
|
||||
"confirmRestore": "Lecseréled a jelenlegi űrlapot a böngészőben tárolt helyreállítási másolatra? Mentés előtt ellenőrizd a tartalmát.",
|
||||
"confirmDiscard": "Törlöd ezt a helyreállítási másolatot a böngészőből? A jelenlegi űrlap változatlan marad."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Otthon",
|
||||
"community": "közösség",
|
||||
@@ -3783,6 +3793,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Fájldiagnosztika",
|
||||
"preliminary": "A címek előzetes ellenőrzése; a tartalmat az importálás ellenőrzi.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Sikertelen kapcsolódás",
|
||||
"timeout": "A forrás nem válaszolt időben",
|
||||
"invalidResponse": "A forrás fájl helyett weboldalt adott vissza",
|
||||
"available": "Fájl megtalálva; ellenőrzésre vár",
|
||||
"recover": "Eredeti csatolása és folytatás",
|
||||
"recoveryHint": "Válassza ki: {file} (legfeljebb 50 MB). Az azonosságát ellenőrizzük. A sikertelen elemek újrapróbálhatók; a kész importok megmaradnak.",
|
||||
"attach": "Válassza ki az eredeti .nitro fájlt",
|
||||
"working": "Ellenőrzés vagy folytatás...",
|
||||
"attached": "Megfelelő eredeti fájl csatolva",
|
||||
"resume": "Sikertelen importok folytatása ezzel a fájllal",
|
||||
"invalidFile": "Válasszon megfelelő eredeti .nitro fájlt legfeljebb 50 MB méretben, és próbálja újra.",
|
||||
"resumeError": "Frissítse az előzményeket, és használja a legutóbbi próbálkozást; ellenőrizze a fájlt, és próbálja újra.",
|
||||
"mismatchedFile": "Ez a fájl másik bútorhoz tartozik.",
|
||||
"alreadyStarted": "A helyreállítás már elindult. Frissítse az előzményeket, és használja a legutóbbi próbálkozást.",
|
||||
"notEligible": "Ez az elem nem állítható helyre csatolmánnyal."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Una copia di recupero sarà conservata in questo browser per 7 giorni.",
|
||||
"saved": "Copia di recupero salvata in questo browser per 7 giorni.",
|
||||
"unavailable": "Recupero nel browser non disponibile. Tieni aperta questa pagina fino al salvataggio delle modifiche.",
|
||||
"found": "È disponibile una copia di recupero in questo browser, salvata il",
|
||||
"restore": "Ripristina copia del browser",
|
||||
"discard": "Elimina copia del browser",
|
||||
"confirmRestore": "Sostituire il modulo attuale con la copia di recupero del browser? Controlla il contenuto prima di salvare.",
|
||||
"confirmDiscard": "Eliminare questa copia di recupero del browser? Il modulo attuale resterà invariato."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"community": "Community",
|
||||
@@ -4666,6 +4676,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Diagnostica file sorgente",
|
||||
"preliminary": "Controllo preliminare: questi indirizzi sono stati verificati senza scaricare i file. L'importazione validerà i file originali.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Connessione alla sorgente non riuscita",
|
||||
"timeout": "La sorgente non ha risposto in tempo",
|
||||
"invalidResponse": "La sorgente ha restituito una pagina web invece di un file",
|
||||
"available": "File trovato; il contenuto sarà verificato durante l'importazione",
|
||||
"recover": "Carica il file originale e riprendi",
|
||||
"recoveryHint": "Scegli {file} per questo furni (massimo 50 MB). L'identità viene verificata prima dell'uso. La ripresa ritenta gli elementi falliti e preserva le importazioni completate.",
|
||||
"attach": "Scegli il file .nitro originale",
|
||||
"working": "Validazione del file o ripresa dell'importazione...",
|
||||
"attached": "File originale corrispondente allegato",
|
||||
"resume": "Riprendi gli import falliti con questo file",
|
||||
"invalidFile": "Impossibile allegare il file. Scegli un .nitro originale valido per questo furni, fino a 50 MB, e riprova.",
|
||||
"resumeError": "Impossibile riprendere. Aggiorna la cronologia e usa l'ultimo tentativo; controlla il file originale e riprova.",
|
||||
"mismatchedFile": "Questo file appartiene a un altro furni.",
|
||||
"alreadyStarted": "Ripresa già avviata. Aggiorna la cronologia e usa l’ultimo tentativo.",
|
||||
"notEligible": "Questo elemento non consente il recupero tramite allegato."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completezza e prossimi passi",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "復元用のコピーをこのブラウザーに7日間保存します。",
|
||||
"saved": "復元用のコピーをこのブラウザーに7日間保存しました。",
|
||||
"unavailable": "ブラウザーでの復元を利用できません。変更が保存されるまで、このページを開いたままにしてください。",
|
||||
"found": "このブラウザーに復元用のコピーがあります。保存日時:",
|
||||
"restore": "ブラウザーのコピーを復元",
|
||||
"discard": "ブラウザーのコピーを削除",
|
||||
"confirmRestore": "現在のフォームをブラウザー内の復元用コピーで置き換えますか?保存する前に内容を確認してください。",
|
||||
"confirmDiscard": "このブラウザー内の復元用コピーを削除しますか?現在のフォームは変更されません。"
|
||||
},
|
||||
"nav": {},
|
||||
"header": {},
|
||||
"footer": {},
|
||||
@@ -539,6 +549,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "ファイル診断",
|
||||
"preliminary": "アドレスの事前確認です。内容はインポート時に検証します。",
|
||||
"http": "HTTP {status}",
|
||||
"network": "接続に失敗しました",
|
||||
"timeout": "ソースの応答がタイムアウトしました",
|
||||
"invalidResponse": "ソースがファイルではなくウェブページを返しました",
|
||||
"available": "ファイルを検出しました。検証待ちです",
|
||||
"recover": "元のファイルを添付して再開",
|
||||
"recoveryHint": "{file}(最大50 MB)を選択してください。一致を確認し、失敗した項目を再試行します。完了したインポートは保持されます。",
|
||||
"attach": "元の .nitro ファイルを選択",
|
||||
"working": "検証または再開中...",
|
||||
"attached": "一致する元のファイルを添付しました",
|
||||
"resume": "このファイルで失敗したインポートを再開",
|
||||
"invalidFile": "対応する元の .nitro ファイル(最大50 MB)を選択して再試行してください。",
|
||||
"resumeError": "履歴を更新し、最新の試行を使用してください。ファイルを確認して再試行してください。",
|
||||
"mismatchedFile": "このファイルは別の家具のものです。",
|
||||
"alreadyStarted": "再開は既に開始されています。履歴を更新し、最新の試行を使用してください。",
|
||||
"notEligible": "この項目は添付ファイルで復旧できません。"
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Een herstelkopie wordt 7 dagen in deze browser bewaard.",
|
||||
"saved": "De herstelkopie is voor 7 dagen in deze browser opgeslagen.",
|
||||
"unavailable": "Herstel via de browser is niet beschikbaar. Houd deze pagina open totdat je wijzigingen zijn opgeslagen.",
|
||||
"found": "Er is een herstelkopie beschikbaar in deze browser, opgeslagen op",
|
||||
"restore": "Browserkopie herstellen",
|
||||
"discard": "Browserkopie verwijderen",
|
||||
"confirmRestore": "Het huidige formulier vervangen door de herstelkopie uit de browser? Controleer de inhoud voordat je opslaat.",
|
||||
"confirmDiscard": "Deze herstelkopie uit de browser verwijderen? Het huidige formulier blijft ongewijzigd."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"community": "Community",
|
||||
@@ -4137,6 +4147,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Bestandsdiagnostiek",
|
||||
"preliminary": "Voorafgaande controle van adressen; inhoud wordt tijdens import gevalideerd.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Verbinding mislukt",
|
||||
"timeout": "De bron antwoordde niet op tijd",
|
||||
"invalidResponse": "De bron gaf een webpagina in plaats van een bestand",
|
||||
"available": "Bestand gevonden; wacht op validatie",
|
||||
"recover": "Origineel toevoegen en hervatten",
|
||||
"recoveryHint": "Kies {file} (maximaal 50 MB). De identiteit wordt gecontroleerd. Mislukte items worden opnieuw geprobeerd; voltooide imports blijven behouden.",
|
||||
"attach": "Kies het originele .nitro-bestand",
|
||||
"working": "Valideren of hervatten...",
|
||||
"attached": "Overeenkomend origineel toegevoegd",
|
||||
"resume": "Mislukte imports hervatten met dit bestand",
|
||||
"invalidFile": "Kies een overeenkomend origineel .nitro-bestand van maximaal 50 MB en probeer opnieuw.",
|
||||
"resumeError": "Vernieuw de geschiedenis en gebruik de laatste poging; controleer het bestand en probeer opnieuw.",
|
||||
"mismatchedFile": "Dit bestand hoort bij een ander meubel.",
|
||||
"alreadyStarted": "Herstel is al gestart. Vernieuw de geschiedenis en gebruik de laatste poging.",
|
||||
"notEligible": "Dit item kan niet met een bijlage worden hersteld."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Volledigheid en vervolgstappen",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "En gjenopprettingskopi lagres i denne nettleseren i 7 dager.",
|
||||
"saved": "En gjenopprettingskopi er lagret i denne nettleseren i 7 dager.",
|
||||
"unavailable": "Gjenoppretting i nettleseren er ikke tilgjengelig. Hold denne siden åpen til endringene dine er lagret.",
|
||||
"found": "En gjenopprettingskopi er tilgjengelig i denne nettleseren, lagret",
|
||||
"restore": "Gjenopprett nettleserkopi",
|
||||
"discard": "Slett nettleserkopi",
|
||||
"confirmRestore": "Erstatte det gjeldende skjemaet med gjenopprettingskopien fra nettleseren? Kontroller innholdet før du lagrer.",
|
||||
"confirmDiscard": "Slette denne gjenopprettingskopien fra nettleseren? Det gjeldende skjemaet forblir uendret."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Hjem",
|
||||
"community": "Fellesskap",
|
||||
@@ -3783,6 +3793,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Fildiagnostikk",
|
||||
"preliminary": "Foreløpig kontroll av adresser; innholdet valideres ved import.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Tilkoblingen mislyktes",
|
||||
"timeout": "Kilden svarte ikke i tide",
|
||||
"invalidResponse": "Kilden returnerte en nettside i stedet for en fil",
|
||||
"available": "Fil funnet; venter på validering",
|
||||
"recover": "Legg ved originalen og fortsett",
|
||||
"recoveryHint": "Velg {file} (maksimalt 50 MB). Identiteten kontrolleres. Mislykkede elementer prøves på nytt; fullførte importer bevares.",
|
||||
"attach": "Velg den originale .nitro-filen",
|
||||
"working": "Validerer eller gjenopptar...",
|
||||
"attached": "Tilsvarende originalfil vedlagt",
|
||||
"resume": "Gjenoppta mislykkede importer med filen",
|
||||
"invalidFile": "Velg en tilsvarende original .nitro-fil på opptil 50 MB og prøv igjen.",
|
||||
"resumeError": "Oppdater historikken og bruk siste forsøk; kontroller filen og prøv igjen.",
|
||||
"mismatchedFile": "Denne filen tilhører et annet møbel.",
|
||||
"alreadyStarted": "Gjenopprettingen har startet. Oppdater historikken og bruk siste forsøk.",
|
||||
"notEligible": "Dette elementet kan ikke gjenopprettes med et vedlegg."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Kopia do odzyskania będzie przechowywana w tej przeglądarce przez 7 dni.",
|
||||
"saved": "Kopia do odzyskania została zapisana w tej przeglądarce na 7 dni.",
|
||||
"unavailable": "Odzyskiwanie w przeglądarce jest niedostępne. Nie zamykaj tej strony, dopóki zmiany nie zostaną zapisane.",
|
||||
"found": "W tej przeglądarce jest dostępna kopia do odzyskania, zapisana",
|
||||
"restore": "Przywróć kopię z przeglądarki",
|
||||
"discard": "Usuń kopię z przeglądarki",
|
||||
"confirmRestore": "Zastąpić bieżący formularz kopią z przeglądarki? Sprawdź zawartość przed zapisaniem.",
|
||||
"confirmDiscard": "Usunąć tę kopię z przeglądarki? Bieżący formularz pozostanie bez zmian."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Dom",
|
||||
"community": "Społeczność",
|
||||
@@ -3784,6 +3794,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Diagnostyka plików",
|
||||
"preliminary": "Wstępna kontrola adresów; zawartość zostanie sprawdzona podczas importu.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Połączenie nie powiodło się",
|
||||
"timeout": "Źródło nie odpowiedziało na czas",
|
||||
"invalidResponse": "Źródło zwróciło stronę zamiast pliku",
|
||||
"available": "Plik znaleziony; oczekuje na weryfikację",
|
||||
"recover": "Dołącz oryginał i wznów",
|
||||
"recoveryHint": "Wybierz {file} (do 50 MB). Tożsamość pliku jest sprawdzana. Ponawiane są nieudane pozycje; ukończone importy zostają zachowane.",
|
||||
"attach": "Wybierz oryginalny plik .nitro",
|
||||
"working": "Sprawdzanie lub wznawianie...",
|
||||
"attached": "Dołączono pasujący oryginał",
|
||||
"resume": "Wznów nieudane importy z tym plikiem",
|
||||
"invalidFile": "Wybierz pasujący oryginalny plik .nitro do 50 MB i spróbuj ponownie.",
|
||||
"resumeError": "Odśwież historię i użyj ostatniej próby; sprawdź plik i spróbuj ponownie.",
|
||||
"mismatchedFile": "Ten plik należy do innego mebla.",
|
||||
"alreadyStarted": "Odzyskiwanie już rozpoczęto. Odśwież historię i użyj ostatniej próby.",
|
||||
"notEligible": "Tej pozycji nie można odzyskać za pomocą załącznika."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Uma cópia de recuperação será mantida neste navegador durante 7 dias.",
|
||||
"saved": "Cópia de recuperação guardada neste navegador durante 7 dias.",
|
||||
"unavailable": "A recuperação no navegador não está disponível. Mantém esta página aberta até que as alterações sejam guardadas.",
|
||||
"found": "Existe uma cópia de recuperação neste navegador, guardada em",
|
||||
"restore": "Restaurar cópia do navegador",
|
||||
"discard": "Eliminar cópia do navegador",
|
||||
"confirmRestore": "Substituir o formulário atual pela cópia de recuperação do navegador? Verifica o conteúdo antes de guardar.",
|
||||
"confirmDiscard": "Eliminar esta cópia de recuperação do navegador? O formulário atual permanecerá inalterado."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Página inicial",
|
||||
"community": "Comunidade",
|
||||
@@ -3784,6 +3794,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Diagnóstico dos ficheiros",
|
||||
"preliminary": "Verificação preliminar dos endereços; o conteúdo será validado na importação.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Falha na ligação",
|
||||
"timeout": "A fonte não respondeu a tempo",
|
||||
"invalidResponse": "A fonte devolveu uma página em vez de um ficheiro",
|
||||
"available": "Ficheiro encontrado; aguarda validação",
|
||||
"recover": "Anexar original e retomar",
|
||||
"recoveryHint": "Escolha {file} (até 50 MB). A identidade é verificada. Os itens com falha são repetidos; as importações concluídas são preservadas.",
|
||||
"attach": "Escolher o ficheiro .nitro original",
|
||||
"working": "A validar ou retomar...",
|
||||
"attached": "Original correspondente anexado",
|
||||
"resume": "Retomar importações com falha usando este ficheiro",
|
||||
"invalidFile": "Escolha um .nitro original correspondente de até 50 MB e tente novamente.",
|
||||
"resumeError": "Atualize o histórico e use a última tentativa; verifique o ficheiro e tente novamente.",
|
||||
"mismatchedFile": "Este ficheiro pertence a outro móvel.",
|
||||
"alreadyStarted": "A recuperação já começou. Atualize o histórico e use a última tentativa.",
|
||||
"notEligible": "Este item não permite recuperação por anexo."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "O copie de recuperare va fi păstrată în acest browser timp de 7 zile.",
|
||||
"saved": "Copia de recuperare a fost salvată în acest browser pentru 7 zile.",
|
||||
"unavailable": "Recuperarea în browser nu este disponibilă. Păstrează această pagină deschisă până când modificările sunt salvate.",
|
||||
"found": "În acest browser este disponibilă o copie de recuperare, salvată la",
|
||||
"restore": "Restaurează copia din browser",
|
||||
"discard": "Șterge copia din browser",
|
||||
"confirmRestore": "Înlocuiești formularul actual cu copia de recuperare din browser? Verifică conținutul înainte de salvare.",
|
||||
"confirmDiscard": "Ștergi această copie de recuperare din browser? Formularul actual va rămâne neschimbat."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Acasă",
|
||||
"community": "comunitate",
|
||||
@@ -3783,6 +3793,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Diagnosticarea fișierelor",
|
||||
"preliminary": "Verificare preliminară a adreselor; conținutul se validează la import.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Conexiunea a eșuat",
|
||||
"timeout": "Sursa nu a răspuns la timp",
|
||||
"invalidResponse": "Sursa a returnat o pagină web în loc de fișier",
|
||||
"available": "Fișier găsit; așteaptă validarea",
|
||||
"recover": "Atașează originalul și reia",
|
||||
"recoveryHint": "Alege {file} (maximum 50 MB). Identitatea se verifică. Elementele eșuate se reîncearcă; importurile finalizate se păstrează.",
|
||||
"attach": "Alege fișierul .nitro original",
|
||||
"working": "Validare sau reluare...",
|
||||
"attached": "Fișierul original corespunzător a fost atașat",
|
||||
"resume": "Reia importurile eșuate cu acest fișier",
|
||||
"invalidFile": "Alege un .nitro original corespunzător de maximum 50 MB și încearcă din nou.",
|
||||
"resumeError": "Actualizează istoricul și folosește ultima încercare; verifică fișierul și încearcă din nou.",
|
||||
"mismatchedFile": "Acest fișier aparține altui obiect de mobilier.",
|
||||
"alreadyStarted": "Recuperarea a început deja. Actualizează istoricul și folosește ultima încercare.",
|
||||
"notEligible": "Acest element nu permite recuperarea prin atașament."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Копия для восстановления будет храниться в этом браузере 7 дней.",
|
||||
"saved": "Копия для восстановления сохранена в этом браузере на 7 дней.",
|
||||
"unavailable": "Восстановление в браузере недоступно. Не закрывай эту страницу, пока изменения не будут сохранены.",
|
||||
"found": "В этом браузере доступна копия для восстановления. Дата сохранения:",
|
||||
"restore": "Восстановить копию из браузера",
|
||||
"discard": "Удалить копию из браузера",
|
||||
"confirmRestore": "Заменить текущую форму копией из браузера? Проверь содержимое перед сохранением.",
|
||||
"confirmDiscard": "Удалить эту копию из браузера? Текущая форма останется без изменений."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Главная",
|
||||
"community": "Сообщество",
|
||||
@@ -3782,6 +3792,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Диагностика файлов",
|
||||
"preliminary": "Предварительная проверка адресов; содержимое проверяется при импорте.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Не удалось подключиться",
|
||||
"timeout": "Источник не ответил вовремя",
|
||||
"invalidResponse": "Источник вернул веб-страницу вместо файла",
|
||||
"available": "Файл найден; ожидает проверки",
|
||||
"recover": "Прикрепить оригинал и продолжить",
|
||||
"recoveryHint": "Выберите {file} (до 50 MB). Подлинность соответствия проверяется. Повторяются неудачные элементы; завершённые импорты сохраняются.",
|
||||
"attach": "Выберите исходный файл .nitro",
|
||||
"working": "Проверка или возобновление...",
|
||||
"attached": "Соответствующий оригинал прикреплён",
|
||||
"resume": "Повторить неудачные импорты с этим файлом",
|
||||
"invalidFile": "Выберите соответствующий исходный .nitro до 50 MB и повторите попытку.",
|
||||
"resumeError": "Обновите историю и используйте последнюю попытку; проверьте файл и попробуйте снова.",
|
||||
"mismatchedFile": "Этот файл относится к другой мебели.",
|
||||
"alreadyStarted": "Восстановление уже началось. Обновите историю и используйте последнюю попытку.",
|
||||
"notEligible": "Этот элемент нельзя восстановить вложением."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Kópia na obnovenie bude v tomto prehliadači uchovaná 7 dní.",
|
||||
"saved": "Kópia na obnovenie bola v tomto prehliadači uložená na 7 dní.",
|
||||
"unavailable": "Obnovenie v prehliadači nie je dostupné. Nechaj túto stránku otvorenú, kým sa zmeny neuložia.",
|
||||
"found": "V tomto prehliadači je dostupná kópia na obnovenie, uložená",
|
||||
"restore": "Obnoviť kópiu z prehliadača",
|
||||
"discard": "Odstrániť kópiu z prehliadača",
|
||||
"confirmRestore": "Nahradiť aktuálny formulár kópiou z prehliadača? Pred uložením skontroluj obsah.",
|
||||
"confirmDiscard": "Odstrániť túto kópiu z prehliadača? Aktuálny formulár zostane nezmenený."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Domov",
|
||||
"community": "Spoločenstva",
|
||||
@@ -3783,6 +3793,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Diagnostika súborov",
|
||||
"preliminary": "Predbežná kontrola adries; obsah sa overí pri importe.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Pripojenie zlyhalo",
|
||||
"timeout": "Zdroj neodpovedal včas",
|
||||
"invalidResponse": "Zdroj vrátil webovú stránku namiesto súboru",
|
||||
"available": "Súbor nájdený; čaká na overenie",
|
||||
"recover": "Priložiť originál a pokračovať",
|
||||
"recoveryHint": "Vyberte {file} (do 50 MB). Overí sa jeho identita. Opakujú sa neúspešné položky; dokončené importy zostanú zachované.",
|
||||
"attach": "Vyberte pôvodný súbor .nitro",
|
||||
"working": "Overovanie alebo pokračovanie...",
|
||||
"attached": "Zodpovedajúci súbor priložený",
|
||||
"resume": "Obnoviť neúspešné importy s týmto súborom",
|
||||
"invalidFile": "Vyberte zodpovedajúci pôvodný .nitro do 50 MB a skúste to znova.",
|
||||
"resumeError": "Obnovte históriu a použite posledný pokus; skontrolujte súbor a skúste to znova.",
|
||||
"mismatchedFile": "Tento súbor patrí inému nábytku.",
|
||||
"alreadyStarted": "Obnova už začala. Obnovte históriu a použite posledný pokus.",
|
||||
"notEligible": "Túto položku nemožno obnoviť prílohou."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Копија за опоравак чуваће се у овом прегледачу 7 дана.",
|
||||
"saved": "Копија за опоравак сачувана је у овом прегледачу на 7 дана.",
|
||||
"unavailable": "Опоравак у прегледачу није доступан. Остави ову страницу отвореном док се промене не сачувају.",
|
||||
"found": "У овом прегледачу доступна је копија за опоравак, сачувана",
|
||||
"restore": "Врати копију из прегледача",
|
||||
"discard": "Избриши копију из прегледача",
|
||||
"confirmRestore": "Заменити тренутни образац копијом из прегледача? Провери садржај пре чувања.",
|
||||
"confirmDiscard": "Избрисати ову копију из прегледача? Тренутни образац остаће непромењен."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Хоме",
|
||||
"community": "Заједница",
|
||||
@@ -3783,6 +3793,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Дијагностика датотека",
|
||||
"preliminary": "Прелиминарна провера адреса; садржај се проверава при увозу.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Повезивање није успело",
|
||||
"timeout": "Извор није одговорио на време",
|
||||
"invalidResponse": "Извор је вратио веб-страницу уместо датотеке",
|
||||
"available": "Датотека пронађена; чека проверу",
|
||||
"recover": "Приложи оригинал и настави",
|
||||
"recoveryHint": "Изаберите {file} (до 50 MB). Проверава се идентитет. Понављају се неуспеле ставке; завршени увози се чувају.",
|
||||
"attach": "Изаберите изворну .nitro датотеку",
|
||||
"working": "Провера или наставак...",
|
||||
"attached": "Одговарајућа датотека је приложена",
|
||||
"resume": "Настави неуспеле увозе са датотеком",
|
||||
"invalidFile": "Изаберите одговарајућу изворну .nitro датотеку до 50 MB и покушајте поново.",
|
||||
"resumeError": "Освежите историју и користите последњи покушај; проверите датотеку и покушајте поново.",
|
||||
"mismatchedFile": "Ова датотека припада другом комаду намештаја.",
|
||||
"alreadyStarted": "Опоравак је већ покренут. Освежите историју и користите последњи покушај.",
|
||||
"notEligible": "Ова ставка не подржава опоравак прилогом."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "En återställningskopia sparas i den här webbläsaren i 7 dagar.",
|
||||
"saved": "En återställningskopia har sparats i den här webbläsaren i 7 dagar.",
|
||||
"unavailable": "Återställning i webbläsaren är inte tillgänglig. Låt sidan vara öppen tills dina ändringar har sparats.",
|
||||
"found": "En återställningskopia finns i den här webbläsaren, sparad",
|
||||
"restore": "Återställ webbläsarkopia",
|
||||
"discard": "Ta bort webbläsarkopia",
|
||||
"confirmRestore": "Ersätta det aktuella formuläret med webbläsarens återställningskopia? Kontrollera innehållet innan du sparar.",
|
||||
"confirmDiscard": "Ta bort den här återställningskopian från webbläsaren? Det aktuella formuläret förblir oförändrat."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Hem",
|
||||
"community": "gemenskapen",
|
||||
@@ -3783,6 +3793,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Fildiagnostik",
|
||||
"preliminary": "Förhandskontroll av adresser; innehållet valideras vid import.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Anslutningen misslyckades",
|
||||
"timeout": "Källan svarade inte i tid",
|
||||
"invalidResponse": "Källan returnerade en webbsida i stället för en fil",
|
||||
"available": "Fil hittad; väntar på validering",
|
||||
"recover": "Bifoga originalet och fortsätt",
|
||||
"recoveryHint": "Välj {file} (högst 50 MB). Identiteten kontrolleras. Misslyckade objekt försöks igen; slutförda importer bevaras.",
|
||||
"attach": "Välj den ursprungliga .nitro-filen",
|
||||
"working": "Validerar eller återupptar...",
|
||||
"attached": "Matchande originalfil bifogad",
|
||||
"resume": "Återuppta misslyckade importer med filen",
|
||||
"invalidFile": "Välj en matchande originalfil i .nitro-format på högst 50 MB och försök igen.",
|
||||
"resumeError": "Uppdatera historiken och använd senaste försöket; kontrollera filen och försök igen.",
|
||||
"mismatchedFile": "Den här filen tillhör en annan möbel.",
|
||||
"alreadyStarted": "Återställningen har redan startat. Uppdatera historiken och använd senaste försöket.",
|
||||
"notEligible": "Det här objektet kan inte återställas med en bilaga."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Kurtarma kopyası bu tarayıcıda 7 gün saklanacak.",
|
||||
"saved": "Kurtarma kopyası bu tarayıcıya 7 gün süreyle kaydedildi.",
|
||||
"unavailable": "Tarayıcıda kurtarma kullanılamıyor. Değişikliklerin kaydedilene kadar bu sayfayı açık tut.",
|
||||
"found": "Bu tarayıcıda bir kurtarma kopyası mevcut. Kaydedilme tarihi:",
|
||||
"restore": "Tarayıcı kopyasını geri yükle",
|
||||
"discard": "Tarayıcı kopyasını sil",
|
||||
"confirmRestore": "Mevcut form tarayıcıdaki kurtarma kopyasıyla değiştirilsin mi? Kaydetmeden önce içeriği kontrol et.",
|
||||
"confirmDiscard": "Bu kurtarma kopyası tarayıcıdan silinsin mi? Mevcut form değişmeden kalacak."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Ana sayfa",
|
||||
"community": "Topluluk",
|
||||
@@ -3784,6 +3794,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Dosya tanılaması",
|
||||
"preliminary": "Adreslerin ön kontrolü; içerik içe aktarma sırasında doğrulanır.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Bağlantı başarısız",
|
||||
"timeout": "Kaynak zamanında yanıt vermedi",
|
||||
"invalidResponse": "Kaynak dosya yerine web sayfası döndürdü",
|
||||
"available": "Dosya bulundu; doğrulama bekleniyor",
|
||||
"recover": "Özgün dosyayı ekle ve sürdür",
|
||||
"recoveryHint": "{file} dosyasını seçin (en fazla 50 MB). Kimliği doğrulanır. Başarısız öğeler yeniden denenir; tamamlanan aktarımlar korunur.",
|
||||
"attach": "Özgün .nitro dosyasını seçin",
|
||||
"working": "Doğrulanıyor veya sürdürülüyor...",
|
||||
"attached": "Eşleşen özgün dosya eklendi",
|
||||
"resume": "Başarısız aktarımları bu dosyayla sürdür",
|
||||
"invalidFile": "En fazla 50 MB olan eşleşen özgün .nitro dosyasını seçip yeniden deneyin.",
|
||||
"resumeError": "Geçmişi yenileyip son denemeyi kullanın; dosyayı kontrol edip yeniden deneyin.",
|
||||
"mismatchedFile": "Bu dosya başka bir mobilyaya ait.",
|
||||
"alreadyStarted": "Kurtarma zaten başladı. Geçmişi yenileyip son denemeyi kullanın.",
|
||||
"notEligible": "Bu öğe ek dosyayla kurtarılamaz."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"draftRecovery": {
|
||||
"ready": "Копія для відновлення зберігатиметься в цьому браузері 7 днів.",
|
||||
"saved": "Копію для відновлення збережено в цьому браузері на 7 днів.",
|
||||
"unavailable": "Відновлення в браузері недоступне. Не закривай цю сторінку, доки зміни не буде збережено.",
|
||||
"found": "У цьому браузері доступна копія для відновлення. Дата збереження:",
|
||||
"restore": "Відновити копію з браузера",
|
||||
"discard": "Видалити копію з браузера",
|
||||
"confirmRestore": "Замінити поточну форму копією з браузера? Перевір вміст перед збереженням.",
|
||||
"confirmDiscard": "Видалити цю копію з браузера? Поточна форма залишиться без змін."
|
||||
},
|
||||
"nav": {
|
||||
"home": "додому",
|
||||
"community": "Спільнота",
|
||||
@@ -3783,6 +3793,26 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"importDiagnostics": {
|
||||
"attempts": "Діагностика файлів",
|
||||
"preliminary": "Попередня перевірка адрес; вміст перевіряється під час імпорту.",
|
||||
"http": "HTTP {status}",
|
||||
"network": "Не вдалося підключитися",
|
||||
"timeout": "Джерело не відповіло вчасно",
|
||||
"invalidResponse": "Джерело повернуло вебсторінку замість файлу",
|
||||
"available": "Файл знайдено; очікує перевірки",
|
||||
"recover": "Прикріпити оригінал і продовжити",
|
||||
"recoveryHint": "Виберіть {file} (до 50 MB). Його відповідність перевіряється. Повторюються невдалі елементи; завершені імпорти зберігаються.",
|
||||
"attach": "Виберіть оригінальний файл .nitro",
|
||||
"working": "Перевірка або відновлення...",
|
||||
"attached": "Відповідний оригінал прикріплено",
|
||||
"resume": "Повторити невдалі імпорти з цим файлом",
|
||||
"invalidFile": "Виберіть відповідний оригінальний .nitro до 50 MB і повторіть спробу.",
|
||||
"resumeError": "Оновіть історію й використайте останню спробу; перевірте файл і спробуйте знову.",
|
||||
"mismatchedFile": "Цей файл належить іншому предмету меблів.",
|
||||
"alreadyStarted": "Відновлення вже розпочато. Оновіть історію й використайте останню спробу.",
|
||||
"notEligible": "Цей елемент не можна відновити вкладенням."
|
||||
},
|
||||
"completeness": {
|
||||
"title": "Completeness and next steps",
|
||||
"parts": {
|
||||
|
||||
@@ -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}`;
|
||||