# Contenu — import-export CSV 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:** round-trip CSV des `content_items` sur `/content/planning` — export toutes colonnes ré-importable, import preview→commit (R2) avec matching par titre et résolution des mots-clés par terme.

**Architecture:** logique pure (`parseCsv`/`buildContentCsv`/`parseContentRows`, TDD), deux actions serveur tenancy-gardées (preview/commit), boutons + dialog sur la toolbar du content-board. Zéro migration.

**Tech Stack:** Next 16, Drizzle/Postgres, bun:test, shadcn Dialog/Table.

**Spec:** `docs/superpowers/specs/2026-06-10-content-csv-import-export-design.md` (lis-la). **Branche:** `feature/content-csv-import-export` (créée). Ne JAMAIS toucher `docs/project/bug-fix.md`. Tests via `bun run test <path>` uniquement. Gate final : `bun run test:all`.

**Patterns à lire avant de coder :**
- `src/lib/keywords/export-full.ts` — `csvField` (réutiliser par import), BOM download.
- `src/lib/content/actions.ts:69-99` — création d'item (`randomUUID`, défauts), lignes 130-185 — écritures `content_keywords` (roles primary/secondary, sync `primaryKeywordId`).
- `src/lib/content/content.test.ts` — pattern de test DB du répertoire (mock server-only + assert-site permissif, `DB_TESTS=1`, `assertTestDb`).
- `src/components/content/content-board.tsx` — toolbar (boutons Kanban/Tableau/+ Nouvel article), callback `load`/`onCreated`.

---

### Task 1: Logique pure `src/lib/content/csv.ts` (TDD)

**Files:** Create `src/lib/content/csv.ts` + `src/lib/content/csv.test.ts`

- [ ] **Step 1 — tests qui échouent** (`bun run test src/lib/content/csv.test.ts` → FAIL) :

```ts
// src/lib/content/csv.test.ts
import { expect, test } from "bun:test";
import { buildContentCsv, parseContentRows, parseCsv } from "./csv";

test("parseCsv: champs simples, quotés, quotes échappées, retour ligne quoté, CRLF", () => {
  expect(parseCsv("a,b\nc,d")).toEqual([["a", "b"], ["c", "d"]]);
  expect(parseCsv('a,"b,c"\r\nd,"e ""f"""')).toEqual([
    ["a", "b,c"],
    ["d", 'e "f"'],
  ]);
  expect(parseCsv('"l1\nl2",x')).toEqual([["l1\nl2", "x"]]);
  expect(parseCsv("")).toEqual([]);
  expect(parseCsv("a,b\n")).toEqual([["a", "b"]]); // ligne vide finale ignorée
});

test("buildContentCsv: en-têtes, échappement, champs vides", () => {
  const csv = buildContentCsv([
    {
      title: "Guide, complet",
      status: "writing",
      primaryKeywordTerm: "audit énergie",
      secondaryKeywordTerms: ["a", "b"],
      lang: "fr",
      country: "FR",
      assignee: null,
      dueDate: "2026-07-01",
      url: null,
    },
  ]);
  const [header, row] = csv.trim().split("\n");
  expect(header).toBe(
    "Titre,Statut,Mot-clé principal,Mots-clés secondaires,Langue,Pays,Assigné,Échéance,URL",
  );
  expect(row).toBe('"Guide, complet",writing,audit énergie,a|b,fr,FR,,2026-07-01,');
});

test("round-trip: build → parse → parseContentRows", () => {
  const csv = buildContentCsv([
    {
      title: "Page X",
      status: "brief",
      primaryKeywordTerm: "kw",
      secondaryKeywordTerms: [],
      lang: "fr",
      country: "FR",
      assignee: "bob",
      dueDate: null,
      url: "https://a.fr/x",
    },
  ]);
  const { rows, errors } = parseContentRows(parseCsv(csv));
  expect(errors).toEqual([]);
  expect(rows).toEqual([
    {
      title: "Page X",
      status: "brief",
      primaryKeywordTerm: "kw",
      secondaryKeywordTerms: [],
      lang: "fr",
      country: "FR",
      assignee: "bob",
      dueDate: null,
      url: "https://a.fr/x",
    },
  ]);
});

test("parseContentRows: en-têtes insensibles casse/ordre, colonnes inconnues ignorées, défauts", () => {
  const { rows, errors } = parseContentRows([
    ["statut", "TITRE", "bidule"],
    ["", "Mon titre", "x"],
  ]);
  expect(errors).toEqual([]);
  expect(rows[0].title).toBe("Mon titre");
  expect(rows[0].status).toBe("idea"); // statut vide → idea
  expect(rows[0].primaryKeywordTerm).toBe(null);
});

test("parseContentRows: erreurs — titre requis, statut invalide, date invalide", () => {
  const { rows, errors } = parseContentRows([
    ["Titre", "Statut", "Échéance"],
    ["", "idea", ""],
    ["Ok", "pas-un-statut", ""],
    ["Ok2", "idea", "07/2026"],
    ["Ok3", "review", "2026-07-01"],
  ]);
  expect(rows).toHaveLength(1);
  expect(rows[0].title).toBe("Ok3");
  expect(errors.map((e) => e.line)).toEqual([2, 3, 4]); // 1-indexé, en-tête = ligne 1
});
```

