feat: dedupe duplicate catalog_pages to prevent double links
The maintenance "Fix alles" now collapses catalog_pages that share the same caption_save + parent_id into a single survivor (moving catalog_items and reparenting child pages before deleting duplicates), so the catalog tree never renders double links. The duplicate-page count is also surfaced in the health panel.
This commit is contained in:
1 parent
02f8ffc0bc
commit
d6af754211
2 files changed
+88
-3
No files matched your search
@@ -31,6 +31,7 @@ interface Health {
|
||||
idMismatchVsFurnidata: number;
|
||||
catalogDuplicates: number;
|
||||
catalogBcDuplicates: number;
|
||||
catalogPageDuplicates: number;
|
||||
}
|
||||
|
||||
export function CatalogMaintenancePanel() {
|
||||
@@ -167,6 +168,11 @@ export function CatalogMaintenancePanel() {
|
||||
value={health.catalogBcDuplicates}
|
||||
hint="same product listed twice in builder club"
|
||||
/>
|
||||
<Stat
|
||||
label="Duplicate catalog pages"
|
||||
value={health.catalogPageDuplicates}
|
||||
hint="same caption under the same parent (double links)"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface FurniHealth {
|
||||
idMismatchVsFurnidata: number;
|
||||
catalogDuplicates: number;
|
||||
catalogBcDuplicates: number;
|
||||
catalogPageDuplicates: number;
|
||||
}
|
||||
|
||||
/** Read-only snapshot of how far the furniture tables have drifted. */
|
||||
@@ -145,6 +146,13 @@ export async function getFurniHealth(): Promise<FurniHealth> {
|
||||
GROUP BY ${sql.raw(bcCols)} HAVING COUNT(*) > 1
|
||||
) g`);
|
||||
|
||||
const [cpd] = await query<{ c: number }>(sql`
|
||||
SELECT COUNT(*) c FROM (
|
||||
SELECT caption_save, parent_id FROM catalog_pages
|
||||
WHERE caption_save <> ''
|
||||
GROUP BY caption_save, parent_id HAVING COUNT(*) > 1
|
||||
) g`);
|
||||
|
||||
const furniData = (await readFurniData()) as Record<
|
||||
string,
|
||||
{ furnitype?: Array<{ id?: unknown; classname?: unknown }> }
|
||||
@@ -176,6 +184,7 @@ export async function getFurniHealth(): Promise<FurniHealth> {
|
||||
idMismatchVsFurnidata: idMismatch,
|
||||
catalogDuplicates: Number(cd?.c ?? 0),
|
||||
catalogBcDuplicates: Number(cdb?.c ?? 0),
|
||||
catalogPageDuplicates: Number(cpd?.c ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,6 +213,67 @@ export interface DedupResult {
|
||||
remapped: number;
|
||||
catalogRemoved: number;
|
||||
catalogBcRemoved: number;
|
||||
catalogPageRemoved: number;
|
||||
}
|
||||
|
||||
export interface CatalogPageDedupResult {
|
||||
groups: number;
|
||||
removed: number;
|
||||
remapped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse duplicate catalog_pages that share the same (caption_save, parent_id)
|
||||
* into a single survivor so the catalog tree never renders double links. The
|
||||
* lowest id wins; its catalog_items (and builder-club items) are moved onto the
|
||||
* survivor and any child pages are reparented to it, then the duplicates are
|
||||
* deleted. Every write runs inside one transaction.
|
||||
*/
|
||||
export async function dedupeCatalogPages(): Promise<CatalogPageDedupResult> {
|
||||
const groups = await query<{ caption_save: string; parent_id: number }>(sql`
|
||||
SELECT caption_save, parent_id
|
||||
FROM catalog_pages
|
||||
WHERE caption_save <> ''
|
||||
GROUP BY caption_save, parent_id
|
||||
HAVING COUNT(*) > 1`);
|
||||
if (groups.length === 0) return { groups: 0, removed: 0, remapped: 0 };
|
||||
|
||||
let removed = 0;
|
||||
let remapped = 0;
|
||||
await withFurniDataLock(async () => {
|
||||
await db.transaction(async (tx) => {
|
||||
for (const g of groups) {
|
||||
const pages = await query<{ id: number }>(sql`
|
||||
SELECT id FROM catalog_pages
|
||||
WHERE caption_save = ${g.caption_save} AND parent_id = ${g.parent_id}
|
||||
ORDER BY id ASC`);
|
||||
if (pages.length < 2) continue;
|
||||
const [winner, ...losers] = pages.map((p) => p.id);
|
||||
for (const loser of losers) {
|
||||
const ci = (await tx.execute(sql`
|
||||
UPDATE catalog_items SET page_id = ${winner} WHERE page_id = ${loser}`)) as unknown as [
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
];
|
||||
remapped += Number(ci[0]?.affectedRows ?? 0);
|
||||
if (await exists("catalog_items_bc")) {
|
||||
const bci = (await tx.execute(sql`
|
||||
UPDATE catalog_items_bc SET page_id = ${winner} WHERE page_id = ${loser}`)) as unknown as [
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
];
|
||||
remapped += Number(bci[0]?.affectedRows ?? 0);
|
||||
}
|
||||
// Keep any child pages by reparenting them onto the survivor.
|
||||
await tx.execute(sql`
|
||||
UPDATE catalog_pages SET parent_id = ${winner} WHERE parent_id = ${loser}`);
|
||||
await tx.execute(sql`DELETE FROM catalog_pages WHERE id = ${loser}`);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return { groups: groups.length, removed, remapped };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -286,6 +356,7 @@ export async function removeDuplicateItemsBase(): Promise<DedupResult> {
|
||||
remapped,
|
||||
catalogRemoved: 0,
|
||||
catalogBcRemoved: 0,
|
||||
catalogPageRemoved: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -312,14 +383,22 @@ async function dedupCatalogTable(table: string): Promise<number> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Full duplicate cleanup: items_base (by classname) + catalog_items +
|
||||
* catalog_items_bc (by identical product on the same page).
|
||||
* Full duplicate cleanup: items_base (by classname) + catalog_pages (by
|
||||
* caption_save + parent_id) + catalog_items / catalog_items_bc (by identical
|
||||
* product on the same page). The page merge runs before the row dedup so items
|
||||
* moved onto the survivor are still de-duplicated within the page afterwards.
|
||||
*/
|
||||
export async function removeDuplicates(): Promise<DedupResult> {
|
||||
const ib = await removeDuplicateItemsBase();
|
||||
const pageDedup = await dedupeCatalogPages();
|
||||
const catalogRemoved = await dedupCatalogTable("catalog_items");
|
||||
const catalogBcRemoved = await dedupCatalogTable("catalog_items_bc");
|
||||
return { ...ib, catalogRemoved, catalogBcRemoved };
|
||||
return {
|
||||
...ib,
|
||||
catalogPageRemoved: pageDedup.removed,
|
||||
catalogRemoved,
|
||||
catalogBcRemoved,
|
||||
};
|
||||
}
|
||||
|
||||
export interface AlignResult {
|
||||
|
||||
Reference in new issue
Block a user