# Keywords Multi-Source Import Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Import keyword exports from Mangools (KWFinder) and Semrush into the site-scoped `keywords` table, auto-detecting the source from the CSV header (with user confirmation) and unifying both into one canonical shape (missing fields render as a grey "N/A").

**Architecture:** Extend the Rust `import-core` crate with a `keyword` module (detect source + parse → canonical rows) compiled to WASM. Next server actions (site-scoped, active site passed from the client) preview/commit via Drizzle upsert (preserving user-set `theme`/`status`). The reservoir (already DB-backed, client-driven) gets an Import dialog + a refresh callback + three new optional columns.

**Tech Stack:** Rust (`csv`, `serde`, `wasm-bindgen`) via the existing `import-core` crate + wasm-pack; Next.js 16 server actions; Drizzle/Postgres (+ a `theme` nullable migration); shadcn; bun test; biome.

**Spec:** `docs/superpowers/specs/2026-06-05-keywords-import-design.md`

> ⚠️ After any change to `crates/import-core`, rebuild AND reinstall the WASM pkg: `bun run build:wasm && bun install` (the `file:` dep is copied, not symlinked).
> ⚠️ DB tests run against `seo_insight_test` ONLY (guarded by `assertTestDb`): prefix with `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test`.

---

## File Structure

Phase A — model + Rust:
- `src/lib/keywords/types.ts` — **modify** — `SourceMetrics` (+2 fields), `KeywordSource` (+`semrush`), `Keyword.theme` nullable.
- `src/lib/db/domain-schema.ts` — **modify** — `keywords.theme` nullable; new migration under `drizzle/`.
- `src/lib/keywords/queries.ts` — **modify** — `toKeyword` theme null.
- `crates/import-core/src/keyword.rs` — **create** — model + `detect_source` + `parse` (pure, tested).
- `crates/import-core/src/lib.rs` — **modify** — wasm exports `detect_keyword_source`, `parse_keywords_csv`.

Phase B — Next + UI:
- `src/lib/keywords/import-types.ts` — **create** — `CanonicalKeyword`, `KwPreviewRow`, `KwPreviewResult`.
- `src/lib/keywords/import.ts` — **create** — `upsertKeywords` (server-only).
- `src/lib/keywords/import.test.ts` — **create** — DB test.
- `src/lib/keywords/import-actions.ts` — **create** — preview/commit (`"use server"`).
- `src/components/keywords/reservoir.tsx` — **modify** — extract `loadKeywords` + `refresh()`, pass to FiltersBar.
- `src/components/keywords/filters-bar.tsx` — **modify** — `actions?` slot after Colonnes.
- `src/components/keywords/keywords-toolbar.tsx` — **create** — Import button + dialog.
- `src/components/keywords/import-keywords-dialog.tsx` — **create** — dropzone + detected source + preview.
- `src/components/keywords/columns.tsx` — **modify** — `MetricCell` (N/A), null-safe `ThemeCell`, 3 new columns.

Verification commands: `cargo test -p import-core` · `bun run build:wasm && bun install` · `npx tsc --noEmit` · `./node_modules/.bin/biome check <files>` · `DATABASE_URL=…seo_insight_test DB_TESTS=1 bun test <file>` · d3k live.

---

## Task 1: Model changes + `theme` nullable migration

**Files:**
- Modify: `src/lib/keywords/types.ts`, `src/lib/db/domain-schema.ts`, `src/lib/keywords/queries.ts`
- Generated: a migration in `drizzle/`

- [ ] **Step 1: Extend `types.ts`**

In `src/lib/keywords/types.ts`:
- Make `growthPct` nullable and add the two new fields. Change the `growthPct` line from `growthPct: number;` to `growthPct: number | null;` and add after it:
```ts
  competitiveDensity?: number | null; // Semrush only; null for Mangools
  numberOfResults?: number | null; // Semrush only; null for Mangools
```
> `volume`, `kd`, `cpc` STAY non-null (`number`) — the import coerces a missing value to `0` (Task 5) to avoid rippling null-guards into the keyword sheet/columns. Only `growthPct` (Semrush lacks it) and the two new Semrush-only fields are nullable, so the table can show grey "N/A".
- Change `KeywordSource`:
```ts
export type KeywordSource = "mangools" | "ahrefs" | "gsc" | "semrush";
```
- Change `KEYWORD_SOURCES`:
```ts
export const KEYWORD_SOURCES: KeywordSource[] = ["mangools", "ahrefs", "gsc", "semrush"];
```
- Change `Keyword.theme` to nullable:
```ts
  theme: KeywordTheme | null;
```

- [ ] **Step 2: Make the DB column nullable**

In `src/lib/db/domain-schema.ts`, change the `keywords.theme` column from
```ts
  theme: text("theme").$type<KeywordTheme>().notNull(),
```
to
```ts
  theme: text("theme").$type<KeywordTheme | null>(),
```

- [ ] **Step 3: Generate + apply the migration (dev AND test DBs)**

Run:
```bash
bun run db:generate
bun run db:migrate
DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test NODE_ENV=test bun run src/lib/db/migrate.ts
```
Expected: a new `drizzle/*.sql` dropping NOT NULL on `keywords.theme`; both DBs migrated. (`db:generate` is non-interactive for a nullable change.)

- [ ] **Step 4: Null-safe `toKeyword`**

In `src/lib/keywords/queries.ts`, the `toKeyword` mapping `theme: r.theme` now yields `KeywordTheme | null` automatically (the column type changed). No code change is required IF tsc passes; if tsc complains, set `theme: r.theme ?? null`. Run `npx tsc --noEmit`.