- [ ] **Step 2 — implémenter** :

```ts
// src/lib/content/csv.ts — round-trip CSV des content_items (ELEVA-59).
import { csvField } from "@/lib/keywords/export-full";
import type { ContentStatus } from "@/lib/db/domain-schema";

const STATUSES: ContentStatus[] = ["idea", "brief", "writing", "review", "published"];

/** Parser RFC-4180 minimal : quotes, "" échappé, retours ligne quotés, CRLF. */
export function parseCsv(text: string): string[][] {
  const rows: string[][] = [];
  let row: string[] = [];
  let field = "";
  let inQuotes = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inQuotes) {
      if (c === '"') {
        if (text[i + 1] === '"') {
          field += '"';
          i++;
        } else inQuotes = false;
      } else field += c;
    } else if (c === '"') {
      inQuotes = true;
    } else if (c === ",") {
      row.push(field);
      field = "";
    } else if (c === "\n" || c === "\r") {
      if (c === "\r" && text[i + 1] === "\n") i++;
      row.push(field);
      field = "";
      rows.push(row);
      row = [];
    } else field += c;
  }
  if (field !== "" || row.length > 0) {
    row.push(field);
    rows.push(row);
  }
  // ignore les lignes entièrement vides (ex. ligne finale)
  return rows.filter((r) => r.some((f) => f.trim() !== ""));
}

export type ContentExportRow = {
  title: string;
  status: ContentStatus;
  primaryKeywordTerm: string | null;
  secondaryKeywordTerms: string[];
  lang: string | null;
  country: string | null;
  assignee: string | null;
  dueDate: string | null; // YYYY-MM-DD
  url: string | null;
};

const HEADER = [
  "Titre",
  "Statut",
  "Mot-clé principal",
  "Mots-clés secondaires",
  "Langue",
  "Pays",
  "Assigné",
  "Échéance",
  "URL",
];

export function buildContentCsv(rows: ContentExportRow[]): string {
  const lines = [HEADER.join(",")];
  for (const r of rows) {
    lines.push(
      [
        r.title,
        r.status,
        r.primaryKeywordTerm ?? "",
        r.secondaryKeywordTerms.join("|"),
        r.lang ?? "",
        r.country ?? "",
        r.assignee ?? "",
        r.dueDate ?? "",
        r.url ?? "",
      ]
        .map(csvField)
        .join(","),
    );
  }
  return `${lines.join("\n")}\n`;
}

export type ParsedContentRow = ContentExportRow;

/** Normalise un nom d'en-tête : minuscules, sans accents. */
function headerKey(h: string): string {
  return h
    .normalize("NFD")
    .replace(/[̀-ͯ]/g, "")
    .toLowerCase()
    .trim();
}

const COLUMNS: Record<string, keyof ParsedContentRow | "secondary"> = {
  titre: "title",
  statut: "status",
  "mot-cle principal": "primaryKeywordTerm",
  "mots-cles secondaires": "secondary",
  langue: "lang",
  pays: "country",
  assigne: "assignee",
  echeance: "dueDate",
  url: "url",
};

export function parseContentRows(cells: string[][]): {
  rows: ParsedContentRow[];
  errors: { line: number; error: string }[];
} {
  if (cells.length === 0) return { rows: [], errors: [] };
  const map = cells[0].map((h) => COLUMNS[headerKey(h)] ?? null);
  const rows: ParsedContentRow[] = [];
  const errors: { line: number; error: string }[] = [];
  for (let i = 1; i < cells.length; i++) {
    const line = i + 1;
    const get = (k: keyof ParsedContentRow | "secondary"): string => {
      const idx = map.indexOf(k);
      return idx >= 0 ? (cells[i][idx] ?? "").trim() : "";
    };
    const title = get("title");
    if (!title) {
      errors.push({ line, error: "Titre requis" });
      continue;
    }
    const statusRaw = get("status");
    const status = (statusRaw || "idea") as ContentStatus;
    if (!STATUSES.includes(status)) {
      errors.push({ line, error: `Statut invalide : ${statusRaw}` });
      continue;
    }
    const dueRaw = get("dueDate");
    if (dueRaw && !/^\d{4}-\d{2}-\d{2}$/.test(dueRaw)) {
      errors.push({ line, error: `Échéance invalide (YYYY-MM-DD) : ${dueRaw}` });
      continue;
    }
    const secondary = get("secondary");
    rows.push({
      title,
      status,
      primaryKeywordTerm: get("primaryKeywordTerm") || null,
      secondaryKeywordTerms: secondary
        ? secondary.split("|").map((s) => s.trim()).filter(Boolean)
        : [],
      lang: get("lang") || null,
      country: get("country") || null,
      assignee: get("assignee") || null,
      dueDate: dueRaw || null,
      url: get("url") || null,
    });
  }
  return { rows, errors };
}
```

