Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9130ecd2d | ||
|
|
44c34dbae6 | ||
|
|
5308ce6a12 | ||
|
|
0c8e62357f | ||
|
|
cde15ad022 | ||
|
|
1f9e8a0eec | ||
|
|
bac2f653af | ||
|
|
88ac5ac1ba | ||
|
|
cf563f3550 | ||
|
|
6b6fc5dc64 |
No files matched your search
@@ -177,10 +177,15 @@ jobs:
|
||||
cd "${LIVE}"
|
||||
echo "Hard reset live tree to origin/main (no nuclear src wipe)..."
|
||||
git reset --hard origin/main
|
||||
# Keep env, uploads, and deps we are about to replace from stage.
|
||||
# Keep env, uploads, runtime-imported furni assets, and deps we are
|
||||
# about to replace from stage. The app can write furni files while it
|
||||
# remains online during staging, so cleaning those paths races with
|
||||
# active imports and can fail with "Directory not empty".
|
||||
git clean -fd \
|
||||
-e .env -e .env.local -e .env.production -e .env*.local \
|
||||
-e storage -e public/cache \
|
||||
-e public/swf/dcr/hof_furni \
|
||||
-e public/nitro-assets/bundled/furniture \
|
||||
-e node_modules -e node_modules.prev -e .next -e .next.prev
|
||||
|
||||
if ! git diff --exit-code HEAD -- src >/dev/null; then
|
||||
|
||||
@@ -24,5 +24,10 @@ gitea
|
||||
# Runtime uploaded media (persistent, outside public/)
|
||||
storage/
|
||||
|
||||
# Runtime downloaded furni assets (mirrored from gamedata / audit repair)
|
||||
public/nitro-assets/bundled/furniture/
|
||||
public/swf/dcr/
|
||||
public/tmp/
|
||||
|
||||
# Test coverage reports
|
||||
coverage/
|
||||
@@ -0,0 +1,357 @@
|
||||
# Manual Recent Furni Resync Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a guarded Tools command to Admin Import Furni that resyncs furniture imported during the last seven days and displays the operation result.
|
||||
|
||||
**Architecture:** Keep the existing permission-protected `/api/admin/import/furni/resync` route unchanged. Add a client-safe request/normalization helper with an injected fetcher so its exact URL, HTTP method, success payload, partial failures, and transport failures are testable without rendering the large Import Furni client. The existing client component owns the confirmation, loading, and result UI state.
|
||||
|
||||
**Tech Stack:** TypeScript, React 19, Next.js 16 App Router, Vitest, shadcn/Radix UI, Sonner, Biome.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- The operation targets only `furni_import` audit entries from the last seven days.
|
||||
- Use the existing `ASSETS_IMPORT`-protected endpoint and `adminFetch` CSRF flow.
|
||||
- Do not expose `all=1`, delete database rows, or regenerate `.nitro`, SWF, or icon files.
|
||||
- Display examined, resynced, failed, RCON status, and returned per-item errors.
|
||||
- Prevent duplicate submissions while a request is active.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Tested recent-resync client contract
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/admin/recent-furni-resync.ts`
|
||||
- Test: `src/lib/admin/recent-furni-resync.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: a fetcher matching `(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>`.
|
||||
- Produces: `RECENT_FURNI_RESYNC_URL`, `RecentFurniResyncError`, `RecentFurniResyncResult`, and `requestRecentFurniResync(fetcher): Promise<RecentFurniResyncResult>`.
|
||||
|
||||
- [ ] **Step 1: Write the failing request-contract tests**
|
||||
|
||||
Create `src/lib/admin/recent-furni-resync.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
RECENT_FURNI_RESYNC_URL,
|
||||
requestRecentFurniResync,
|
||||
} from "./recent-furni-resync";
|
||||
|
||||
describe("requestRecentFurniResync", () => {
|
||||
it("posts to the fixed seven-day resync endpoint and normalizes the result", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({
|
||||
ok: true,
|
||||
mode: "days",
|
||||
days: 7,
|
||||
examined: 4,
|
||||
resynced: 3,
|
||||
failed: 1,
|
||||
rconOk: false,
|
||||
errors: [{ classname: "chair", message: "invalid entry" }],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(requestRecentFurniResync(fetcher)).resolves.toEqual({
|
||||
examined: 4,
|
||||
resynced: 3,
|
||||
failed: 1,
|
||||
rconOk: false,
|
||||
errors: [{ classname: "chair", message: "invalid entry" }],
|
||||
});
|
||||
expect(RECENT_FURNI_RESYNC_URL).toBe(
|
||||
"/api/admin/import/furni/resync?days=7",
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledWith(RECENT_FURNI_RESYNC_URL, {
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws the API error when the request fails", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({ error: "Forbidden" }, { status: 403 }),
|
||||
);
|
||||
|
||||
await expect(requestRecentFurniResync(fetcher)).rejects.toThrow(
|
||||
"Forbidden",
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because `recent-furni-resync.ts` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal typed request helper**
|
||||
|
||||
Create `src/lib/admin/recent-furni-resync.ts`:
|
||||
|
||||
```ts
|
||||
export const RECENT_FURNI_RESYNC_URL =
|
||||
"/api/admin/import/furni/resync?days=7";
|
||||
|
||||
export interface RecentFurniResyncError {
|
||||
classname: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RecentFurniResyncResult {
|
||||
examined: number;
|
||||
resynced: number;
|
||||
failed: number;
|
||||
rconOk: boolean;
|
||||
errors: RecentFurniResyncError[];
|
||||
}
|
||||
|
||||
type AdminFetcher = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export async function requestRecentFurniResync(
|
||||
fetcher: AdminFetcher,
|
||||
): Promise<RecentFurniResyncResult> {
|
||||
const response = await fetcher(RECENT_FURNI_RESYNC_URL, { method: "POST" });
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
typeof payload.error === "string" ? payload.error : "Furni resync failed",
|
||||
);
|
||||
}
|
||||
|
||||
const errors = Array.isArray(payload.errors)
|
||||
? payload.errors.filter(
|
||||
(value): value is RecentFurniResyncError =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as RecentFurniResyncError).classname === "string" &&
|
||||
typeof (value as RecentFurniResyncError).message === "string",
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
examined: Number(payload.examined) || 0,
|
||||
resynced: Number(payload.resynced) || 0,
|
||||
failed: Number(payload.failed) || 0,
|
||||
rconOk: payload.rconOk === true,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts
|
||||
pnpm exec biome check --write src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
|
||||
```
|
||||
|
||||
Expected: 2 tests pass and Biome reports no remaining errors.
|
||||
|
||||
- [ ] **Step 5: Commit the tested contract**
|
||||
|
||||
```powershell
|
||||
git add -- src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
|
||||
git commit -m "feat: add recent furni resync client contract"
|
||||
```
|
||||
|
||||
### Task 2: Import Furni Tools action and result UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/admin/import/furni/import-furni-client.tsx`
|
||||
- Consume: `src/lib/admin/recent-furni-resync.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `requestRecentFurniResync(adminFetch): Promise<RecentFurniResyncResult>`.
|
||||
- Produces: a `Tools → Update imported furni` action, confirmation dialog, loading state, and dismissible result panel.
|
||||
|
||||
- [ ] **Step 1: Add state and the guarded request handler**
|
||||
|
||||
Import `DatabaseZap`, `RecentFurniResyncResult`, and
|
||||
`requestRecentFurniResync`. Add state alongside the existing reorganization
|
||||
state:
|
||||
|
||||
```ts
|
||||
const [confirmRecentResync, setConfirmRecentResync] = useState(false);
|
||||
const [recentResyncing, setRecentResyncing] = useState(false);
|
||||
const [recentResyncResult, setRecentResyncResult] =
|
||||
useState<RecentFurniResyncResult | null>(null);
|
||||
```
|
||||
|
||||
Add the handler near `startReorganize`:
|
||||
|
||||
```ts
|
||||
async function resyncRecentImports() {
|
||||
setConfirmRecentResync(false);
|
||||
setRecentResyncing(true);
|
||||
setRecentResyncResult(null);
|
||||
try {
|
||||
const result = await requestRecentFurniResync(adminFetch);
|
||||
setRecentResyncResult(result);
|
||||
if (result.examined === 0) {
|
||||
toast.info("No imported furni found in the last 7 days");
|
||||
} else if (result.failed > 0 || !result.rconOk) {
|
||||
toast.warning("Furni resync completed with warnings");
|
||||
} else {
|
||||
toast.success(`Updated ${result.resynced} imported furni`);
|
||||
}
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Furni resync failed",
|
||||
);
|
||||
} finally {
|
||||
setRecentResyncing(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the Tools entry and duplicate-submit guard**
|
||||
|
||||
Insert before the Tools separator:
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfirmRecentResync(true)}
|
||||
disabled={recentResyncing}
|
||||
>
|
||||
{recentResyncing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<DatabaseZap className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{recentResyncing ? "Updating imported furni..." : "Update imported furni"}
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the confirmation dialog**
|
||||
|
||||
Add a controlled dialog beside the existing batch confirmation dialogs:
|
||||
|
||||
```tsx
|
||||
<Dialog open={confirmRecentResync} onOpenChange={setConfirmRecentResync}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update imported furni?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This updates FurnitureData for furni imported during the last 7 days,
|
||||
then refreshes the emulator catalog and item caches. No database rows or
|
||||
asset files are deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmRecentResync(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={resyncRecentImports} disabled={recentResyncing}>
|
||||
Update last 7 days
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Render a dismissible result panel**
|
||||
|
||||
Place the panel after the existing error banner and before batch summaries:
|
||||
|
||||
```tsx
|
||||
{recentResyncResult && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">Imported Furni Update</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRecentResyncResult(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<span>{recentResyncResult.examined} examined</span>
|
||||
<span className="text-[var(--admin-success)]">
|
||||
{recentResyncResult.resynced} updated
|
||||
</span>
|
||||
<span className={recentResyncResult.failed ? "text-destructive" : ""}>
|
||||
{recentResyncResult.failed} failed
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
recentResyncResult.rconOk
|
||||
? "text-[var(--admin-success)]"
|
||||
: "text-[var(--admin-warning)]"
|
||||
}
|
||||
>
|
||||
RCON {recentResyncResult.rconOk ? "refreshed" : "not refreshed"}
|
||||
</span>
|
||||
</div>
|
||||
{recentResyncResult.errors.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
View errors
|
||||
</summary>
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
|
||||
{recentResyncResult.errors.map((error) => (
|
||||
<p key={`${error.classname}:${error.message}`}>
|
||||
<span className="font-mono">{error.classname}</span>: {error.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run focused and static verification**
|
||||
|
||||
```powershell
|
||||
pnpm exec biome check --write src/app/admin/import/furni/import-furni-client.tsx src/lib/admin/recent-furni-resync.ts src/lib/admin/recent-furni-resync.test.ts
|
||||
pnpm exec vitest run --coverage=false src/lib/admin/recent-furni-resync.test.ts src/lib/services/furni-data-paths.test.ts
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
Expected: Biome clean, focused tests pass, and TypeScript exits 0.
|
||||
|
||||
- [ ] **Step 6: Run complete regression verification**
|
||||
|
||||
```powershell
|
||||
pnpm test
|
||||
$env:NODE_ENV='production'
|
||||
$env:DATABASE_URL='mysql://test:test@localhost:3306/test?charset=utf8mb4'
|
||||
$env:AUTH_SECRET='ci-test-secret-key-that-is-long-enough'
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: all tests pass and the production build exits 0. Redis/database
|
||||
availability warnings during static generation are acceptable in the local test
|
||||
environment; compilation or route-generation errors are not.
|
||||
|
||||
- [ ] **Step 7: Commit the UI integration**
|
||||
|
||||
```powershell
|
||||
git add -- src/app/admin/import/furni/import-furni-client.tsx
|
||||
git commit -m "feat: add manual recent furni resync action"
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Verify the final repository state**
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git log --oneline origin/main..HEAD
|
||||
```
|
||||
|
||||
Expected: clean worktree and the design, plan, tested helper, and UI commits are
|
||||
ahead of `origin/main`, ready for an explicit push request.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Manual Recent Furni Resync Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add a safe manual command to the existing Admin Import Furni screen for
|
||||
refreshing furniture imported during the last seven days. The command must
|
||||
update the live FurnitureData metadata and emulator caches without deleting
|
||||
database rows or regenerating existing asset bundles.
|
||||
|
||||
## User interface
|
||||
|
||||
Add `Update imported furni` to the existing `Tools` dropdown on
|
||||
`/admin/import/furni`.
|
||||
|
||||
Selecting it opens a confirmation dialog that states the fixed scope: furniture
|
||||
recorded by the admin audit log as imported during the last seven days. While
|
||||
the operation is running, the action and confirmation button are disabled and a
|
||||
loading state is shown to prevent duplicate requests.
|
||||
|
||||
After completion, show a result panel containing:
|
||||
|
||||
- examined item count;
|
||||
- successfully resynced item count;
|
||||
- failed item count;
|
||||
- emulator RCON refresh status;
|
||||
- returned per-item errors, when present.
|
||||
|
||||
## Data flow
|
||||
|
||||
The client sends an authenticated, CSRF-protected `POST` request through
|
||||
`adminFetch` to the existing endpoint:
|
||||
|
||||
`/api/admin/import/furni/resync?days=7`
|
||||
|
||||
The endpoint remains authoritative for selecting the targets. It reads only
|
||||
`ItemsBase` IDs referenced by `admin_audit_log` entries whose action is
|
||||
`furni_import`, target is `ItemsBase`, and timestamp falls within the last seven
|
||||
days.
|
||||
|
||||
For each target, the existing resync logic rebuilds its FurnitureData entry,
|
||||
enriches it from the configured `habbo_gamedata_hotel`, and upserts it into the
|
||||
live `FurnitureData.json`. It then requests `updateCatalog` and `updateItems`
|
||||
through RCON.
|
||||
|
||||
## Safety and permissions
|
||||
|
||||
- Reuse the endpoint's `ASSETS_IMPORT` permission check and admin CSRF guard.
|
||||
- Do not expose a UI option for `all=1`.
|
||||
- Do not delete or recreate `items_base` or catalog rows.
|
||||
- Do not regenerate `.nitro`, SWF, or icon files.
|
||||
- Keep returned errors visible without treating an RCON failure as a successful
|
||||
refresh.
|
||||
|
||||
## Error handling
|
||||
|
||||
Network or non-JSON failures produce an error toast and leave the command
|
||||
available for retry. A successful API response is rendered even when individual
|
||||
items failed, so the administrator can distinguish partial completion from a
|
||||
request failure.
|
||||
|
||||
The result panel is dismissible. Running the command again replaces the prior
|
||||
result.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit-test the result normalization used by the UI, including partial failures
|
||||
and RCON status.
|
||||
- Verify that the client calls exactly `resync?days=7` through `adminFetch`.
|
||||
- Run focused tests, Biome, TypeScript, the full test suite, and a production
|
||||
build.
|
||||
+1
-1
@@ -93,7 +93,7 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "^8.5.25",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.4",
|
||||
"tsx": "^4.23.5",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "8.2.0",
|
||||
"vitest": "4.1.10"
|
||||
|
||||
Generated
+16
-16
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
ExternalLink,
|
||||
@@ -39,6 +40,21 @@ interface AuditSummary {
|
||||
missingIcon: number;
|
||||
duplicates: number;
|
||||
missingFromSourcesTotal: number;
|
||||
iconsRepaired: number;
|
||||
iconsStillMissing: number;
|
||||
nitrosRepaired: number;
|
||||
nitrosStillMissing: number;
|
||||
catalogSqlCount: number;
|
||||
catalogSqlApplied: number;
|
||||
furniDataAdded: number;
|
||||
furniDataDuplicatesRemoved: number;
|
||||
}
|
||||
|
||||
interface RepairStats {
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
stillMissing: number;
|
||||
}
|
||||
|
||||
interface AuditEvent {
|
||||
@@ -50,6 +66,13 @@ interface AuditEvent {
|
||||
issues?: AuditIssue[];
|
||||
missingFromSources?: MissingFromSource[];
|
||||
summary?: AuditSummary;
|
||||
repair?: RepairStats;
|
||||
repairNitro?: RepairStats;
|
||||
sql?: string;
|
||||
sqlCount?: number;
|
||||
applied?: number;
|
||||
sqlFailed?: number;
|
||||
furniData?: { added: number; removedDuplicates: number };
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
@@ -61,16 +84,72 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
source_fetch_failed: "Source fetch failed",
|
||||
};
|
||||
|
||||
function AuditCheckbox({
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: custom checkbox so the checked state shows a visible checkmark on every admin theme
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`flex items-center gap-2 text-sm text-muted-foreground cursor-pointer select-none ${
|
||||
disabled ? "opacity-50 cursor-not-allowed" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-[3px] border-2 transition-colors ${
|
||||
checked
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
|
||||
: "border-[var(--admin-border)] bg-transparent"
|
||||
}`}
|
||||
>
|
||||
{checked && <Check className="h-3 w-3 text-[var(--admin-surface)]" />}
|
||||
</span>
|
||||
<span className="pointer-events-none">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditClient() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"issues" | "sources">("issues");
|
||||
const [repairEnabled, setRepairEnabled] = useState(true);
|
||||
const [repairNitros, setRepairNitros] = useState(true);
|
||||
const [generateSql, setGenerateSql] = useState(false);
|
||||
const [applySql, setApplySql] = useState(false);
|
||||
const [repairFurniData, setRepairFurniData] = useState(false);
|
||||
|
||||
const [issues, setIssues] = useState<AuditIssue[]>([]);
|
||||
const [missingFromSources, setMissingFromSources] = useState<
|
||||
MissingFromSource[]
|
||||
>([]);
|
||||
const [summary, setSummary] = useState<AuditSummary | null>(null);
|
||||
const [repairStats, setRepairStats] = useState<RepairStats | null>(null);
|
||||
const [nitroRepairStats, setNitroRepairStats] = useState<RepairStats | null>(
|
||||
null,
|
||||
);
|
||||
const [sqlText, setSqlText] = useState<string | null>(null);
|
||||
const [sqlApplied, setSqlApplied] = useState<{
|
||||
applied: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
const [furniDataResult, setFurniDataResult] = useState<{
|
||||
added: number;
|
||||
removedDuplicates: number;
|
||||
} | null>(null);
|
||||
|
||||
const [checkProgress, setCheckProgress] = useState<{
|
||||
section: string;
|
||||
@@ -86,6 +165,11 @@ export function AuditClient() {
|
||||
setIssues([]);
|
||||
setMissingFromSources([]);
|
||||
setSummary(null);
|
||||
setRepairStats(null);
|
||||
setNitroRepairStats(null);
|
||||
setSqlText(null);
|
||||
setSqlApplied(null);
|
||||
setFurniDataResult(null);
|
||||
setCheckProgress(null);
|
||||
setActiveTab("issues");
|
||||
|
||||
@@ -96,6 +180,14 @@ export function AuditClient() {
|
||||
const res = await adminFetch("/api/admin/import/audit", {
|
||||
method: "POST",
|
||||
signal: abort.signal,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
repair: repairEnabled,
|
||||
repairNitros,
|
||||
sql: generateSql || applySql,
|
||||
applySql,
|
||||
repairFurniData,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.body) {
|
||||
@@ -155,6 +247,26 @@ export function AuditClient() {
|
||||
}
|
||||
}
|
||||
|
||||
if (evt.type === "repair_complete") {
|
||||
if (evt.repair) setRepairStats(evt.repair);
|
||||
if (evt.repairNitro) setNitroRepairStats(evt.repairNitro);
|
||||
}
|
||||
|
||||
if (evt.type === "sql_ready") {
|
||||
setSqlText(evt.sql ?? null);
|
||||
}
|
||||
|
||||
if (evt.type === "sql_applied") {
|
||||
setSqlApplied({
|
||||
applied: evt.applied ?? 0,
|
||||
failed: evt.sqlFailed ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (evt.type === "furnidata_repair_complete") {
|
||||
if (evt.furniData) setFurniDataResult(evt.furniData);
|
||||
}
|
||||
|
||||
if (evt.type === "batch_complete") {
|
||||
setIssues(evt.issues ?? []);
|
||||
setMissingFromSources(evt.missingFromSources ?? []);
|
||||
@@ -197,11 +309,22 @@ export function AuditClient() {
|
||||
setLoading(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
}, [repairEnabled, repairNitros, generateSql, applySql, repairFurniData]);
|
||||
|
||||
const errors = issues.filter((i) => i.severity === "error");
|
||||
const warnings = issues.filter((i) => i.severity === "warning");
|
||||
|
||||
const downloadSql = () => {
|
||||
if (!sqlText) return;
|
||||
const blob = new Blob([sqlText], { type: "text/sql" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `catalog-repair-${new Date().toISOString().slice(0, 10)}.sql`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!summary && (
|
||||
@@ -214,6 +337,41 @@ export function AuditClient() {
|
||||
)}
|
||||
{loading ? progress || "Running…" : "Run Full Catalog Audit"}
|
||||
</Button>
|
||||
<AuditCheckbox
|
||||
checked={repairEnabled}
|
||||
disabled={loading}
|
||||
onChange={setRepairEnabled}
|
||||
>
|
||||
Repair missing icons
|
||||
</AuditCheckbox>
|
||||
<AuditCheckbox
|
||||
checked={repairNitros}
|
||||
disabled={loading}
|
||||
onChange={setRepairNitros}
|
||||
>
|
||||
Repair missing .nitro
|
||||
</AuditCheckbox>
|
||||
<AuditCheckbox
|
||||
checked={generateSql}
|
||||
disabled={loading}
|
||||
onChange={setGenerateSql}
|
||||
>
|
||||
Generate catalog SQL
|
||||
</AuditCheckbox>
|
||||
<AuditCheckbox
|
||||
checked={applySql}
|
||||
disabled={loading}
|
||||
onChange={setApplySql}
|
||||
>
|
||||
Apply SQL to database
|
||||
</AuditCheckbox>
|
||||
<AuditCheckbox
|
||||
checked={repairFurniData}
|
||||
disabled={loading}
|
||||
onChange={setRepairFurniData}
|
||||
>
|
||||
Repair FurnitureData.json (add missing + dedupe)
|
||||
</AuditCheckbox>
|
||||
{loading && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -328,6 +486,121 @@ export function AuditClient() {
|
||||
sources
|
||||
</span>
|
||||
</div>
|
||||
{repairStats && (
|
||||
<div className="flex items-center gap-2 col-span-full">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Icon repair:{" "}
|
||||
<span className="text-[var(--admin-success)] font-medium">
|
||||
{repairStats.succeeded} fixed
|
||||
</span>
|
||||
{repairStats.failed > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-[var(--admin-error)] font-medium">
|
||||
{repairStats.failed} failed
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{repairStats.skipped > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
{repairStats.skipped} skipped
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{repairStats.stillMissing > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-[var(--admin-warning)] font-medium">
|
||||
{repairStats.stillMissing} still missing
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{nitroRepairStats && (
|
||||
<div className="flex items-center gap-2 col-span-full">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Nitro repair:{" "}
|
||||
<span className="text-[var(--admin-success)] font-medium">
|
||||
{nitroRepairStats.succeeded} fixed
|
||||
</span>
|
||||
{nitroRepairStats.failed > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-[var(--admin-error)] font-medium">
|
||||
{nitroRepairStats.failed} failed
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{nitroRepairStats.skipped > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
{nitroRepairStats.skipped} skipped
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{nitroRepairStats.stillMissing > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-[var(--admin-warning)] font-medium">
|
||||
{nitroRepairStats.stillMissing} still missing
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{sqlText && (
|
||||
<div className="flex items-center gap-2 col-span-full">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Catalog SQL:{" "}
|
||||
<span className="font-medium">
|
||||
{summary.catalogSqlCount} statement(s) generated
|
||||
</span>
|
||||
{sqlApplied && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-[var(--admin-success)] font-medium">
|
||||
{sqlApplied.applied} applied
|
||||
</span>
|
||||
{sqlApplied.failed > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-[var(--admin-error)] font-medium">
|
||||
{sqlApplied.failed} failed
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={downloadSql}>
|
||||
Download .sql
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{furniDataResult && (
|
||||
<div className="flex items-center gap-2 col-span-full">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
FurnitureData repair:{" "}
|
||||
<span className="text-[var(--admin-success)] font-medium">
|
||||
{furniDataResult.added} added
|
||||
</span>
|
||||
{furniDataResult.removedDuplicates > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-[var(--admin-warning)] font-medium">
|
||||
{furniDataResult.removedDuplicates} duplicates removed
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -455,6 +728,11 @@ export function AuditClient() {
|
||||
setSummary(null);
|
||||
setIssues([]);
|
||||
setMissingFromSources([]);
|
||||
setRepairStats(null);
|
||||
setNitroRepairStats(null);
|
||||
setSqlText(null);
|
||||
setSqlApplied(null);
|
||||
setFurniDataResult(null);
|
||||
setProgress("");
|
||||
setCheckProgress(null);
|
||||
}}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
BarChart3,
|
||||
Check,
|
||||
CheckCheck,
|
||||
DatabaseZap,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileWarning,
|
||||
@@ -53,6 +54,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
type RecentFurniResyncResult,
|
||||
requestRecentFurniResync,
|
||||
} from "@/lib/admin/recent-furni-resync";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||
import { NitroEditorDialog } from "./nitro-editor-dialog";
|
||||
@@ -211,6 +216,10 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
||||
|
||||
// Reorganize catalog
|
||||
const [reorganizing, setReorganizing] = useState(false);
|
||||
const [confirmRecentResync, setConfirmRecentResync] = useState(false);
|
||||
const [recentResyncing, setRecentResyncing] = useState(false);
|
||||
const [recentResyncResult, setRecentResyncResult] =
|
||||
useState<RecentFurniResyncResult | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const batchAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
@@ -792,6 +801,30 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resyncRecentImports() {
|
||||
setConfirmRecentResync(false);
|
||||
setRecentResyncing(true);
|
||||
setRecentResyncResult(null);
|
||||
try {
|
||||
const result = await requestRecentFurniResync(adminFetch);
|
||||
setRecentResyncResult(result);
|
||||
if (result.examined === 0) {
|
||||
toast.info("No imported furni found in the last 7 days");
|
||||
} else if (result.failed > 0 || !result.rconOk) {
|
||||
toast.warning("Furni resync completed with warnings");
|
||||
} else {
|
||||
toast.success(`Updated ${result.resynced} imported furni`);
|
||||
}
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Furni resync failed",
|
||||
);
|
||||
} finally {
|
||||
setRecentResyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyReorganize() {
|
||||
setReorgPreview(null);
|
||||
setReorganizing(true);
|
||||
@@ -1038,6 +1071,56 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recentResyncResult && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">Imported Furni Update</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRecentResyncResult(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<span>{recentResyncResult.examined} examined</span>
|
||||
<span className="text-[var(--admin-success)]">
|
||||
{recentResyncResult.resynced} updated
|
||||
</span>
|
||||
<span
|
||||
className={recentResyncResult.failed ? "text-destructive" : ""}
|
||||
>
|
||||
{recentResyncResult.failed} failed
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
recentResyncResult.rconOk
|
||||
? "text-[var(--admin-success)]"
|
||||
: "text-[var(--admin-warning)]"
|
||||
}
|
||||
>
|
||||
RCON {recentResyncResult.rconOk ? "refreshed" : "not refreshed"}
|
||||
</span>
|
||||
</div>
|
||||
{recentResyncResult.errors.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
View errors
|
||||
</summary>
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
|
||||
{recentResyncResult.errors.map((error) => (
|
||||
<p key={`${error.classname}:${error.message}`}>
|
||||
<span className="font-mono">{error.classname}</span>:{" "}
|
||||
{error.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Summary */}
|
||||
{batchSummary && (
|
||||
<div className="rounded-lg border p-4 space-y-3 bg-card">
|
||||
@@ -1535,6 +1618,19 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfirmRecentResync(true)}
|
||||
disabled={recentResyncing}
|
||||
>
|
||||
{recentResyncing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<DatabaseZap className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{recentResyncing
|
||||
? "Updating imported furni..."
|
||||
: "Update imported furni"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={startReorganize} disabled={reorganizing}>
|
||||
<FolderSync className="w-4 h-4 mr-2" />
|
||||
{reorganizing ? "Reorganizing..." : "Reorganize Catalog"}
|
||||
@@ -1936,6 +2032,30 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={confirmRecentResync} onOpenChange={setConfirmRecentResync}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update imported furni?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This updates FurnitureData for furni imported during the last 7
|
||||
days, then refreshes the emulator catalog and item caches. No
|
||||
database rows or asset files are deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmRecentResync(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={resyncRecentImports} disabled={recentResyncing}>
|
||||
Update last 7 days
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Dialog
|
||||
open={!!confirmDelete}
|
||||
|
||||
@@ -6,41 +6,72 @@ function sseEvent(data: unknown): string {
|
||||
return `data: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
|
||||
const encoder = new TextEncoder();
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(sseEvent(data)));
|
||||
} catch {
|
||||
/* stream closed */
|
||||
}
|
||||
let repair = false;
|
||||
let repairNitros = false;
|
||||
let sql = false;
|
||||
let applySql = false;
|
||||
let repairFurniData = false;
|
||||
try {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
repair?: unknown;
|
||||
repairNitros?: unknown;
|
||||
sql?: unknown;
|
||||
applySql?: unknown;
|
||||
repairFurniData?: unknown;
|
||||
};
|
||||
repair = body.repair === true;
|
||||
repairNitros = body.repairNitros === true;
|
||||
sql = body.sql === true;
|
||||
applySql = body.applySql === true;
|
||||
repairFurniData = body.repairFurniData === true;
|
||||
} catch {
|
||||
/* no body */
|
||||
}
|
||||
|
||||
try {
|
||||
await runCatalogAudit(send);
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
message: `Audit threw: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(sseEvent(data)));
|
||||
} catch {
|
||||
/* stream closed */
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
});
|
||||
try {
|
||||
await runCatalogAudit(send, {
|
||||
repair,
|
||||
repairNitros,
|
||||
sql,
|
||||
applySql,
|
||||
repairFurniData,
|
||||
});
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
message: `Audit threw: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
});
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
RECENT_FURNI_RESYNC_URL,
|
||||
requestRecentFurniResync,
|
||||
} from "./recent-furni-resync";
|
||||
|
||||
describe("requestRecentFurniResync", () => {
|
||||
it("posts to the fixed seven-day resync endpoint and normalizes the result", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({
|
||||
ok: true,
|
||||
mode: "days",
|
||||
days: 7,
|
||||
examined: 4,
|
||||
resynced: 3,
|
||||
failed: 1,
|
||||
rconOk: false,
|
||||
errors: [{ classname: "chair", message: "invalid entry" }],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(requestRecentFurniResync(fetcher)).resolves.toEqual({
|
||||
examined: 4,
|
||||
resynced: 3,
|
||||
failed: 1,
|
||||
rconOk: false,
|
||||
errors: [{ classname: "chair", message: "invalid entry" }],
|
||||
});
|
||||
expect(RECENT_FURNI_RESYNC_URL).toBe(
|
||||
"/api/admin/import/furni/resync?days=7",
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledWith(RECENT_FURNI_RESYNC_URL, {
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws the API error when the request fails", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({ error: "Forbidden" }, { status: 403 }),
|
||||
);
|
||||
|
||||
await expect(requestRecentFurniResync(fetcher)).rejects.toThrow(
|
||||
"Forbidden",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
export const RECENT_FURNI_RESYNC_URL = "/api/admin/import/furni/resync?days=7";
|
||||
|
||||
export interface RecentFurniResyncError {
|
||||
classname: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RecentFurniResyncResult {
|
||||
examined: number;
|
||||
resynced: number;
|
||||
failed: number;
|
||||
rconOk: boolean;
|
||||
errors: RecentFurniResyncError[];
|
||||
}
|
||||
|
||||
type AdminFetcher = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export async function requestRecentFurniResync(
|
||||
fetcher: AdminFetcher,
|
||||
): Promise<RecentFurniResyncResult> {
|
||||
const response = await fetcher(RECENT_FURNI_RESYNC_URL, { method: "POST" });
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
typeof payload.error === "string" ? payload.error : "Furni resync failed",
|
||||
);
|
||||
}
|
||||
|
||||
const errors = Array.isArray(payload.errors)
|
||||
? payload.errors.filter(
|
||||
(value): value is RecentFurniResyncError =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as RecentFurniResyncError).classname === "string" &&
|
||||
typeof (value as RecentFurniResyncError).message === "string",
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
examined: Number(payload.examined) || 0,
|
||||
resynced: Number(payload.resynced) || 0,
|
||||
failed: Number(payload.failed) || 0,
|
||||
rconOk: payload.rconOk === true,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,13 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -42,6 +51,47 @@ describe("production deploy workflow", () => {
|
||||
expect(deployJob).toContain("pnpm install --frozen-lockfile");
|
||||
});
|
||||
|
||||
it("preserves runtime furni assets when cleaning the live checkout", () => {
|
||||
const cleanStart = deployJob.indexOf("git clean -fd \\");
|
||||
const commandLines: string[] = [];
|
||||
for (const line of deployJob.slice(cleanStart).split(/\r?\n/)) {
|
||||
commandLines.push(line);
|
||||
if (!line.trimEnd().endsWith("\\")) break;
|
||||
}
|
||||
const cleanLines = commandLines.join(" ");
|
||||
const excludes = Array.from(
|
||||
cleanLines.matchAll(/-e\s+([^\s\\]+)/g),
|
||||
).flatMap(([, pattern]) => ["-e", pattern]);
|
||||
const work = mkdtempSync(resolve(tmpdir(), "epicnext-deploy-clean-"));
|
||||
const runtimeFiles = [
|
||||
"public/swf/dcr/hof_furni/icons/runtime_icon.png",
|
||||
"public/swf/dcr/hof_furni/swf/runtime.swf",
|
||||
"public/nitro-assets/bundled/furniture/runtime.nitro",
|
||||
];
|
||||
|
||||
try {
|
||||
spawnSync("git", ["init", "--quiet"], { cwd: work });
|
||||
for (const file of [...runtimeFiles, "remove-me.tmp"]) {
|
||||
const absolute = resolve(work, file);
|
||||
mkdirSync(resolve(absolute, ".."), { recursive: true });
|
||||
writeFileSync(absolute, "runtime");
|
||||
}
|
||||
|
||||
const clean = spawnSync("git", ["clean", "-fd", ...excludes], {
|
||||
cwd: work,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
expect(clean.status, clean.stderr).toBe(0);
|
||||
for (const file of runtimeFiles) {
|
||||
expect(existsSync(resolve(work, file)), file).toBe(true);
|
||||
}
|
||||
expect(existsSync(resolve(work, "remove-me.tmp"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reclaims ownership before git operations so www-data files can be overwritten", () => {
|
||||
const chownCmd = `sudo chown -R "${userVar}:${groupVar}"`;
|
||||
expect(toContainLiteral(workflow, chownCmd)).toBe(true);
|
||||
|
||||
@@ -5,7 +5,15 @@ import { db } from "@/lib/db";
|
||||
import { fetchSourceFurnidata } from "@/lib/services/clone-import";
|
||||
import { listSources } from "@/lib/services/clone-sources";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import {
|
||||
applyCatalogSql,
|
||||
generateCatalogSql,
|
||||
type MissingCatalogEntry,
|
||||
repairFurniData,
|
||||
} from "./catalog-repair";
|
||||
import { ensureDirectories } from "./furni-import";
|
||||
import { repairMissingIcons } from "./repair-icons";
|
||||
import { repairMissingNitros } from "./repair-nitros";
|
||||
|
||||
export interface AuditEvent {
|
||||
type:
|
||||
@@ -13,6 +21,10 @@ export interface AuditEvent {
|
||||
| "items_loaded"
|
||||
| "catalog_loaded"
|
||||
| "checking"
|
||||
| "repair_complete"
|
||||
| "sql_ready"
|
||||
| "sql_applied"
|
||||
| "furnidata_repair_complete"
|
||||
| "batch_complete"
|
||||
| "error";
|
||||
message?: string;
|
||||
@@ -22,6 +34,20 @@ export interface AuditEvent {
|
||||
issues?: AuditIssue[];
|
||||
missingFromSources?: MissingFromSource[];
|
||||
summary?: AuditSummary;
|
||||
repair?: RepairStats;
|
||||
repairNitro?: RepairStats;
|
||||
sql?: string;
|
||||
sqlCount?: number;
|
||||
applied?: number;
|
||||
sqlFailed?: number;
|
||||
furniData?: { added: number; removedDuplicates: number };
|
||||
}
|
||||
|
||||
export interface RepairStats {
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
stillMissing: number;
|
||||
}
|
||||
|
||||
export interface AuditIssue {
|
||||
@@ -50,12 +76,29 @@ export interface AuditSummary {
|
||||
missingIcon: number;
|
||||
duplicates: number;
|
||||
missingFromSourcesTotal: number;
|
||||
iconsRepaired: number;
|
||||
iconsStillMissing: number;
|
||||
nitrosRepaired: number;
|
||||
nitrosStillMissing: number;
|
||||
catalogSqlCount: number;
|
||||
catalogSqlApplied: number;
|
||||
furniDataAdded: number;
|
||||
furniDataDuplicatesRemoved: number;
|
||||
}
|
||||
|
||||
export interface CatalogAuditOptions {
|
||||
repair?: boolean;
|
||||
repairNitros?: boolean;
|
||||
sql?: boolean;
|
||||
applySql?: boolean;
|
||||
repairFurniData?: boolean;
|
||||
}
|
||||
|
||||
export async function runCatalogAudit(
|
||||
onEvent?: (evt: AuditEvent) => void,
|
||||
options?: CatalogAuditOptions,
|
||||
): Promise<void> {
|
||||
const issues: AuditIssue[] = [];
|
||||
let issues: AuditIssue[] = [];
|
||||
const missingFromSources: MissingFromSource[] = [];
|
||||
|
||||
try {
|
||||
@@ -164,6 +207,7 @@ export async function runCatalogAudit(
|
||||
total: items.length,
|
||||
});
|
||||
|
||||
const missingCatalogEntries: MissingCatalogEntry[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (!itemIdsWithCatalog.has(item.id)) {
|
||||
@@ -174,6 +218,15 @@ export async function runCatalogAudit(
|
||||
itemId: item.id,
|
||||
message: `"${item.item_name}" (ID ${item.id}) has no catalog_items entry — not purchasable`,
|
||||
});
|
||||
// Only include items that actually have a .nitro asset — a catalog
|
||||
// entry for a furni without a bundle would be un-placeable in-game.
|
||||
if (existsSync(path.join(nitroDir, `${item.item_name}.nitro`))) {
|
||||
missingCatalogEntries.push({
|
||||
classname: item.item_name,
|
||||
itemId: item.id,
|
||||
itemType: item.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (i % 100 === 0) {
|
||||
onEvent?.({
|
||||
@@ -233,10 +286,12 @@ export async function runCatalogAudit(
|
||||
total: items.length,
|
||||
});
|
||||
|
||||
const missingNitroClassnames: string[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const nitroPath = path.join(nitroDir, `${item.item_name}.nitro`);
|
||||
if (!existsSync(nitroPath)) {
|
||||
missingNitroClassnames.push(item.item_name);
|
||||
issues.push({
|
||||
type: "missing_nitro",
|
||||
severity: "error",
|
||||
@@ -257,10 +312,12 @@ export async function runCatalogAudit(
|
||||
|
||||
onEvent?.({ type: "checking", section: "missing_icon", total: items.length });
|
||||
|
||||
const missingIconClassnames: string[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const iconPath = path.join(iconDir, `${item.item_name}_icon.png`);
|
||||
if (!existsSync(iconPath)) {
|
||||
missingIconClassnames.push(item.item_name);
|
||||
issues.push({
|
||||
type: "missing_icon",
|
||||
severity: "warning",
|
||||
@@ -279,6 +336,177 @@ export async function runCatalogAudit(
|
||||
}
|
||||
}
|
||||
|
||||
let repairStats: RepairStats | null = null;
|
||||
if (options?.repair && missingIconClassnames.length > 0) {
|
||||
onEvent?.({
|
||||
type: "progress",
|
||||
message: `Repairing ${missingIconClassnames.length} missing icons…`,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await repairMissingIcons((evt) => {
|
||||
if (evt.type === "progress") {
|
||||
onEvent?.({
|
||||
type: "checking",
|
||||
section: "repair_icons",
|
||||
current: evt.index,
|
||||
total: evt.total,
|
||||
message:
|
||||
evt.status === "done"
|
||||
? `Repaired ${evt.classname}`
|
||||
: evt.status === "failed"
|
||||
? `Failed: ${evt.classname}`
|
||||
: undefined,
|
||||
});
|
||||
} else if (evt.type === "error") {
|
||||
onEvent?.({ type: "error", message: evt.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Re-check which of the previously-missing icons are still missing
|
||||
// and drop the ones the repair fixed from the issue list.
|
||||
const stillMissing: string[] = [];
|
||||
for (const classname of missingIconClassnames) {
|
||||
if (!existsSync(path.join(iconDir, `${classname}_icon.png`))) {
|
||||
stillMissing.push(classname);
|
||||
}
|
||||
}
|
||||
issues = issues.filter(
|
||||
(i) =>
|
||||
i.type !== "missing_icon" || stillMissing.includes(i.classname ?? ""),
|
||||
);
|
||||
|
||||
repairStats = {
|
||||
succeeded: result.succeeded,
|
||||
failed: result.failed,
|
||||
skipped: result.skipped,
|
||||
stillMissing: stillMissing.length,
|
||||
};
|
||||
onEvent?.({ type: "repair_complete", repair: repairStats });
|
||||
} catch (err) {
|
||||
onEvent?.({
|
||||
type: "error",
|
||||
message: `Icon repair failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let nitroRepairStats: RepairStats | null = null;
|
||||
if (options?.repairNitros && missingNitroClassnames.length > 0) {
|
||||
onEvent?.({
|
||||
type: "progress",
|
||||
message: `Repairing ${missingNitroClassnames.length} missing .nitro files…`,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await repairMissingNitros((evt) => {
|
||||
if (evt.type === "progress") {
|
||||
onEvent?.({
|
||||
type: "checking",
|
||||
section: "repair_nitros",
|
||||
current: evt.index,
|
||||
total: evt.total,
|
||||
message:
|
||||
evt.status === "done"
|
||||
? `Repaired ${evt.classname}`
|
||||
: evt.status === "failed"
|
||||
? `Failed: ${evt.classname}`
|
||||
: undefined,
|
||||
});
|
||||
} else if (evt.type === "error") {
|
||||
onEvent?.({ type: "error", message: evt.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Re-check which of the previously-missing nitros are still missing
|
||||
// and drop the ones the repair fixed from the issue list.
|
||||
const stillMissing: string[] = [];
|
||||
for (const classname of missingNitroClassnames) {
|
||||
if (!existsSync(path.join(nitroDir, `${classname}.nitro`))) {
|
||||
stillMissing.push(classname);
|
||||
}
|
||||
}
|
||||
issues = issues.filter(
|
||||
(i) =>
|
||||
i.type !== "missing_nitro" ||
|
||||
stillMissing.includes(i.classname ?? ""),
|
||||
);
|
||||
|
||||
nitroRepairStats = {
|
||||
succeeded: result.succeeded,
|
||||
failed: result.failed,
|
||||
skipped: result.skipped,
|
||||
stillMissing: stillMissing.length,
|
||||
};
|
||||
onEvent?.({
|
||||
type: "repair_complete",
|
||||
repairNitro: nitroRepairStats,
|
||||
});
|
||||
} catch (err) {
|
||||
onEvent?.({
|
||||
type: "error",
|
||||
message: `.nitro repair failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Catalog SQL generation / apply ────────────────────────────────
|
||||
let catalogSqlCount = 0;
|
||||
let catalogSqlApplied = 0;
|
||||
if ((options?.sql || options?.applySql) && missingCatalogEntries.length > 0) {
|
||||
onEvent?.({
|
||||
type: "progress",
|
||||
message: `Generating catalog SQL for ${missingCatalogEntries.length} items…`,
|
||||
});
|
||||
try {
|
||||
const result = await generateCatalogSql(missingCatalogEntries);
|
||||
catalogSqlCount = result.count;
|
||||
onEvent?.({
|
||||
type: "sql_ready",
|
||||
sql: result.sql,
|
||||
sqlCount: result.count,
|
||||
});
|
||||
if (options.applySql) {
|
||||
const applyResult = await applyCatalogSql(result.statements);
|
||||
catalogSqlApplied = applyResult.applied;
|
||||
onEvent?.({
|
||||
type: "sql_applied",
|
||||
applied: applyResult.applied,
|
||||
sqlFailed: applyResult.failed,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
onEvent?.({
|
||||
type: "error",
|
||||
message: `Catalog SQL failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── FurnitureData.json repair (add missing + dedupe) ──────────────
|
||||
let furniDataAdded = 0;
|
||||
let furniDataDuplicatesRemoved = 0;
|
||||
if (options?.repairFurniData) {
|
||||
onEvent?.({ type: "progress", message: "Repairing FurnitureData.json…" });
|
||||
try {
|
||||
const result = await repairFurniData(items);
|
||||
furniDataAdded = result.added;
|
||||
furniDataDuplicatesRemoved = result.removedDuplicates;
|
||||
onEvent?.({
|
||||
type: "furnidata_repair_complete",
|
||||
furniData: {
|
||||
added: result.added,
|
||||
removedDuplicates: result.removedDuplicates,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
onEvent?.({
|
||||
type: "error",
|
||||
message: `FurnitureData repair failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onEvent?.({ type: "progress", message: "Comparing with clone sources…" });
|
||||
|
||||
const sources = await listSources();
|
||||
@@ -319,6 +547,16 @@ export async function runCatalogAudit(
|
||||
missingIcon: issues.filter((i) => i.type === "missing_icon").length,
|
||||
duplicates: issues.filter((i) => i.type === "duplicate_classname").length,
|
||||
missingFromSourcesTotal: missingFromSources.length,
|
||||
iconsRepaired: repairStats?.succeeded ?? 0,
|
||||
iconsStillMissing:
|
||||
repairStats?.stillMissing ?? missingIconClassnames.length,
|
||||
nitrosRepaired: nitroRepairStats?.succeeded ?? 0,
|
||||
nitrosStillMissing:
|
||||
nitroRepairStats?.stillMissing ?? missingNitroClassnames.length,
|
||||
catalogSqlCount,
|
||||
catalogSqlApplied,
|
||||
furniDataAdded,
|
||||
furniDataDuplicatesRemoved,
|
||||
};
|
||||
|
||||
onEvent?.({
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
readFurniData,
|
||||
withFurniDataLock,
|
||||
writeFurniData,
|
||||
} from "@/lib/services/furni-data";
|
||||
import {
|
||||
autoPriceFurni,
|
||||
getOrCreateCategoryPage,
|
||||
} from "@/lib/services/furni-import";
|
||||
|
||||
export interface MissingCatalogEntry {
|
||||
classname: string;
|
||||
itemId: number;
|
||||
itemType: string;
|
||||
}
|
||||
|
||||
export interface CatalogSqlResult {
|
||||
statements: string[];
|
||||
sql: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
function escSqlLiteral(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build standalone `catalog_items` INSERT statements for items_base entries
|
||||
* that exist (with assets) but have no catalog entry yet. Ids and prices are
|
||||
* resolved the same way the app's own imports do it (autoPriceFurni + category
|
||||
* page + next catalog id), so running the SQL matches an app-managed import.
|
||||
*/
|
||||
export async function generateCatalogSql(
|
||||
entries: MissingCatalogEntry[],
|
||||
): Promise<CatalogSqlResult> {
|
||||
const statements: string[] = [
|
||||
"-- AtomCMS catalog repair",
|
||||
`-- Generated ${new Date().toISOString()}`,
|
||||
`-- ${entries.length} item(s) to add`,
|
||||
"START TRANSACTION;",
|
||||
];
|
||||
|
||||
// The generation pass does not INSERT, so MAX(id) would never advance and
|
||||
// per-entry allocateCatalogItemId would hand out the same id every time.
|
||||
// Read the current max once and hand out sequential ids from there.
|
||||
const [maxIdResult] = (await db.execute(sql`
|
||||
SELECT MAX(id) as maxId FROM catalog_items
|
||||
`)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown];
|
||||
let nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1;
|
||||
|
||||
let generated = 0;
|
||||
for (const entry of entries) {
|
||||
let pageId: number;
|
||||
try {
|
||||
pageId = await getOrCreateCategoryPage(entry.classname, entry.itemType);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const price = autoPriceFurni(entry.classname);
|
||||
statements.push(
|
||||
`INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) VALUES (${nextId}, ${pageId}, ${entry.itemId}, '${escSqlLiteral(entry.classname)}', ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '');`,
|
||||
);
|
||||
nextId++;
|
||||
generated++;
|
||||
}
|
||||
|
||||
statements.push("COMMIT;");
|
||||
return { statements, sql: statements.join("\n"), count: generated };
|
||||
}
|
||||
|
||||
/** Execute generated INSERT statements directly against the database. */
|
||||
export async function applyCatalogSql(
|
||||
statements: string[],
|
||||
): Promise<{ applied: number; failed: number }> {
|
||||
let applied = 0;
|
||||
let failed = 0;
|
||||
for (const statement of statements) {
|
||||
const trimmed = statement.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.startsWith("--") ||
|
||||
trimmed === "START TRANSACTION;" ||
|
||||
trimmed === "COMMIT;"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await db.execute(sql.raw(trimmed));
|
||||
applied++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { applied, failed };
|
||||
}
|
||||
|
||||
export interface FurniDataRepairResult {
|
||||
added: number;
|
||||
removedDuplicates: number;
|
||||
}
|
||||
|
||||
function buildMinimalFurniEntry(item: {
|
||||
id: number;
|
||||
item_name: string;
|
||||
public_name: string;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
id: item.id,
|
||||
classname: item.item_name,
|
||||
revision: 0,
|
||||
category: "unknown",
|
||||
defaultdir: 0,
|
||||
xdim: 1,
|
||||
ydim: 1,
|
||||
partcolors: { color: [] },
|
||||
name: item.public_name || item.item_name,
|
||||
description: "",
|
||||
adurl: "",
|
||||
offerid: item.id,
|
||||
buyout: false,
|
||||
rentofferid: -1,
|
||||
rentbuyout: false,
|
||||
bc: false,
|
||||
excludeddynamic: false,
|
||||
customparams: "",
|
||||
specialtype: 0,
|
||||
canstandon: false,
|
||||
cansiton: false,
|
||||
canlayon: false,
|
||||
furniline: "",
|
||||
environment: "",
|
||||
rare: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add FurnitureData.json entries for items_base rows that are missing from the
|
||||
* file, and remove duplicate classnames (keeping the first occurrence).
|
||||
*/
|
||||
export async function repairFurniData(
|
||||
items: Array<{
|
||||
id: number;
|
||||
item_name: string;
|
||||
public_name: string;
|
||||
type: string;
|
||||
}>,
|
||||
): Promise<FurniDataRepairResult> {
|
||||
if (items.length === 0) return { added: 0, removedDuplicates: 0 };
|
||||
|
||||
return withFurniDataLock(async () => {
|
||||
const furniData = (await readFurniData()) as Record<
|
||||
string,
|
||||
{ furnitype: Array<Record<string, unknown>> }
|
||||
>;
|
||||
|
||||
// 1) Deduplicate classnames per section (keep the first occurrence).
|
||||
let removedDuplicates = 0;
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
const sec = furniData[section];
|
||||
if (!sec?.furnitype) continue;
|
||||
const seen = new Set<string>();
|
||||
const deduped = sec.furnitype.filter((e) => {
|
||||
const classname = typeof e.classname === "string" ? e.classname : "";
|
||||
if (!classname) return true;
|
||||
if (seen.has(classname)) {
|
||||
removedDuplicates++;
|
||||
return false;
|
||||
}
|
||||
seen.add(classname);
|
||||
return true;
|
||||
});
|
||||
if (deduped.length !== sec.furnitype.length) {
|
||||
furniData[section].furnitype = deduped;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Add entries for items_base rows missing from the file.
|
||||
const existing = new Set<string>();
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
for (const e of furniData[section]?.furnitype ?? []) {
|
||||
if (typeof e.classname === "string") existing.add(e.classname);
|
||||
}
|
||||
}
|
||||
const added: Array<{ entry: Record<string, unknown>; itemType: string }> =
|
||||
[];
|
||||
for (const item of items) {
|
||||
if (existing.has(item.item_name)) continue;
|
||||
added.push({
|
||||
entry: buildMinimalFurniEntry(item),
|
||||
itemType: item.type,
|
||||
});
|
||||
}
|
||||
|
||||
// 3) Write once if anything changed.
|
||||
if (removedDuplicates > 0 || added.length > 0) {
|
||||
for (const { entry, itemType } of added) {
|
||||
const sectionKey = itemType === "i" ? "wallitemtypes" : "roomitemtypes";
|
||||
if (!furniData[sectionKey]) {
|
||||
furniData[sectionKey] = { furnitype: [] };
|
||||
}
|
||||
furniData[sectionKey].furnitype.push(entry);
|
||||
}
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
}
|
||||
return { added: added.length, removedDuplicates };
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,40 @@ export interface CloneSource {
|
||||
|
||||
const KEY = "clone_sources";
|
||||
|
||||
export const DEFAULT_ICON_SOURCES: CloneSource[] = [
|
||||
{
|
||||
id: "default-habboassets",
|
||||
name: "HabboAssets",
|
||||
furnidataUrl: "",
|
||||
nitroBaseUrl: "",
|
||||
iconBaseUrl: "https://www.habboassets.com/assets/furniture",
|
||||
},
|
||||
{
|
||||
id: "default-hubbly",
|
||||
name: "Hubbly",
|
||||
furnidataUrl: "",
|
||||
nitroBaseUrl: "",
|
||||
iconBaseUrl: "https://hubbly.pw/swf/dcr/hof_furni/icons",
|
||||
},
|
||||
{
|
||||
id: "default-leet",
|
||||
name: "Leet",
|
||||
furnidataUrl: "",
|
||||
nitroBaseUrl: "",
|
||||
iconBaseUrl: "https://images.leet.city/library/hof_furni/icons",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_NITRO_SOURCES: CloneSource[] = [
|
||||
{
|
||||
id: "default-wibbo",
|
||||
name: "Wibbo",
|
||||
furnidataUrl: "",
|
||||
nitroBaseUrl: "https://assets.wibbo.org/bundled/furniture",
|
||||
iconBaseUrl: "",
|
||||
},
|
||||
];
|
||||
|
||||
export async function listSources(): Promise<CloneSource[]> {
|
||||
const raw = (await siteSettings.get(KEY, "[]")) ?? "[]";
|
||||
try {
|
||||
|
||||
@@ -29,7 +29,14 @@ export async function downloadFile(
|
||||
if (attempt > 0) {
|
||||
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
|
||||
}
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36",
|
||||
Accept: "image/png,image/*,*/*;q=0.8",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const deterministic =
|
||||
res.status === 404 || res.status === 410 || res.status === 403;
|
||||
|
||||
@@ -1,13 +1,91 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
||||
import { listSources } from "@/lib/services/clone-sources";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import {
|
||||
type CloneSource,
|
||||
DEFAULT_ICON_SOURCES,
|
||||
listSources,
|
||||
} from "@/lib/services/clone-sources";
|
||||
import {
|
||||
type FurniAssetWriteTargets,
|
||||
getFurniAssetWriteTargets,
|
||||
} from "@/lib/services/furni-asset-dirs";
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { ensureDirectories } from "./furni-import";
|
||||
|
||||
const REPAIR_CONCURRENCY = 6;
|
||||
|
||||
/** Sliding-window worker pool: keeps up to `concurrency` tasks in flight. */
|
||||
async function runPool<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const limit = Math.min(Math.max(concurrency, 1), items.length);
|
||||
if (limit === 0) return;
|
||||
let next = 0;
|
||||
await Promise.allSettled(
|
||||
Array.from({ length: limit }, async () => {
|
||||
while (true) {
|
||||
const i = next++;
|
||||
if (i >= items.length) break;
|
||||
await worker(items[i], i);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueDirs(dirs: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
const key = process.platform === "win32" ? dir.toLowerCase() : dir;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(dir);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function writeIconToDirs(
|
||||
icon: Buffer,
|
||||
dirs: string[],
|
||||
fileName: string,
|
||||
): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, fileName), icon);
|
||||
} catch (err) {
|
||||
errors.push(`write icon to ${dir} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function copyIconAcrossDirs(
|
||||
srcDir: string,
|
||||
destDirs: string[],
|
||||
fileName: string,
|
||||
): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
const src = path.join(srcDir, fileName);
|
||||
for (const dir of destDirs) {
|
||||
if (dir === srcDir) continue;
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.copyFile(src, path.join(dir, fileName));
|
||||
} catch (err) {
|
||||
errors.push(`copy icon to ${dir} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export interface RepairEvent {
|
||||
type: "started" | "progress" | "done" | "batch_complete" | "error";
|
||||
classname?: string;
|
||||
@@ -39,20 +117,29 @@ export async function repairMissingIcons(
|
||||
return { succeeded: 0, failed: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
let iconDir: string;
|
||||
let nitroDir: string;
|
||||
let targets: FurniAssetWriteTargets;
|
||||
try {
|
||||
const dirs = await getFurniAssetDirs();
|
||||
iconDir = dirs.iconDir;
|
||||
nitroDir = dirs.nitroDir;
|
||||
targets = await getFurniAssetWriteTargets();
|
||||
} catch (err) {
|
||||
onEvent?.({
|
||||
type: "error",
|
||||
message: `getFurniAssetDirs failed: ${(err as Error).message}`,
|
||||
message: `getFurniAssetWriteTargets failed: ${(err as Error).message}`,
|
||||
});
|
||||
return { succeeded: 0, failed: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
// Every location the app mirrors furni assets into — the webroot (public/)
|
||||
// plus the live gamedata (e.g. /var/www/Gamedata). Repair writes to all of
|
||||
// them so both the CMS and the client see the icon.
|
||||
const iconDirs = uniqueDirs([
|
||||
targets.iconDir,
|
||||
...targets.mirrorDirs.map((d) => d.iconDir),
|
||||
]);
|
||||
const nitroDirs = uniqueDirs([
|
||||
targets.nitroDir,
|
||||
...targets.mirrorDirs.map((d) => d.nitroDir),
|
||||
]);
|
||||
|
||||
let items: Array<{ id: number; item_name: string }>;
|
||||
try {
|
||||
const [rows] = (await db.execute(sql`
|
||||
@@ -70,11 +157,177 @@ export async function repairMissingIcons(
|
||||
const total = items.length;
|
||||
onEvent?.({ type: "started", total });
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const { item_name: classname } = items[i];
|
||||
const iconPath = path.join(iconDir, `${classname}_icon.png`);
|
||||
const processItem = async (classname: string) => {
|
||||
const fileName = `${classname}_icon.png`;
|
||||
|
||||
if (existsSync(iconPath)) {
|
||||
// A dir may already have the icon (e.g. the full gamedata) while the
|
||||
// webroot is missing it — treat the item as present when every target has it.
|
||||
const presentDirs = iconDirs.filter((dir) =>
|
||||
existsSync(path.join(dir, fileName)),
|
||||
);
|
||||
const missingDirs = iconDirs.filter((dir) => !presentDirs.includes(dir));
|
||||
|
||||
if (missingDirs.length === 0) {
|
||||
return { status: "skipped" } as const;
|
||||
}
|
||||
|
||||
let ok = false;
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 1) Copy across mirrors — the icon already exists in one target (e.g.
|
||||
// gamedata), so just fill the ones that are missing it.
|
||||
if (presentDirs.length > 0) {
|
||||
try {
|
||||
const copyErrors = await copyIconAcrossDirs(
|
||||
presentDirs[0],
|
||||
missingDirs,
|
||||
fileName,
|
||||
);
|
||||
warnings.push(...copyErrors);
|
||||
ok = copyErrors.length === 0;
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`copy across icon dirs failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Extract from a local .nitro bundle (checked across every target).
|
||||
if (!ok) {
|
||||
let foundNitro = false;
|
||||
for (const dir of nitroDirs) {
|
||||
const nitroPath = path.join(dir, `${classname}.nitro`);
|
||||
if (!existsSync(nitroPath)) continue;
|
||||
foundNitro = true;
|
||||
try {
|
||||
const nitroBuf = await fs.readFile(nitroPath);
|
||||
const icon = extractFurniIconPng(nitroBuf);
|
||||
if (icon) {
|
||||
const writeErrors = await writeIconToDirs(
|
||||
icon,
|
||||
missingDirs,
|
||||
fileName,
|
||||
);
|
||||
warnings.push(...writeErrors);
|
||||
ok = writeErrors.length === 0;
|
||||
break;
|
||||
}
|
||||
warnings.push("no icon asset in .nitro bundle");
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`extract from .nitro failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!foundNitro) warnings.push(".nitro file not found on disk");
|
||||
}
|
||||
|
||||
// 3) Download from configured or default public sources.
|
||||
if (!ok) {
|
||||
let sources: CloneSource[] = [];
|
||||
try {
|
||||
sources = (await listSources()).filter(
|
||||
(s) => s.iconBaseUrl || s.nitroBaseUrl,
|
||||
);
|
||||
} catch (err) {
|
||||
warnings.push(`listSources failed: ${(err as Error).message}`);
|
||||
}
|
||||
if (sources.length === 0) sources = DEFAULT_ICON_SOURCES;
|
||||
|
||||
for (const source of sources) {
|
||||
const iconCandidates = [
|
||||
`${classname}_icon.png`,
|
||||
// Variant classnames (e.g. `table_silo_med*10`) often only exist
|
||||
// on sources under their base name (e.g. `table_silo_med_icon.png`)
|
||||
// or with the `*` replaced by `_` (e.g. `table_silo_med_10_icon.png`).
|
||||
...(classname.includes("*")
|
||||
? [
|
||||
`${classname.split("*")[0]}_icon.png`,
|
||||
`${classname.replace(/\*/g, "_")}_icon.png`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
for (const iconName of iconCandidates) {
|
||||
try {
|
||||
const dl = await downloadFile(
|
||||
`${source.iconBaseUrl}/${iconName}`,
|
||||
path.join(targets.iconDir, fileName),
|
||||
{ maxRetries: 1, validate: "png" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
const icon = await fs.readFile(
|
||||
path.join(targets.iconDir, fileName),
|
||||
);
|
||||
const writeErrors = await writeIconToDirs(
|
||||
icon,
|
||||
missingDirs,
|
||||
fileName,
|
||||
);
|
||||
warnings.push(...writeErrors);
|
||||
ok = writeErrors.length === 0;
|
||||
break;
|
||||
}
|
||||
warnings.push(`${iconName} not found on ${source.name}`);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`download from ${source.name} failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (ok) break;
|
||||
|
||||
// Some sources only ship icons embedded in their `.nitro` bundles —
|
||||
// grab the bundle and extract the catalog icon from it.
|
||||
if (source.nitroBaseUrl) {
|
||||
const nitroTmp = path.join(os.tmpdir(), `.repair_${classname}.nitro`);
|
||||
try {
|
||||
const dl = await downloadFile(
|
||||
`${source.nitroBaseUrl}/${classname}.nitro`,
|
||||
nitroTmp,
|
||||
{ maxRetries: 1 },
|
||||
);
|
||||
if (dl.ok) {
|
||||
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
|
||||
if (icon) {
|
||||
const writeErrors = await writeIconToDirs(
|
||||
icon,
|
||||
missingDirs,
|
||||
fileName,
|
||||
);
|
||||
warnings.push(...writeErrors);
|
||||
ok = writeErrors.length === 0;
|
||||
} else {
|
||||
warnings.push(`no icon asset in ${source.name} .nitro bundle`);
|
||||
}
|
||||
} else {
|
||||
warnings.push(`.nitro not found on ${source.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`extract from ${source.name} .nitro failed: ${(err as Error).message}`,
|
||||
);
|
||||
} finally {
|
||||
await fs.unlink(nitroTmp).catch(() => {});
|
||||
}
|
||||
if (ok) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
return { status: "done", warnings } as const;
|
||||
}
|
||||
return {
|
||||
status: "failed",
|
||||
message: warnings[0] || "could not obtain icon",
|
||||
} as const;
|
||||
};
|
||||
|
||||
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
|
||||
const classname = item.item_name;
|
||||
const result = await processItem(classname);
|
||||
if (result.status === "skipped") {
|
||||
skipped++;
|
||||
onEvent?.({
|
||||
type: "progress",
|
||||
@@ -83,54 +336,9 @@ export async function repairMissingIcons(
|
||||
index: i,
|
||||
total,
|
||||
});
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
let ok = false;
|
||||
const warnings: string[] = [];
|
||||
|
||||
const nitroPath = path.join(nitroDir, `${classname}.nitro`);
|
||||
if (existsSync(nitroPath)) {
|
||||
try {
|
||||
const nitroBuf = await fs.readFile(nitroPath);
|
||||
const icon = extractFurniIconPng(nitroBuf);
|
||||
if (icon) {
|
||||
await fs.writeFile(iconPath, icon);
|
||||
ok = true;
|
||||
} else {
|
||||
warnings.push("no icon asset in .nitro bundle");
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(`extract from .nitro failed: ${(err as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
warnings.push(".nitro file not found on disk");
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
try {
|
||||
const sources = await listSources();
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const dl = await downloadFile(
|
||||
`${source.iconBaseUrl}/${classname}_icon.png`,
|
||||
iconPath,
|
||||
{ maxRetries: 1, validate: "png" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
warnings.push(`download from ${source.name} failed`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(`listSources failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
if (result.status === "done") {
|
||||
succeeded++;
|
||||
onEvent?.({
|
||||
type: "progress",
|
||||
@@ -138,20 +346,20 @@ export async function repairMissingIcons(
|
||||
status: "done",
|
||||
index: i,
|
||||
total,
|
||||
warnings: warnings.length ? warnings : undefined,
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
onEvent?.({
|
||||
type: "progress",
|
||||
classname,
|
||||
status: "failed",
|
||||
index: i,
|
||||
total,
|
||||
message: warnings[0] || "could not obtain icon",
|
||||
warnings: result.warnings.length ? result.warnings : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
failed++;
|
||||
onEvent?.({
|
||||
type: "progress",
|
||||
classname,
|
||||
status: "failed",
|
||||
index: i,
|
||||
total,
|
||||
message: result.message,
|
||||
});
|
||||
});
|
||||
|
||||
onEvent?.({
|
||||
type: "batch_complete",
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
type CloneSource,
|
||||
DEFAULT_NITRO_SOURCES,
|
||||
listSources,
|
||||
} from "@/lib/services/clone-sources";
|
||||
import {
|
||||
type FurniAssetWriteTargets,
|
||||
getFurniAssetWriteTargets,
|
||||
} from "@/lib/services/furni-asset-dirs";
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
import { ensureDirectories } from "./furni-import";
|
||||
|
||||
const REPAIR_CONCURRENCY = 6;
|
||||
|
||||
/** Sliding-window worker pool: keeps up to `concurrency` tasks in flight. */
|
||||
async function runPool<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const limit = Math.min(Math.max(concurrency, 1), items.length);
|
||||
if (limit === 0) return;
|
||||
let next = 0;
|
||||
await Promise.allSettled(
|
||||
Array.from({ length: limit }, async () => {
|
||||
while (true) {
|
||||
const i = next++;
|
||||
if (i >= items.length) break;
|
||||
await worker(items[i], i);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueDirs(dirs: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
const key = process.platform === "win32" ? dir.toLowerCase() : dir;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(dir);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function writeNitroToDirs(
|
||||
nitro: Buffer,
|
||||
dirs: string[],
|
||||
fileName: string,
|
||||
): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, fileName), nitro);
|
||||
} catch (err) {
|
||||
errors.push(`write .nitro to ${dir} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function copyNitroAcrossDirs(
|
||||
srcDir: string,
|
||||
destDirs: string[],
|
||||
fileName: string,
|
||||
): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
const src = path.join(srcDir, fileName);
|
||||
for (const dir of destDirs) {
|
||||
if (dir === srcDir) continue;
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.copyFile(src, path.join(dir, fileName));
|
||||
} catch (err) {
|
||||
errors.push(`copy .nitro to ${dir} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export interface RepairNitroEvent {
|
||||
type: "started" | "progress" | "done" | "batch_complete" | "error";
|
||||
classname?: string;
|
||||
status?: string;
|
||||
index?: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
warnings?: string[];
|
||||
succeeded?: number;
|
||||
failed?: number;
|
||||
skipped?: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export async function repairMissingNitros(
|
||||
onEvent?: (evt: RepairNitroEvent) => void,
|
||||
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
try {
|
||||
await ensureDirectories();
|
||||
} catch (err) {
|
||||
onEvent?.({
|
||||
type: "error",
|
||||
message: `ensureDirectories failed: ${(err as Error).message}`,
|
||||
});
|
||||
return { succeeded: 0, failed: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
let targets: FurniAssetWriteTargets;
|
||||
try {
|
||||
targets = await getFurniAssetWriteTargets();
|
||||
} catch (err) {
|
||||
onEvent?.({
|
||||
type: "error",
|
||||
message: `getFurniAssetWriteTargets failed: ${(err as Error).message}`,
|
||||
});
|
||||
return { succeeded: 0, failed: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
// Every location the app mirrors furni assets into — the webroot (public/)
|
||||
// plus the live gamedata (e.g. /var/www/Gamedata). Repair writes to all of
|
||||
// them so both the CMS and the client see the .nitro.
|
||||
const nitroDirs = uniqueDirs([
|
||||
targets.nitroDir,
|
||||
...targets.mirrorDirs.map((d) => d.nitroDir),
|
||||
]);
|
||||
|
||||
let items: Array<{ id: number; item_name: string }>;
|
||||
try {
|
||||
const [rows] = (await db.execute(sql`
|
||||
SELECT id, item_name FROM items_base ORDER BY id
|
||||
`)) as unknown as [Array<{ id: number; item_name: string }>, unknown];
|
||||
items = rows;
|
||||
} catch (err) {
|
||||
onEvent?.({
|
||||
type: "error",
|
||||
message: `Database query failed: ${(err as Error).message}`,
|
||||
});
|
||||
return { succeeded: 0, failed: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const total = items.length;
|
||||
onEvent?.({ type: "started", total });
|
||||
|
||||
const processItem = async (classname: string) => {
|
||||
const fileName = `${classname}.nitro`;
|
||||
|
||||
const presentDirs = nitroDirs.filter((dir) =>
|
||||
existsSync(path.join(dir, fileName)),
|
||||
);
|
||||
const missingDirs = nitroDirs.filter((dir) => !presentDirs.includes(dir));
|
||||
|
||||
if (missingDirs.length === 0) {
|
||||
return { status: "skipped" } as const;
|
||||
}
|
||||
|
||||
let ok = false;
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 1) Copy across mirrors — the .nitro already exists in one target (e.g.
|
||||
// gamedata), so just fill the ones that are missing it.
|
||||
if (presentDirs.length > 0) {
|
||||
try {
|
||||
const copyErrors = await copyNitroAcrossDirs(
|
||||
presentDirs[0],
|
||||
missingDirs,
|
||||
fileName,
|
||||
);
|
||||
warnings.push(...copyErrors);
|
||||
ok = copyErrors.length === 0;
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`copy across .nitro dirs failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Download from configured or default nitro sources.
|
||||
if (!ok) {
|
||||
let sources: CloneSource[] = [];
|
||||
try {
|
||||
sources = (await listSources()).filter((s) => s.nitroBaseUrl);
|
||||
} catch (err) {
|
||||
warnings.push(`listSources failed: ${(err as Error).message}`);
|
||||
}
|
||||
if (sources.length === 0) sources = DEFAULT_NITRO_SOURCES;
|
||||
|
||||
const nitroCandidates = [
|
||||
`${classname}.nitro`,
|
||||
// Variant classnames (e.g. `table_silo_med*10`) often only exist
|
||||
// on sources under their base name (e.g. `table_silo_med.nitro`)
|
||||
// or with the `*` replaced by `_` (e.g. `table_silo_med_10.nitro`).
|
||||
...(classname.includes("*")
|
||||
? [
|
||||
`${classname.split("*")[0]}.nitro`,
|
||||
`${classname.replace(/\*/g, "_")}.nitro`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
for (const source of sources) {
|
||||
for (const nitroName of nitroCandidates) {
|
||||
const tmpPath = path.join(
|
||||
os.tmpdir(),
|
||||
`.repair_nitro_${classname}_${Math.random().toString(36).slice(2, 8)}.nitro`,
|
||||
);
|
||||
try {
|
||||
const dl = await downloadFile(
|
||||
`${source.nitroBaseUrl}/${nitroName}`,
|
||||
tmpPath,
|
||||
{ maxRetries: 1 },
|
||||
);
|
||||
if (dl.ok) {
|
||||
// Validate it is a real Nitro bundle before writing it
|
||||
// anywhere (mirrors clone-import.ts).
|
||||
const buffer = await fs.readFile(tmpPath);
|
||||
parseNitroBundle(buffer);
|
||||
const writeErrors = await writeNitroToDirs(
|
||||
buffer,
|
||||
missingDirs,
|
||||
fileName,
|
||||
);
|
||||
warnings.push(...writeErrors);
|
||||
ok = writeErrors.length === 0;
|
||||
break;
|
||||
}
|
||||
warnings.push(`${nitroName} not found on ${source.name}`);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`download from ${source.name} failed: ${(err as Error).message}`,
|
||||
);
|
||||
} finally {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
if (ok) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
return { status: "done", warnings } as const;
|
||||
}
|
||||
return {
|
||||
status: "failed",
|
||||
message: warnings[0] || "could not obtain .nitro",
|
||||
} as const;
|
||||
};
|
||||
|
||||
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
|
||||
const classname = item.item_name;
|
||||
const result = await processItem(classname);
|
||||
if (result.status === "skipped") {
|
||||
skipped++;
|
||||
onEvent?.({
|
||||
type: "progress",
|
||||
classname,
|
||||
status: "skipped",
|
||||
index: i,
|
||||
total,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.status === "done") {
|
||||
succeeded++;
|
||||
onEvent?.({
|
||||
type: "progress",
|
||||
classname,
|
||||
status: "done",
|
||||
index: i,
|
||||
total,
|
||||
warnings: result.warnings.length ? result.warnings : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
failed++;
|
||||
onEvent?.({
|
||||
type: "progress",
|
||||
classname,
|
||||
status: "failed",
|
||||
index: i,
|
||||
total,
|
||||
message: result.message,
|
||||
});
|
||||
});
|
||||
|
||||
onEvent?.({
|
||||
type: "batch_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
skipped,
|
||||
});
|
||||
|
||||
return { succeeded, failed, skipped };
|
||||
}
|
||||
Reference in new issue
Block a user