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

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

No files matched your search

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