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;
|
idMismatchVsFurnidata: number;
|
||||||
catalogDuplicates: number;
|
catalogDuplicates: number;
|
||||||
catalogBcDuplicates: number;
|
catalogBcDuplicates: number;
|
||||||
|
catalogPageDuplicates: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CatalogMaintenancePanel() {
|
export function CatalogMaintenancePanel() {
|
||||||
@@ -167,6 +168,11 @@ export function CatalogMaintenancePanel() {
|
|||||||
value={health.catalogBcDuplicates}
|
value={health.catalogBcDuplicates}
|
||||||
hint="same product listed twice in builder club"
|
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>
|
</CardContent>
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export interface FurniHealth {
|
|||||||
idMismatchVsFurnidata: number;
|
idMismatchVsFurnidata: number;
|
||||||
catalogDuplicates: number;
|
catalogDuplicates: number;
|
||||||
catalogBcDuplicates: number;
|
catalogBcDuplicates: number;
|
||||||
|
catalogPageDuplicates: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read-only snapshot of how far the furniture tables have drifted. */
|
/** 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
|
GROUP BY ${sql.raw(bcCols)} HAVING COUNT(*) > 1
|
||||||
) g`);
|
) 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<
|
const furniData = (await readFurniData()) as Record<
|
||||||
string,
|
string,
|
||||||
{ furnitype?: Array<{ id?: unknown; classname?: unknown }> }
|
{ furnitype?: Array<{ id?: unknown; classname?: unknown }> }
|
||||||
@@ -176,6 +184,7 @@ export async function getFurniHealth(): Promise<FurniHealth> {
|
|||||||
idMismatchVsFurnidata: idMismatch,
|
idMismatchVsFurnidata: idMismatch,
|
||||||
catalogDuplicates: Number(cd?.c ?? 0),
|
catalogDuplicates: Number(cd?.c ?? 0),
|
||||||
catalogBcDuplicates: Number(cdb?.c ?? 0),
|
catalogBcDuplicates: Number(cdb?.c ?? 0),
|
||||||
|
catalogPageDuplicates: Number(cpd?.c ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +213,67 @@ export interface DedupResult {
|
|||||||
remapped: number;
|
remapped: number;
|
||||||
catalogRemoved: number;
|
catalogRemoved: number;
|
||||||
catalogBcRemoved: 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,
|
remapped,
|
||||||
catalogRemoved: 0,
|
catalogRemoved: 0,
|
||||||
catalogBcRemoved: 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 +
|
* Full duplicate cleanup: items_base (by classname) + catalog_pages (by
|
||||||
* catalog_items_bc (by identical product on the same page).
|
* 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> {
|
export async function removeDuplicates(): Promise<DedupResult> {
|
||||||
const ib = await removeDuplicateItemsBase();
|
const ib = await removeDuplicateItemsBase();
|
||||||
|
const pageDedup = await dedupeCatalogPages();
|
||||||
const catalogRemoved = await dedupCatalogTable("catalog_items");
|
const catalogRemoved = await dedupCatalogTable("catalog_items");
|
||||||
const catalogBcRemoved = await dedupCatalogTable("catalog_items_bc");
|
const catalogBcRemoved = await dedupCatalogTable("catalog_items_bc");
|
||||||
return { ...ib, catalogRemoved, catalogBcRemoved };
|
return {
|
||||||
|
...ib,
|
||||||
|
catalogPageRemoved: pageDedup.removed,
|
||||||
|
catalogRemoved,
|
||||||
|
catalogBcRemoved,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlignResult {
|
export interface AlignResult {
|
||||||
|
|||||||
Reference in new issue
Block a user