- [ ] **Step 3 — vérifier** : `bun run test src/lib/content` (baseline 30 + nouveaux), `bunx tsc --noEmit`, biome.
- [ ] **Step 4 — commit** : `feat(content): logique pure CSV — parser RFC-4180, build export, mapping import`

---

### Task 2: Actions serveur (TDD DB) + export rows

**Files:** Create `src/lib/content/import-actions.ts` + tests dans `src/lib/content/import-actions.test.ts` ; Modify `src/lib/content/actions.ts` (action read export)

- [ ] **Step 1 — action read export** dans `src/lib/content/actions.ts` :

```ts
/** Rows complètes pour l'export CSV (items + termes principal/secondaires). */
export async function getContentExportRowsAction(
  siteId: string,
): Promise<ContentExportRow[]> {
  await assertSiteInActiveOrg(siteId);
  // items du site + terme principal (join keywords) + secondaires (content_keywords role=secondary)
  // construis avec 2 requêtes (items+principal joint ; secondaires groupés par contentItemId)
  // dueDate → toISOString().slice(0, 10)
}
```

(corps complet : lire les requêtes existantes du fichier — `getContentItemsForSite` dans `src/lib/content/queries.ts` — et suivre le même style ; trie par `createdAt`.)

- [ ] **Step 2 — tests DB qui échouent** (`src/lib/content/import-actions.test.ts`, pattern de `content.test.ts` : mocks server-only/next-cache/assert-site permissif, `DB_TESTS=1`, cleanup beforeEach, org demo-org, site dédié `wt-csv-a` + site B pour l'isolation) :

  1. `previewContentImportAction` : titre inconnu → `new` ; titre existant (insensible casse/trim) → `update` ; terme principal inconnu → warning sur la ligne ; counts corrects.
  2. `commitContentImportAction` crée (avec défauts lang/pays du site quand colonnes vides) et met à jour (statut/assignee/dueDate/url) par titre ; ne touche pas les items du site B.
  3. commit lie le **principal** par terme (sync `primaryKeywordId` + `content_keywords role=primary`) et les **secondaires** (`role=secondary`, onConflictDoNothing) ; terme inconnu → compté `keywordsUnknown`, non bloquant.

- [ ] **Step 3 — implémenter** `src/lib/content/import-actions.ts` :

```ts
"use server";
// preview/commit de l'import CSV des content_items (ELEVA-59).
```

Signatures :

```ts
export type ContentImportPreviewRow = {
  line: number;
  title: string;
  action: "new" | "update";
  status: ContentStatus;
  primaryKeywordTerm: string | null;
  primaryKeywordFound: boolean;
};
export type ContentImportPreview = {
  rows: ContentImportPreviewRow[];
  errors: { line: number; error: string }[];
  counts: { created: number; updated: number; errors: number };
};
export async function previewContentImportAction(siteId: string, csvText: string): Promise<ContentImportPreview>;

export type ContentImportResult = {
  created: number;
  updated: number;
  errors: number;
  keywordsLinked: number;
  keywordsUnknown: number;
};
export async function commitContentImportAction(siteId: string, csvText: string): Promise<ContentImportResult>;
```

Corps : `assertSiteInActiveOrg(siteId)` en tête ; `parseCsv` + `parseContentRows` ; existants par `lower(trim(title))` ; keywords du site par `lower(term)` ; commit en transaction — create via `randomUUID()` + défauts site (`primaryLanguage`, `targetCountries[0]`), update partiel (seulement les champs fournis non-null + status toujours) ; liens keywords : principal → `update contentItems.primaryKeywordId` + `insert contentKeywords {role:"primary"} onConflictDoUpdate role` (lis le pattern exact des lignes 130-185 de actions.ts et réutilise-le), secondaires → `insert {role:"secondary"} onConflictDoNothing` ; `revalidatePath("/content/planning")` au commit.

- [ ] **Step 4 — vérifier** : `bun run test src/lib/content` (tout vert), `bunx tsc --noEmit`, biome.
- [ ] **Step 5 — commit** : `feat(content): actions import/export CSV — preview/commit par titre, liens keywords par terme`

---

### Task 3: UI — boutons toolbar + dialog import

**Files:** Create `src/components/content/import-csv-dialog.tsx` ; Modify `src/components/content/content-board.tsx`

- [ ] **Step 1 — export** : dans la toolbar du board (à côté de « + Nouvel article »), bouton outline « Exporter CSV » → handler :

```ts
async function exportCsv() {
  const rows = await getContentExportRowsAction(activeSiteId);
  if (rows.length === 0) {
    toast.error("Aucun contenu à exporter");
    return;
  }
  const blob = new Blob(["﻿", buildContentCsv(rows)], {
    type: "text/csv;charset=utf-8",
  });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `contenu-${activeSiteId}.csv`;
  a.click();
  URL.revokeObjectURL(url);
}
```

- [ ] **Step 2 — dialog import** `import-csv-dialog.tsx` (client) : props `{ siteId, open, onOpenChange, onImported }`. Étapes internes : (1) input fichier `.csv` (garde 1 Mo, message clair — Server Action limit) → `previewContentImportAction` ; (2) tableau preview (ligne · titre · New/Update badge · mot-clé principal ou « ⚠️ introuvable » · erreurs en `text-destructive`) + counts ; (3) bouton « Importer ({created+updated}) » → `commitContentImportAction` → toast récap (`{created} créé(s), {updated} mis à jour…`) → `onImported()` + close. Reset des états à la fermeture. Miroir du style de `import-keywords-dialog.tsx` (lis-le).
- [ ] **Step 3 — wiring board** : bouton « Importer CSV » ouvre le dialog ; `onImported={load}` (callback existant). Imports : `buildContentCsv` depuis `@/lib/content/csv`, actions depuis leurs modules.
- [ ] **Step 4 — vérifier** : `bunx tsc --noEmit`, `bun run test:all`, biome sur les fichiers touchés.
- [ ] **Step 5 — commit** : `feat(content): UI import-export CSV sur /content/planning (export BOM, dialog preview→commit)`

---

### Task 4: Vérification live + docs + Linear (contrôleur)

- [ ] Live (d3k, Acieb) : « Exporter CSV » → fichier ; ré-import du même fichier → preview 0 new / N update → commit OK ; import d'un CSV avec 1 nouveau + 1 statut invalide → preview avec erreur ligne, commit crée 1 ; board rafraîchi sans reload ; psql : item créé + liens keywords.
- [ ] Roadmap : ligne Contenu « ⬜ import-export — CSV des content_items » → ✅ (avec scission inventaire).
- [ ] Linear : ELEVA-59 → Done + commentaire ; **créer l'issue différée** « Contenu — import inventaire (sitemap/Firecrawl → online) » (CdC F1/F2/F3-ICS/F5, milestone Contenu, Low).
- [ ] Review finale de branche, ff-merge `dev`, push, suppression branche. Nettoyer les données de test importées.
