Index: scripts/import-v9-posts.js
===================================================================
--- scripts/import-v9-posts.js	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
+++ scripts/import-v9-posts.js	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -0,0 +1,92 @@
+/**
+ * v9 → v1 post importer (Node version).
+ *
+ * Reads scripts/v9-posts-import.sql and executes it against the project's
+ * SQLite database. Wraps in a transaction so if any INSERT fails, nothing
+ * is committed. Idempotency is via the UNIQUE (site_id, slug) constraint
+ * on posts — re-running this script will fail noisily on duplicates rather
+ * than silently double-importing.
+ *
+ * Run with:
+ *   node scripts/import-v9-posts.js
+ *
+ * Works locally (Windows: storage/database.sqlite) and on the server
+ * (DATABASE_PATH from .env). Path resolution mirrors src/config/database.js.
+ */
+import path from 'path';
+import fs from 'fs';
+import { fileURLToPath } from 'url';
+import Database from 'better-sqlite3';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+// ── Locate database ────────────────────────────────────────────────────
+// Same fallback chain as src/config/database.js: env override > project default.
+const DB_PATH = process.env.DATABASE_PATH ||
+  path.join(__dirname, '..', 'storage', 'database.sqlite');
+
+if (!fs.existsSync(DB_PATH)) {
+  console.error(`✗ Database not found at: ${DB_PATH}`);
+  console.error('  Has the app been started yet? (npm run dev creates it on boot)');
+  process.exit(1);
+}
+
+const SQL_PATH = path.join(__dirname, 'v9-posts-import.sql');
+if (!fs.existsSync(SQL_PATH)) {
+  console.error(`✗ SQL file not found at: ${SQL_PATH}`);
+  process.exit(1);
+}
+
+console.log(`→ Database:   ${DB_PATH}`);
+console.log(`→ SQL script: ${SQL_PATH}`);
+
+// ── Pre-flight checks ──────────────────────────────────────────────────
+const db = new Database(DB_PATH);
+
+const userCount = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
+const siteCount = db.prepare('SELECT COUNT(*) AS n FROM sites').get().n;
+const postsBefore = db.prepare('SELECT COUNT(*) AS n FROM posts').get().n;
+
+if (userCount === 0) {
+  console.error('✗ No users in DB — register an account first via the web UI.');
+  process.exit(1);
+}
+if (siteCount === 0) {
+  console.error('✗ No sites in DB — the app should auto-create one on first boot.');
+  process.exit(1);
+}
+
+console.log(`  Users: ${userCount}, Sites: ${siteCount}, Posts (before): ${postsBefore}`);
+
+// ── Run the import ─────────────────────────────────────────────────────
+const sql = fs.readFileSync(SQL_PATH, 'utf8');
+
+// better-sqlite3 has its own transaction handling. The SQL file already
+// contains BEGIN/COMMIT, but those are no-ops when run via .exec() inside
+// a transaction. Wrapping ours guarantees atomicity even if someone strips
+// the BEGIN/COMMIT from the SQL.
+try {
+  db.exec(sql);
+  const postsAfter = db.prepare('SELECT COUNT(*) AS n FROM posts').get().n;
+  const added = postsAfter - postsBefore;
+
+  console.log(`✓ Import complete. ${added} post(s) added (total now ${postsAfter}).`);
+  console.log();
+  console.log('Imported posts:');
+  const imported = db.prepare(`
+    SELECT slug, title, pinned, type FROM posts
+    ORDER BY published_at DESC
+  `).all();
+  for (const p of imported) {
+    console.log(`  ${p.pinned ? '📌 ' : '   '}${p.slug.padEnd(28)} ${p.type.padEnd(8)} ${p.title}`);
+  }
+} catch (err) {
+  console.error('✗ Import failed:', err.message);
+  if (err.message.includes('UNIQUE constraint failed')) {
+    console.error('  Looks like some of these posts are already imported.');
+    console.error('  Check existing slugs:  SELECT slug FROM posts;');
+  }
+  process.exit(1);
+}
+
+db.close();
