Support Gitea configuration and safe Catalog Studio furniture completion
CI / check (push) Successful in 1m31s
CI / deploy (push) Successful in 1m18s
CI / e2e (push) Successful in 21s

This commit is contained in:
Simo committed 2026-09-05 13:33:40 +02:00
1 parent 26f071117b
commit 9f791ba284
22 files changed
+1120 -166

No files matched your search

+2 -2
View File
@@ -5,8 +5,8 @@ import { canAccess, getApiAdminContext } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import {
beginCatalogExport,
catalogExportEnabled,
catalogExportQueue,
isCatalogMutation,
} from "@/lib/services/catalog-git-queue";
@@ -92,7 +92,7 @@ export function withAdmin(
catalogExportEnabled() &&
isCatalogMutation(request.method, request.nextUrl.pathname)
) {
finishExport = await catalogExportQueue().begin();
finishExport = await beginCatalogExport();
}
const response = await handler(request, context, routeContext);
if (
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
catalogGitEnvironment,
decryptCatalogToken,
publicCatalogConfig,
validateCatalogConfig,
} from "./catalog-git-config";
const input = {
enabled: true,
remote: "https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily",
branch: "Beta-3",
username: "remco",
token: "private-token",
};
describe("Gitea catalog configuration", () => {
beforeEach(() => vi.stubEnv("AUTH_SECRET", "test-auth-secret"));
afterEach(() => vi.unstubAllEnvs());
it("supports self-hosted Gitea and encrypts the token", () => {
const config = validateCatalogConfig(input, null);
expect(config.remote).toBe(`${input.remote}.git`);
expect(config.encryptedToken).not.toContain(input.token);
expect(decryptCatalogToken(config.encryptedToken)).toBe(input.token);
expect(JSON.stringify(publicCatalogConfig(config))).not.toContain(
input.token,
);
expect(publicCatalogConfig(config).provider).toBe("gitea");
});
it("preserves saved token only for the same repository and user", () => {
const previous = validateCatalogConfig(input, null);
expect(
validateCatalogConfig({ ...input, token: "" }, previous).encryptedToken,
).toBe(previous.encryptedToken);
expect(() =>
validateCatalogConfig(
{ ...input, token: "", remote: "https://other.example/user/repo" },
previous,
),
).toThrow();
});
it("rejects embedded credentials and unsafe branches", () => {
expect(() =>
validateCatalogConfig(
{ ...input, remote: "https://user:secret@host/user/repo" },
null,
),
).toThrow();
for (const branch of [
"--help",
"main..evil",
"../branch",
"a.lock",
"a\\b",
])
expect(() => validateCatalogConfig({ ...input, branch }, null)).toThrow();
});
it("supplies credentials through environment and disables redirects", () => {
const config = validateCatalogConfig(input, null);
const env = catalogGitEnvironment(config);
expect(env.GIT_CONFIG_KEY_0).toBe(
"http.https://gitlab.epicnabbo.nl/.extraHeader",
);
expect(env.GIT_CONFIG_VALUE_1).toBe("false");
});
});
+177
View File
@@ -0,0 +1,177 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
randomUUID,
} from "node:crypto";
import { promises as fs, readFileSync } from "node:fs";
import path from "node:path";
import { CATALOG_BRANCH, CATALOG_REMOTE } from "./catalog-git-core";
export interface ManagedCatalogConfig {
enabled: boolean;
remote: string;
branch: string;
username: string;
encryptedToken: string;
}
export function catalogStateRoot() {
return (
process.env.CATALOG_GIT_STATE_DIR ||
path.join(process.cwd(), "storage", "catalog-git")
);
}
export function readManagedCatalogConfig(): ManagedCatalogConfig | null {
try {
return JSON.parse(
readFileSync(path.join(catalogStateRoot(), "config.json"), "utf8"),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw new Error("Catalog Git configuration cannot be read");
}
}
function key() {
const secret = process.env.AUTH_SECRET;
if (!secret)
throw new Error("The application authentication secret is unavailable");
return createHash("sha256").update(secret).digest();
}
export function encryptCatalogToken(token: string) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key(), iv);
const value = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
return [iv, cipher.getAuthTag(), value]
.map((part) => part.toString("base64"))
.join(".");
}
export function decryptCatalogToken(value: string) {
if (!value) return "";
const [iv, tag, data] = value
.split(".")
.map((part) => Buffer.from(part, "base64"));
const cipher = createDecipheriv("aes-256-gcm", key(), iv);
cipher.setAuthTag(tag);
return Buffer.concat([cipher.update(data), cipher.final()]).toString("utf8");
}
export function validateCatalogConfig(
input: unknown,
previous: ManagedCatalogConfig | null,
): ManagedCatalogConfig {
const value = input as Record<string, unknown>;
if (
!value ||
typeof value.remote !== "string" ||
typeof value.branch !== "string" ||
typeof value.username !== "string" ||
typeof value.enabled !== "boolean"
)
throw new Error(
"Repository, branch, username and enabled state are required",
);
let url: URL;
try {
url = new URL(value.remote.trim());
} catch {
throw new Error("Enter a valid HTTPS Gitea repository URL");
}
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.search ||
url.hash ||
!/^\/(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+(?:\.git)?\/?$/.test(url.pathname)
)
throw new Error(
"Use an HTTPS repository URL without credentials or query parameters",
);
const remote = `${url
.toString()
.replace(/\/$/, "")
.replace(/\.git$/, "")}.git`;
const branch = value.branch.trim(),
username = value.username.trim();
if (
!branch ||
branch.startsWith("-") ||
branch.startsWith("/") ||
branch.endsWith("/") ||
branch.endsWith(".") ||
branch.includes("..") ||
branch.includes("//") ||
branch.includes("@{") ||
/[\s~^:?*[\\]/.test(branch) ||
[...branch].some(
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
) ||
branch
.split("/")
.some((part) => part.startsWith(".") || part.endsWith(".lock")) ||
branch === "@"
)
throw new Error("Enter a valid Git branch");
if (!username || /[\r\n:]/.test(username))
throw new Error("Enter a valid Gitea username");
const token = typeof value.token === "string" ? value.token.trim() : "";
if (/[\r\n]/.test(token)) throw new Error("Invalid access token");
const same = previous?.remote === remote && previous.username === username;
const encryptedToken = token
? encryptCatalogToken(token)
: same
? previous.encryptedToken
: "";
if (value.enabled && !encryptedToken)
throw new Error("Provide a Gitea token before enabling export");
return { enabled: value.enabled, remote, branch, username, encryptedToken };
}
export function publicCatalogConfig(config = readManagedCatalogConfig()) {
return {
provider: "gitea",
enabled: config?.enabled ?? false,
remote: config?.remote ?? CATALOG_REMOTE,
branch: config?.branch ?? CATALOG_BRANCH,
username: config?.username ?? "",
hasToken: !!config?.encryptedToken,
};
}
export async function saveCatalogConfig(config: ManagedCatalogConfig) {
const root = catalogStateRoot();
await fs.mkdir(root, { recursive: true });
const temp = path.join(root, `config-${randomUUID()}.tmp`);
try {
await fs.writeFile(temp, JSON.stringify(config), {
mode: 0o600,
flag: "wx",
});
await fs.rename(temp, path.join(root, "config.json"));
} finally {
await fs.rm(temp, { force: true });
}
}
export function catalogGitEnvironment(
config: ManagedCatalogConfig,
): NodeJS.ProcessEnv {
const token = decryptCatalogToken(config.encryptedToken);
return {
...process.env,
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_COUNT: "2",
GIT_CONFIG_KEY_0: `http.${new URL(config.remote).origin}/.extraHeader`,
GIT_CONFIG_VALUE_0:
"Authorization: Basic " +
Buffer.from(`${config.username}:${token}`).toString("base64"),
GIT_CONFIG_KEY_1: "http.followRedirects",
GIT_CONFIG_VALUE_1: "false",
};
}
export function managedCatalogCheckout(config: ManagedCatalogConfig) {
return path.join(
catalogStateRoot(),
"checkouts",
createHash("sha256")
.update(`${config.remote}\n${config.branch}`)
.digest("hex")
.slice(0, 24),
);
}
+2 -1
View File
@@ -107,6 +107,7 @@ export async function publishCatalogFiles(options: {
remote: string;
branch: string;
files: CatalogFile[];
env?: NodeJS.ProcessEnv;
}) {
const { checkout, remote, branch, files } = options;
const git = async (...args: string[]) => {
@@ -114,7 +115,7 @@ export async function publishCatalogFiles(options: {
cwd: checkout,
timeout: 120_000,
maxBuffer: 16 * 1024 * 1024,
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
env: { ...process.env, ...options.env, GIT_TERMINAL_PROMPT: "0" },
});
return stdout.trim();
};
+15 -4
View File
@@ -1,18 +1,22 @@
import { promises as fs } from "node:fs";
import { hostname } from "node:os";
import path from "node:path";
import {
catalogGitEnvironment,
readManagedCatalogConfig,
} from "./catalog-git-config";
import {
CATALOG_BRANCH,
CATALOG_REMOTE,
publishCatalogFiles,
recoverCatalogQueue,
} from "./catalog-git-core";
import { ensureManagedCatalogCheckout } from "./catalog-git-managed";
import { catalogExportEnabled, catalogExportQueue } from "./catalog-git-queue";
import { createCatalogSnapshot } from "./catalog-git-snapshot";
export async function runCatalogExport() {
const checkout = process.env.CATALOG_GIT_CHECKOUT?.trim();
if (!catalogExportEnabled() || !checkout) return;
if (!catalogExportEnabled()) return;
const queue = catalogExportQueue();
await queue.entries();
await recoverCatalogQueue(queue.root);
@@ -51,10 +55,17 @@ export async function runCatalogExport() {
// Any operation started/completed during capture invalidates the snapshot.
const current = await queue.batch();
if (!current || JSON.stringify(current) !== JSON.stringify(batch)) return;
const config = readManagedCatalogConfig();
if (config && !config.enabled) return;
const checkout = config
? await ensureManagedCatalogCheckout(config)
: process.env.CATALOG_GIT_CHECKOUT?.trim();
if (!checkout) return;
const commit = await publishCatalogFiles({
checkout,
remote: CATALOG_REMOTE,
branch: CATALOG_BRANCH,
remote: config?.remote ?? CATALOG_REMOTE,
branch: config?.branch ?? CATALOG_BRANCH,
env: config ? catalogGitEnvironment(config) : undefined,
files,
});
await queue.complete(batch);
+72
View File
@@ -0,0 +1,72 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import {
catalogGitEnvironment,
type ManagedCatalogConfig,
managedCatalogCheckout,
} from "./catalog-git-config";
const exec = promisify(execFile);
export async function testCatalogConnection(config: ManagedCatalogConfig) {
try {
const result = await exec(
"git",
[
"ls-remote",
"--exit-code",
"--heads",
config.remote,
`refs/heads/${config.branch}`,
],
{
env: catalogGitEnvironment(config),
timeout: 30000,
maxBuffer: 1024 * 1024,
},
);
if (!result.stdout.trim()) throw new Error("Missing branch");
} catch {
throw new Error(
"Cannot read this Gitea repository and branch. Check the URL, username, token and server Git installation.",
);
}
}
export async function ensureManagedCatalogCheckout(
config: ManagedCatalogConfig,
) {
const checkout = managedCatalogCheckout(config);
try {
await fs.access(path.join(checkout, ".git"));
return checkout;
} catch {
/* first export */
}
await fs.mkdir(path.dirname(checkout), { recursive: true });
const temporary = `${checkout}.clone-${randomUUID()}`;
try {
await exec(
"git",
[
"clone",
"--single-branch",
"--branch",
config.branch,
"--",
config.remote,
temporary,
],
{
env: catalogGitEnvironment(config),
timeout: 600000,
maxBuffer: 4 * 1024 * 1024,
},
);
await fs.rename(temporary, checkout);
return checkout;
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
}
+30 -6
View File
@@ -1,15 +1,19 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import {
catalogStateRoot,
readManagedCatalogConfig,
} from "./catalog-git-config";
import { CatalogExportQueue } from "./catalog-git-core";
export function catalogExportEnabled() {
return Boolean(process.env.CATALOG_GIT_CHECKOUT?.trim());
const config = readManagedCatalogConfig();
return config
? config.enabled
: Boolean(process.env.CATALOG_GIT_CHECKOUT?.trim());
}
export function catalogExportQueue() {
return new CatalogExportQueue(
process.env.CATALOG_GIT_STATE_DIR ||
path.join(process.cwd(), "storage", "catalog-git"),
);
return new CatalogExportQueue(catalogStateRoot());
}
export function isCatalogMutation(method: string, pathname: string) {
return (
@@ -45,10 +49,30 @@ export async function withCatalogExport<T>(
operation: () => Promise<T>,
): Promise<T> {
if (!catalogExportEnabled()) return operation();
const finish = await catalogExportQueue().begin();
const finish = await beginCatalogExport();
try {
return await operation();
} finally {
await finish();
}
}
export async function scheduleCatalogExport() {
if (!readManagedCatalogConfig()?.enabled) return;
try {
const { after } = await import("next/server");
after(async () => {
const { runCatalogExport } = await import("./catalog-git-export");
await runCatalogExport();
});
} catch {
/* Standalone worker contexts use their scheduled retry. */
}
}
export async function beginCatalogExport() {
const finish = await catalogExportQueue().begin();
return async () => {
await finish();
await scheduleCatalogExport();
};
}
@@ -0,0 +1,120 @@
// @vitest-environment node
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execute: vi.fn(),
select: vi.fn(),
stat: vi.fn(),
conflict: vi.fn(),
build: vi.fn(),
append: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
db: { select: mocks.select, execute: mocks.execute },
ItemsBase: {
id: "id",
itemName: "item_name",
spriteId: "sprite_id",
type: "type",
},
CatalogPages: {},
}));
vi.mock("node:fs", () => ({
existsSync: () => true,
promises: {
stat: mocks.stat,
readFile: vi.fn().mockResolvedValue(Buffer.alloc(256)),
},
}));
vi.mock("@/lib/services/furni-asset-dirs", () => ({
DEFAULT_FURNI_NITRO_DIR: "/nitro",
getFurniAssetWriteTargets: async () => ({
swfDir: "/swf",
nitroDir: "/nitro",
iconDir: "/icons",
mirrorDirs: [],
}),
}));
vi.mock("@/lib/services/furni-data", () => ({
findFurniDataIdConflict: mocks.conflict,
buildFurniEntry: mocks.build,
appendFurniEntry: mocks.append,
}));
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
lookupOfficialHabboFurni: async () => null,
}));
vi.mock("@/lib/services/habbo-gamedata-hotel", () => ({
getHabboGamedataHotel: async () => "it",
}));
vi.mock("@/lib/services/import/core/download", () => ({
downloadFile: vi.fn(),
tryDownloadCandidates: async () => false,
}));
vi.mock("@/lib/services/swf-to-nitro", () => ({
parseNitroBundle: () => ({ json: {} }),
extractIconFromSwfAsync: async () => null,
}));
import { importSingleFurni } from "./furni-import";
const input = {
id: 42,
classname: "recycler_scrapbookA",
name: "Scrapbook",
description: "Book",
type: "flooritem",
revision: 1,
category: "unknown",
repairExisting: true,
};
const dialect = new MySqlDialect();
function queries() {
return mocks.execute.mock.calls.map(([query]) => dialect.sqlToQuery(query));
}
describe("completion of existing furniture", () => {
beforeEach(() => {
vi.clearAllMocks();
const rows = [
[{ id: 2000037257, spriteId: 9001, type: "s" }],
[{ itemName: input.classname }],
];
mocks.select.mockImplementation(() => ({
from: () => ({ where: () => ({ limit: async () => rows.shift() }) }),
}));
mocks.stat.mockResolvedValue({ size: 256 });
mocks.conflict.mockResolvedValue(null);
mocks.execute.mockResolvedValue([[{ id: 123, page_id: "15" }], []]);
mocks.build.mockImplementation((value) => value);
});
it("reuses local item and sprite IDs without rewriting SQL settings or offers", async () => {
const result = await importSingleFurni(input);
expect(result.ok).toBe(true);
expect(result.itemId).toBe(2000037257);
expect(mocks.conflict).toHaveBeenCalledWith(9001, input.classname);
expect(mocks.build).toHaveBeenCalledWith(
expect.objectContaining({ id: 9001, spriteId: 9001 }),
);
expect(mocks.append).toHaveBeenCalled();
expect(
queries().every((query) => query.sql.trim().startsWith("SELECT")),
).toBe(true);
expect(queries()[0].params).toContain("2000037257");
});
it("never deletes existing SQL when the nitro cannot be recovered", async () => {
mocks.stat.mockRejectedValue(new Error("missing"));
const result = await importSingleFurni(input);
expect(result.ok).toBe(false);
expect(result.error).toContain("preserved");
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.append).not.toHaveBeenCalled();
});
it("rejects an occupied furnidata sprite before any mutation", async () => {
mocks.conflict.mockResolvedValue("other_furniture");
const result = await importSingleFurni(input);
expect(result.ok).toBe(false);
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.append).not.toHaveBeenCalled();
});
});
+46 -22
View File
@@ -510,6 +510,7 @@ export async function importSingleFurni(params: {
category: string;
skipFurniDataWrite?: boolean;
updateExisting?: boolean;
repairExisting?: boolean;
onProgress?: (status: string) => void;
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
sourceSwfBaseUrl?: string;
@@ -527,6 +528,7 @@ export async function importSingleFurni(params: {
category,
skipFurniDataWrite,
updateExisting,
repairExisting,
onProgress,
sourceSwfBaseUrl,
nitroBaseUrl: sourceNitroBaseUrl,
@@ -536,18 +538,22 @@ export async function importSingleFurni(params: {
// Check if already exists by classname OR by spriteId (primary key collision).
const [existsByName] = await db
.select({ id: ItemsBase.id })
.select({
id: ItemsBase.id,
spriteId: ItemsBase.spriteId,
type: ItemsBase.type,
})
.from(ItemsBase)
.where(eq(ItemsBase.itemName, classname))
.limit(1);
if (existsByName && !updateExisting) {
if (existsByName && !updateExisting && !repairExisting) {
return {
ok: false,
warnings,
error: `Item "${classname}" already exists (id: ${existsByName.id})`,
};
}
if (existsByName && existsByName.id !== originalId) {
if (existsByName && existsByName.id !== originalId && !repairExisting) {
// The classname already occupies a different spriteId. Reassigning the
// id would orphan every reference (catalog item_ids, player items,
// furnidata), so this can never be an "update".
@@ -557,10 +563,14 @@ export async function importSingleFurni(params: {
error: `Item "${classname}" already exists with id ${existsByName.id} — cannot reassign to spriteId ${originalId}`,
};
}
const itemId = repairExisting && existsByName ? existsByName.id : originalId;
const spriteId =
repairExisting && existsByName ? existsByName.spriteId : originalId;
const preservingExisting = !!(repairExisting && existsByName);
const [existsById] = await db
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase)
.where(eq(ItemsBase.id, originalId))
.where(eq(ItemsBase.id, itemId))
.limit(1);
if (existsById && (!existsByName || existsById.itemName !== classname)) {
// The spriteId is owned by a different classname. Even with
@@ -575,17 +585,20 @@ export async function importSingleFurni(params: {
const isUpdate = !!existsByName;
// ── Check if spriteId already used in FurnitureData.json ──
const takenBy = await findFurniDataIdConflict(originalId, classname);
if (takenBy && !updateExisting) {
const takenBy = await findFurniDataIdConflict(spriteId, classname);
if (takenBy) {
return {
ok: false,
warnings,
error: `spriteId ${originalId} already used by "${takenBy}" in FurnitureData.json`,
error: `spriteId ${spriteId} already used by "${takenBy}" in FurnitureData.json`,
};
}
const itemType = type === "wallitem" ? "i" : "s";
const spriteId = originalId;
const itemType = preservingExisting
? existsByName.type
: type === "wallitem"
? "i"
: "s";
// ── Cross-reference with configured official Habbo furnidata ──────
let officialHabbo: OfficialHabboFurniEntry | null = null;
@@ -608,7 +621,11 @@ export async function importSingleFurni(params: {
// ── Insert into DB with enriched data ──────────────────────────────
onProgress?.("writing_db");
try {
if (isUpdate) {
if (preservingExisting) {
warnings.push(
`Existing furniture #${itemId}: SQL settings and catalog offers preserved`,
);
} else if (isUpdate) {
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
// those metadata fields are persisted in FurnitureData.json (see entry below),
// not in the DB. Only refresh the columns this import manages.
@@ -704,8 +721,12 @@ export async function importSingleFurni(params: {
// Download icon + SWF concurrently (independent assets). Each tries its
// candidate URLs in order until one succeeds.
const [iconOkResult, swfOk] = await Promise.all([
tryDownloadCandidates(iconUrls, iconPath, "png"),
tryDownloadCandidates(swfUrls, swfPath, "swf"),
preservingExisting && existsSync(iconPath)
? Promise.resolve(true)
: tryDownloadCandidates(iconUrls, iconPath, "png"),
preservingExisting && existsSync(swfPath)
? Promise.resolve(true)
: tryDownloadCandidates(swfUrls, swfPath, "swf"),
]);
let iconOk = iconOkResult;
@@ -799,16 +820,19 @@ export async function importSingleFurni(params: {
}
}
try {
await db.execute(
sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`,
);
if (!isUpdate)
await db.execute(
sql`DELETE FROM items_base WHERE id = ${itemId} AND item_name = ${classname}`,
);
} catch {
/* best effort cleanup */
}
return {
ok: false,
warnings,
error: "Import rolled back: no .nitro file could be created",
error: isUpdate
? "No .nitro file could be created; existing furniture was preserved"
: "Import rolled back: no .nitro file could be created",
};
}
@@ -924,7 +948,7 @@ export async function importSingleFurni(params: {
// Update items_base with ALL enriched data
try {
const update = buildItemsBaseEnrichmentUpdate({
id: spriteId,
id: itemId,
dims,
cansiton: autoInteraction.canSit,
canlayon: autoInteraction.canLay,
@@ -933,7 +957,7 @@ export async function importSingleFurni(params: {
interactionType,
interactionModesCount,
});
await db.execute(update);
if (!preservingExisting) await db.execute(update);
} catch (err) {
warnings.push(
`items_base dimension update failed: ${(err as Error).message}`,
@@ -972,16 +996,16 @@ export async function importSingleFurni(params: {
let catalogItemId: number | null = null;
let catalogUpdated = false;
try {
const itemIdStr = String(spriteId);
const itemIdStr = String(itemId);
// Check if this item already exists anywhere in the catalog
const [existingCatalog] = (await db.execute(sql`
SELECT id, page_id FROM catalog_items WHERE item_ids = ${itemIdStr} LIMIT 1
SELECT id, page_id FROM catalog_items WHERE FIND_IN_SET(${itemIdStr}, REPLACE(item_ids, ';', ',')) > 0 LIMIT 1
`)) as unknown as [Array<{ id: number; page_id: string }>, unknown];
if (existingCatalog.length > 0) {
catalogItemId = existingCatalog[0].id;
if (updateExisting) {
if (updateExisting && !preservingExisting) {
// Update existing catalog entry: price, page, etc.
const categoryPageId = await getOrCreateCategoryPage(
classname,
@@ -1025,7 +1049,7 @@ export async function importSingleFurni(params: {
return {
ok: true,
itemId: spriteId,
itemId,
catalogItemId,
catalogUpdated,
dimensions: dims,