Skip to content

HOLD: Import stories from a WordPress export CSV with an admin preview UI#1856

Draft
maebeale wants to merge 2 commits into
mainfrom
maebeale/csv-story-importer
Draft

HOLD: Import stories from a WordPress export CSV with an admin preview UI#1856
maebeale wants to merge 2 commits into
mainfrom
maebeale/csv-story-importer

Conversation

@maebeale

Copy link
Copy Markdown
Collaborator

What is the goal of this PR and why is this important?

  • AWBW is migrating ~300 stories off the legacy WordPress site, and staff need a safe, repeatable way to bring them in without engineering involvement.
  • Each CSV row becomes a StoryIdea (the canonical submission record); rows that were public on WordPress also get a connected, published Story — mirroring the in-app idea→story promotion flow.
  • Marked HOLD: pending product decisions on the few unmappable fields (below) and a review of the import-user / visibility defaults before any real import is run.

How did you approach the change?

  • StoryImporter does the mapping with no schema changes, logging fields that have no home rather than dropping them silently, and supports a dry run.
  • Two entry points: a rake import:stories[path,user_email] task and an admin-only UI from the Stories index — upload → dry-run preview of what will be created (ideas/stories/skipped/warnings) → confirm.
  • The uploaded CSV is stashed as an ActiveStorage blob between preview and confirm (too large for the cookie session) and purged after import; created records are attributed to the importing admin.
  • Covered by service specs and request specs; AGENTS.md updated for the new service, rake task, and controller.

UI Testing Checklist

  • As an admin, the Stories index shows an Import button next to New Story
  • Uploading a WordPress export CSV shows a preview (counts + warnings) and saves nothing
  • Confirming creates the story ideas/stories and redirects to Stories with a summary notice
  • Non-admins and signed-out users cannot reach the import screens
  • Re-uploading after the preview blob is gone shows a friendly "choose the file again" message

Anything else to add?

  • This works with zero schema changes, at the cost of dropping (but logging) four things with no current home: the original WordPress publish date, the US state, the free-text facilitator, and the source permalink / post id.
  • Recommended follow-ups: add original_published_at and legacy_wp_id columns (correct chronological sort + idempotent re-import — note the permalink can't go in website_url because that triggers an external redirect on Story#show), and seed a StoryCategory taxonomy for the ~131 thematic WordPress categories that don't map onto an existing Sector.
  • Screenshots of the upload/preview screens to be added.

maebeale and others added 2 commits June 22, 2026 08:11
AWBW is migrating ~300 stories off the legacy WordPress site. The export is
a single flat CSV; we need each row as a StoryIdea (the canonical submission
record) and a connected published Story for rows that were public on
WordPress, mirroring the in-app idea->story promotion flow.

StoryImporter does this with no schema changes, mapping the WP fields we can
represent and logging the ones we can't (original publish date, US state,
free-text facilitator, source permalink/post id) so the gaps are visible
rather than silently dropped. A dry-run mode reports what would be created.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Running the importer from a rake task means only engineers can do it and
nobody sees what a CSV will produce until after it's written. This adds an
admin-only flow from the Stories index: upload the WordPress export, see a
dry-run preview (ideas/stories to be created, skipped rows, and the
unmappable-field warnings) and only then confirm.

The uploaded CSV is stashed as an ActiveStorage blob between preview and
confirm — it's far too big for the cookie session — and purged once the
import runs. Records are attributed to the importing admin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
return redirect_to(new_story_import_path, alert: "That file is not a CSV.") unless csv?(file)

@filename = file.original_filename
@blob = ActiveStorage::Blob.create_and_upload!(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: The CSV is ~1MB, so it is stashed as an ActiveStorage blob between preview and confirm rather than the cookie session. The blob is purged in confirm once the import runs; an abandoned preview leaves an orphan blob — open to a periodic sweep if that matters.

end

# Records that have no home in the current schema — logged, not dropped.
def note_unmapped_fields(row)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: These fields (original publish date, US state, free-text facilitator) have no home in the current schema, so they are logged as warnings instead of dropped silently — that surfaces the data-model gap in the preview. The permalink is deliberately not mapped to website_url, since that triggers an external redirect on Story#show.


# Best-effort: tag whatever WordPress categories resolve to an existing
# Sector. Unmatched values are surfaced as warnings, never invented.
def tag_taxonomies(record, row)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Best-effort: a WordPress category is tagged only when it resolves to an existing Sector (directly or via SECTOR_SYNONYMS). The ~131 thematic categories with no Sector match are warned, never invented — they would need a seeded StoryCategory taxonomy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant