From 66756a25fc7c54797b9320d33a79579a2bb45f5b Mon Sep 17 00:00:00 2001 From: fbritoferreira Date: Fri, 9 Oct 2026 23:22:17 +0400 Subject: [PATCH 1/3] feat(demo): wire the CSV import/export plugin with relation fixtures and an end-to-end script Adds category and tag collections, relations and media on article, and scripts/try-csv.mjs, which imports a fixture twice through the plugin and fails unless the second run creates nothing. Also adds the strapi::favicon middleware and a favicon.png: Strapi 5.57 refuses to boot without the middleware, so the demo on main does not start. --- demo/README.md | 25 +++- demo/config/middlewares.ts | 1 + demo/config/plugins.ts | 3 + demo/favicon.png | Bin 0 -> 70 bytes demo/package.json | 4 +- demo/pnpm-lock.yaml | 3 + demo/scripts/articles.csv | 6 + demo/scripts/try-csv.mjs | 139 ++++++++++++++++++ .../article/content-types/article/schema.json | 23 +++ .../content-types/category/schema.json | 28 ++++ demo/src/api/category/controllers/category.ts | 3 + demo/src/api/category/routes/category.ts | 3 + demo/src/api/category/services/category.ts | 3 + .../src/api/tag/content-types/tag/schema.json | 19 +++ demo/src/api/tag/controllers/tag.ts | 3 + demo/src/api/tag/routes/tag.ts | 3 + demo/src/api/tag/services/tag.ts | 3 + demo/src/index.ts | 14 ++ 18 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 demo/favicon.png create mode 100644 demo/scripts/articles.csv create mode 100644 demo/scripts/try-csv.mjs create mode 100644 demo/src/api/category/content-types/category/schema.json create mode 100644 demo/src/api/category/controllers/category.ts create mode 100644 demo/src/api/category/routes/category.ts create mode 100644 demo/src/api/category/services/category.ts create mode 100644 demo/src/api/tag/content-types/tag/schema.json create mode 100644 demo/src/api/tag/controllers/tag.ts create mode 100644 demo/src/api/tag/routes/tag.ts create mode 100644 demo/src/api/tag/services/tag.ts diff --git a/demo/README.md b/demo/README.md index 07715d3..1f23259 100644 --- a/demo/README.md +++ b/demo/README.md @@ -4,6 +4,7 @@ A Strapi 5 app linked to this repo's packages, for trying changes against a real Strapi instead of mocks: - `@fbritoferreira/strapi-admin-api` plugin (`/admin-api/*`) +- `@fbritoferreira/strapi-csv-import-export` plugin (admin menu **CSV Import / Export**) - `@fbritoferreira/strapi-provider-email-cloudflare` (only when Cloudflare credentials are set) - `@fbritoferreira/strapi` client (`pnpm try-client`, `pnpm generate`) - `@strapi/plugin-graphql` (`/graphql`) @@ -29,8 +30,9 @@ pnpm develop ``` The admin panel is at http://localhost:1337/admin. On first boot the app seeds -two published articles, one draft and the homepage, and lets the public role -read them. +two published articles, one draft, the homepage, two categories (`news`, +`sport`) and three tags (`strapi`, `typescript`, `csv`), and lets the public +role read the articles and the homepage. Create an admin from the CLI instead of the browser: @@ -45,7 +47,9 @@ and the email provider) and restart `pnpm develop`. | UID | Kind | Notes | | --- | --- | --- | -| `api::article.article` | collection | Draft & Publish on: `title`, `slug`, `body` | +| `api::article.article` | collection | Draft & Publish on: `title`, `slug`, `body`, `views`, `featured`, `category` (manyToOne), `tags` (manyToMany), `cover` (media) | +| `api::category.category` | collection | `name`, `slug`; no Draft & Publish | +| `api::tag.tag` | collection | `label` (unique); no Draft & Publish | | `api::homepage.homepage` | single | `title`, `description` | ## Try the admin API plugin @@ -59,6 +63,21 @@ curl -s -X POST localhost:1337/admin-api/tokens -H "Authorization: Bearer $TOKEN -H 'content-type: application/json' -d '{"name":"demo","type":"full-access"}' ``` +## Try the CSV import/export plugin + +With the app running and the admin created: + +```bash +pnpm try-csv +``` + +It imports `scripts/articles.csv` twice through the plugin's admin API and +fails unless the second run creates nothing. It also checks that a missing +relation target aborts its batch when `onMissingRelation` is `fail`, and that an +export re-imports on `documentId` as updates only. In the admin panel the same +flow is under **CSV Import / Export**, and the Article list view has **Import +CSV** and **Export CSV** buttons. + ## Try the client ```bash diff --git a/demo/config/middlewares.ts b/demo/config/middlewares.ts index 6ff3cb9..829f5c0 100644 --- a/demo/config/middlewares.ts +++ b/demo/config/middlewares.ts @@ -7,5 +7,6 @@ export default [ 'strapi::query', 'strapi::body', 'strapi::session', + 'strapi::favicon', 'strapi::public', ]; diff --git a/demo/config/plugins.ts b/demo/config/plugins.ts index 2872ec9..c3a1e44 100644 --- a/demo/config/plugins.ts +++ b/demo/config/plugins.ts @@ -2,6 +2,9 @@ export default ({ env }) => ({ // @fbritoferreira/strapi-admin-api, linked from packages/admin-api 'admin-api': { enabled: true }, + // @fbritoferreira/strapi-csv-import-export, linked from packages/csv-import-export + 'csv-import-export': { enabled: true }, + graphql: { config: { landingPage: true, diff --git a/demo/favicon.png b/demo/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..693e618e9aeb9f410e8282c4616c35636bf450ea GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9x<|Kb8sr@a_z;cnP`c=z} Qy+8>DPgg&ebxsLQ0M>F57XSbN literal 0 HcmV?d00001 diff --git a/demo/package.json b/demo/package.json index aa3740e..370e04d 100644 --- a/demo/package.json +++ b/demo/package.json @@ -9,11 +9,13 @@ "strapi": "strapi", "build:admin": "strapi build", "generate": "strapi-client generate --dir . -o types/strapi.ts", - "try-client": "node scripts/try-client.mjs" + "try-client": "node scripts/try-client.mjs", + "try-csv": "node scripts/try-csv.mjs" }, "dependencies": { "@fbritoferreira/strapi": "link:../packages/client", "@fbritoferreira/strapi-admin-api": "link:../packages/admin-api", + "@fbritoferreira/strapi-csv-import-export": "link:../packages/csv-import-export", "@fbritoferreira/strapi-provider-email-cloudflare": "link:../packages/provider-email-cloudflare", "@strapi/plugin-graphql": "5.57.0", "@strapi/plugin-users-permissions": "5.57.0", diff --git a/demo/pnpm-lock.yaml b/demo/pnpm-lock.yaml index 1c4265b..b101697 100644 --- a/demo/pnpm-lock.yaml +++ b/demo/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@fbritoferreira/strapi-admin-api': specifier: link:../packages/admin-api version: link:../packages/admin-api + '@fbritoferreira/strapi-csv-import-export': + specifier: link:../packages/csv-import-export + version: link:../packages/csv-import-export '@fbritoferreira/strapi-provider-email-cloudflare': specifier: link:../packages/provider-email-cloudflare version: link:../packages/provider-email-cloudflare diff --git a/demo/scripts/articles.csv b/demo/scripts/articles.csv new file mode 100644 index 0000000..e07c049 --- /dev/null +++ b/demo/scripts/articles.csv @@ -0,0 +1,6 @@ +slug,title,views,featured,category,tags +hello-strapi,Hello Strapi (imported),10,true,news,strapi +csv-import,CSV import,5,false,sport,csv|typescript +csv-export,CSV export,,yes,news, +bad-views,Bad views,many,false,news, +missing-category,Missing category,1,false,cooking, diff --git a/demo/scripts/try-csv.mjs b/demo/scripts/try-csv.mjs new file mode 100644 index 0000000..27bdba9 --- /dev/null +++ b/demo/scripts/try-csv.mjs @@ -0,0 +1,139 @@ +// Exercise the CSV import/export plugin against the running demo app through +// its admin API: import scripts/articles.csv twice and check the second run +// updates instead of creating, then export and read the job history. +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; + +const base = process.env.STRAPI_URL ?? 'http://localhost:1337'; +const email = process.env.ADMIN_EMAIL ?? 'admin@demo.local'; +const password = process.env.ADMIN_PASSWORD ?? 'DemoPass123'; +const ARTICLE = 'api::article.article'; + +const login = await fetch(`${base}/admin/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), +}); +assert.equal(login.status, 200, `admin login failed; create the admin first (see README)`); +const { token } = (await login.json()).data; + +const api = async (method, path, body) => { + const res = await fetch(`${base}/csv-import-export${path}`, { + method, + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: body && JSON.stringify(body), + }); + const text = await res.text(); + assert.ok(res.ok, `${method} ${path} -> ${res.status} ${text}`); + return res.headers.get('content-type')?.includes('json') ? JSON.parse(text) : text; +}; + +// The fixture has no quoted fields, so a plain split is enough here. +const [header, ...lines] = (await readFile(new URL('./articles.csv', import.meta.url), 'utf8')).trim().split('\n'); +const columns = header.split(','); +const rows = lines.map((line) => Object.fromEntries(line.split(',').map((cell, i) => [columns[i], cell]))); + +const runImport = async () => { + const config = { + status: 'published', + matchField: 'slug', + mapping: { slug: 'slug', title: 'title', views: 'views', featured: 'featured', category: 'category', tags: 'tags' }, + relations: { category: { matchOn: 'slug' }, tags: { matchOn: 'label' } }, + onMissingRelation: 'skip', + }; + const { data: job } = await api('POST', '/jobs', { + uid: ARTICLE, + status: config.status, + fileName: 'articles.csv', + totalRows: rows.length, + config, + }); + const { data: result } = await api('POST', `/import/${ARTICLE}`, { ...config, dryRun: false, jobId: job.id, rowOffset: 0, rows }); + await api('POST', `/jobs/${job.id}/finish`, { state: 'completed' }); + const { data: logged } = await api('GET', `/jobs/${job.id}`); + const count = (action) => result.results.filter((r) => r.action === action).length; + return { result, logged, created: count('created'), updated: count('updated'), skipped: count('skipped'), errored: count('error') }; +}; + +const first = await runImport(); +console.log('first import', { created: first.created, updated: first.updated, skipped: first.skipped, errored: first.errored }); +for (const r of first.result.results.filter((r) => r.error)) console.log(` row ${r.row}: ${r.action}: ${r.error}`); + +const second = await runImport(); +console.log('second import', { created: second.created, updated: second.updated, skipped: second.skipped, errored: second.errored }); + +assert.equal(second.created, 0, 'second import must not create anything'); +assert.equal(second.updated, first.created + first.updated, 'second import must update every row the first one wrote'); +assert.equal(second.skipped, 1, 'missing-category row is skipped'); +assert.equal(second.errored, 1, 'bad-views row is an error'); +assert.equal(second.logged.state, 'completed'); +assert.equal(second.logged.updated, second.updated, 'job log counts match the batch result'); +assert.equal(second.logged.errors.length, 2, 'job log keeps the skipped and failed rows only'); + +const { data: exported } = await api('POST', `/export/${ARTICLE}`, { + status: 'published', + columns: [ + { field: 'slug', header: 'slug' }, + { field: 'title', header: 'title' }, + { field: 'category', header: 'category', matchOn: 'slug' }, + { field: 'tags', header: 'tags', matchOn: 'label' }, + ], +}); +const { csv } = exported; +assert.match(csv, /^slug,title,category,tags\r\n/); +// Relations landed on the published version, read back through the export. +assert.match(csv, /csv-import,CSV import,sport,(csv\|typescript|typescript\|csv)\r\n/); +assert.match(csv, /hello-strapi,Hello Strapi \(imported\),news,strapi\r\n/); +assert.doesNotMatch(csv, /missing-category|bad-views/, 'skipped and failed rows were never written'); +console.log(`export: ${csv.trim().split('\r\n').length - 1} rows`); + +// A row whose relation target is missing aborts its whole batch with onMissingRelation: fail. +const { data: failed } = await api('POST', `/import/${ARTICLE}`, { + status: 'draft', + matchField: 'slug', + mapping: { slug: 'slug', category: 'category' }, + relations: { category: { matchOn: 'slug' } }, + onMissingRelation: 'fail', + dryRun: false, + rowOffset: 0, + rows: [ + { slug: 'should-not-exist', category: 'news' }, + { slug: 'missing-category', category: 'cooking' }, + ], +}); +assert.equal(failed.aborted, true, 'fail mode aborts the batch'); +const check = await api('POST', `/export/${ARTICLE}`, { status: 'draft', columns: [{ field: 'slug', header: 'slug' }] }); +assert.doesNotMatch(check.data.csv, /should-not-exist/, 'nothing in an aborted batch is written'); +console.log('fail mode: batch aborted, nothing written'); + +// Round trip: the export re-imports on documentId with no creates. +const roundTrip = await api('POST', `/export/${ARTICLE}`, { + status: 'published', + columns: [ + { field: 'documentId', header: 'documentId' }, + { field: 'views', header: 'views' }, + ], +}); +const [, ...exportedLines] = roundTrip.data.csv.trim().split('\r\n'); +const { data: reimport } = await api('POST', `/import/${ARTICLE}`, { + status: 'published', + matchField: 'documentId', + mapping: { documentId: 'documentId', views: 'views' }, + relations: {}, + onMissingRelation: 'skip', + dryRun: false, + rowOffset: 0, + rows: exportedLines.map((line) => { + const [documentId, views] = line.split(','); + return { documentId, views }; + }), +}); +assert.ok(reimport.results.every((r) => r.action === 'updated'), 'every exported row updates its own entry'); +console.log(`round trip on documentId: ${reimport.results.length} updated, 0 created`); + +const { data: history, meta } = await api('GET', `/jobs?uid=${ARTICLE}`); +assert.ok(history.length >= 3, 'history lists both imports and the export'); +assert.ok(!('errors' in history[0]), 'history list omits the error rows'); +console.log(`history: ${meta.pagination.total} jobs, latest ${history[0].kind} ${history[0].state}`); + +console.log('csv import/export ok'); diff --git a/demo/src/api/article/content-types/article/schema.json b/demo/src/api/article/content-types/article/schema.json index d42b9dc..70de8ff 100644 --- a/demo/src/api/article/content-types/article/schema.json +++ b/demo/src/api/article/content-types/article/schema.json @@ -20,6 +20,29 @@ }, "body": { "type": "text" + }, + "views": { + "type": "integer" + }, + "featured": { + "type": "boolean", + "default": false + }, + "category": { + "type": "relation", + "relation": "manyToOne", + "target": "api::category.category", + "inversedBy": "articles" + }, + "tags": { + "type": "relation", + "relation": "manyToMany", + "target": "api::tag.tag" + }, + "cover": { + "type": "media", + "multiple": false, + "allowedTypes": ["images"] } } } diff --git a/demo/src/api/category/content-types/category/schema.json b/demo/src/api/category/content-types/category/schema.json new file mode 100644 index 0000000..d34bee8 --- /dev/null +++ b/demo/src/api/category/content-types/category/schema.json @@ -0,0 +1,28 @@ +{ + "kind": "collectionType", + "collectionName": "categories", + "info": { + "singularName": "category", + "pluralName": "categories", + "displayName": "Category" + }, + "options": { + "draftAndPublish": false + }, + "attributes": { + "name": { + "type": "string", + "required": true + }, + "slug": { + "type": "uid", + "targetField": "name" + }, + "articles": { + "type": "relation", + "relation": "oneToMany", + "target": "api::article.article", + "mappedBy": "category" + } + } +} diff --git a/demo/src/api/category/controllers/category.ts b/demo/src/api/category/controllers/category.ts new file mode 100644 index 0000000..b656afd --- /dev/null +++ b/demo/src/api/category/controllers/category.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi'; + +export default factories.createCoreController('api::category.category'); diff --git a/demo/src/api/category/routes/category.ts b/demo/src/api/category/routes/category.ts new file mode 100644 index 0000000..97bb852 --- /dev/null +++ b/demo/src/api/category/routes/category.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi'; + +export default factories.createCoreRouter('api::category.category'); diff --git a/demo/src/api/category/services/category.ts b/demo/src/api/category/services/category.ts new file mode 100644 index 0000000..1764f40 --- /dev/null +++ b/demo/src/api/category/services/category.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi'; + +export default factories.createCoreService('api::category.category'); diff --git a/demo/src/api/tag/content-types/tag/schema.json b/demo/src/api/tag/content-types/tag/schema.json new file mode 100644 index 0000000..e68d17c --- /dev/null +++ b/demo/src/api/tag/content-types/tag/schema.json @@ -0,0 +1,19 @@ +{ + "kind": "collectionType", + "collectionName": "tags", + "info": { + "singularName": "tag", + "pluralName": "tags", + "displayName": "Tag" + }, + "options": { + "draftAndPublish": false + }, + "attributes": { + "label": { + "type": "string", + "required": true, + "unique": true + } + } +} diff --git a/demo/src/api/tag/controllers/tag.ts b/demo/src/api/tag/controllers/tag.ts new file mode 100644 index 0000000..f66fa1b --- /dev/null +++ b/demo/src/api/tag/controllers/tag.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi'; + +export default factories.createCoreController('api::tag.tag'); diff --git a/demo/src/api/tag/routes/tag.ts b/demo/src/api/tag/routes/tag.ts new file mode 100644 index 0000000..5d13ba4 --- /dev/null +++ b/demo/src/api/tag/routes/tag.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi'; + +export default factories.createCoreRouter('api::tag.tag'); diff --git a/demo/src/api/tag/services/tag.ts b/demo/src/api/tag/services/tag.ts new file mode 100644 index 0000000..e2888ff --- /dev/null +++ b/demo/src/api/tag/services/tag.ts @@ -0,0 +1,3 @@ +import { factories } from '@strapi/strapi'; + +export default factories.createCoreService('api::tag.tag'); diff --git a/demo/src/index.ts b/demo/src/index.ts index 1ddb71d..473a625 100644 --- a/demo/src/index.ts +++ b/demo/src/index.ts @@ -28,6 +28,20 @@ export default { } } + // Relation targets for trying the CSV import/export plugin. + const categories = strapi.documents('api::category.category'); + if ((await categories.count({})) === 0) { + for (const name of ['News', 'Sport']) { + await categories.create({ data: { name, slug: name.toLowerCase() } }); + } + } + const tags = strapi.documents('api::tag.tag'); + if ((await tags.count({})) === 0) { + for (const label of ['strapi', 'typescript', 'csv']) { + await tags.create({ data: { label } }); + } + } + const articles = strapi.documents('api::article.article'); if ((await articles.count({})) === 0) { for (const title of ['Hello Strapi', 'Typed clients', 'Draft only']) { From ed1dc72f2eb43032244273f9dadb16a5bff4092c Mon Sep 17 00:00:00 2001 From: fbritoferreira Date: Fri, 9 Oct 2026 23:23:59 +0400 Subject: [PATCH 2/3] docs(csv-import-export): usage and admin API docs, and mark the package for release --- .changeset/csv-import-export-initial.md | 5 + packages/csv-import-export/README.md | 23 +++- packages/csv-import-export/docs/api.md | 123 +++++++++++++++++++++ packages/csv-import-export/docs/index.md | 133 ++++++++++++++++++++++- packages/csv-import-export/package.json | 5 +- 5 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 .changeset/csv-import-export-initial.md create mode 100644 packages/csv-import-export/docs/api.md diff --git a/.changeset/csv-import-export-initial.md b/.changeset/csv-import-export-initial.md new file mode 100644 index 0000000..af02c1c --- /dev/null +++ b/.changeset/csv-import-export-initial.md @@ -0,0 +1,5 @@ +--- +"@fbritoferreira/strapi-csv-import-export": minor +--- + +First release of the CSV import/export plugin for Strapi 5: import CSV files into any collection type with column mapping, upsert on `documentId` or a unique field, and relation linking by any field of the related collection; export collection types as CSV with chosen columns; and a history of every import and export. diff --git a/packages/csv-import-export/README.md b/packages/csv-import-export/README.md index 264cebb..0c605c7 100644 --- a/packages/csv-import-export/README.md +++ b/packages/csv-import-export/README.md @@ -1,5 +1,24 @@ # @fbritoferreira/strapi-csv-import-export -Strapi 5 plugin to import and export collection types as CSV from the admin panel, with column mapping, relation linking and upsert. +Strapi 5 plugin to import and export collection types as CSV from the admin panel. -Not published yet. Follow progress at https://strapi.fbritoferreira.com/packages/csv-import-export/. +- Map CSV columns to fields, with automatic mapping by name and reuse of the last mapping for the same columns +- Upsert on `documentId` or any unique field, so re-importing a file updates instead of duplicating +- Link relations by any field of the related collection (`slug`, `label`, `documentId`), with `|` for to-many +- Dry-run preview, batched imports with progress, and a downloadable file of failed rows +- Export with chosen columns and headers, relation values and formula-injection escaping +- History of every import and export +- Content Manager permissions enforced per collection + +```bash +npm install @fbritoferreira/strapi-csv-import-export +``` + +```typescript +// config/plugins.ts +export default () => ({ + 'csv-import-export': { enabled: true }, +}); +``` + +Full documentation: https://strapi.fbritoferreira.com/packages/csv-import-export/ diff --git a/packages/csv-import-export/docs/api.md b/packages/csv-import-export/docs/api.md new file mode 100644 index 0000000..bd54332 --- /dev/null +++ b/packages/csv-import-export/docs/api.md @@ -0,0 +1,123 @@ +--- +title: Admin API +description: HTTP routes of the CSV import/export plugin +--- + +# Admin API + +The admin panel uses these routes, and scripts can call them with an admin JWT. Every route needs an authenticated admin and the Content Manager permissions listed in [Permissions](./#permissions). All paths are under `/csv-import-export`. + +```bash +TOKEN=$(curl -s -X POST localhost:1337/admin/login -H 'content-type: application/json' \ + -d '{"email":"admin@example.com","password":"..."}' | jq -r .data.token) +``` + +## Collections and fields + +`GET /content-types` lists the collection types the user can read: + +```json +{ + "data": [{ "uid": "api::article.article", "displayName": "Article", "draftAndPublish": true, "localized": false }], + "meta": { "maxFileSizeMb": 10 } +} +``` + +`GET /content-types/:uid/schema` lists the fields an import can map to, `documentId` first: + +```json +{ + "data": [ + { "name": "documentId", "type": "string", "required": false, "unique": true }, + { "name": "slug", "type": "uid", "required": false, "unique": true }, + { "name": "category", "type": "relation", "required": false, "unique": false, + "relation": { "target": "api::category.category", "multiple": false } } + ] +} +``` + +## Importing + +An import is a job plus one request per batch of at most 500 rows (the admin panel sends 100). + +`POST /jobs` starts an import job. Needs the *Import CSV* plugin permission. + +```json +{ "uid": "api::article.article", "status": "published", "fileName": "articles.csv", "totalRows": 2, "config": {} } +``` + +Returns `201` with `{ "data": { "id": 12, "state": "running", ... } }`. `config` is stored as-is; the admin panel saves the mapping there to reuse it. + +`POST /import/:uid` imports one batch: + +```json +{ + "status": "published", + "locale": "en", + "matchField": "slug", + "mapping": { "Slug": "slug", "Title": "title", "Category": "category" }, + "relations": { "category": { "matchOn": "slug" } }, + "onMissingRelation": "skip", + "dryRun": false, + "jobId": 12, + "rowOffset": 0, + "rows": [ + { "Slug": "csv-import", "Title": "CSV import", "Category": "sport" }, + { "Slug": "other", "Title": "Other", "Category": "cooking" } + ] +} +``` + +| Field | Meaning | +| --- | --- | +| `mapping` | CSV column to field name. Unlisted columns are ignored. | +| `matchField` | `documentId` or a unique field, and it must be mapped. | +| `relations` | For each mapped relation, the target field the cells hold. | +| `onMissingRelation` | `skip` the row, or `fail` the whole batch without writing any of it. | +| `rowOffset` | Rows sent in earlier batches. Result row numbers are `rowOffset + index + 1`. | +| `dryRun` | Decide every action without writing, and leave the job alone. | +| `jobId` | Optional. Adds the batch counts and failed rows to that running job. | + +```json +{ + "data": { + "aborted": false, + "results": [ + { "row": 1, "action": "created", "documentId": "a34anq3a3y9vfjfluz2tewkg" }, + { "row": 2, "action": "skipped", "error": "category: no entry with slug \"cooking\"" } + ] + } +} +``` + +`action` is `created`, `updated`, `skipped` or `error`. `aborted` is `true` when `onMissingRelation` is `fail` and a target was missing; every row then has action `error` and nothing in the batch was written. + +`POST /jobs/:id/finish` with `{ "state": "completed" }` or `{ "state": "failed" }` closes the job. Only the admin who started it can finish it. + +## Exporting + +`POST /export/:uid` needs the *Export CSV* plugin permission. + +```json +{ + "status": "published", + "locale": "en", + "fileName": "articles.csv", + "columns": [ + { "field": "documentId", "header": "id" }, + { "field": "category", "header": "category", "matchOn": "slug" } + ] +} +``` + +The response is JSON, not a file, because the admin panel's fetch client only reads JSON responses: + +```json +{ "data": { "fileName": "articles.csv", "rowCount": 2, "csv": "id,category\r\n..." } } +``` + +## History + +`GET /jobs?page=1&pageSize=20&uid=api::article.article&kind=import` lists jobs newest first, without their failed rows. `pageSize` is capped at 100. + +`GET /jobs/:id` returns one job, including `errors`: `[{ "row": 2, "data": { ...the CSV row... }, "message": "..." }]`. diff --git a/packages/csv-import-export/docs/index.md b/packages/csv-import-export/docs/index.md index ce19ad4..effae86 100644 --- a/packages/csv-import-export/docs/index.md +++ b/packages/csv-import-export/docs/index.md @@ -1,10 +1,137 @@ --- title: CSV Import / Export -description: Strapi 5 plugin to import and export collection types as CSV +description: Strapi 5 plugin to import and export collection types as CSV with column mapping, relation linking and upsert --- # CSV Import / Export -Strapi 5 plugin to import and export collection types as CSV from the admin panel. +Strapi 5 plugin that imports CSV files into any collection type and exports collection types as CSV, from the admin panel. Imports upsert: a row whose match value already exists updates that entry, so importing the same file twice creates nothing the second time. -This package is under development and not published to npm yet. +## Installation + +```bash +npm install @fbritoferreira/strapi-csv-import-export +# or +pnpm add @fbritoferreira/strapi-csv-import-export +``` + +Requires Strapi 5 and Node.js `^20.19.0 || >=22.12.0`. + +Enable it and rebuild the admin panel: + +```typescript +// config/plugins.ts +export default () => ({ + 'csv-import-export': { + enabled: true, + }, +}); +``` + +```bash +npm run build +``` + +## Permissions + +The plugin adds two actions under **Settings → Roles → Plugins → CSV Import / Export**: *Import CSV* and *Export CSV*. Super Admins have both. + +On top of those, every request checks the Content Manager permissions of the collection it touches: + +| Operation | Content Manager permissions needed | +| --- | --- | +| Import as draft | create, update | +| Import as published | create, update, publish | +| Export, history, schema | read | + +A role that cannot edit a collection in the Content Manager cannot import into it either. The History tab only lists jobs for collections the user can read. + +## Importing + +Open **CSV Import / Export** in the main menu, or use **Import CSV** in a collection's list view to preselect it. + +1. **Source.** Choose the collection, the locale (localized collections only) and whether entries are saved as draft or published (Draft & Publish collections only). Pick the file. +2. **Mapping.** Each CSV column maps to a field or is ignored. Columns whose header matches a field name are mapped automatically, ignoring case, spaces, `_` and `-`. If an earlier import of the same collection had the same set of columns, its whole mapping is reused. +3. **Preview.** A dry run of the first 100 rows shows what each row will do (create, update, skip, error) without writing anything. +4. **Run.** Rows are sent in batches of 100. A progress bar and live counts show the result. Rows that failed or were skipped can be downloaded as a CSV with the row number and the reason, fixed and imported again. + +### How existing entries are matched + +**Find existing entries by** chooses the field that identifies a row: `documentId` or any unique field (`uid` fields such as `slug`, or fields marked unique). + +| Match cell | Existing entries with that value | Result | +| --- | --- | --- | +| empty | n/a | create | +| `news` | none | create | +| `news` | one | update that entry | +| `news` | more than one | error, nothing written | + +Values are trimmed and compared as text. Two rows with the same value in one file create the entry once and then update it. For localized collections, matching on a field other than `documentId` only looks at entries in the chosen locale. Matching on `documentId` finds the document in any locale, so importing a translation of an exported file adds that locale to the existing document. + +An empty cell clears the field. Leave a column unmapped to keep the current value. + +### Relations + +A relation column holds values of a field on the related collection, for example a category `slug`. Choose that field under **Relation matches on**. To-many relations take several values separated by `|`: + +```csv +slug,title,category,tags +csv-import,CSV import,sport,csv|typescript +``` + +The relation is replaced with exactly the listed entries, and an empty cell removes all of them. The plugin never creates related entries. When a value matches no entry, **Missing relation** decides what happens: + +- **Skip the row**: the row is not written and is listed with the failed rows. +- **Stop the import**: nothing in that batch of 100 rows is written and the import stops. Earlier batches stay imported. + +Media fields take the URL or the file name of a file already in the Media Library. + +### Field formats + +| Type | Accepted values | +| --- | --- | +| integer, big integer | `-42` | +| float, decimal | `3.5` | +| boolean | `true`/`false`, `1`/`0`, `yes`/`no`, `y`/`n`, any case | +| date | `2026-10-09` | +| datetime | anything `Date.parse` reads, stored as ISO 8601 UTC | +| time | `09:30`, `09:30:15`, `09:30:15.5` | +| enumeration | one of the enum values, exact case | +| json, blocks | JSON text | + +Components, dynamic zones, passwords and polymorphic relations are not imported or exported. + +## Exporting + +Choose the collection, locale and draft or published version. Every scalar field and `documentId` is selected by default. Relations and media are off until you tick them. Each column has an editable header and can be moved up or down. A relation column writes the chosen field of each related entry, joined with `|`. Media columns write the file URL. + +The file is UTF-8 with a byte order mark, so Excel opens accented characters correctly. Cells that start with `=`, `+`, `-`, `@`, a tab or a carriage return are prefixed with `'` so spreadsheet programs do not run them as formulas ([OWASP CSV injection](https://owasp.org/www-community/attacks/CSV_Injection)). Numbers are not prefixed. + +An export that includes `documentId` can be imported back with **Find existing entries by: documentId**, which updates every row in place. + +## History + +Every import and export is recorded with the collection, file name, user, state and counts. **Details** shows the mapping that was used and the failed rows, and downloads them as CSV. Only failed and skipped rows are stored, never the rows that succeeded. + +An import whose browser tab was closed before it finished stays `running`. + +## Configuration + +```typescript +// config/plugins.ts +export default () => ({ + 'csv-import-export': { + enabled: true, + config: { + // Prefix formula-looking cells in exports with '. Default true. + escapeFormulas: true, + // Largest CSV the import screen accepts, in MB. Default 10. + maxFileSizeMb: 10, + }, + }, +}); +``` + +Each import batch is one JSON request of up to 100 rows. Strapi's body parser limits JSON bodies to 1 MB by default. If rows are very wide, raise `jsonLimit` for `strapi::body` in `config/middlewares.ts`. + +See the [admin API reference](./api) to drive imports and exports from scripts. diff --git a/packages/csv-import-export/package.json b/packages/csv-import-export/package.json index e011e13..d2fa517 100644 --- a/packages/csv-import-export/package.json +++ b/packages/csv-import-export/package.json @@ -1,7 +1,6 @@ { "name": "@fbritoferreira/strapi-csv-import-export", "version": "0.0.0", - "private": true, "description": "Strapi 5 plugin to import and export collection types as CSV with column mapping and upsert", "type": "commonjs", "exports": { @@ -106,5 +105,9 @@ }, "dependencies": { "papaparse": "^5.7.0" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" } } From bfa164eab9741272807a2c3801ccd9f2f7ddaa41 Mon Sep 17 00:00:00 2001 From: fbritoferreira Date: Fri, 9 Oct 2026 23:46:03 +0400 Subject: [PATCH 3/3] docs(csv-import-export): document per-field, locale and relation permission checks; demo checks them against a real role --- demo/scripts/try-csv.mjs | 124 ++++++++++++++++++----- packages/csv-import-export/docs/api.md | 4 +- packages/csv-import-export/docs/index.md | 13 ++- 3 files changed, 109 insertions(+), 32 deletions(-) diff --git a/demo/scripts/try-csv.mjs b/demo/scripts/try-csv.mjs index 27bdba9..7526426 100644 --- a/demo/scripts/try-csv.mjs +++ b/demo/scripts/try-csv.mjs @@ -17,15 +17,34 @@ const login = await fetch(`${base}/admin/login`, { assert.equal(login.status, 200, `admin login failed; create the admin first (see README)`); const { token } = (await login.json()).data; -const api = async (method, path, body) => { - const res = await fetch(`${base}/csv-import-export${path}`, { +const request = async (bearer, method, path, body) => { + const res = await fetch(`${base}${path}`, { method, - headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + headers: { authorization: `Bearer ${bearer}`, 'content-type': 'application/json' }, body: body && JSON.stringify(body), }); const text = await res.text(); - assert.ok(res.ok, `${method} ${path} -> ${res.status} ${text}`); - return res.headers.get('content-type')?.includes('json') ? JSON.parse(text) : text; + return { status: res.status, body: text ? JSON.parse(text) : null }; +}; + +const api = async (method, path, body) => { + const res = await request(token, method, `/csv-import-export${path}`, body); + assert.ok(res.status < 300, `${method} ${path} -> ${res.status} ${JSON.stringify(res.body)}`); + return res.body; +}; + +/** Real imports are logged, so each one needs a running job. */ +const importWithJob = async (config, rows) => { + const { data: job } = await api('POST', '/jobs', { + uid: ARTICLE, + status: config.status, + fileName: 'try-csv', + totalRows: rows.length, + config, + }); + const { data: result } = await api('POST', `/import/${ARTICLE}`, { ...config, dryRun: false, jobId: job.id, rowOffset: 0, rows }); + await api('POST', `/jobs/${job.id}/finish`, { state: result.aborted ? 'failed' : 'completed' }); + return result; }; // The fixture has no quoted fields, so a plain split is enough here. @@ -88,19 +107,19 @@ assert.doesNotMatch(csv, /missing-category|bad-views/, 'skipped and failed rows console.log(`export: ${csv.trim().split('\r\n').length - 1} rows`); // A row whose relation target is missing aborts its whole batch with onMissingRelation: fail. -const { data: failed } = await api('POST', `/import/${ARTICLE}`, { - status: 'draft', - matchField: 'slug', - mapping: { slug: 'slug', category: 'category' }, - relations: { category: { matchOn: 'slug' } }, - onMissingRelation: 'fail', - dryRun: false, - rowOffset: 0, - rows: [ +const failed = await importWithJob( + { + status: 'draft', + matchField: 'slug', + mapping: { slug: 'slug', category: 'category' }, + relations: { category: { matchOn: 'slug' } }, + onMissingRelation: 'fail', + }, + [ { slug: 'should-not-exist', category: 'news' }, { slug: 'missing-category', category: 'cooking' }, - ], -}); + ] +); assert.equal(failed.aborted, true, 'fail mode aborts the batch'); const check = await api('POST', `/export/${ARTICLE}`, { status: 'draft', columns: [{ field: 'slug', header: 'slug' }] }); assert.doesNotMatch(check.data.csv, /should-not-exist/, 'nothing in an aborted batch is written'); @@ -115,22 +134,73 @@ const roundTrip = await api('POST', `/export/${ARTICLE}`, { ], }); const [, ...exportedLines] = roundTrip.data.csv.trim().split('\r\n'); -const { data: reimport } = await api('POST', `/import/${ARTICLE}`, { - status: 'published', - matchField: 'documentId', - mapping: { documentId: 'documentId', views: 'views' }, - relations: {}, - onMissingRelation: 'skip', - dryRun: false, - rowOffset: 0, - rows: exportedLines.map((line) => { +const reimport = await importWithJob( + { + status: 'published', + matchField: 'documentId', + mapping: { documentId: 'documentId', views: 'views' }, + relations: {}, + onMissingRelation: 'skip', + }, + exportedLines.map((line) => { const [documentId, views] = line.split(','); return { documentId, views }; - }), -}); + }) +); assert.ok(reimport.results.every((r) => r.action === 'updated'), 'every exported row updates its own entry'); console.log(`round trip on documentId: ${reimport.results.length} updated, 0 created`); +// Field-level permissions: an editor who may read only title and slug cannot export views. +const EDITOR = { email: 'csv-editor@demo.local', password: 'EditorPass123' }; +const roles = (await request(token, 'GET', '/admin/roles')).body.data; +let role = roles.find((r) => r.name === 'CSV editor'); +if (!role) { + role = (await request(token, 'POST', '/admin/roles', { name: 'CSV editor', description: 'try-csv' })).body.data; +} +await request(token, 'PUT', `/admin/roles/${role.id}/permissions`, { + permissions: [ + { + action: 'plugin::content-manager.explorer.read', + subject: ARTICLE, + properties: { fields: ['title', 'slug'] }, + conditions: [], + }, + { action: 'plugin::csv-import-export.export', subject: null, properties: {}, conditions: [] }, + ], +}); +let editorLogin = await request('', 'POST', '/admin/login', EDITOR); +if (editorLogin.status !== 200) { + const invited = await request(token, 'POST', '/admin/users', { + email: EDITOR.email, + firstname: 'CSV', + lastname: 'Editor', + roles: [role.id], + }); + await request('', 'POST', '/admin/register', { + registrationToken: invited.body.data.registrationToken, + userInfo: { firstname: 'CSV', lastname: 'Editor', password: EDITOR.password }, + }); + editorLogin = await request('', 'POST', '/admin/login', EDITOR); +} +const editorToken = editorLogin.body.data.token; +const exportAs = (columns) => + request(editorToken, 'POST', `/csv-import-export/export/${ARTICLE}`, { status: 'published', columns }); +const allowed = await exportAs([{ field: 'title', header: 'title' }]); +assert.equal(allowed.status, 200, `editor exports title: ${JSON.stringify(allowed.body)}`); +const refused = await exportAs([ + { field: 'title', header: 'title' }, + { field: 'views', header: 'views' }, +]); +assert.equal(refused.status, 403, 'editor cannot export a field outside the role'); +const importRefused = await request(editorToken, 'POST', '/csv-import-export/jobs', { + uid: ARTICLE, + status: 'draft', + totalRows: 1, + config: {}, +}); +assert.equal(importRefused.status, 403, 'editor without the import permission cannot start an import'); +console.log(`permissions: editor export of title ${allowed.status}, of views ${refused.status}, import ${importRefused.status}`); + const { data: history, meta } = await api('GET', `/jobs?uid=${ARTICLE}`); assert.ok(history.length >= 3, 'history lists both imports and the export'); assert.ok(!('errors' in history[0]), 'history list omits the error rows'); diff --git a/packages/csv-import-export/docs/api.md b/packages/csv-import-export/docs/api.md index bd54332..953fccd 100644 --- a/packages/csv-import-export/docs/api.md +++ b/packages/csv-import-export/docs/api.md @@ -5,7 +5,7 @@ description: HTTP routes of the CSV import/export plugin # Admin API -The admin panel uses these routes, and scripts can call them with an admin JWT. Every route needs an authenticated admin and the Content Manager permissions listed in [Permissions](./#permissions). All paths are under `/csv-import-export`. +The admin panel uses these routes, and scripts can call them with an admin JWT. Every route needs an authenticated admin and the Content Manager permissions listed in [Permissions](./#permissions), checked per field, locale and relation target. A missing permission returns `403` with the action, collection, fields and locale in the message. All paths are under `/csv-import-export`. ```bash TOKEN=$(curl -s -X POST localhost:1337/admin/login -H 'content-type: application/json' \ @@ -76,7 +76,7 @@ Returns `201` with `{ "data": { "id": 12, "state": "running", ... } }`. `config` | `onMissingRelation` | `skip` the row, or `fail` the whole batch without writing any of it. | | `rowOffset` | Rows sent in earlier batches. Result row numbers are `rowOffset + index + 1`. | | `dryRun` | Decide every action without writing, and leave the job alone. | -| `jobId` | Optional. Adds the batch counts and failed rows to that running job. | +| `jobId` | Required unless `dryRun` is `true`: a running import job for this collection that the caller started. The batch counts and failed rows are added to it. | ```json { diff --git a/packages/csv-import-export/docs/index.md b/packages/csv-import-export/docs/index.md index effae86..4fd1f0a 100644 --- a/packages/csv-import-export/docs/index.md +++ b/packages/csv-import-export/docs/index.md @@ -44,7 +44,14 @@ On top of those, every request checks the Content Manager permissions of the col | Import as published | create, update, publish | | Export, history, schema | read | -A role that cannot edit a collection in the Content Manager cannot import into it either. The History tab only lists jobs for collections the user can read. +The checks follow the role's limits in detail: + +- **Fields.** Every mapped column needs the permission on that field, and every exported column needs read on it. A role that may update only `title` cannot import a `price` column. +- **Locales.** The import or export locale must be one the role may use. Without a locale the default locale applies. +- **Relations.** Linking or exporting a relation by a field of the related collection, such as `category.slug`, needs read permission on that field of the related collection. +- **Entry conditions.** Rules limited by a condition on the entry itself, such as "is creator", are refused: a bulk import or export cannot apply them row by row. + +Private attributes (`private: true` in the schema, such as users-permissions' reset tokens) are never imported, exported or used to match relations. The History tab only lists jobs for collections the user can read. ## Importing @@ -53,7 +60,7 @@ Open **CSV Import / Export** in the main menu, or use **Import CSV** in a collec 1. **Source.** Choose the collection, the locale (localized collections only) and whether entries are saved as draft or published (Draft & Publish collections only). Pick the file. 2. **Mapping.** Each CSV column maps to a field or is ignored. Columns whose header matches a field name are mapped automatically, ignoring case, spaces, `_` and `-`. If an earlier import of the same collection had the same set of columns, its whole mapping is reused. 3. **Preview.** A dry run of the first 100 rows shows what each row will do (create, update, skip, error) without writing anything. -4. **Run.** Rows are sent in batches of 100. A progress bar and live counts show the result. Rows that failed or were skipped can be downloaded as a CSV with the row number and the reason, fixed and imported again. +4. **Run.** Rows are sent in batches of 100. A progress bar and live counts show the result. Rows that failed or were skipped can be downloaded as a CSV with the row number and the reason, fixed and imported again. A batch whose request fails is not retried automatically, because it may have been written before the connection dropped; importing the file again updates the rows that match. ### How existing entries are matched @@ -107,7 +114,7 @@ Choose the collection, locale and draft or published version. Every scalar field The file is UTF-8 with a byte order mark, so Excel opens accented characters correctly. Cells that start with `=`, `+`, `-`, `@`, a tab or a carriage return are prefixed with `'` so spreadsheet programs do not run them as formulas ([OWASP CSV injection](https://owasp.org/www-community/attacks/CSV_Injection)). Numbers are not prefixed. -An export that includes `documentId` can be imported back with **Find existing entries by: documentId**, which updates every row in place. +On import, a leading `'` before one of those characters is removed again, so an export re-imports unchanged. An export that includes `documentId` can be imported back with **Find existing entries by: documentId**, which updates every row in place. ## History