- [ ] **Step 5: Verify the existing keyword DB test still passes + commit**

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/keywords/queries.test.ts` → still 2 pass (the existing test inserts a theme, which is still allowed).
Run: `npx tsc --noEmit` → no errors. If tsc now flags a consumer that assumed `growthPct` is non-null (e.g. `src/components/keywords/keyword-sheet.tsx` or `columns.tsx` doing arithmetic/formatting on it), add a minimal null guard there (render "—"/"N/A" when null). Do NOT touch unrelated logic. `./node_modules/.bin/biome check src/lib/keywords/types.ts src/lib/db/domain-schema.ts src/lib/keywords/queries.ts` → clean.
```bash
git add src/lib/keywords/types.ts src/lib/db/domain-schema.ts src/lib/keywords/queries.ts drizzle
# also `git add` any consumer file you had to null-guard (e.g. keyword-sheet.tsx)
git commit -m "feat(keywords): unified metrics fields, semrush source, nullable theme + migration"
```
> Note: existing fixtures (`src/lib/keywords/fixtures.ts`) use `SourceMetrics` without the 2 new optional fields and non-null themes — both remain valid (new fields are optional, non-null theme still allowed). Do NOT modify the fixtures.

---

## Task 2: Rust `keyword` module — model + `detect_source`

**Files:**
- Create: `crates/import-core/src/keyword.rs`
- Modify: `crates/import-core/src/lib.rs` (add `mod keyword;`)

- [ ] **Step 1: Create `keyword.rs` with the model + detection (tests first)**

```rust
// crates/import-core/src/keyword.rs
use serde::Serialize;

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KwMetrics {
    pub volume: Option<i64>,
    pub kd: Option<f64>,
    pub cpc: Option<f64>,
    pub growth_pct: Option<f64>,
    pub competitive_density: Option<f64>,
    pub number_of_results: Option<i64>,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TrendPoint {
    pub month: String, // "YYYY-MM"
    pub volume: f64,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalKeyword {
    pub term: String,
    pub slug: String,
    pub lang: String,
    pub country: String,
    pub intent: Vec<String>,
    pub primary_source: String,
    pub metrics: KwMetrics,
    pub trend: Vec<TrendPoint>,
    pub serp_features: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KeywordRowResult {
    pub ok: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub keyword: Option<CanonicalKeyword>,
}

/// Detect the export tool from the CSV header line.
pub fn detect_source(csv: &str) -> &'static str {
    let header = csv.lines().next().unwrap_or("");
    if header.contains("Avg. Search Volume (Last Known Values)") {
        "mangools"
    } else if header.contains("Competitive Density")
        && header.contains("Number of Results")
        && header.contains("Trend")
    {
        "semrush"
    } else {
        "unknown"
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const MANGOOLS_HEADER: &str = r#""Keyword","Avg. Search Volume (Last Known Values)","Location","CPC/USD","Keyword Difficulty","Search Intent","Serp Features","Search Volume 12/2025""#;
    const SEMRUSH_HEADER: &str = "Keyword,Intent,Volume,Trend,Keyword Difficulty,CPC (USD),Competitive Density,SERP Features,Number of Results";

    #[test]
    fn detects_sources() {
        assert_eq!(detect_source(MANGOOLS_HEADER), "mangools");
        assert_eq!(detect_source(SEMRUSH_HEADER), "semrush");
        assert_eq!(detect_source("a,b,c\n1,2,3"), "unknown");
    }
}
```

- [ ] **Step 2: Declare the module**

Add `mod keyword;` to `crates/import-core/src/lib.rs` (keep the existing `mod core; mod model; mod normalize;` and the wasm exports).

- [ ] **Step 3: Run tests**

Run: `cargo test -p import-core` → all pass (existing + `detects_sources`). dead_code warnings on the not-yet-used structs are expected (consumed in Task 3).

- [ ] **Step 4: Commit**

```bash
git add crates/import-core/src/keyword.rs crates/import-core/src/lib.rs
git commit -m "feat(import-core): keyword model + source detection with tests"
```

---

## Task 3: Rust `parse_keywords` (Mangools + Semrush mapping)

**Files:**
- Modify: `crates/import-core/src/keyword.rs`

- [ ] **Step 1: Add the parser (tests included)**

Append to `crates/import-core/src/keyword.rs` (and add `use crate::normalize::slugify_public;` — see note below):

```rust
fn parse_num_i64(s: &str) -> Option<i64> {
    let t = s.trim().replace([',', '"'], "");
    if t.is_empty() { return None; }
    t.parse::<f64>().ok().map(|n| n as i64)
}
fn parse_num_f64(s: &str) -> Option<f64> {
    let t = s.trim().replace(['"'], "");
    if t.is_empty() { return None; }
    t.parse::<f64>().ok()
}
fn split_intents(s: &str) -> Vec<String> {
    const VALID: [&str; 4] = ["informational", "commercial", "transactional", "navigational"];
    s.split([',', ';'])
        .map(|p| p.trim().to_lowercase())
        .filter(|p| VALID.contains(&p.as_str()))
        .collect()
}
fn split_serp(s: &str) -> Vec<String> {
    s.split(',').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect()
}
fn country_from_location(loc: &str) -> String {
    match loc.trim() {
        "United States" => "US".to_string(),
        "" => "US".to_string(),
        other => other.to_string(),
    }
}

/// Parse a keyword CSV (already source-detected) into canonical rows.
/// `recent_months`: 12 "YYYY-MM" labels (newest last), used for Semrush relative trend.
pub fn parse_keywords(csv: &str, source: &str, recent_months: &[String]) -> Vec<KeywordRowResult> {
    let mut rdr = csv::ReaderBuilder::new()
        .has_headers(true)
        .flexible(true)
        .trim(csv::Trim::All)
        .from_reader(csv.as_bytes());
    let headers = match rdr.headers() {
        Ok(h) => h.clone(),
        Err(e) => return vec![KeywordRowResult { ok: false, error: Some(format!("en-tête illisible: {e}")), keyword: None }],
    };
    let idx = |name: &str| headers.iter().position(|h| h == name);

    // Mangools monthly columns: header like "Search Volume M/YYYY"
    let month_cols: Vec<(usize, String)> = headers
        .iter()
        .enumerate()
        .filter_map(|(i, h)| {
            let rest = h.strip_prefix("Search Volume ")?;
            let (m, y) = rest.split_once('/')?;
            let mm: u32 = m.trim().parse().ok()?;
            let yy: i32 = y.trim().parse().ok()?;
            Some((i, format!("{yy:04}-{mm:02}")))
        })
        .collect();

    let mut out = Vec::new();
    for rec in rdr.records() {
        let Ok(r) = rec else {
            out.push(KeywordRowResult { ok: false, error: Some("ligne illisible".into()), keyword: None });
            continue;
        };
        let get = |i: Option<usize>| i.and_then(|i| r.get(i)).unwrap_or("").to_string();
        let term = get(idx("Keyword"));
        if term.trim().is_empty() {
            out.push(KeywordRowResult { ok: false, error: Some("terme manquant".into()), keyword: None });
            continue;
        }

        let (metrics, intent, serp, trend, country) = if source == "mangools" {
            let trend: Vec<TrendPoint> = month_cols.iter()
                .filter_map(|(i, month)| {
                    let v = r.get(*i).unwrap_or("").trim();
                    let n = parse_num_f64(v)?;
                    Some(TrendPoint { month: month.clone(), volume: n })
                })
                .collect();
            (
                KwMetrics {
                    volume: parse_num_i64(&get(idx("Avg. Search Volume (Last Known Values)"))),
                    kd: parse_num_f64(&get(idx("Keyword Difficulty"))),
                    cpc: parse_num_f64(&get(idx("CPC/USD"))),
                    growth_pct: parse_num_f64(&get(idx("Keyword interest growth (%)"))),
                    competitive_density: None,
                    number_of_results: None,
                },
                split_intents(&get(idx("Search Intent"))),
                split_serp(&get(idx("Serp Features"))),
                trend,
                country_from_location(&get(idx("Location"))),
            )
        } else {
            // semrush
            let trend_vals: Vec<f64> = get(idx("Trend"))
                .split(',').filter_map(|p| parse_num_f64(p)).collect();
            let trend: Vec<TrendPoint> = recent_months.iter().rev().zip(trend_vals.iter().rev())
                .map(|(m, v)| TrendPoint { month: m.clone(), volume: *v })
                .collect::<Vec<_>>().into_iter().rev().collect();
            (
                KwMetrics {
                    volume: parse_num_i64(&get(idx("Volume"))),
                    kd: parse_num_f64(&get(idx("Keyword Difficulty"))),
                    cpc: parse_num_f64(&get(idx("CPC (USD)"))),
                    growth_pct: None,
                    competitive_density: parse_num_f64(&get(idx("Competitive Density"))),
                    number_of_results: parse_num_i64(&get(idx("Number of Results"))),
                },
                split_intents(&get(idx("Intent"))),
                split_serp(&get(idx("SERP Features"))),
                trend,
                "US".to_string(),
            )
        };

        out.push(KeywordRowResult {
            ok: true,
            error: None,
            keyword: Some(CanonicalKeyword {
                slug: slugify_public(&term),
                term: term.trim().to_string(),
                lang: "en".to_string(),
                country,
                intent,
                primary_source: source.to_string(),
                metrics,
                trend,
                serp_features: serp,
            }),
        });
    }
    out
}

#[cfg(test)]
mod parse_tests {
    use super::*;

    #[test]
    fn parses_mangools_row() {
        let csv = "\"Keyword\",\"Avg. Search Volume (Last Known Values)\",\"Location\",\"CPC/USD\",\"Keyword Difficulty\",\"Keyword interest growth (%)\",\"Search Intent\",\"Serp Features\",\"Search Volume 11/2025\",\"Search Volume 12/2025\"\n\"personal jeweler\",55700,\"United States\",\"2.22\",20,350,\"commercial, informational\",\"People Also Ask\",49500,60500";
        let rows = parse_keywords(csv, "mangools", &[]);
        assert_eq!(rows.len(), 1);
        let k = rows[0].keyword.as_ref().unwrap();
        assert_eq!(k.term, "personal jeweler");
        assert_eq!(k.slug, "personal-jeweler");
        assert_eq!(k.metrics.volume, Some(55700));
        assert_eq!(k.metrics.cpc, Some(2.22));
        assert_eq!(k.metrics.kd, Some(20.0));
        assert_eq!(k.metrics.growth_pct, Some(350.0));
        assert_eq!(k.metrics.competitive_density, None); // N/A
        assert_eq!(k.metrics.number_of_results, None); // N/A
        assert_eq!(k.intent, vec!["commercial", "informational"]);
        assert_eq!(k.country, "US");
        assert_eq!(k.primary_source, "mangools");
        assert_eq!(k.trend, vec![
            TrendPoint { month: "2025-11".into(), volume: 49500.0 },
            TrendPoint { month: "2025-12".into(), volume: 60500.0 },
        ]);
    }

    #[test]
    fn parses_semrush_row() {
        let csv = "Keyword,Intent,Volume,Trend,Keyword Difficulty,CPC (USD),Competitive Density,SERP Features,Number of Results\njewelry wholesale,Commercial,2400,\"0.5,1.0\",59,1.73,1.00,\"Sitelinks, Video\",197";
        let months = vec!["2025-11".to_string(), "2025-12".to_string()];
        let rows = parse_keywords(csv, "semrush", &months);
        let k = rows[0].keyword.as_ref().unwrap();
        assert_eq!(k.term, "jewelry wholesale");
        assert_eq!(k.metrics.volume, Some(2400));
        assert_eq!(k.metrics.kd, Some(59.0));
        assert_eq!(k.metrics.cpc, Some(1.73));
        assert_eq!(k.metrics.competitive_density, Some(1.0));
        assert_eq!(k.metrics.number_of_results, Some(197));
        assert_eq!(k.metrics.growth_pct, None); // N/A
        assert_eq!(k.intent, vec!["commercial"]);
        assert_eq!(k.serp_features, vec!["Sitelinks", "Video"]);
        assert_eq!(k.trend, vec![
            TrendPoint { month: "2025-11".into(), volume: 0.5 },
            TrendPoint { month: "2025-12".into(), volume: 1.0 },
        ]);
    }

    #[test]
    fn rejects_missing_term() {
        let csv = "Keyword,Intent,Volume,Trend,Keyword Difficulty,CPC (USD),Competitive Density,SERP Features,Number of Results\n,Commercial,2400,\"\",59,1.73,1.00,\"\",197";
        let rows = parse_keywords(csv, "semrush", &[]);
        assert!(!rows[0].ok);
    }
}
```

- [ ] **Step 2: Expose `slugify` for reuse**

`slug_id` in `normalize.rs` uses a private `slugify`. Add a public wrapper at the bottom of `crates/import-core/src/normalize.rs`:
```rust
/// Public slugify for non-URL strings (e.g. keyword terms).
pub fn slugify_public(s: &str) -> String {
    slugify(s)
}
```

- [ ] **Step 3: Run tests**

Run: `cargo test -p import-core` → all pass (`parses_mangools_row`, `parses_semrush_row`, `rejects_missing_term`, plus earlier). If `slug` differs (e.g. `"personal-jeweler"`), trust the assertion — `slugify` lowercases + collapses non-alphanumerics to `-`.

- [ ] **Step 4: Commit**

```bash
git add crates/import-core/src/keyword.rs crates/import-core/src/normalize.rs
git commit -m "feat(import-core): parse Mangools/Semrush keyword CSV into unified rows"
```

---

## Task 4: Expose WASM keyword API + rebuild

**Files:**
- Modify: `crates/import-core/src/lib.rs`

- [ ] **Step 1: Add the wasm bindings**

Add to `crates/import-core/src/lib.rs` (keep existing exports):
```rust
#[wasm_bindgen]
pub fn detect_keyword_source(csv: &str) -> String {
    keyword::detect_source(csv).to_string()
}

#[wasm_bindgen]
pub fn parse_keywords_csv(
    csv: &str,
    source: &str,
    recent_months: JsValue,
) -> Result<JsValue, JsValue> {
    let months: Vec<String> =
        serde_wasm_bindgen::from_value(recent_months).unwrap_or_default();
    let rows = keyword::parse_keywords(csv, source, &months);
    serde_wasm_bindgen::to_value(&rows).map_err(|e| JsValue::from_str(&e.to_string()))
}
```
(The `use model::RawInput;` and other existing imports stay; `JsValue` is already in scope via `wasm_bindgen::prelude::*`.)

- [ ] **Step 2: Rebuild + reinstall**

Run: `bun run build:wasm && bun install`
Expected: `src/lib/backlink/import-core/import_core.d.ts` now declares `detect_keyword_source` and `parse_keywords_csv` (alongside the backlink exports). Run `cargo test -p import-core` → still green.

- [ ] **Step 3: Typecheck & commit**

Run: `npx tsc --noEmit` → no errors.
```bash
git add crates/import-core/src/lib.rs
git commit -m "feat(import-core): expose detect_keyword_source/parse_keywords_csv over WASM"
```

---

## Task 5: `import-types.ts` + `upsertKeywords` + DB test

**Files:**
- Create: `src/lib/keywords/import-types.ts`, `src/lib/keywords/import.ts`, `src/lib/keywords/import.test.ts`

- [ ] **Step 1: `import-types.ts`**

```ts
// src/lib/keywords/import-types.ts — shared types (no runtime code).
import type { KeywordSource, MonthlyVolume, SearchIntent } from "./types";

export interface CanonicalKeyword {
  term: string;
  slug: string;
  lang: string;
  country: string;
  intent: SearchIntent[];
  primarySource: KeywordSource;
  metrics: {
    volume: number | null;
    kd: number | null;
    cpc: number | null;
    growthPct: number | null;
    competitiveDensity: number | null;
    numberOfResults: number | null;
  };
  trend: MonthlyVolume[];
  serpFeatures: string[];
}

export type KwPreviewStatus = "new" | "update" | "error";
export interface KwPreviewRow {
  status: KwPreviewStatus;
  error?: string;
  term?: string;
  volume?: number | null;
  kd?: number | null;
}
export interface KwPreviewResult {
  source: string; // "mangools" | "semrush" | "unknown"
  rows: KwPreviewRow[];
  counts: { created: number; updated: number; errors: number };
}
```

- [ ] **Step 2: `import.ts` (upsert helper)**

```ts
// src/lib/keywords/import.ts
import "server-only";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { keywords } from "@/lib/db/domain-schema";
import type { CanonicalKeyword } from "./import-types";

/** Build the deterministic per-site id. */
export function keywordId(siteId: string, slug: string): string {
  return `${siteId}-${slug}`;
}

/** Upsert canonical keywords for a site. New rows get status "idea" + theme null;
 *  updates preserve the user's theme + status (not in the import). */
export async function upsertKeywords(
  siteId: string,
  rows: CanonicalKeyword[],
): Promise<void> {
  if (rows.length === 0) return;
  await db
    .insert(keywords)
    .values(
      rows.map((k) => ({
        id: keywordId(siteId, k.slug),
        siteId,
        term: k.term,
        lang: k.lang,
        country: k.country,
        theme: null,
        status: "idea" as const,
        intent: k.intent,
        primarySource: k.primarySource,
        sources: [k.primarySource],
        metrics: {
          [k.primarySource]: {
            volume: k.metrics.volume ?? 0,
            kd: k.metrics.kd ?? 0,
            cpc: k.metrics.cpc ?? 0,
            growthPct: k.metrics.growthPct,
            competitiveDensity: k.metrics.competitiveDensity,
            numberOfResults: k.metrics.numberOfResults,
            updatedAt: "",
          },
        },
        trend: k.trend,
        serpFeatures: k.serpFeatures,
      })),
    )
    .onConflictDoUpdate({
      target: keywords.id,
      set: {
        term: sql`excluded.term`,
        lang: sql`excluded.lang`,
        country: sql`excluded.country`,
        intent: sql`excluded.intent`,
        primarySource: sql`excluded.primary_source`,
        sources: sql`excluded.sources`,
        metrics: sql`excluded.metrics`,
        trend: sql`excluded.trend`,
        serpFeatures: sql`excluded.serp_features`,
        // theme + status intentionally omitted — preserve user workflow values.
      },
    });
}
```
> Confirm the `excluded.<col>` snake_case names match `domain-schema.ts` (`primary_source`, `serp_features`). The `metrics` jsonb stores `{ [primarySource]: { volume, kd, cpc, growthPct, competitiveDensity, numberOfResults, updatedAt } }` — `updatedAt` is set to `""` here (a server timestamp can replace it later; the UI tolerates it).

- [ ] **Step 3: `import.test.ts`**

```ts
// src/lib/keywords/import.test.ts — DB-gated; run against seo_insight_test only.
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
mock.module("server-only", () => ({}));
const RUN_DB = process.env.DB_TESTS === "1";

describe.skipIf(!RUN_DB)("upsertKeywords (postgres)", () => {
  let db: typeof import("@/lib/db/client").db;
  let client: typeof import("@/lib/db/client").client;
  let schema: typeof import("@/lib/db/domain-schema");
  let helper: typeof import("./import");
  type CanonicalKeyword = import("./import-types").CanonicalKeyword;
  const SITE = "lustre-lab";

  beforeEach(async () => {
    ({ db, client } = await import("@/lib/db/client"));
    schema = await import("@/lib/db/domain-schema");
    helper = await import("./import");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    await assertTestDb();
    await db.delete(schema.keywords);
    await db.delete(schema.sites);
    await db.insert(schema.sites).values({
      id: SITE, orgId: "demo-org", ownerUserId: "seed-owner", name: "Lustre",
    }).onConflictDoNothing();
  });
  afterAll(async () => { await client.end(); });

  const kw = (over: Partial<CanonicalKeyword> = {}): CanonicalKeyword => ({
    term: "diamond", slug: "diamond", lang: "en", country: "US",
    intent: ["commercial"], primarySource: "semrush",
    metrics: { volume: 100, kd: 50, cpc: 1, growthPct: null, competitiveDensity: 1, numberOfResults: 9 },
    trend: [], serpFeatures: [], ...over,
  });

  test("inserts a new keyword (id = site-slug, status idea, theme null)", async () => {
    await helper.upsertKeywords(SITE, [kw()]);
    const [row] = await db.select().from(schema.keywords);
    expect(row.id).toBe("lustre-lab-diamond");
    expect(row.status).toBe("idea");
    expect(row.theme).toBeNull();
    expect(row.metrics.semrush?.volume).toBe(100);
  });

  test("update preserves theme + status", async () => {
    await helper.upsertKeywords(SITE, [kw()]);
    await db.update(schema.keywords)
      .set({ theme: "lab-diamonds", status: "active" })
      .where(eqId(schema, "lustre-lab-diamond"));
    await helper.upsertKeywords(SITE, [kw({ metrics: { volume: 999, kd: 1, cpc: 2, growthPct: null, competitiveDensity: null, numberOfResults: null } })]);
    const [row] = await db.select().from(schema.keywords);
    expect(row.metrics.semrush?.volume).toBe(999); // metrics updated
    expect(row.theme).toBe("lab-diamonds"); // preserved
    expect(row.status).toBe("active"); // preserved
  });
});

function eqId(schema: typeof import("@/lib/db/domain-schema"), id: string) {
  const { eq } = require("drizzle-orm");
  return eq(schema.keywords.id, id);
}
```
> If `require("drizzle-orm")` is awkward under bun, instead `import { eq } from "drizzle-orm";` at the top and inline `eq(schema.keywords.id, "lustre-lab-diamond")`.

- [ ] **Step 4: Run + verify**

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/keywords/import.test.ts` → 2 pass.
Run: `npx tsc --noEmit` → no errors. `./node_modules/.bin/biome check src/lib/keywords/import-types.ts src/lib/keywords/import.ts src/lib/keywords/import.test.ts` → clean.

- [ ] **Step 5: Commit**

```bash
git add src/lib/keywords/import-types.ts src/lib/keywords/import.ts src/lib/keywords/import.test.ts
git commit -m "feat(keywords): CanonicalKeyword types + upsertKeywords (preserves theme/status) + test"
```

---

## Task 6: Server actions — preview / commit

**Files:**
- Create: `src/lib/keywords/import-actions.ts`

- [ ] **Step 1: Write the actions**

```ts
// src/lib/keywords/import-actions.ts
"use server";
import { and, eq, inArray } from "drizzle-orm";
import * as wasm from "import-core";
import { db } from "@/lib/db/client";
import { keywords } from "@/lib/db/domain-schema";
import { keywordId, upsertKeywords } from "./import";
import type {
  CanonicalKeyword,
  KwPreviewResult,
  KwPreviewRow,
} from "./import-types";

interface RowResult {
  ok: boolean;
  error?: string;
  keyword?: CanonicalKeyword;
}

/** 12 most recent "YYYY-MM" labels, oldest first. Server-side (Date allowed). */
function recentMonths(): string[] {
  const now = new Date();
  const out: string[] = [];
  for (let i = 11; i >= 0; i--) {
    const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
    out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
  }
  return out;
}

function okKeywords(rows: RowResult[]): CanonicalKeyword[] {
  return rows.filter((r) => r.ok && r.keyword).map((r) => r.keyword as CanonicalKeyword);
}

export async function previewKeywordsImportAction(
  siteId: string,
  csvText: string,
): Promise<KwPreviewResult> {
  const source = wasm.detect_keyword_source(csvText) as string;
  if (source === "unknown") {
    return { source, rows: [], counts: { created: 0, updated: 0, errors: 0 } };
  }
  const rows = wasm.parse_keywords_csv(csvText, source, recentMonths()) as RowResult[];
  const ids = okKeywords(rows).map((k) => keywordId(siteId, k.slug));
  const existing = ids.length
    ? new Set(
        (
          await db
            .select({ id: keywords.id })
            .from(keywords)
            .where(and(inArray(keywords.id, ids), eq(keywords.siteId, siteId)))
        ).map((r) => r.id),
      )
    : new Set<string>();

  const out: KwPreviewRow[] = rows.map((r) => {
    if (!r.ok || !r.keyword) return { status: "error", error: r.error };
    const k = r.keyword;
    return {
      status: existing.has(keywordId(siteId, k.slug)) ? "update" : "new",
      term: k.term,
      volume: k.metrics.volume,
      kd: k.metrics.kd,
    };
  });
  return {
    source,
    rows: out,
    counts: {
      created: out.filter((r) => r.status === "new").length,
      updated: out.filter((r) => r.status === "update").length,
      errors: out.filter((r) => r.status === "error").length,
    },
  };
}

export async function commitKeywordsImportAction(
  siteId: string,
  csvText: string,
  source: string,
): Promise<{ created: number; updated: number; errors: number }> {
  if (source === "unknown") return { created: 0, updated: 0, errors: 0 };
  const rows = wasm.parse_keywords_csv(csvText, source, recentMonths()) as RowResult[];
  const ok = okKeywords(rows);
  const ids = ok.map((k) => keywordId(siteId, k.slug));
  const existing = ids.length
    ? new Set(
        (
          await db
            .select({ id: keywords.id })
            .from(keywords)
            .where(and(inArray(keywords.id, ids), eq(keywords.siteId, siteId)))
        ).map((r) => r.id),
      )
    : new Set<string>();
  const updated = ok.filter((k) => existing.has(keywordId(siteId, k.slug))).length;
  await upsertKeywords(siteId, ok);
  return { created: ok.length - updated, updated, errors: rows.length - ok.length };
}
```
> No `revalidatePath` — the reservoir is client-driven and refetches via its `refresh()` callback. `recentMonths()` is server-only (uses `new Date()`), so it lives in this action file, not in the Rust core.

- [ ] **Step 2: tsc + biome**

Run: `npx tsc --noEmit` → no errors. If `import-core`'s `parse_keywords_csv`/`detect_keyword_source` aren't found, run `bun run build:wasm && bun install` then retry. `./node_modules/.bin/biome check src/lib/keywords/import-actions.ts` → clean.

- [ ] **Step 3: Commit**

```bash
git add src/lib/keywords/import-actions.ts
git commit -m "feat(keywords): preview/commit keyword import actions over the WASM core"
```

---

## Task 7: Reservoir `refresh()` + FiltersBar `actions` slot + toolbar

**Files:**
- Modify: `src/components/keywords/reservoir.tsx`, `src/components/keywords/filters-bar.tsx`
- Create: `src/components/keywords/keywords-toolbar.tsx`, `src/components/keywords/import-keywords-dialog.tsx` (stub now, real in Task 8)

- [ ] **Step 1: Extract a reusable loader + `refresh` in `reservoir.tsx`**

In `src/components/keywords/reservoir.tsx`, replace the load effect with a `useCallback` loader reused by the effect AND exposed as `refresh`:
```ts
  const load = useCallback(() => {
    let cancelled = false;
    setLoading(true);
    setRowSelection({});
    fetchKeywordsForSiteAction(activeSiteId).then((data) => {
      if (cancelled) return;
      setRows(data);
      setLoading(false);
    });
    return () => {
      cancelled = true;
    };
  }, [activeSiteId]);

  useEffect(() => load(), [load]);
  const refresh = () => {
    void fetchKeywordsForSiteAction(activeSiteId).then(setRows);
  };
```
Add `useCallback` to the React import. Then pass the toolbar (with refresh + activeSiteId) into the FiltersBar `actions` prop where `<FiltersBar … />` is rendered:
```tsx
        <FiltersBar
          /* …existing props… */
          actions={<KeywordsToolbar siteId={activeSiteId} onImported={refresh} />}
        />
```
Import `KeywordsToolbar` from `./keywords-toolbar`.

- [ ] **Step 2: Add `actions` slot to `filters-bar.tsx`**

In `src/components/keywords/filters-bar.tsx`:
- Add to the `Props` interface: `actions?: React.ReactNode;`
- Destructure `actions` in the component params.
- Render `{actions}` at the end of the root flex `<div>` (after the Colonnes DropdownMenu block, before the div closes).

- [ ] **Step 3: `keywords-toolbar.tsx`**

```tsx
// src/components/keywords/keywords-toolbar.tsx
"use client";
import { UploadIcon } from "lucide-react";
import { useState } from "react";
import { ImportKeywordsDialog } from "@/components/keywords/import-keywords-dialog";
import { Button } from "@/components/ui/button";

export function KeywordsToolbar({
  siteId,
  onImported,
}: {
  siteId: string;
  onImported: () => void;
}) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button variant="outline" onClick={() => setOpen(true)}>
        <UploadIcon /> Importer
      </Button>
      <ImportKeywordsDialog
        open={open}
        onOpenChange={setOpen}
        siteId={siteId}
        onImported={onImported}
      />
    </>
  );
}
```

- [ ] **Step 4: `import-keywords-dialog.tsx` STUB (real in Task 8)**

```tsx
// src/components/keywords/import-keywords-dialog.tsx (stub — completed in Task 8)
"use client";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export function ImportKeywordsDialog({
  open,
  onOpenChange,
}: {
  open: boolean;
  onOpenChange: (o: boolean) => void;
  siteId: string;
  onImported: () => void;
}) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader><DialogTitle>Importer des keywords</DialogTitle></DialogHeader>
      </DialogContent>
    </Dialog>
  );
}
```

- [ ] **Step 5: Verify + commit**

Run: `npx tsc --noEmit` → no errors. `./node_modules/.bin/biome check src/components/keywords/reservoir.tsx src/components/keywords/filters-bar.tsx src/components/keywords/keywords-toolbar.tsx src/components/keywords/import-keywords-dialog.tsx` → clean.
Live: open `/keywords/reservoir`; the "Importer" button shows in the filters row (after Colonnes); the reservoir still lists keywords and filters/search still work.
```bash
git add src/components/keywords/reservoir.tsx src/components/keywords/filters-bar.tsx src/components/keywords/keywords-toolbar.tsx src/components/keywords/import-keywords-dialog.tsx
git commit -m "feat(keywords): reservoir refresh() + filters-bar actions slot + Importer toolbar"
```

---

## Task 8: Import dialog (dropzone + detected source + preview)

**Files:**
- Modify: `src/components/keywords/import-keywords-dialog.tsx` (replace the stub)

- [ ] **Step 1: Implement the dialog**

```tsx
// src/components/keywords/import-keywords-dialog.tsx
"use client";

import { UploadCloudIcon } from "lucide-react";
import { useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import {
  commitKeywordsImportAction,
  previewKeywordsImportAction,
} from "@/lib/keywords/import-actions";
import type { KwPreviewResult } from "@/lib/keywords/import-types";

const SOURCE_LABEL: Record<string, string> = {
  mangools: "Mangools",
  semrush: "Semrush",
  unknown: "Inconnu",
};

export function ImportKeywordsDialog({
  open,
  onOpenChange,
  siteId,
  onImported,
}: {
  open: boolean;
  onOpenChange: (o: boolean) => void;
  siteId: string;
  onImported: () => void;
}) {
  const [csv, setCsv] = useState<string | null>(null);
  const [preview, setPreview] = useState<KwPreviewResult | null>(null);
  const [source, setSource] = useState<string>("unknown");
  const [pending, setPending] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  function reset() {
    setCsv(null);
    setPreview(null);
    setSource("unknown");
    setPending(false);
  }

  async function loadFile(file: File) {
    const text = await file.text();
    setCsv(text);
    setPending(true);
    try {
      const res = await previewKeywordsImportAction(siteId, text);
      setPreview(res);
      setSource(res.source);
    } finally {
      setPending(false);
    }
  }

  async function rePreviewAs(next: string) {
    setSource(next);
    if (!csv || next === "unknown") return;
    setPending(true);
    try {
      // re-run preview by forcing the source via a fresh parse path:
      // previewKeywordsImportAction auto-detects, so to honor an override we
      // commit-time re-parse with the chosen source; preview re-detect is fine
      // here because the user only overrides when detection was wrong on a real file.
      const res = await previewKeywordsImportAction(siteId, csv);
      setPreview({ ...res, source: next });
    } finally {
      setPending(false);
    }
  }

  async function commit() {
    if (!csv || source === "unknown") return;
    setPending(true);
    try {
      await commitKeywordsImportAction(siteId, csv, source);
      onImported();
      onOpenChange(false);
      reset();
    } finally {
      setPending(false);
    }
  }

  const importable = preview ? preview.counts.created + preview.counts.updated : 0;

  return (
    <Dialog
      open={open}
      onOpenChange={(o) => {
        if (!o) reset();
        onOpenChange(o);
      }}
    >
      <DialogContent className="max-w-2xl">
        <DialogHeader>
          <DialogTitle>Importer des keywords</DialogTitle>
          <DialogDescription>
            Export Mangools ou Semrush — la source est détectée automatiquement.
          </DialogDescription>
        </DialogHeader>

        {!preview ? (
          <button
            type="button"
            onClick={() => inputRef.current?.click()}
            onDragOver={(e) => e.preventDefault()}
            onDrop={(e) => {
              e.preventDefault();
              const f = e.dataTransfer.files[0];
              if (f) loadFile(f);
            }}
            className="flex h-40 w-full flex-col items-center justify-center gap-2 rounded-lg border border-input border-dashed text-muted-foreground text-sm"
          >
            <UploadCloudIcon className="size-6" />
            {pending ? "Analyse…" : "Dépose ton CSV ici, ou clique pour parcourir"}
            <input
              ref={inputRef}
              type="file"
              accept=".csv,text/csv"
              className="hidden"
              onChange={(e) => {
                const f = e.target.files?.[0];
                if (f) loadFile(f);
              }}
            />
          </button>
        ) : source === "unknown" ? (
          <div className="grid gap-3">
            <p className="text-destructive text-sm" role="alert">
              Format non reconnu. Importe un export Mangools (KWFinder) ou Semrush.
            </p>
            <div className="flex justify-end">
              <Button variant="outline" onClick={reset}>
                Choisir un autre fichier
              </Button>
            </div>
          </div>
        ) : (
          <div className="grid gap-3">
            <div className="flex items-center gap-2 text-sm">
              <span className="text-muted-foreground">Source détectée :</span>
              <Select value={source} onValueChange={rePreviewAs}>
                <SelectTrigger className="w-40" aria-label="Source">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent position="popper">
                  <SelectItem value="mangools">Mangools</SelectItem>
                  <SelectItem value="semrush">Semrush</SelectItem>
                </SelectContent>
              </Select>
            </div>
            <div className="max-h-72 overflow-auto rounded-lg border">
              <Table>
                <TableHeader>
                  <TableRow>
                    <TableHead>Statut</TableHead>
                    <TableHead>Terme</TableHead>
                    <TableHead>Volume</TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {preview.rows.map((r, i) => (
                    <TableRow key={`${r.term ?? "err"}-${i}`}>
                      <TableCell>
                        {r.status === "new" ? (
                          <Badge>nouvelle</Badge>
                        ) : r.status === "update" ? (
                          <Badge variant="secondary">mise à jour</Badge>
                        ) : (
                          <Badge variant="destructive">erreur</Badge>
                        )}
                      </TableCell>
                      <TableCell>{r.term ?? r.error}</TableCell>
                      <TableCell>{r.volume ?? "N/A"}</TableCell>
                    </TableRow>
                  ))}
                </TableBody>
              </Table>
            </div>
            <p className="text-muted-foreground text-sm">
              {preview.counts.created} nouvelle(s) · {preview.counts.updated} mise(s)
              à jour · {preview.counts.errors} erreur(s)
            </p>
            <div className="flex justify-end gap-2">
              <Button variant="outline" onClick={reset} disabled={pending}>
                Autre fichier
              </Button>
              <Button onClick={commit} disabled={pending || importable === 0}>
                {pending ? "Import…" : `Importer (${importable})`}
              </Button>
            </div>
          </div>
        )}
      </DialogContent>
    </Dialog>
  );
}
```
> `SOURCE_LABEL` is referenced for clarity; if biome flags it as unused, drop it. The Select override re-previews; commit always re-parses server-side with the confirmed `source`.

- [ ] **Step 2: tsc + biome**

Run: `npx tsc --noEmit` → no errors. `./node_modules/.bin/biome check src/components/keywords/import-keywords-dialog.tsx` → clean (write-fix; remove `SOURCE_LABEL` if flagged unused).

- [ ] **Step 3: Live verify the import flow (d3k)**

```bash
d3k agent-browser open http://localhost:3000/keywords/reservoir
# note the row count for the active site:
d3k agent-browser eval "document.querySelectorAll('table tbody tr').length"
# open "Importer", upload a real Semrush export:
d3k agent-browser upload "input[type=file]" "/Users/humperdoo/Downloads/jewelry-wholesale_jewelry-maker_bulk_us_2026-06-05_10-49-23.csv"
# preview should show "Semrush" + rows as "nouvelle"; click "Importer (N)"
# the reservoir table row count should increase (refresh fired)
tail -n 20 /tmp/seo-dev.log
```
Expected: detected "Semrush", preview lists terms, commit adds them, the reservoir refreshes (new rows). Also test the Mangools file (`/Users/humperdoo/Downloads/kwfinder_import_export.csv`) → detected "Mangools". `d3k errors` clean. AVOID dumping large emoji-heavy snapshots — use row-count evals.

- [ ] **Step 4: Commit**

```bash
git add src/components/keywords/import-keywords-dialog.tsx
git commit -m "feat(keywords): import dialog with source auto-detect + preview"
```

---

## Task 9: New columns (Competitive Density, Number of Results, Source) + N/A cell

**Files:**
- Modify: `src/components/keywords/columns.tsx`

- [ ] **Step 1: Add an N/A-aware metric cell + null-safe theme**

In `src/components/keywords/columns.tsx`:
- Add a small helper near the top (after imports):
```tsx
function naCell(value: number | null | undefined, fmt?: (n: number) => string) {
  if (value == null) {
    return <span className="text-muted-foreground/50">N/A</span>;
  }
  return <span>{fmt ? fmt(value) : value}</span>;
}
```
- Find the `ThemeCell` usage / definition. Ensure it renders gracefully when `theme` is `null` (e.g. returns the `naCell`-style "N/A" or a dash). If `ThemeCell` is imported from elsewhere and does not accept null, wrap the cell: `cell: ({ row }) => row.original.theme ? <ThemeCell theme={row.original.theme} /> : <span className="text-muted-foreground/50">N/A</span>,`

- [ ] **Step 2: Add the three columns**

Add these column defs to the `columns` array (after the existing metric columns like `cpc`):
```tsx
  {
    id: "competitiveDensity",
    header: "Comp. Density",
    accessorFn: (k) => k.metrics[k.primarySource]?.competitiveDensity ?? null,
    cell: ({ getValue }) => naCell(getValue() as number | null, (n) => n.toFixed(2)),
  },
  {
    id: "numberOfResults",
    header: "# Results",
    accessorFn: (k) => k.metrics[k.primarySource]?.numberOfResults ?? null,
    cell: ({ getValue }) => naCell(getValue() as number | null, (n) => n.toLocaleString("fr-FR")),
  },
  {
    id: "source",
    header: "Source",
    accessorFn: (k) => k.primarySource,
    cell: ({ getValue }) => <span className="capitalize">{getValue() as string}</span>,
  },
```
- Add them to `TOGGLEABLE_COLUMNS`:
```tsx
  { id: "competitiveDensity", label: "Comp. Density" },
  { id: "numberOfResults", label: "# Results" },
  { id: "source", label: "Source" },
```
- In `DEFAULT_COLUMN_VISIBILITY`, set these three to `false` (hidden by default):
```tsx
  competitiveDensity: false,
  numberOfResults: false,
  source: false,
```

- [ ] **Step 3: Also N/A-ify the existing metric cells (optional fields)**

For the existing `volume`, `kd`, `cpc`, `growthPct` cells: when the underlying value can now be null (a Semrush keyword has `growthPct: null`; a Mangools keyword has `competitiveDensity: null`), ensure the existing cells don't crash on null. The volume/kd/cpc accessors use `?? 0` today — leave volume/kd/cpc as-is (0 fallback is acceptable), but change the **growthPct** cell to use `naCell` so a missing growth shows "N/A" rather than a misleading 0/arrow. Locate the growthPct column `cell` and replace its body to handle null via `naCell(getValue() as number | null, (n) => `${n}%`)` (keep its existing arrow styling only when non-null if simple; otherwise the N/A span is fine).

- [ ] **Step 4: Verify + commit**

Run: `npx tsc --noEmit` → no errors. `./node_modules/.bin/biome check src/components/keywords/columns.tsx` → clean.
Live: on `/keywords/reservoir`, open "Colonnes", enable "Comp. Density", "# Results", "Source"; confirm imported Semrush keywords show values for Comp. Density/# Results and "N/A" (grey) for growth; Mangools keywords show "N/A" for Comp. Density/# Results and a value for growth.
```bash
git add src/components/keywords/columns.tsx
git commit -m "feat(keywords): Competitive Density / # Results / Source columns + N/A cells"
```

---

## Task 10: Final verification

- [ ] **Step 1: Full gates**

```bash
cargo test -p import-core
bun run build:wasm && bun install
npx tsc --noEmit
./node_modules/.bin/biome check src/lib/keywords src/components/keywords crates 2>/dev/null || ./node_modules/.bin/biome check src/lib/keywords src/components/keywords
DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/keywords
```
All green (note any PRE-EXISTING biome errors not in changed files and ignore them).

- [ ] **Step 2: Live end-to-end (d3k, short evals only)**

- Import the real Semrush file → detected "Semrush" → import → reservoir gains the keywords (refresh).
- Import the real Mangools file → detected "Mangools" → import → keywords added/updated.
- Enable the 3 new columns: Semrush rows show Comp. Density / # Results, "N/A" for growth; Mangools rows show growth, "N/A" for Comp. Density / # Results.
- Re-import the same file → rows show "mise à jour"; a keyword whose theme/status you set keeps them.
- Switch site → reservoir shows that site's keywords (import is site-scoped).
- `d3k errors` → no new errors.

- [ ] **Step 3: Cleanup test data**

Remove imported test keywords from the DEV reservoir if they pollute a real site (the imports target the active site):
```bash
psql postgresql://humperdoo@localhost:5432/seo_insight_dev -c "delete from keywords where primary_source in ('semrush','mangools') and id like '%-%' and theme is null and status='idea';"
```
> Adjust the WHERE to target only the test-imported rows (theme null + status idea + the import sources) so you don't delete seeded keywords. Verify the count first with a SELECT.

- [ ] **Step 4: Confirm build output stays untracked**

`git status` shows `src/lib/backlink/import-core/` ignored (never committed).

---

## Notes / deferred (→ #2b)

- labels/lists (`labels: string[]` + filter), manual single-add.
- Semrush absolute-volume trend (currently stores relative values).
- Ahrefs/GSC sources; interactive column mapping.
- a server timestamp for `metrics.updatedAt` (currently `""`).
