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