import chalk from 'chalk' import type { MetabaseClient } from '../client.js' import { Resolver } from '../resolver.js' import type { MetabaseCard, MetabaseCollection, MetabaseDashboard, } from '../types.js' interface DiffEntry { kind: 'collection' | 'question' | 'dashboard' name: string status: 'added' | 'removed' | 'changed' | 'unchanged' changes?: string[] } export async function diff(client: MetabaseClient, resourceDir: string = '.') { const { loadResources } = await import('../loader.js') console.log('Loading local resources...') const localResources = await loadResources(resourceDir) console.log('Fetching remote state from Metabase...\n') const resolver = new Resolver() await resolver.init(client) const remoteCollections = await client.getCollections() const remoteCards = await client.getCards() const remoteDashboardsList = await client.getDashboards() const activeCollections = remoteCollections.filter((c) => !c.archived && c.id !== 0) const activeCards = remoteCards.filter((c) => !c.archived) const activeDashboards = remoteDashboardsList.filter((d) => !d.archived) const entries: DiffEntry[] = [] // Diff collections const remoteCollectionsByName = new Map() for (const c of activeCollections) remoteCollectionsByName.set(c.name, c) const localCollections = localResources.filter((r) => r.kind === 'collection') const seenCollections = new Set() for (const local of localCollections) { seenCollections.add(local.name) const remote = remoteCollectionsByName.get(local.name) if (!remote) { entries.push({ kind: 'collection', name: local.name, status: 'added' }) } else { const changes = diffCollection(local, remote) entries.push({ kind: 'collection', name: local.name, status: changes.length > 0 ? 'changed' : 'unchanged', changes, }) } } for (const remote of activeCollections) { if (!seenCollections.has(remote.name) && remote.name !== 'Our analytics') { entries.push({ kind: 'collection', name: remote.name, status: 'removed' }) } } // Diff questions const remoteCardsByName = new Map() for (const c of activeCards) remoteCardsByName.set(c.name, c) const localQuestions = localResources.filter((r) => r.kind === 'question') const seenQuestions = new Set() for (const local of localQuestions) { seenQuestions.add(local.name) const remote = remoteCardsByName.get(local.name) if (!remote) { entries.push({ kind: 'question', name: local.name, status: 'added' }) } else { const changes = diffQuestion(local, remote, resolver) entries.push({ kind: 'question', name: local.name, status: changes.length > 0 ? 'changed' : 'unchanged', changes, }) } } for (const remote of activeCards) { if (!seenQuestions.has(remote.name)) { entries.push({ kind: 'question', name: remote.name, status: 'removed' }) } } // Diff dashboards const remoteDashboardsByName = new Map() for (const d of activeDashboards) { const full = await client.getDashboard(d.id) remoteDashboardsByName.set(full.name, full) } const localDashboards = localResources.filter((r) => r.kind === 'dashboard') const seenDashboards = new Set() for (const local of localDashboards) { seenDashboards.add(local.name) const remote = remoteDashboardsByName.get(local.name) if (!remote) { entries.push({ kind: 'dashboard', name: local.name, status: 'added' }) } else { const changes = diffDashboard(local, remote, activeCards, resolver) entries.push({ kind: 'dashboard', name: local.name, status: changes.length > 0 ? 'changed' : 'unchanged', changes, }) } } for (const [name] of remoteDashboardsByName) { if (!seenDashboards.has(name)) { entries.push({ kind: 'dashboard', name, status: 'removed' }) } } // Print results printDiff(entries) } function diffCollection(local: any, remote: MetabaseCollection): string[] { const changes: string[] = [] if (local.description && local.description !== (remote.description ?? '')) { changes.push(`description: "${remote.description ?? ''}" → "${local.description}"`) } return changes } function diffQuestion(local: any, remote: MetabaseCard, resolver: Resolver): string[] { const changes: string[] = [] const remoteCollection = resolver.getCollectionName(remote.collection_id) if (local.collection && local.collection !== remoteCollection) { changes.push(`collection: "${remoteCollection}" → "${local.collection}"`) } if (local.display && local.display !== remote.display) { changes.push(`display: "${remote.display}" → "${local.display}"`) } const remoteDb = resolver.getDatabaseName(remote.database_id) if (local.database && local.database !== remoteDb) { changes.push(`database: "${remoteDb}" → "${local.database}"`) } // Compare native query text if (local.type === 'native' && remote.dataset_query?.native) { const localQuery = local.query.trim() const remoteQuery = (remote.dataset_query.native.query ?? '').trim() if (localQuery !== remoteQuery) { changes.push('query: content changed') } } return changes } function diffDashboard( local: any, remote: MetabaseDashboard, allCards: MetabaseCard[], resolver: Resolver, ): string[] { const changes: string[] = [] const remoteCollection = resolver.getCollectionName(remote.collection_id) if (local.collection && local.collection !== remoteCollection) { changes.push(`collection: "${remoteCollection}" → "${local.collection}"`) } if (local.description !== undefined && local.description !== (remote.description ?? null)) { changes.push(`description changed`) } // Compare card count const localCardCount = (local.cards ?? []).length const remoteCardCount = (remote.dashcards ?? []).length if (localCardCount !== remoteCardCount) { changes.push(`cards: ${remoteCardCount} → ${localCardCount}`) } // Compare card positions and sizes const cardsById = new Map() for (const c of allCards) cardsById.set(c.id, c) const localCards = local.cards ?? [] const remoteCards = remote.dashcards ?? [] const minLen = Math.min(localCards.length, remoteCards.length) for (let i = 0; i < minLen; i++) { const lc = localCards[i] const rc = remoteCards[i] if ('question' in lc && rc.card_id) { const remoteCard = cardsById.get(rc.card_id) const remoteName = remoteCard?.name ?? `card-${rc.card_id}` if (lc.question !== remoteName) { changes.push(`card[${i}]: question "${remoteName}" → "${lc.question}"`) } if (lc.width !== rc.size_x || lc.height !== rc.size_y) { changes.push(`card[${i}] "${lc.question}": size ${rc.size_x}x${rc.size_y} → ${lc.width}x${lc.height}`) } if (lc.row !== rc.row || lc.col !== rc.col) { changes.push(`card[${i}] "${lc.question}": position [${rc.row},${rc.col}] → [${lc.row},${lc.col}]`) } } } // Compare parameters const localParams = (local.parameters ?? []).length const remoteParams = (remote.parameters ?? []).length if (localParams !== remoteParams) { changes.push(`parameters: ${remoteParams} → ${localParams}`) } return changes } function printDiff(entries: DiffEntry[]) { const added = entries.filter((e) => e.status === 'added') const removed = entries.filter((e) => e.status === 'removed') const changed = entries.filter((e) => e.status === 'changed') const unchanged = entries.filter((e) => e.status === 'unchanged') if (added.length === 0 && removed.length === 0 && changed.length === 0) { console.log(chalk.green('No differences. Local state matches Metabase.')) return } // Added (local only) if (added.length > 0) { console.log(chalk.green.bold(`\n+ Added locally (${added.length}):\n`)) for (const e of added) { console.log(` ${chalk.green('+')} ${e.kind}: ${chalk.bold(e.name)}`) } } // Removed (remote only) if (removed.length > 0) { console.log(chalk.red.bold(`\n- Only in Metabase (${removed.length}):\n`)) for (const e of removed) { console.log(` ${chalk.red('-')} ${e.kind}: ${chalk.bold(e.name)}`) } } // Changed if (changed.length > 0) { console.log(chalk.yellow.bold(`\n~ Changed (${changed.length}):\n`)) for (const e of changed) { console.log(` ${chalk.yellow('~')} ${e.kind}: ${chalk.bold(e.name)}`) for (const c of e.changes ?? []) { console.log(` ${chalk.gray(c)}`) } } } // Summary console.log( chalk.bold( `\nSummary: ${chalk.green(`+${added.length}`)} added, ${chalk.yellow(`~${changed.length}`)} changed, ${chalk.red(`-${removed.length}`)} remote only, ${unchanged.length} unchanged`, ), ) }