10 Commits
Author SHA1 Message Date
remco c9130ecd2d chore(deps): update All dependencies
CI / check (pull_request) Successful in 24s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-08-03 00:00:47 +00:00
openhands 44c34dbae6 fix: catalog-repair - allocate sequential ids in generateCatalogSql
CI / check (push) Successful in 45s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m13s
Previously allocateCatalogItemId was called per entry, but since generation
does not INSERT, MAX(id) never advanced and every entry got the same id.
Now MAX(id) is read once and a local counter hands out sequential ids.
2026-08-02 19:57:21 +02:00
openhands 5308ce6a12 feat: parallelize audit repair and add nitro/SQL repair options
CI / check (push) Successful in 48s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m55s
- Parallelize icon and nitro downloads in the audit repair with a
  sliding-window worker pool (6 concurrent) to speed up large catalogs
- Add repairMissingNitros: fetch missing .nitro bundles from configured
  nitro sources (Wibbo default), validating each bundle before writing
- Add catalog-repair service: generate/apply catalog_items SQL for furni
  missing a catalog entry and repair FurnitureData.json (add missing +
  dedupe classnames)
- Wire all options through the audit API and client UI with live progress
  and result stats (icons, nitros, SQL, furnidata)
- Add Wibbo as default nitro source alongside existing icon sources
- Ignore runtime furni assets downloaded into public/ during repair
2026-08-02 19:12:28 +02:00
Simo 0c8e62357f fix: preserve runtime furni assets during deploy
CI / check (push) Successful in 46s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m33s
2026-08-02 17:32:32 +02:00
openhands cde15ad022 fix: mirror repaired icons to gamedata
CI / check (push) Successful in 25s
CI / release (push) Skipped
CI / deploy (push) Failing after 1m18s
Repair Icons now resolves all furni asset write targets (webroot plus
the live gamedata like /var/www/Gamedata) and writes downloaded or
extracted icons to every missing location. Icons already present in one
target are copied across instead of re-downloaded, and local .nitro
bundles are searched in every target.
2026-08-02 16:56:22 +02:00
openhands 1f9e8a0eec feat: add default furni icon repair sources
CI / check (push) Successful in 26s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m1s
Fall back to well-known public furni icon hosts (HabboAssets, Hubbly,
Leet) when no clone sources are configured, so Repair Icons can download
missing icons out of the box. Also handle variant classnames (base name
and '*' replaced with '_') and extract icons from remote .nitro bundles.
Send a browser User-Agent on downloads to avoid being blocked by hotels.
2026-08-02 16:44:24 +02:00
Simo bac2f653af feat: add manual recent furni resync action
CI / check (push) Successful in 24s
CI / release (push) Skipped
CI / deploy (push) Successful in 44s
2026-08-02 14:59:31 +02:00
Simo 88ac5ac1ba feat: add recent furni resync client contract 2026-08-02 14:56:12 +02:00
Simo cf563f3550 docs: plan manual recent furni resync 2026-08-02 14:54:19 +02:00
Simo 6b6fc5dc64 docs: design manual recent furni resync 2026-08-02 14:51:46 +02:00
18 changed files with 2138 additions and 126 deletions

No files matched your search

+6 -1
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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"
+16 -16
View File
@@ -190,17 +190,17 @@ importers:
specifier: ^4.3.3
version: 4.3.3
tsx:
specifier: ^4.23.4
version: 4.23.4
specifier: ^4.23.5
version: 4.23.5
typescript:
specifier: ^7.0.2
version: 7.0.2
vite:
specifier: 8.2.0
version: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected])
version: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].5)([email protected])
vitest:
specifier: 4.1.10
version: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected]))
version: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].5)([email protected]))
packages:
@@ -3924,8 +3924,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
[email protected].4:
resolution: {integrity: sha512-ZiUQ8oT/KzN51mJUWPqARYqwFLFJZtGZipRkw1ynHMr9vy3eU77m5yfF3Gzm6meEg/beW+lUu3fHYgskTN2oVQ==}
[email protected].5:
resolution: {integrity: sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -5806,7 +5806,7 @@ snapshots:
obug: 2.1.4
std-env: 4.2.0
tinyrainbow: 3.1.1
vitest: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected]))
vitest: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].5)([email protected]))
'@vitest/[email protected]':
dependencies:
@@ -5817,13 +5817,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
'@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected]))':
'@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].5)([email protected]))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected])
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].5)([email protected])
'@vitest/[email protected]':
dependencies:
@@ -6091,7 +6091,7 @@ snapshots:
'@drizzle-team/brocli': 0.10.2
'@esbuild-kit/esm-loader': 2.6.5
esbuild: 0.25.12
tsx: 4.23.4
tsx: 4.23.5
[email protected](@electric-sql/[email protected])(@opentelemetry/[email protected])(@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))([email protected](@types/[email protected]))([email protected])([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])([email protected])):
optionalDependencies:
@@ -7274,7 +7274,7 @@ snapshots:
[email protected]: {}
[email protected].4:
[email protected].5:
dependencies:
esbuild: 0.28.1
optionalDependencies:
@@ -7345,7 +7345,7 @@ snapshots:
typescript: 7.0.2
optional: true
[email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected]):
[email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].5)([email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
@@ -7358,13 +7358,13 @@ snapshots:
fsevents: 2.3.3
jiti: 2.7.0
terser: 5.49.0
tsx: 4.23.4
tsx: 4.23.5
yaml: 2.9.0
[email protected](@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected])):
[email protected](@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].5)([email protected])):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected]))
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected].5)([email protected]))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@@ -7381,7 +7381,7 @@ snapshots:
tinyexec: 1.2.4
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].4)([email protected])
vite: 8.2.0(@types/[email protected])([email protected])([email protected])([email protected])([email protected].5)([email protected])
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
+279 -1
View File
@@ -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}
+64 -33
View File
@@ -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",
},
});
},
);
+46
View File
@@ -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",
);
});
});
+49
View File
@@ -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,
};
}
+51 -1
View File
@@ -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);
+239 -1
View File
@@ -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?.({
+209
View File
@@ -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 };
});
}
+34
View File
@@ -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 {
+8 -1
View File
@@ -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;
+279 -71
View File
@@ -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",
+305
View File
@@ -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 };
}