# Contenu — import-export CSV des content_items

**Date:** 2026-06-10 · **Linear:** [ELEVA-59](https://linear.app/elevaseo/issue/ELEVA-59) · **CdC:** `docs/specs/contenu/import-export.md` (slice CSV ; conventions R1/R2)
**Décision de scope :** v1 = **round-trip CSV des content_items** (l'objet exact de l'issue). Le reste du CdC — **import inventaire sitemap/Firecrawl → online (F1/F5 jobs async), import CMS (F2), exports briefs EEAT / ICS (F3)** — est **scindé en issue Linear dédiée différée**.

**Périmètre v1 :** export CSV du board éditorial (toutes colonnes) + import CSV **preview → commit** (R2) avec matching par titre, sur `/content/planning`. Zéro migration.

---

## A. Logique pure — `src/lib/content/csv.ts` (TDD)

- `parseCsv(text: string): string[][]` — parser RFC-4180 minimal (champs quotés, `""` échappé, retours ligne dans les quotes, CRLF/LF). Garde-fou : fichier ≤ 1 Mo (côté client — Server Action limit, volumes réels minuscules).
- `buildContentCsv(rows: ContentExportRow[]): string` — en-têtes : `Titre,Statut,Mot-clé principal,Mots-clés secondaires,Langue,Pays,Assigné,Échéance,URL` ; échappement via `csvField` (réutilisé de `@/lib/keywords/export-full`) ; secondaires joints par `|` ; échéance `YYYY-MM-DD` ; champs absents vides. **Le fichier exporté se ré-importe tel quel** (mêmes en-têtes).
- `parseContentRows(cells: string[][]): { rows: ParsedContentRow[]; errors: { line: number; error: string }[] }` — mappe l'en-tête (insensible casse/accents sur les noms de colonnes ci-dessus, ordre libre, colonnes inconnues ignorées) ; validations : Titre requis ; Statut ∈ `idea|brief|writing|review|published` (vide → `idea`, invalide → erreur) ; Échéance ISO `YYYY-MM-DD` ou vide (invalide → erreur) ; le reste libre.

```ts
export type ParsedContentRow = {
  title: string;
  status: ContentStatus;
  primaryKeywordTerm: string | null;
  secondaryKeywordTerms: string[];   // depuis « a|b|c »
  lang: string | null;               // défaut serveur : langue du site
  country: string | null;
  assignee: string | null;
  dueDate: string | null;            // YYYY-MM-DD
  url: string | null;
};
```

## B. Actions — `src/lib/content/import-actions.ts` (`"use server"`, tenancy gardée)

- `previewContentImportAction(siteId, csvText)` → par ligne valide : `new` (titre inconnu) ou `update` (titre existant, match `lower(trim(title))` sur le site) + résolution du **mot-clé principal par terme** (`lower(term)` sur le site ; introuvable → warning sur la ligne, pas bloquant) ; counts {created, updated, errors}.
- `commitContentImportAction(siteId, csvText)` →
  - **create** : `content_items` (id généré comme `createContentItemAction` existant — lire le pattern), lang/country défauts du site ;
  - **update** (par titre) : statut, assignee, dueDate, url, lang/country si fournis ;
  - **mot-clé principal** résolu par terme → `primaryKeywordId` + upsert `content_keywords (role primary)` (pattern promote existant — maintenir la synchro) ; **secondaires** → `content_keywords (role secondary)` onConflictDoNothing ; termes introuvables ignorés (comptés) ;
  - retours {created, updated, errors, keywordsLinked, keywordsUnknown} ; `revalidatePath("/content/planning")`.
- Matching par titre = comportement R2 simple et prévisible ; pas de suppression à l'import (jamais destructif).

## C. UI — `/content/planning` (content-board toolbar)

- **« Exporter CSV »** (outline, à côté de « + Nouvel article ») : fetch des rows complètes (action read `getContentExportRowsAction(siteId)` — items + termes principal/secondaires joints) → `buildContentCsv` → download `contenu-{siteId}.csv` (BOM UTF-8, pattern export-full).
- **« Importer CSV »** : dialog (input fichier ≤ 1 Mo → preview tableau : ligne, titre, action New/Update, mot-clé principal résolu ou ⚠️, erreurs en rouge → bouton « Importer (N) ») → commit → toast récap + refresh board (callback `load` existant).

## D. Tests

- **Purs (TDD)** : `parseCsv` (quotes, `""`, retours ligne quotés, CRLF) · `buildContentCsv` (en-têtes, échappement, round-trip avec `parseCsv`+`parseContentRows`) · `parseContentRows` (mapping en-têtes, statut invalide → erreur, date invalide → erreur, secondaires `|`).
- **DB** : preview new/update par titre + résolution keyword par terme ; commit crée/met à jour + lie principal (sync `primaryKeywordId` + `content_keywords`) et secondaires ; terme inconnu compté non bloquant ; isolation par site.
- **Live (d3k, Acieb)** : export du board réel → ré-import du même fichier (0 création, N updates) ; import d'un CSV avec 1 nouveau titre + 1 update + 1 statut invalide → preview correcte, commit, board rafraîchi.

## Hors scope (→ issue Linear dédiée)

Import inventaire **sitemap/Firecrawl → online** (F1) · jobs async + dry-run serveur (F5) · import CMS WordPress (F2) · exports briefs EEAT et calendrier **ICS** (F3) · matrice de mapping de colonnes (profils).
