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