revert fb8e77bb68
Local Build and Deploy / deploy (push) Successful in 1m11s
Local Build and Deploy / deploy (push) Successful in 1m11s
revert style: clean up code with prettier and eslint
This commit is contained in:
1 parent
27de078f54
commit
e85e4d74ea
378 files changed
+20710
-22428
No files matched your search
+159
-152
@@ -1,93 +1,94 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
||||
import type { CloneSource } from "@/lib/services/clone-sources";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import { appendFurniEntry } from "@/lib/services/furni-data";
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { extractFurniIconPng } from '@/lib/services/clone-icon'
|
||||
import type { CloneSource } from '@/lib/services/clone-sources'
|
||||
import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs'
|
||||
import { appendFurniEntry } from '@/lib/services/furni-data'
|
||||
import {
|
||||
allocateCatalogItemId,
|
||||
autoPriceFurni,
|
||||
ensureDirectories,
|
||||
getOrCreateCategoryPage,
|
||||
} from "@/lib/services/furni-import";
|
||||
import { downloadFile } from "@/lib/services/import/core/download";
|
||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
||||
} from '@/lib/services/furni-import'
|
||||
import { downloadFile } from '@/lib/services/import/core/download'
|
||||
import { parseNitroBundle } from '@/lib/services/swf/nitro-builder'
|
||||
|
||||
export interface SourceFurni {
|
||||
id: number;
|
||||
classname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
xdim: number;
|
||||
ydim: number;
|
||||
canstandon: boolean;
|
||||
cansiton: boolean;
|
||||
canlayon: boolean;
|
||||
customparams: string;
|
||||
itemType: "s" | "i";
|
||||
[k: string]: unknown;
|
||||
id: number
|
||||
classname: string
|
||||
name: string
|
||||
description: string
|
||||
xdim: number
|
||||
ydim: number
|
||||
canstandon: boolean
|
||||
cansiton: boolean
|
||||
canlayon: boolean
|
||||
customparams: string
|
||||
itemType: 's' | 'i'
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export interface CloneResult {
|
||||
ok: boolean;
|
||||
classname: string;
|
||||
skipped?: boolean;
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
ok: boolean
|
||||
classname: string
|
||||
skipped?: boolean
|
||||
warnings: string[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface FurniType {
|
||||
furnitype?: Array<Record<string, unknown>>;
|
||||
furnitype?: Array<Record<string, unknown>>
|
||||
}
|
||||
interface RemoteFurnidata {
|
||||
roomitemtypes?: FurniType;
|
||||
wallitemtypes?: FurniType;
|
||||
furnitype?: Array<Record<string, unknown>>;
|
||||
roomitemtypes?: FurniType
|
||||
wallitemtypes?: FurniType
|
||||
furnitype?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
// Spread ...raw first so the coerced typed fields always win over raw values.
|
||||
function toSourceFurni(raw: Record<string, unknown>, itemType: "s" | "i"): SourceFurni {
|
||||
function toSourceFurni(raw: Record<string, unknown>, itemType: 's' | 'i'): SourceFurni {
|
||||
return {
|
||||
...raw,
|
||||
id: Number(raw.id ?? 0),
|
||||
classname: String(raw.classname ?? ""),
|
||||
name: String(raw.name ?? raw.classname ?? ""),
|
||||
description: String(raw.description ?? ""),
|
||||
classname: String(raw.classname ?? ''),
|
||||
name: String(raw.name ?? raw.classname ?? ''),
|
||||
description: String(raw.description ?? ''),
|
||||
xdim: Number(raw.xdim ?? 1),
|
||||
ydim: Number(raw.ydim ?? 1),
|
||||
canstandon: raw.canstandon === true,
|
||||
cansiton: raw.cansiton === true,
|
||||
canlayon: raw.canlayon === true,
|
||||
customparams: String(raw.customparams ?? ""),
|
||||
customparams: String(raw.customparams ?? ''),
|
||||
itemType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseFurnidata(data: RemoteFurnidata): SourceFurni[] {
|
||||
const out: SourceFurni[] = [];
|
||||
for (const r of data.roomitemtypes?.furnitype ?? data.furnitype ?? []) out.push(toSourceFurni(r, "s"));
|
||||
for (const r of data.wallitemtypes?.furnitype ?? []) out.push(toSourceFurni(r, "i"));
|
||||
return out.filter((e) => e.classname);
|
||||
const out: SourceFurni[] = []
|
||||
for (const r of data.roomitemtypes?.furnitype ?? data.furnitype ?? [])
|
||||
out.push(toSourceFurni(r, 's'))
|
||||
for (const r of data.wallitemtypes?.furnitype ?? []) out.push(toSourceFurni(r, 'i'))
|
||||
return out.filter((e) => e.classname)
|
||||
}
|
||||
|
||||
const cache = new Map<string, { list: SourceFurni[]; ts: number }>();
|
||||
const TTL = 5 * 60 * 1000;
|
||||
const cache = new Map<string, { list: SourceFurni[]; ts: number }>()
|
||||
const TTL = 5 * 60 * 1000
|
||||
|
||||
export async function fetchSourceFurnidata(url: string, now = Date.now()): Promise<SourceFurni[]> {
|
||||
const hit = cache.get(url);
|
||||
if (hit && now && now - hit.ts < TTL) return hit.list;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||
if (!res.ok) throw new Error(`furnidata fetch failed: ${res.status} ${url}`);
|
||||
let list: SourceFurni[];
|
||||
const hit = cache.get(url)
|
||||
if (hit && now && now - hit.ts < TTL) return hit.list
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(30000) })
|
||||
if (!res.ok) throw new Error(`furnidata fetch failed: ${res.status} ${url}`)
|
||||
let list: SourceFurni[]
|
||||
try {
|
||||
const json = await res.json();
|
||||
list = parseFurnidata(json);
|
||||
const json = await res.json()
|
||||
list = parseFurnidata(json)
|
||||
} catch (err) {
|
||||
throw new Error(`furnidata parse failed for ${url}: ${(err as Error).message}`);
|
||||
throw new Error(`furnidata parse failed for ${url}: ${(err as Error).message}`)
|
||||
}
|
||||
cache.set(url, { list, ts: now });
|
||||
return list;
|
||||
cache.set(url, { list, ts: now })
|
||||
return list
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,94 +96,94 @@ export async function fetchSourceFurnidata(url: string, now = Date.now()): Promi
|
||||
* concurrent clones in the same Node process cannot race on MAX(id)+1.
|
||||
* Mirrors the `allocateCatalogItemId` pattern from furni-import.ts.
|
||||
*/
|
||||
let itemsBaseIdAllocChain: Promise<unknown> = Promise.resolve();
|
||||
let itemsBaseIdAllocChain: Promise<unknown> = Promise.resolve()
|
||||
|
||||
async function allocateItemsBaseId<T>(insertFn: (nextId: number) => Promise<T>): Promise<T> {
|
||||
const prev = itemsBaseIdAllocChain;
|
||||
let settle!: () => void;
|
||||
const prev = itemsBaseIdAllocChain
|
||||
let settle!: () => void
|
||||
itemsBaseIdAllocChain = new Promise<void>((r) => {
|
||||
settle = r;
|
||||
});
|
||||
await prev.catch(() => {});
|
||||
settle = r
|
||||
})
|
||||
await prev.catch(() => {})
|
||||
try {
|
||||
const idRow = await prisma.$queryRaw<Array<{ next: number }>>`
|
||||
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base`;
|
||||
const nextId = Number(idRow[0]?.next ?? 1);
|
||||
return await insertFn(nextId);
|
||||
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base`
|
||||
const nextId = Number(idRow[0]?.next ?? 1)
|
||||
return await insertFn(nextId)
|
||||
} finally {
|
||||
settle();
|
||||
settle()
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneSingleFurni(params: {
|
||||
source: CloneSource;
|
||||
entry: SourceFurni;
|
||||
onProgress?: (status: string) => void;
|
||||
source: CloneSource
|
||||
entry: SourceFurni
|
||||
onProgress?: (status: string) => void
|
||||
}): Promise<CloneResult> {
|
||||
const { source, entry, onProgress } = params;
|
||||
const { classname, itemType } = entry;
|
||||
const warnings: string[] = [];
|
||||
const { source, entry, onProgress } = params
|
||||
const { classname, itemType } = entry
|
||||
const warnings: string[] = []
|
||||
|
||||
// Path-traversal guard: classname comes from remote furnidata and is used to
|
||||
// build file paths — reject anything that doesn't look like a safe furni name.
|
||||
if (!/^[\w\-.*]+$/.test(classname)) {
|
||||
return { ok: false, classname, warnings, error: "invalid classname" };
|
||||
return { ok: false, classname, warnings, error: 'invalid classname' }
|
||||
}
|
||||
|
||||
// Dedup by classname.
|
||||
const existing = await prisma.$queryRaw<Array<{ id: number }>>`
|
||||
SELECT id FROM items_base WHERE item_name = ${classname} LIMIT 1`;
|
||||
SELECT id FROM items_base WHERE item_name = ${classname} LIMIT 1`
|
||||
if (existing.length > 0) {
|
||||
return { ok: false, classname, skipped: true, warnings, error: "Already present" };
|
||||
return { ok: false, classname, skipped: true, warnings, error: 'Already present' }
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
const { iconDir, nitroDir } = await getFurniAssetDirs();
|
||||
await ensureDirectories()
|
||||
const { iconDir, nitroDir } = await getFurniAssetDirs()
|
||||
|
||||
// Download .nitro + icon directly from the source hotel.
|
||||
onProgress?.("downloading");
|
||||
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${classname}.nitro`);
|
||||
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${classname}_icon.png`);
|
||||
onProgress?.('downloading')
|
||||
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${classname}.nitro`)
|
||||
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${classname}_icon.png`)
|
||||
const dl = await downloadFile(`${source.nitroBaseUrl}/${classname}.nitro`, nitroPath, {
|
||||
maxRetries: 2,
|
||||
});
|
||||
})
|
||||
if (!dl.ok) {
|
||||
console.warn("[clone-import] nitro download failed for", classname);
|
||||
return { ok: false, classname, warnings, error: "nitro download failed" };
|
||||
console.warn('[clone-import] nitro download failed for', classname)
|
||||
return { ok: false, classname, warnings, error: 'nitro download failed' }
|
||||
}
|
||||
// Validate it is a real Nitro bundle.
|
||||
try {
|
||||
parseNitroBundle(await fs.readFile(/*turbopackIgnore: true*/ nitroPath));
|
||||
parseNitroBundle(await fs.readFile(/*turbopackIgnore: true*/ nitroPath))
|
||||
} catch {
|
||||
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
|
||||
console.warn("[clone-import] invalid .nitro bundle for", classname);
|
||||
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
|
||||
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {})
|
||||
console.warn('[clone-import] invalid .nitro bundle for', classname)
|
||||
return { ok: false, classname, warnings, error: 'invalid .nitro bundle' }
|
||||
}
|
||||
const iconDl = await downloadFile(`${source.iconBaseUrl}/${classname}_icon.png`, iconPath, {
|
||||
maxRetries: 1,
|
||||
validate: "png",
|
||||
});
|
||||
validate: 'png',
|
||||
})
|
||||
if (!iconDl.ok) {
|
||||
// Source serves no standalone icon (e.g. icons embedded in the .nitro) —
|
||||
// extract the catalog icon from the bundle we just downloaded.
|
||||
try {
|
||||
const icon = extractFurniIconPng(await fs.readFile(/*turbopackIgnore: true*/ nitroPath));
|
||||
const icon = extractFurniIconPng(await fs.readFile(/*turbopackIgnore: true*/ nitroPath))
|
||||
if (icon) {
|
||||
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, icon);
|
||||
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, icon)
|
||||
} else {
|
||||
warnings.push("no icon (not in source or bundle)");
|
||||
warnings.push('no icon (not in source or bundle)')
|
||||
}
|
||||
} catch {
|
||||
warnings.push("icon extraction failed");
|
||||
warnings.push('icon extraction failed')
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.("writing_db");
|
||||
const stackHeight = entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0;
|
||||
onProgress?.('writing_db')
|
||||
const stackHeight = entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0
|
||||
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
|
||||
// allow_walk = canstandon, allow_sit = cansiton, allow_lay = canlayon.
|
||||
const allowStack = stackHeight > 0 ? "1" : "0";
|
||||
let newId: number;
|
||||
const allowStack = stackHeight > 0 ? '1' : '0'
|
||||
let newId: number
|
||||
try {
|
||||
newId = await allocateItemsBaseId(async (nextId) => {
|
||||
await prisma.$executeRaw`
|
||||
@@ -191,111 +192,117 @@ export async function cloneSingleFurni(params: {
|
||||
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)
|
||||
VALUES
|
||||
(${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight},
|
||||
${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, 'default', ${entry.customparams})`;
|
||||
return nextId;
|
||||
});
|
||||
${allowStack}, ${entry.cansiton ? '1' : '0'}, ${entry.canlayon ? '1' : '0'}, ${entry.canstandon ? '1' : '0'}, 'default', ${entry.customparams})`
|
||||
return nextId
|
||||
})
|
||||
} catch (err) {
|
||||
// Rollback: remove both downloaded files so we don't leave orphaned assets.
|
||||
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
|
||||
await fs.unlink(/*turbopackIgnore: true*/ iconPath).catch(() => {});
|
||||
console.warn("[clone-import] items_base insert failed for", classname, (err as Error).message);
|
||||
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {})
|
||||
await fs.unlink(/*turbopackIgnore: true*/ iconPath).catch(() => {})
|
||||
console.warn('[clone-import] items_base insert failed for', classname, (err as Error).message)
|
||||
return {
|
||||
ok: false,
|
||||
classname,
|
||||
warnings,
|
||||
error: `items_base insert failed: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// FurnitureData entry — reuse the source's furnitype object, with our id.
|
||||
onProgress?.("writing_furnidata");
|
||||
onProgress?.('writing_furnidata')
|
||||
try {
|
||||
await appendFurniEntry({ ...entry, id: newId } as Record<string, unknown>, itemType);
|
||||
await appendFurniEntry({ ...entry, id: newId } as Record<string, unknown>, itemType)
|
||||
} catch (err) {
|
||||
console.warn("[clone-import] FurnitureData append failed for", classname, (err as Error).message);
|
||||
warnings.push(`FurnitureData append failed: ${(err as Error).message}`);
|
||||
console.warn(
|
||||
'[clone-import] FurnitureData append failed for',
|
||||
classname,
|
||||
(err as Error).message,
|
||||
)
|
||||
warnings.push(`FurnitureData append failed: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
// Catalog entry (category sub-page + auto price), serialized id allocation.
|
||||
try {
|
||||
const pageId = await getOrCreateCategoryPage(classname, itemType);
|
||||
const price = autoPriceFurni(classname);
|
||||
const pageId = await getOrCreateCategoryPage(classname, itemType)
|
||||
const price = autoPriceFurni(classname)
|
||||
await allocateCatalogItemId(async (nextCatalogId) => {
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
|
||||
VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`;
|
||||
return nextCatalogId;
|
||||
});
|
||||
VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`
|
||||
return nextCatalogId
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn("[clone-import] catalog entry failed for", classname, (err as Error).message);
|
||||
warnings.push(`catalog entry failed: ${(err as Error).message}`);
|
||||
console.warn('[clone-import] catalog entry failed for', classname, (err as Error).message)
|
||||
warnings.push(`catalog entry failed: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
return { ok: true, classname, warnings };
|
||||
return { ok: true, classname, warnings }
|
||||
}
|
||||
|
||||
export async function getCloneList(params: {
|
||||
source: CloneSource;
|
||||
search: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
filter?: "all" | "missing" | "present";
|
||||
source: CloneSource
|
||||
search: string
|
||||
page: number
|
||||
perPage: number
|
||||
filter?: 'all' | 'missing' | 'present'
|
||||
}): Promise<{
|
||||
items: Array<SourceFurni & { present: boolean }>;
|
||||
meta: { page: number; perPage: number; total: number };
|
||||
items: Array<SourceFurni & { present: boolean }>
|
||||
meta: { page: number; perPage: number; total: number }
|
||||
}> {
|
||||
const { source, search, page, perPage, filter = "all" } = params;
|
||||
const all = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
const term = search.trim().toLowerCase();
|
||||
const { source, search, page, perPage, filter = 'all' } = params
|
||||
const all = await fetchSourceFurnidata(source.furnidataUrl)
|
||||
const term = search.trim().toLowerCase()
|
||||
const searched = term
|
||||
? all.filter((e) => e.classname.toLowerCase().includes(term) || e.name.toLowerCase().includes(term))
|
||||
: all;
|
||||
? all.filter(
|
||||
(e) => e.classname.toLowerCase().includes(term) || e.name.toLowerCase().includes(term),
|
||||
)
|
||||
: all
|
||||
|
||||
// Resolve present status across the whole searched list (not just the page),
|
||||
// so the missing/present filter and the page total stay correct.
|
||||
const allNames = searched.map((e) => e.classname);
|
||||
const present = new Set<string>();
|
||||
const allNames = searched.map((e) => e.classname)
|
||||
const present = new Set<string>()
|
||||
if (allNames.length) {
|
||||
const placeholders = allNames.map(() => "?").join(",");
|
||||
const placeholders = allNames.map(() => '?').join(',')
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
|
||||
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
|
||||
...allNames,
|
||||
);
|
||||
for (const r of rows) present.add(r.item_name);
|
||||
)
|
||||
for (const r of rows) present.add(r.item_name)
|
||||
}
|
||||
|
||||
const filtered =
|
||||
filter === "missing"
|
||||
filter === 'missing'
|
||||
? searched.filter((e) => !present.has(e.classname))
|
||||
: filter === "present"
|
||||
: filter === 'present'
|
||||
? searched.filter((e) => present.has(e.classname))
|
||||
: searched;
|
||||
: searched
|
||||
|
||||
const total = filtered.length;
|
||||
const start = (Math.max(page, 1) - 1) * perPage;
|
||||
const slice = filtered.slice(start, start + perPage);
|
||||
const total = filtered.length
|
||||
const start = (Math.max(page, 1) - 1) * perPage
|
||||
const slice = filtered.slice(start, start + perPage)
|
||||
return {
|
||||
items: slice.map((e) => ({ ...e, present: present.has(e.classname) })),
|
||||
meta: { page: Math.max(page, 1), perPage, total },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCloneStats(
|
||||
source: CloneSource,
|
||||
): Promise<{ total: number; present: number; clonable: number }> {
|
||||
const all = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
const classnames = all.map((e) => e.classname);
|
||||
const have = new Set<string>();
|
||||
const all = await fetchSourceFurnidata(source.furnidataUrl)
|
||||
const classnames = all.map((e) => e.classname)
|
||||
const have = new Set<string>()
|
||||
if (classnames.length) {
|
||||
const placeholders = classnames.map(() => "?").join(",");
|
||||
const placeholders = classnames.map(() => '?').join(',')
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
|
||||
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
|
||||
...classnames,
|
||||
);
|
||||
for (const r of rows) have.add(r.item_name);
|
||||
)
|
||||
for (const r of rows) have.add(r.item_name)
|
||||
}
|
||||
const present = all.filter((e) => have.has(e.classname)).length;
|
||||
return { total: all.length, present, clonable: all.length - present };
|
||||
const present = all.filter((e) => have.has(e.classname)).length
|
||||
return { total: all.length, present, clonable: all.length - present }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,16 +310,16 @@ export async function getCloneStats(
|
||||
* in our items_base — used by the "clone all" action (cloned in chunks by the UI).
|
||||
*/
|
||||
export async function getClonableClassnames(source: CloneSource): Promise<string[]> {
|
||||
const all = await fetchSourceFurnidata(source.furnidataUrl);
|
||||
const classnames = all.map((e) => e.classname);
|
||||
const have = new Set<string>();
|
||||
const all = await fetchSourceFurnidata(source.furnidataUrl)
|
||||
const classnames = all.map((e) => e.classname)
|
||||
const have = new Set<string>()
|
||||
if (classnames.length) {
|
||||
const placeholders = classnames.map(() => "?").join(",");
|
||||
const placeholders = classnames.map(() => '?').join(',')
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
|
||||
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
|
||||
...classnames,
|
||||
);
|
||||
for (const r of rows) have.add(r.item_name);
|
||||
)
|
||||
for (const r of rows) have.add(r.item_name)
|
||||
}
|
||||
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname);
|
||||
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname)
|
||||
}
|
||||
Reference in new issue
Block a user