Skip to content

Instantly share code, notes, and snippets.

@Sal7one
Last active November 29, 2025 01:34
Show Gist options
  • Select an option

  • Save Sal7one/783c7e903dc68e6ec15b87dd1241cc3f to your computer and use it in GitHub Desktop.

Select an option

Save Sal7one/783c7e903dc68e6ec15b87dd1241cc3f to your computer and use it in GitHub Desktop.
Notion to md. AI slop but mostly works!

Complete Guide: Export Your Notion Workspace to Markdown Files

Overview: The Two-Step Processs

We'll use two specialized scripts:

  1. Backup Script (bulk-exporter.js): Downloads your Notion content as structured JSON
  2. Converter Script (json-to-markdown.js): Transforms JSON into beautifully formatted Markdown

Step 1: Setup Your Project

Create and configure your export directory:

mkdir notion-backup
cd notion-backup

Package Configuration

Create package.json:

{
  "name": "notion-bulk-exporter",
  "version": "1.0.0",
  "description": "Bulk Notion workspace backup to JSON and Markdown",
  "main": "bulk-exporter.js",
  "type": "commonjs",
  "scripts": {
    "backup": "node bulk-exporter.js",
    "convert": "node json-to-markdown.js",
    "full-export": "node bulk-exporter.js && node json-to-markdown.js"
  },
  "dependencies": {
    "@notionhq/client": "^2.3.0",
    "dotenv": "^16.4.5"
  }
}

Install dependencies:

npm install

Step 2: Configure Notion Integration

Get Your Notion Tokens

  1. Go to Notion Integrations
  2. Click "+ New integration"
  3. Give it a name (e.g., "Workspace Backup Bot")
  4. Select the workspace you want to backup
  5. Click "Submit" to create the integration
  6. Copy the "Internal Integration Token" (starts with ntn_ or secret_)
  7. Repeat for each workspace you want to backup

Note: Each Notion workspace requires its own integration token. You cannot use one token across multiple workspaces.

Share Pages with Your Integration

Critical Step: Your integration can only access pages explicitly shared with it.

For each workspace:

  1. Open Notion and navigate to the top-level page you want to export
  2. Click "Share" in the top-right corner
  3. Click "Invite" and search for your integration name
  4. Select your integration from the dropdown
  5. The integration now has access to that page and all its children

Pro Tip: Share your workspace's root page to export everything, or share specific pages for selective backups.

Step 3: Set Environment Variables

Create a .env file in your project directory:

# .env file
NOTION_TOKENS="ntn_64xxx...,ntn_333xxx...,ntn_444xxx..."
NOTION_BACKUP_DIR="./notion-bulk-backup"
NOTION_MARKDOWN_DIR="./notion-markdown-export"

Or export directly in your terminal:

export NOTION_TOKENS="ntn_64xxx...,ntn_333xxx...,ntn_444xxx..."
export NOTION_BACKUP_DIR="./notion-bulk-backup"
export NOTION_MARKDOWN_DIR="./notion-markdown-export"

Token Format:

  • Comma-separated list of tokens
  • No spaces between tokens (unless inside quotes)
  • Each token represents one workspace

Step 4: Create the Backup Script

Save this as bulk-exporter.js:

#!/usr/bin/env node
const { Client } = require("@notionhq/client");
const fs = require("fs");
const path = require("path");
const readline = require("readline");
require("dotenv").config();

// [Insert the complete bulk-exporter.js code here]

Make it executable (optional):

chmod +x bulk-exporter.js

Key Features:

  • βœ… Multi-workspace support (unlimited workspaces)
  • βœ… Recursive page/database crawling
  • βœ… Preserves complete block hierarchies (nested blocks)
  • βœ… Handles all Notion block types
  • βœ… Creates manifest.json for tracking
  • βœ… Incremental updates (won't re-download existing pages)
  • βœ… Error recovery (continues if one workspace fails)

Step 5: Run the Backup

Execute the backup script:

node bulk-exporter.js
# Or if you made it executable:
# ./bulk-exporter.js

You'll be prompted with export options:

How do you want to export from each workspace?
  1) Export EVERYTHING each integration can see
  2) Search and pick root pages interactively (per workspace)
  3) Same root IDs for all workspaces (enter manually)

Recommendations:

  • Option 1: Best for complete workspace backups (recommended for most users)
  • Option 2: Best if you want to select specific pages per workspace interactively
  • Option 3: Best if you have the same page structure across workspaces and know the page IDs

Expected Output:

======================================
WORKSPACE 1 of 3
======================================
βœ“ Successfully connected to workspace

[PAGE] My Important Notes (abc-123...)
[PAGE] Project Documentation (def-456...)
[DATABASE] Task List (ghi-789...)

βœ… Workspace export complete.

Time Estimate:

  • Small workspace (< 100 pages): 2-5 minutes
  • Medium workspace (100-500 pages): 10-30 minutes
  • Large workspace (> 500 pages): 30+ minutes

Step 6: Create the Markdown Converter

Save this as json-to-markdown.js:

#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const https = require("https");
const http = require("http");
const readline = require("readline");

// [Insert the complete json-to-markdown.js code here]

Conversion Capabilities:

  • βœ… All text formatting (bold, italic, strikethrough, code, underline)
  • βœ… Headings (H1, H2, H3)
  • βœ… Lists (bulleted, numbered, to-do with checkboxes)
  • βœ… Code blocks with language syntax
  • βœ… Images (with optional download)
  • βœ… Files and PDFs (with optional download)
  • βœ… Videos (with optional download)
  • βœ… Tables, quotes, callouts
  • βœ… Nested blocks (preserves hierarchy)
  • βœ… Links and bookmarks
  • βœ… Database schemas
  • βœ… YAML frontmatter with metadata

Step 7: Convert to Markdown

Run the converter:

node json-to-markdown.js

Interactive Prompts:

Enter path to backup directory: [./notion-bulk-backup]
Download files, images, PDFs, and videos? [y/N]

Asset Download Recommendation:

  • Yes: Choose if you want a complete offline archive (recommended)
  • No: Choose if you want to keep Notion's hosted URLs (smaller export)

What happens during conversion:

  1. Reads all JSON files from backup
  2. Converts each page to Markdown
  3. Downloads assets if requested (may take time for large workspaces)
  4. Creates INDEX.md with export summary
  5. Preserves folder structure

Step 8: Review Your Export

Your exported structure will look like:

notion-markdown-export/
β”œβ”€β”€ workspace_1/
β”‚   β”œβ”€β”€ INDEX.md              # Summary of this workspace
β”‚   β”œβ”€β”€ pages/
β”‚   β”‚   β”œβ”€β”€ My_Page_abc123.md
β”‚   β”‚   └── Another_Page_def456.md
β”‚   β”œβ”€β”€ databases/
β”‚   β”‚   └── Tasks_ghi789.md
β”‚   β”œβ”€β”€ assets/              # Downloaded files (if enabled)
β”‚   β”‚   β”œβ”€β”€ image_123.png
β”‚   β”‚   β”œβ”€β”€ pdf_456.pdf
β”‚   β”‚   └── video_789.mp4
β”‚   β”œβ”€β”€ manifest.json         # Export metadata
β”‚   └── workspace_info.json   # Workspace details
β”œβ”€β”€ workspace_2/
β”‚   └── [same structure]
└── workspace_3/
    └── [same structure]

File Naming Convention:

  • Pages: Title_NotionID.md (e.g., Project_Notes_abc123.md)
  • Assets: type_NotionID.ext (e.g., image_abc123.png)
  • Databases: DatabaseName_NotionID.md

What Gets Converted

βœ… Fully Supported:

  • Text: All rich text formatting (bold, italic, code, strikethrough, underline)
  • Blocks: Paragraphs, headings, lists, to-dos, quotes, callouts
  • Code: Code blocks with language syntax highlighting
  • Media: Images, videos, files, PDFs (with download option)
  • Embeds: Bookmarks, link previews
  • Databases: Schema documentation with property types
  • Nested Content: Toggles, nested lists, child pages/databases
  • Metadata: Created/edited times, page properties, URLs

⚠️ Partially Supported:

  • Tables: Converted to Markdown tables (complex formatting may be simplified)
  • Equations: Preserved as LaTeX ($$formula$$)
  • Synced Blocks: Marked as references, not duplicated
  • Column Layouts: Marked but flattened to single column
  • Database Relations: Links preserved as IDs, not expanded
  • Advanced Properties: Formulas and rollups show values, not formulas

❌ Not Supported:

  • Interactive Elements: Buttons, embedded apps
  • Real-time Collaboration: Comments, mentions (metadata only)
  • Database Views: Only default view captured
  • Page History: Only current version exported
  • Permissions: All content exported without access restrictions

Troubleshooting

Common Issues

Issue: "NOTION_TOKEN not set"

# Solution: Make sure .env file exists and tokens are set
echo 'NOTION_TOKENS="your_token_here"' > .env

Issue: "Failed to talk to Notion API"

  • Check that your token is valid (starts with ntn_ or secret_)
  • Verify you've shared pages with your integration
  • Ensure your integration has the correct permissions

Issue: "No pages found for this integration"

  • You must explicitly share pages with your integration (see Step 2)
  • Go to Notion β†’ Share β†’ Invite your integration

Issue: "Failed to download image/file"

  • Notion's file URLs expire after ~1 hour
  • Run the converter immediately after backup
  • Or use Notion-hosted URLs (choose 'N' for asset download)

Issue: "Rate limit exceeded"

  • Notion has API rate limits (~3 requests/second)
  • The scripts include built-in delays
  • For very large workspaces, the export may take several hours

Issue: "Memory issues with large workspaces"

# Increase Node.js memory limit
node --max-old-space-size=4096 bulk-exporter.js

Validation Checklist

After export, verify:

  • All expected workspaces have folders
  • Page count matches expectations (check INDEX.md)
  • Images display correctly in Markdown viewers
  • Assets folder contains downloaded files
  • No error messages in console output
  • manifest.json shows exportCompletedAt timestamp

Advanced Usage

Scheduled Backups

Create a cron job for automatic backups:

# Edit crontab
crontab -e

# Add this line: Run every Sunday at 2 AM
0 2 * * 0 cd /path/to/notion-backup && /usr/bin/node bulk-exporter.js && /usr/bin/node json-to-markdown.js

Backup Script

Create backup.sh for easy execution:

#!/bin/bash
set -e

echo "Starting Notion backup..."
node bulk-exporter.js

echo "Converting to Markdown..."
node json-to-markdown.js

echo "Creating archive..."
tar -czf notion-backup-$(date +%Y%m%d).tar.gz notion-markdown-export/

echo "βœ… Backup complete!"

Make it executable:

chmod +x backup.sh
./backup.sh

Selective Export

Export specific pages by ID:

# When prompted, choose option 3 and enter page IDs:
# https://notion.so/page-name-abc123def456...
# Just copy the URL and the script will extract the ID

Docker Support

Create Dockerfile:

FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "full-export"]

Build and run:

docker build -t notion-exporter .
docker run -e NOTION_TOKENS="token1,token2" -v $(pwd)/output:/app/notion-markdown-export notion-exporter

Next Steps: Importing to Other Platforms

After exporting, you can import your Markdown files to:

  1. Wiki.js - Best for team wikis (git-based, searchable)
  2. Obsidian - Best for personal knowledge management
  3. Docusaurus - Best for public documentation
  4. MkDocs - Simple static site generator
  5. BookStack - Good for structured documentation

See the separate "IMPORT_GUIDE.md" for detailed instructions.

FAQ

Q: Can I export multiple workspaces at once? A: Yes! Just provide all tokens comma-separated in NOTION_TOKENS.

Q: How long do exports take? A: Depends on workspace size. Typical: 5-30 minutes. Large workspaces: 1-2 hours.

Q: Will this work with Notion personal accounts? A: Yes, integrations work with all Notion account types.

Q: Do I need to keep Notion open during export? A: No, the scripts use Notion's API independently.

Q: Can I resume a failed export? A: Yes, the backup script tracks progress in manifest.json and skips already-exported pages.

Q: Will this export private pages? A: Only pages shared with your integration. You must explicitly share them.

Q: What about database entries? A: All pages in databases are exported as individual markdown files.

Q: Can I import back to Notion? A: Yes, Notion supports Markdown import, but you'll lose some metadata and structure.

Q: Are there any costs? A: No, this uses Notion's free API. No rate limits for personal use.

Q: What if I have 1000+ pages? A: The scripts handle large workspaces. Just increase Node.js memory if needed.

Support & Contributing

  • Issues: Report bugs on GitHub
  • Features: Suggest improvements via Issues
  • Pull Requests: Contributions welcome!

License

MIT License - Feel free to use, modify, and distribute.


Happy exporting! πŸš€

For import guides and platform-specific instructions, see IMPORT_GUIDE.md

@Sal7one
Copy link
Author

Sal7one commented Nov 16, 2025

bulk-exporter.js

#!/usr/bin/env node
const { Client } = require("@notionhq/client");
const fs = require("fs");
const path = require("path");
const readline = require("readline");
require("dotenv").config();

/**
 * Simple prompt helper (one question β†’ one answer)
 */
function promptInput(message) {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });
  return new Promise((resolve) => {
    rl.question(message, (answer) => {
      rl.close();
      resolve(answer);
    });
  });
}

async function promptYesNo(message, defaultNo = true) {
  const suffix = defaultNo ? " [y/N] " : " [Y/n] ";
  const answer = (await promptInput(message + suffix)).trim().toLowerCase();
  if (!answer) return !defaultNo;
  return answer === "y" || answer === "yes";
}

/**
 * Normalize Notion ID:
 * - Accepts URL
 * - Accepts 32-hex or 36-char id
 * - Returns 36-char hyphenated ID when possible
 */
function normalizeNotionId(input) {
  if (!input) return null;
  const trimmed = input.trim();
  // Try URL form
  try {
    const url = new URL(trimmed);
    const match = url.pathname.match(/([0-9a-f]{32})/i);
    if (match) {
      return hyphenateNotionId(match[1]);
    }
  } catch {
    // Not a URL, ignore
  }
  // Raw ID (with or without hyphens)
  const compact = trimmed.replace(/-/g, "");
  if (/^[0-9a-f]{32}$/i.test(compact)) {
    return hyphenateNotionId(compact);
  }
  // Fallback: return as-is (maybe already valid)
  return trimmed;
}

function hyphenateNotionId(compact32) {
  const clean = compact32.replace(/-/g, "");
  if (clean.length !== 32) return compact32;
  return (
    clean.slice(0, 8) +
    "-" +
    clean.slice(8, 12) +
    "-" +
    clean.slice(12, 16) +
    "-" +
    clean.slice(16, 20) +
    "-" +
    clean.slice(20)
  );
}

/**
 * Extract page title from a Notion page object
 */
function extractPageTitle(page) {
  if (!page || !page.properties) return null;
  const props = Object.values(page.properties);
  const titleProp = props.find((p) => p.type === "title");
  if (!titleProp || !Array.isArray(titleProp.title)) return null;
  return titleProp.title.map((t) => t.plain_text).join("");
}

/**
 * Extract database title from a Notion database object
 */
function extractDatabaseTitle(db) {
  if (!db || !Array.isArray(db.title)) return null;
  return db.title.map((t) => t.plain_text).join("");
}

/**
 * Load or create manifest.json
 */
function loadManifest(outputDir) {
  const manifestPath = path.join(outputDir, "manifest.json");
  if (!fs.existsSync(manifestPath)) {
    return {
      formatVersion: 1,
      exportStartedAt: new Date().toISOString(),
      exportCompletedAt: null,
      roots: [],
      pages: {},
      databases: {}
    };
  }
  const raw = fs.readFileSync(manifestPath, "utf8");
  try {
    return JSON.parse(raw);
  } catch {
    console.warn("Warning: manifest.json was corrupted, creating a new one.");
    return {
      formatVersion: 1,
      exportStartedAt: new Date().toISOString(),
      exportCompletedAt: null,
      roots: [],
      pages: {},
      databases: {}
    };
  }
}

function saveManifest(outputDir, manifest) {
  const manifestPath = path.join(outputDir, "manifest.json");
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
}

/**
 * Fetch full block tree: page/block -> children -> nested children, etc.
 * Adds .children[] to blocks with has_children = true
 */
async function fetchBlockTree(notion, blockId) {
  const allBlocks = [];
  let cursor = undefined;
  do {
    const response = await notion.blocks.children.list({
      block_id: blockId,
      start_cursor: cursor,
      page_size: 100
    });
    for (const block of response.results) {
      if (block.has_children) {
        block.children = await fetchBlockTree(notion, block.id);
      }
      allBlocks.push(block);
    }
    cursor = response.has_more ? response.next_cursor : undefined;
  } while (cursor);
  return allBlocks;
}

/**
 * Collect child page/database IDs from a block tree
 */
function collectChildIdsFromBlocks(blocks, childPageIds, childDatabaseIds) {
  for (const block of blocks) {
    if (block.type === "child_page") {
      childPageIds.push(block.id);
    } else if (block.type === "child_database") {
      childDatabaseIds.push(block.id);
    }
    if (Array.isArray(block.children) && block.children.length > 0) {
      collectChildIdsFromBlocks(block.children, childPageIds, childDatabaseIds);
    }
  }
}

/**
 * Crawl a page by ID (wrapper that retrieves it first)
 */
async function crawlPageById(notion, pageId, ctx) {
  if (ctx.seenPages.has(pageId)) return;
  const page = await notion.pages.retrieve({ page_id: pageId });
  await crawlPage(notion, page, ctx);
}

/**
 * Crawl a database by ID (wrapper that retrieves it first)
 */
async function crawlDatabaseById(notion, databaseId, ctx) {
  if (ctx.seenDatabases.has(databaseId)) return;
  const db = await notion.databases.retrieve({ database_id: databaseId });
  await crawlDatabase(notion, db, ctx);
}

/**
 * Crawl a Notion page:
 * - Fetch metadata
 * - Fetch full nested block tree
 * - Extract child pages/databases
 * - Save JSON
 * - Recurse into child pages & databases
 */
async function crawlPage(notion, page, ctx) {
  const pageId = page.id;
  if (ctx.seenPages.has(pageId)) return;
  ctx.seenPages.add(pageId);
  const title = extractPageTitle(page) || "(untitled page)";
  console.log(`[PAGE] ${title} (${pageId})`);
  const blocks = await fetchBlockTree(notion, pageId);
  const childPageIds = [];
  const childDatabaseIds = [];
  collectChildIdsFromBlocks(blocks, childPageIds, childDatabaseIds);
  const pageJson = {
    id: pageId,
    object: "page",
    title,
    url: page.url,
    parent: page.parent,
    properties: page.properties,
    created_time: page.created_time,
    last_edited_time: page.last_edited_time,
    archived: page.archived,
    blocks,
    childPageIds,
    childDatabaseIds,
    fetchedAt: new Date().toISOString()
  };
  const pagesDir = path.join(ctx.outputDir, "pages");
  fs.mkdirSync(pagesDir, { recursive: true });
  const pagePath = path.join(pagesDir, `${pageId}.json`);
  fs.writeFileSync(pagePath, JSON.stringify(pageJson, null, 2), "utf8");
  ctx.manifest.pages[pageId] = {
    id: pageId,
    title,
    path: path.relative(ctx.outputDir, pagePath),
    url: page.url,
    parent: page.parent,
    created_time: page.created_time,
    last_edited_time: page.last_edited_time,
    archived: page.archived
  };
  saveManifest(ctx.outputDir, ctx.manifest);
  // Recurse into child pages & databases
  for (const childPageId of childPageIds) {
    await crawlPageById(notion, childPageId, ctx);
  }
  for (const dbId of childDatabaseIds) {
    await crawlDatabaseById(notion, dbId, ctx);
  }
}

/**
 * Crawl a Notion database:
 * - Save database metadata
 * - Query all pages in the database
 * - For each page, crawl like normal
 */
async function crawlDatabase(notion, database, ctx) {
  const databaseId = database.id;
  if (ctx.seenDatabases.has(databaseId)) return;
  ctx.seenDatabases.add(databaseId);
  const title = extractDatabaseTitle(database) || "(untitled database)";
  console.log(`[DATABASE] ${title} (${databaseId})`);
  const databasesDir = path.join(ctx.outputDir, "databases");
  fs.mkdirSync(databasesDir, { recursive: true });
  const dbPath = path.join(databasesDir, `${databaseId}.json`);
  const dbJson = {
    id: databaseId,
    object: "database",
    title,
    url: database.url,
    parent: database.parent,
    properties: database.properties,
    created_time: database.created_time,
    last_edited_time: database.last_edited_time,
    archived: database.archived,
    fetchedAt: new Date().toISOString()
  };
  fs.writeFileSync(dbPath, JSON.stringify(dbJson, null, 2), "utf8");
  ctx.manifest.databases[databaseId] = {
    id: databaseId,
    title,
    path: path.relative(ctx.outputDir, dbPath),
    url: database.url,
    parent: database.parent,
    created_time: database.created_time,
    last_edited_time: database.last_edited_time,
    archived: database.archived
  };
  saveManifest(ctx.outputDir, ctx.manifest);
  // Query all pages in this database
  let cursor = undefined;
  do {
    const response = await notion.databases.query({
      database_id: databaseId,
      start_cursor: cursor,
      page_size: 100
    });
    for (const page of response.results) {
      await crawlPage(notion, page, ctx);
    }
    cursor = response.has_more ? response.next_cursor : undefined;
  } while (cursor);
}

/**
 * Search pages for interactive selection
 */
async function searchPages(notion, queryText) {
  const pages = [];
  let cursor = undefined;
  do {
    const response = await notion.search({
      query: queryText || undefined,
      filter: { property: "object", value: "page" },
      start_cursor: cursor,
      page_size: 50
    });
    pages.push(...response.results);
    cursor = response.has_more ? response.next_cursor : undefined;
    if (pages.length >= 200) break; // keep interactive list sane
  } while (cursor);
  return pages;
}

/**
 * Let user pick pages from a list using simple numbered menu
 */
async function promptSelectPagesFromList(pages) {
  if (!pages.length) {
    console.log("No pages found for this integration / search term.");
    return [];
  }
  console.log("\nFound pages (up to 200):\n");
  pages.forEach((page, index) => {
    const title = extractPageTitle(page) || "(untitled page)";
    console.log(
      `${index + 1}. ${title}  [${page.id}]  (last edited: ${page.last_edited_time})`
    );
  });
  console.log(
    "\nEnter the numbers of the pages you want to export, comma-separated (e.g. 1,3,5) or type 'all' to export all listed:"
  );
  const answer = (await promptInput("> ")).trim().toLowerCase();
  if (answer === "all") {
    return pages.map((p) => p.id);
  }
  const indices = answer
    .split(",")
    .map((s) => parseInt(s.trim(), 10))
    .filter((n) => !Number.isNaN(n) && n >= 1 && n <= pages.length);
  const ids = Array.from(new Set(indices)).map((i) => pages[i - 1].id);
  return ids;
}

/**
 * Collect ALL page IDs visible to this integration using search()
 */
async function collectAllPageIdsViaSearch(notion) {
  const pageIds = [];
  let cursor = undefined;
  console.log("\nCollecting all pages visible to this integration...");
  do {
    const response = await notion.search({
      filter: { property: "object", value: "page" },
      start_cursor: cursor,
      page_size: 100
    });
    for (const page of response.results) {
      pageIds.push(page.id);
    }
    cursor = response.has_more ? response.next_cursor : undefined;
    console.log(`  Collected ${pageIds.length} pages so far...`);
  } while (cursor);
  return Array.from(new Set(pageIds));
}

/**
 * Get workspace name/info for a given token
 */
async function getWorkspaceInfo(notion) {
  try {
    // Use search to get any page, then extract workspace info
    const response = await notion.search({ page_size: 1 });
    if (response.results.length > 0) {
      // We don't have direct workspace API, so we'll use the bot user info
      return { accessible: true };
    }
    return { accessible: true };
  } catch (err) {
    return { accessible: false, error: err.message };
  }
}

/**
 * Export a single workspace
 */
async function exportWorkspace(token, workspaceIndex, totalWorkspaces, baseOutputDir, mode, manualRootIds = null) {
  console.log(`\n${"=".repeat(60)}`);
  console.log(`WORKSPACE ${workspaceIndex + 1} of ${totalWorkspaces}`);
  console.log(`${"=".repeat(60)}`);
  
  const notion = new Client({ auth: token });
  
  // Test connection
  const info = await getWorkspaceInfo(notion);
  if (!info.accessible) {
    console.error(`❌ Failed to access workspace: ${info.error}`);
    console.error("Skipping this workspace...\n");
    return { success: false, error: info.error };
  }
  
  console.log("βœ“ Successfully connected to workspace");
  
  // Create workspace-specific output directory
  const workspaceDir = path.join(baseOutputDir, `workspace_${workspaceIndex + 1}`);
  fs.mkdirSync(workspaceDir, { recursive: true });
  
  // Store token info (last 4 chars for identification)
  const tokenInfo = {
    workspaceIndex: workspaceIndex + 1,
    tokenSuffix: token.slice(-4),
    exportStartedAt: new Date().toISOString()
  };
  fs.writeFileSync(
    path.join(workspaceDir, "workspace_info.json"),
    JSON.stringify(tokenInfo, null, 2),
    "utf8"
  );
  
  let rootIds = [];
  
  try {
    if (mode === "everything") {
      rootIds = await collectAllPageIdsViaSearch(notion);
      console.log(`Found ${rootIds.length} pages to export`);
    } else if (mode === "manual" && manualRootIds) {
      rootIds = manualRootIds;
      console.log(`Using ${rootIds.length} manually specified root(s)`);
    } else if (mode === "search") {
      const keyword = await promptInput(
        `[Workspace ${workspaceIndex + 1}] Enter search keyword (or leave empty): `
      );
      const pages = await searchPages(notion, keyword || undefined);
      rootIds = await promptSelectPagesFromList(pages);
      if (!rootIds.length) {
        console.log("No pages selected for this workspace.");
        return { success: true, pagesExported: 0 };
      }
    }
    
    await runExport(notion, workspaceDir, rootIds);
    
    return {
      success: true,
      pagesExported: rootIds.length,
      outputDir: workspaceDir
    };
  } catch (err) {
    console.error(`❌ Error exporting workspace: ${err.message}`);
    return { success: false, error: err.message };
  }
}

/**
 * Main export runner (same as before but now workspace-aware)
 */
async function runExport(notion, outputDir, rootIds) {
  fs.mkdirSync(outputDir, { recursive: true });
  const manifest = loadManifest(outputDir);
  manifest.roots = manifest.roots || [];
  // Add roots into manifest (if not already present)
  const existingRootIds = new Set((manifest.roots || []).map((r) => r.id));
  const now = new Date().toISOString();
  for (const id of rootIds) {
    if (!existingRootIds.has(id)) {
      manifest.roots.push({ id, addedAt: now });
    }
  }
  saveManifest(outputDir, manifest);
  const ctx = {
    outputDir,
    manifest,
    seenPages: new Set(Object.keys(manifest.pages || {})),
    seenDatabases: new Set(Object.keys(manifest.databases || {}))
  };
  for (const rootId of rootIds) {
    const id = normalizeNotionId(rootId);
    console.log(`\n=== ROOT: ${id} ===`);
    // Try treating as page first, then as database
    try {
      await crawlPageById(notion, id, ctx);
    } catch (err) {
      // Try database as fallback
      try {
        await crawlDatabaseById(notion, id, ctx);
      } catch (err2) {
        console.error(
          `Failed to fetch root ${id} as page or database. Skipping.`
        );
        console.error(String(err2));
      }
    }
  }
  manifest.exportCompletedAt = new Date().toISOString();
  saveManifest(outputDir, manifest);
  console.log("\nβœ… Workspace export complete.");
  console.log(`Output directory: ${outputDir}`);
}

/**
 * MAIN BULK EXPORT ENTRY
 */
async function main() {
  console.log("======================================");
  console.log("  Notion BULK Workspace Exporter     ");
  console.log("======================================\n");
  
  // Get tokens array
  const tokensInput = process.env.NOTION_TOKENS || "";
  let tokens = [];
  
  if (tokensInput) {
    // Parse from environment variable (comma-separated)
    tokens = tokensInput.split(",").map(t => t.trim()).filter(Boolean);
    console.log(`Loaded ${tokens.length} token(s) from NOTION_TOKENS environment variable`);
  } else {
    // Prompt user to enter tokens
    console.log("No NOTION_TOKENS environment variable found.");
    console.log("Please enter your Notion integration tokens (comma-separated):");
    console.log("Example: token1,token2,token3\n");
    const input = await promptInput("Tokens: ");
    tokens = input.split(",").map(t => t.trim()).filter(Boolean);
  }
  
  if (tokens.length === 0) {
    console.error("❌ No tokens provided. Exiting.");
    process.exit(1);
  }
  
  console.log(`\nβœ“ Found ${tokens.length} workspace token(s)`);
  
  const baseOutputDir = process.env.NOTION_BACKUP_DIR || path.join(process.cwd(), "notion-bulk-backup");
  console.log(`Base output directory: ${baseOutputDir}\n`);
  
  // Choose export mode (applies to all workspaces)
  console.log("How do you want to export from each workspace?");
  console.log("  1) Export EVERYTHING each integration can see");
  console.log("  2) Search and pick root pages interactively (per workspace)");
  console.log("  3) Same root IDs for all workspaces (enter manually)");
  
  const modeChoice = (await promptInput("\nEnter 1 / 2 / 3: ")).trim();
  
  let exportMode = "everything";
  let sharedRootIds = null;
  
  if (modeChoice === "1") {
    exportMode = "everything";
  } else if (modeChoice === "2") {
    exportMode = "search";
  } else if (modeChoice === "3") {
    exportMode = "manual";
    const raw = await promptInput(
      "Enter page/database URLs or IDs (comma-separated): "
    );
    sharedRootIds = raw
      .split(",")
      .map((s) => s.trim())
      .filter(Boolean)
      .map(normalizeNotionId);
    if (!sharedRootIds.length) {
      console.log("No valid IDs provided. Exiting.");
      process.exit(0);
    }
  } else {
    console.log("Invalid option. Exiting.");
    process.exit(1);
  }
  
  // Confirm before starting
  console.log("\n" + "=".repeat(60));
  console.log("BULK EXPORT SUMMARY");
  console.log("=".repeat(60));
  console.log(`Workspaces to export: ${tokens.length}`);
  console.log(`Export mode: ${exportMode === "everything" ? "Everything" : exportMode === "search" ? "Interactive search" : "Manual root IDs"}`);
  console.log(`Base output directory: ${baseOutputDir}`);
  console.log("=".repeat(60) + "\n");
  
  const proceed = await promptYesNo("Start bulk export now?", true);
  if (!proceed) {
    console.log("Aborted by user.");
    process.exit(0);
  }
  
  // Export each workspace
  const results = [];
  for (let i = 0; i < tokens.length; i++) {
    const result = await exportWorkspace(
      tokens[i],
      i,
      tokens.length,
      baseOutputDir,
      exportMode,
      sharedRootIds
    );
    results.push(result);
  }
  
  // Summary
  console.log("\n" + "=".repeat(60));
  console.log("BULK EXPORT COMPLETE");
  console.log("=".repeat(60));
  
  const successful = results.filter(r => r.success).length;
  const failed = results.filter(r => !r.success).length;
  
  console.log(`\nβœ“ Successful: ${successful} workspace(s)`);
  if (failed > 0) {
    console.log(`βœ— Failed: ${failed} workspace(s)`);
  }
  
  results.forEach((result, i) => {
    if (result.success) {
      console.log(`  Workspace ${i + 1}: βœ“ Exported (${result.outputDir})`);
    } else {
      console.log(`  Workspace ${i + 1}: βœ— Failed - ${result.error}`);
    }
  });
  
  console.log(`\nAll exports saved to: ${baseOutputDir}`);
  console.log("=".repeat(60) + "\n");
}

if (require.main === module) {
  main().catch((err) => {
    console.error("Fatal error:", err);
    process.exit(1);
  });
}

@Sal7one
Copy link
Author

Sal7one commented Nov 16, 2025

json-to-markdown.js

#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const https = require("https");
const http = require("http");
const readline = require("readline");

/**
 * Simple prompt helper
 */
function promptInput(message) {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });
  return new Promise((resolve) => {
    rl.question(message, (answer) => {
      rl.close();
      resolve(answer);
    });
  });
}

async function promptYesNo(message, defaultNo = true) {
  const suffix = defaultNo ? " [y/N] " : " [Y/n] ";
  const answer = (await promptInput(message + suffix)).trim().toLowerCase();
  if (!answer) return !defaultNo;
  return answer === "y" || answer === "yes";
}

/**
 * Download file from URL
 */
function downloadFile(url, destPath) {
  return new Promise((resolve, reject) => {
    const protocol = url.startsWith("https") ? https : http;
    const file = fs.createWriteStream(destPath);
    
    protocol.get(url, (response) => {
      if (response.statusCode === 302 || response.statusCode === 301) {
        // Follow redirect
        file.close();
        fs.unlinkSync(destPath);
        return downloadFile(response.headers.location, destPath)
          .then(resolve)
          .catch(reject);
      }
      
      if (response.statusCode !== 200) {
        file.close();
        fs.unlinkSync(destPath);
        return reject(new Error(`Failed to download: ${response.statusCode}`));
      }
      
      response.pipe(file);
      
      file.on("finish", () => {
        file.close();
        resolve(destPath);
      });
    }).on("error", (err) => {
      file.close();
      fs.unlinkSync(destPath);
      reject(err);
    });
  });
}

/**
 * Get file extension from URL or content type
 */
function getFileExtension(url, contentType) {
  // Try from URL first
  const urlMatch = url.match(/\.([a-zA-Z0-9]+)(?:\?|$)/);
  if (urlMatch) return urlMatch[1];
  
  // Try from content type
  const mimeMap = {
    "application/pdf": "pdf",
    "image/png": "png",
    "image/jpeg": "jpg",
    "image/jpg": "jpg",
    "image/gif": "gif",
    "image/svg+xml": "svg",
    "image/webp": "webp",
    "video/mp4": "mp4",
    "video/webm": "webm",
    "application/zip": "zip",
    "application/vnd.ms-excel": "xls",
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
    "application/msword": "doc",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx"
  };
  
  return mimeMap[contentType] || "bin";
}

/**
 * Convert rich text array to plain text
 */
function richTextToPlain(richTextArray) {
  if (!Array.isArray(richTextArray)) return "";
  return richTextArray.map((rt) => rt.plain_text || "").join("");
}

/**
 * Convert rich text array to Markdown with formatting
 */
function richTextToMarkdown(richTextArray) {
  if (!Array.isArray(richTextArray)) return "";
  
  return richTextArray.map((rt) => {
    let text = rt.plain_text || "";
    
    if (rt.annotations) {
      if (rt.annotations.bold) text = `**${text}**`;
      if (rt.annotations.italic) text = `*${text}*`;
      if (rt.annotations.strikethrough) text = `~~${text}~~`;
      if (rt.annotations.code) text = `\`${text}\``;
      if (rt.annotations.underline) text = `<u>${text}</u>`;
    }
    
    if (rt.href) {
      text = `[${text}](${rt.href})`;
    }
    
    return text;
  }).join("");
}

/**
 * Sanitize filename
 */
function sanitizeFilename(name) {
  return name
    .replace(/[<>:"/\\|?*]/g, "-")
    .replace(/\s+/g, "_")
    .substring(0, 200);
}

/**
 * Convert a single block to Markdown
 */
async function blockToMarkdown(block, depth = 0, ctx = {}) {
  const indent = "  ".repeat(depth);
  let md = "";
  
  switch (block.type) {
    case "paragraph":
      const paraText = richTextToMarkdown(block.paragraph?.rich_text || []);
      if (paraText.trim()) {
        md += `${indent}${paraText}\n\n`;
      }
      break;
      
    case "heading_1":
      md += `${indent}# ${richTextToPlain(block.heading_1?.rich_text || [])}\n\n`;
      break;
      
    case "heading_2":
      md += `${indent}## ${richTextToPlain(block.heading_2?.rich_text || [])}\n\n`;
      break;
      
    case "heading_3":
      md += `${indent}### ${richTextToPlain(block.heading_3?.rich_text || [])}\n\n`;
      break;
      
    case "bulleted_list_item":
      md += `${indent}- ${richTextToMarkdown(block.bulleted_list_item?.rich_text || [])}\n`;
      break;
      
    case "numbered_list_item":
      md += `${indent}1. ${richTextToMarkdown(block.numbered_list_item?.rich_text || [])}\n`;
      break;
      
    case "to_do":
      const checked = block.to_do?.checked ? "x" : " ";
      md += `${indent}- [${checked}] ${richTextToMarkdown(block.to_do?.rich_text || [])}\n`;
      break;
      
    case "toggle":
      md += `${indent}<details>\n`;
      md += `${indent}<summary>${richTextToMarkdown(block.toggle?.rich_text || [])}</summary>\n\n`;
      if (block.children) {
        for (const child of block.children) {
          md += await blockToMarkdown(child, depth + 1, ctx);
        }
      }
      md += `${indent}</details>\n\n`;
      break;
      
    case "code":
      const language = block.code?.language || "";
      const code = richTextToPlain(block.code?.rich_text || []);
      md += `${indent}\`\`\`${language}\n${code}\n\`\`\`\n\n`;
      break;
      
    case "quote":
      const quoteText = richTextToMarkdown(block.quote?.rich_text || []);
      md += `${indent}> ${quoteText}\n\n`;
      break;
      
    case "callout":
      const emoji = block.callout?.icon?.emoji || "πŸ’‘";
      const calloutText = richTextToMarkdown(block.callout?.rich_text || []);
      md += `${indent}> ${emoji} ${calloutText}\n\n`;
      break;
      
    case "divider":
      md += `${indent}---\n\n`;
      break;
      
    case "image":
      const imageUrl = block.image?.file?.url || block.image?.external?.url || "";
      const imageCaption = richTextToPlain(block.image?.caption || []);
      if (imageUrl && ctx.downloadAssets) {
        try {
          const filename = `image_${block.id}.${getFileExtension(imageUrl, "image/png")}`;
          const destPath = path.join(ctx.assetsDir, filename);
          await downloadFile(imageUrl, destPath);
          md += `${indent}![${imageCaption}](${path.relative(ctx.pageDir, destPath)})\n\n`;
          ctx.downloadedAssets++;
        } catch (err) {
          console.warn(`  ⚠️  Failed to download image: ${err.message}`);
          md += `${indent}![${imageCaption}](${imageUrl})\n\n`;
        }
      } else if (imageUrl) {
        md += `${indent}![${imageCaption}](${imageUrl})\n\n`;
      }
      break;
      
    case "video":
      const videoUrl = block.video?.file?.url || block.video?.external?.url || "";
      const videoCaption = richTextToPlain(block.video?.caption || []);
      if (videoUrl && ctx.downloadAssets) {
        try {
          const filename = `video_${block.id}.${getFileExtension(videoUrl, "video/mp4")}`;
          const destPath = path.join(ctx.assetsDir, filename);
          await downloadFile(videoUrl, destPath);
          md += `${indent}[πŸŽ₯ ${videoCaption || "Video"}](${path.relative(ctx.pageDir, destPath)})\n\n`;
          ctx.downloadedAssets++;
        } catch (err) {
          console.warn(`  ⚠️  Failed to download video: ${err.message}`);
          md += `${indent}[πŸŽ₯ ${videoCaption || "Video"}](${videoUrl})\n\n`;
        }
      } else if (videoUrl) {
        md += `${indent}[πŸŽ₯ ${videoCaption || "Video"}](${videoUrl})\n\n`;
      }
      break;
      
    case "file":
      const fileUrl = block.file?.file?.url || block.file?.external?.url || "";
      const fileName = richTextToPlain(block.file?.caption || []) || block.file?.name || "File";
      if (fileUrl && ctx.downloadAssets) {
        try {
          const ext = getFileExtension(fileUrl, "application/octet-stream");
          const safeName = sanitizeFilename(fileName);
          const filename = `${safeName}_${block.id}.${ext}`;
          const destPath = path.join(ctx.assetsDir, filename);
          await downloadFile(fileUrl, destPath);
          md += `${indent}πŸ“Ž [${fileName}](${path.relative(ctx.pageDir, destPath)})\n\n`;
          ctx.downloadedAssets++;
        } catch (err) {
          console.warn(`  ⚠️  Failed to download file: ${err.message}`);
          md += `${indent}πŸ“Ž [${fileName}](${fileUrl})\n\n`;
        }
      } else if (fileUrl) {
        md += `${indent}πŸ“Ž [${fileName}](${fileUrl})\n\n`;
      }
      break;
      
    case "pdf":
      const pdfUrl = block.pdf?.file?.url || block.pdf?.external?.url || "";
      const pdfCaption = richTextToPlain(block.pdf?.caption || []) || "PDF Document";
      if (pdfUrl && ctx.downloadAssets) {
        try {
          const filename = `pdf_${block.id}.pdf`;
          const destPath = path.join(ctx.assetsDir, filename);
          await downloadFile(pdfUrl, destPath);
          md += `${indent}πŸ“„ [${pdfCaption}](${path.relative(ctx.pageDir, destPath)})\n\n`;
          ctx.downloadedAssets++;
        } catch (err) {
          console.warn(`  ⚠️  Failed to download PDF: ${err.message}`);
          md += `${indent}πŸ“„ [${pdfCaption}](${pdfUrl})\n\n`;
        }
      } else if (pdfUrl) {
        md += `${indent}πŸ“„ [${pdfCaption}](${pdfUrl})\n\n`;
      }
      break;
      
    case "bookmark":
      const bookmarkUrl = block.bookmark?.url || "";
      const bookmarkCaption = richTextToPlain(block.bookmark?.caption || []);
      if (bookmarkUrl) {
        md += `${indent}πŸ”– [${bookmarkCaption || bookmarkUrl}](${bookmarkUrl})\n\n`;
      }
      break;
      
    case "embed":
      const embedUrl = block.embed?.url || "";
      if (embedUrl) {
        md += `${indent}[πŸ”— Embed](${embedUrl})\n\n`;
      }
      break;
      
    case "link_preview":
      const previewUrl = block.link_preview?.url || "";
      if (previewUrl) {
        md += `${indent}[πŸ”— ${previewUrl}](${previewUrl})\n\n`;
      }
      break;
      
    case "table":
      md += `${indent}_[Table with ${block.table?.table_width || 0} columns]_\n\n`;
      break;
      
    case "table_row":
      const cells = block.table_row?.cells || [];
      md += `${indent}| ${cells.map(cell => richTextToPlain(cell)).join(" | ")} |\n`;
      break;
      
    case "column_list":
      md += `${indent}<!-- Column Layout Start -->\n\n`;
      break;
      
    case "column":
      md += `${indent}<!-- Column -->\n\n`;
      break;
      
    case "equation":
      const expression = block.equation?.expression || "";
      md += `${indent}$$${expression}$$\n\n`;
      break;
      
    case "synced_block":
      if (block.synced_block?.synced_from) {
        md += `${indent}_[Synced from another block]_\n\n`;
      } else {
        md += `${indent}_[Original synced block]_\n\n`;
      }
      break;
      
    case "template":
      md += `${indent}_[Template: ${richTextToPlain(block.template?.rich_text || [])}]_\n\n`;
      break;
      
    case "link_to_page":
      const linkedPageId = block.link_to_page?.page_id || block.link_to_page?.database_id || "";
      md += `${indent}β†’ [Linked page: \`${linkedPageId}\`]\n\n`;
      break;
      
    case "child_page":
      md += `${indent}πŸ“„ _[Child Page: ${block.id}]_\n\n`;
      break;
      
    case "child_database":
      md += `${indent}πŸ—„οΈ _[Child Database: ${block.id}]_\n\n`;
      break;
      
    case "table_of_contents":
      md += `${indent}_[Table of Contents]_\n\n`;
      break;
      
    case "breadcrumb":
      md += `${indent}_[Breadcrumb]_\n\n`;
      break;
      
    default:
      // Unknown block type
      md += `${indent}_[Unsupported block type: ${block.type}]_\n\n`;
  }
  
  // Process children if they exist (except for toggle which handles its own)
  if (block.children && Array.isArray(block.children) && block.type !== "toggle") {
    for (const child of block.children) {
      md += await blockToMarkdown(child, depth, ctx);
    }
  }
  
  return md;
}

/**
 * Convert page properties to Markdown frontmatter
 */
function propertiesToFrontmatter(properties) {
  const frontmatter = {};
  
  for (const [key, prop] of Object.entries(properties)) {
    try {
      switch (prop.type) {
        case "title":
          // Skip title, it's handled separately
          break;
        case "rich_text":
          const text = richTextToPlain(prop.rich_text);
          if (text) frontmatter[key] = text;
          break;
        case "number":
          if (prop.number !== null) frontmatter[key] = prop.number;
          break;
        case "select":
          if (prop.select?.name) frontmatter[key] = prop.select.name;
          break;
        case "multi_select":
          if (prop.multi_select && prop.multi_select.length > 0) {
            frontmatter[key] = prop.multi_select.map(s => s.name);
          }
          break;
        case "date":
          if (prop.date) {
            frontmatter[key] = prop.date.start;
            if (prop.date.end) {
              frontmatter[key] += ` β†’ ${prop.date.end}`;
            }
          }
          break;
        case "checkbox":
          frontmatter[key] = prop.checkbox;
          break;
        case "url":
          if (prop.url) frontmatter[key] = prop.url;
          break;
        case "email":
          if (prop.email) frontmatter[key] = prop.email;
          break;
        case "phone_number":
          if (prop.phone_number) frontmatter[key] = prop.phone_number;
          break;
        case "status":
          if (prop.status?.name) frontmatter[key] = prop.status.name;
          break;
        case "people":
          if (prop.people && prop.people.length > 0) {
            frontmatter[key] = prop.people.map(p => p.name || p.id);
          }
          break;
        case "files":
          if (prop.files && prop.files.length > 0) {
            frontmatter[key] = prop.files.map(f => f.name || "file");
          }
          break;
        case "relation":
          if (prop.relation && prop.relation.length > 0) {
            frontmatter[key] = prop.relation.map(r => r.id);
          }
          break;
        case "created_time":
          frontmatter[key] = prop.created_time;
          break;
        case "last_edited_time":
          frontmatter[key] = prop.last_edited_time;
          break;
        case "created_by":
          if (prop.created_by) {
            frontmatter[key] = prop.created_by.id;
          }
          break;
        case "last_edited_by":
          if (prop.last_edited_by) {
            frontmatter[key] = prop.last_edited_by.id;
          }
          break;
        case "formula":
          if (prop.formula) {
            frontmatter[key] = prop.formula.string || prop.formula.number || prop.formula.boolean;
          }
          break;
        case "rollup":
          if (prop.rollup) {
            frontmatter[key] = prop.rollup.number || prop.rollup.date?.start || "rollup";
          }
          break;
      }
    } catch (err) {
      console.warn(`Warning: Could not process property ${key}:`, err.message);
    }
  }
  
  return frontmatter;
}

/**
 * Convert a page JSON to Markdown
 */
async function pageToMarkdown(pageJson, ctx = {}) {
  let md = "";
  
  // Add frontmatter
  const frontmatter = propertiesToFrontmatter(pageJson.properties || {});
  frontmatter.notion_id = pageJson.id;
  frontmatter.created_time = pageJson.created_time;
  frontmatter.last_edited_time = pageJson.last_edited_time;
  frontmatter.notion_url = pageJson.url;
  
  if (pageJson.archived) {
    frontmatter.archived = true;
  }
  
  if (Object.keys(frontmatter).length > 0) {
    md += "---\n";
    for (const [key, value] of Object.entries(frontmatter)) {
      if (value !== null && value !== undefined) {
        if (Array.isArray(value)) {
          md += `${key}:\n`;
          value.forEach(v => md += `  - ${v}\n`);
        } else if (typeof value === "string" && value.includes("\n")) {
          md += `${key}: |\n  ${value.replace(/\n/g, "\n  ")}\n`;
        } else {
          const jsonValue = typeof value === "string" ? value : JSON.stringify(value);
          md += `${key}: ${jsonValue}\n`;
        }
      }
    }
    md += "---\n\n";
  }
  
  // Add title as H1
  md += `# ${pageJson.title || "Untitled"}\n\n`;
  
  // Convert blocks
  if (Array.isArray(pageJson.blocks)) {
    for (const block of pageJson.blocks) {
      md += await blockToMarkdown(block, 0, ctx);
    }
  }
  
  // Add child pages/databases references
  if (pageJson.childPageIds && pageJson.childPageIds.length > 0) {
    md += "\n---\n\n";
    md += "## πŸ“„ Child Pages\n\n";
    pageJson.childPageIds.forEach(id => {
      md += `- \`${id}\`\n`;
    });
    md += "\n";
  }
  
  if (pageJson.childDatabaseIds && pageJson.childDatabaseIds.length > 0) {
    if (!pageJson.childPageIds || pageJson.childPageIds.length === 0) {
      md += "\n---\n\n";
    }
    md += "## πŸ—„οΈ Child Databases\n\n";
    pageJson.childDatabaseIds.forEach(id => {
      md += `- \`${id}\`\n`;
    });
    md += "\n";
  }
  
  return md;
}

/**
 * Convert a database JSON to Markdown
 */
function databaseToMarkdown(dbJson) {
  let md = "";
  
  // Add frontmatter
  md += "---\n";
  md += `notion_id: "${dbJson.id}"\n`;
  md += `type: database\n`;
  md += `created_time: "${dbJson.created_time}"\n`;
  md += `last_edited_time: "${dbJson.last_edited_time}"\n`;
  if (dbJson.url) md += `notion_url: "${dbJson.url}"\n`;
  if (dbJson.archived) md += `archived: true\n`;
  md += "---\n\n";
  
  // Add title
  md += `# πŸ—„οΈ ${dbJson.title || "Untitled Database"}\n\n`;
  
  // Add description if exists
  if (dbJson.description && Array.isArray(dbJson.description)) {
    const desc = richTextToPlain(dbJson.description);
    if (desc) {
      md += `${desc}\n\n`;
    }
  }
  
  // Add properties schema
  md += "## Database Schema\n\n";
  if (dbJson.properties) {
    md += "| Property | Type | Configuration |\n";
    md += "|----------|------|---------------|\n";
    
    for (const [name, prop] of Object.entries(dbJson.properties)) {
      let config = "";
      
      if (prop.type === "select" && prop.select?.options) {
        config = `Options: ${prop.select.options.map(o => o.name).join(", ")}`;
      } else if (prop.type === "multi_select" && prop.multi_select?.options) {
        config = `Options: ${prop.multi_select.options.map(o => o.name).join(", ")}`;
      } else if (prop.type === "formula" && prop.formula?.expression) {
        config = `Formula: \`${prop.formula.expression}\``;
      } else if (prop.type === "relation" && prop.relation?.database_id) {
        config = `β†’ \`${prop.relation.database_id}\``;
      } else if (prop.type === "rollup") {
        config = `Rollup`;
      }
      
      md += `| ${name} | \`${prop.type}\` | ${config} |\n`;
    }
  }
  md += "\n";
  
  return md;
}

/**
 * Convert a workspace directory
 */
async function convertWorkspace(workspaceDir, outputDir, options = {}) {
  console.log(`\nConverting workspace: ${workspaceDir}`);
  
  const pagesDir = path.join(workspaceDir, "pages");
  const databasesDir = path.join(workspaceDir, "databases");
  const manifestPath = path.join(workspaceDir, "manifest.json");
  
  if (!fs.existsSync(manifestPath)) {
    console.log(`  ⚠️  No manifest.json found, skipping...`);
    return { success: false, error: "No manifest" };
  }
  
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
  
  let pagesConverted = 0;
  let databasesConverted = 0;
  let totalAssets = 0;
  
  // Create assets directory if downloading
  const assetsDir = options.downloadAssets ? path.join(outputDir, "assets") : null;
  if (assetsDir) {
    fs.mkdirSync(assetsDir, { recursive: true });
  }
  
  // Convert pages
  if (fs.existsSync(pagesDir)) {
    const mdPagesDir = path.join(outputDir, "pages");
    fs.mkdirSync(mdPagesDir, { recursive: true });
    
    const pageFiles = fs.readdirSync(pagesDir).filter(f => f.endsWith(".json"));
    
    console.log(`  Converting ${pageFiles.length} pages...`);
    
    for (const file of pageFiles) {
      try {
        const pageJson = JSON.parse(fs.readFileSync(path.join(pagesDir, file), "utf8"));
        
        const ctx = {
          downloadAssets: options.downloadAssets,
          assetsDir: assetsDir,
          pageDir: mdPagesDir,
          downloadedAssets: 0
        };
        
        const markdown = await pageToMarkdown(pageJson, ctx);
        
        const title = sanitizeFilename(pageJson.title || "untitled");
        const mdFileName = `${title}_${pageJson.id}.md`;
        const mdPath = path.join(mdPagesDir, mdFileName);
        
        fs.writeFileSync(mdPath, markdown, "utf8");
        pagesConverted++;
        totalAssets += ctx.downloadedAssets;
        
        if (pagesConverted % 10 === 0) {
          console.log(`    Progress: ${pagesConverted}/${pageFiles.length} pages`);
        }
      } catch (err) {
        console.error(`  ❌ Error converting page ${file}:`, err.message);
      }
    }
  }
  
  // Convert databases
  if (fs.existsSync(databasesDir)) {
    const mdDbDir = path.join(outputDir, "databases");
    fs.mkdirSync(mdDbDir, { recursive: true });
    
    const dbFiles = fs.readdirSync(databasesDir).filter(f => f.endsWith(".json"));
    
    console.log(`  Converting ${dbFiles.length} databases...`);
    
    for (const file of dbFiles) {
      try {
        const dbJson = JSON.parse(fs.readFileSync(path.join(databasesDir, file), "utf8"));
        const markdown = databaseToMarkdown(dbJson);
        
        const title = sanitizeFilename(dbJson.title || "untitled_database");
        const mdFileName = `${title}_${dbJson.id}.md`;
        const mdPath = path.join(mdDbDir, mdFileName);
        
        fs.writeFileSync(mdPath, markdown, "utf8");
        databasesConverted++;
      } catch (err) {
        console.error(`  ❌ Error converting database ${file}:`, err.message);
      }
    }
  }
  
  // Create index file
  const indexPath = path.join(outputDir, "INDEX.md");
  let indexMd = `# Workspace Export\n\n`;
  indexMd += `**Export Date:** ${manifest.exportCompletedAt || manifest.exportStartedAt}\n\n`;
  indexMd += `**Stats:**\n`;
  indexMd += `- Pages: ${pagesConverted}\n`;
  indexMd += `- Databases: ${databasesConverted}\n`;
  if (options.downloadAssets) {
    indexMd += `- Downloaded Assets: ${totalAssets}\n`;
  }
  indexMd += `\n---\n\n`;
  
  if (manifest.roots && manifest.roots.length > 0) {
    indexMd += `## Root Pages/Databases\n\n`;
    manifest.roots.forEach(root => {
      indexMd += `- \`${root.id}\`\n`;
    });
    indexMd += `\n`;
  }
  
  fs.writeFileSync(indexPath, indexMd, "utf8");
  
  // Copy workspace info and manifest
  const workspaceInfoPath = path.join(workspaceDir, "workspace_info.json");
  if (fs.existsSync(workspaceInfoPath)) {
    fs.copyFileSync(workspaceInfoPath, path.join(outputDir, "workspace_info.json"));
  }
  fs.copyFileSync(manifestPath, path.join(outputDir, "manifest.json"));
  
  console.log(`  βœ“ Converted ${pagesConverted} pages, ${databasesConverted} databases`);
  if (options.downloadAssets) {
    console.log(`  βœ“ Downloaded ${totalAssets} assets`);
  }
  
  return {
    success: true,
    pagesConverted,
    databasesConverted,
    assetsDownloaded: totalAssets
  };
}

/**
 * Main conversion function
 */
async function main() {
  console.log("======================================");
  console.log("   Notion JSON to Markdown Converter  ");
  console.log("======================================\n");
  
  const inputDir = process.env.NOTION_BACKUP_DIR || 
                   (await promptInput("Enter path to backup directory: ")).trim();
  
  if (!fs.existsSync(inputDir)) {
    console.error(`❌ Directory not found: ${inputDir}`);
    process.exit(1);
  }
  
  const outputDir = process.env.NOTION_MARKDOWN_DIR || 
                    path.join(path.dirname(inputDir), path.basename(inputDir) + "-markdown");
  
  console.log(`Input directory: ${inputDir}`);
  console.log(`Output directory: ${outputDir}\n`);
  
  // Ask about downloading assets
  const downloadAssets = await promptYesNo(
    "Download files, images, PDFs, and videos? (This may take time and space)",
    true
  );
  
  console.log();
  
  const options = {
    downloadAssets
  };
  
  // Check if it's a bulk export (multiple workspaces) or single workspace
  const entries = fs.readdirSync(inputDir);
  const workspaceDirs = entries.filter(e => {
    const fullPath = path.join(inputDir, e);
    return fs.statSync(fullPath).isDirectory() && e.startsWith("workspace_");
  });
  
  let results = [];
  
  if (workspaceDirs.length > 0) {
    // Bulk export
    console.log(`Found ${workspaceDirs.length} workspace(s)\n`);
    
    for (const workspaceDir of workspaceDirs) {
      const workspacePath = path.join(inputDir, workspaceDir);
      const workspaceOutputDir = path.join(outputDir, workspaceDir);
      
      const result = await convertWorkspace(workspacePath, workspaceOutputDir, options);
      results.push({ workspace: workspaceDir, ...result });
    }
  } else {
    // Single workspace
    console.log("Converting single workspace...\n");
    const result = await convertWorkspace(inputDir, outputDir, options);
    results.push({ workspace: "main", ...result });
  }
  
  // Summary
  console.log("\n" + "=".repeat(60));
  console.log("CONVERSION COMPLETE");
  console.log("=".repeat(60) + "\n");
  
  let totalPages = 0;
  let totalDatabases = 0;
  let totalAssets = 0;
  
  results.forEach(r => {
    if (r.success) {
      totalPages += r.pagesConverted || 0;
      totalDatabases += r.databasesConverted || 0;
      totalAssets += r.assetsDownloaded || 0;
      console.log(`βœ“ ${r.workspace}: ${r.pagesConverted} pages, ${r.databasesConverted} databases, ${r.assetsDownloaded || 0} assets`);
    } else {
      console.log(`βœ— ${r.workspace}: Failed - ${r.error}`);
    }
  });
  
  console.log(`\nπŸ“Š Total: ${totalPages} pages, ${totalDatabases} databases`);
  if (downloadAssets) {
    console.log(`πŸ“Ž Total assets downloaded: ${totalAssets}`);
  }
  console.log(`\nπŸ“ Markdown files saved to: ${outputDir}`);
  console.log("=".repeat(60) + "\n");
}

if (require.main === module) {
  main().catch((err) => {
    console.error("Fatal error:", err);
    process.exit(1);
  });
}

@Sal7one
Copy link
Author

Sal7one commented Nov 16, 2025

import to wikijs

Notes: its broken and messy and creates pages everywhere
if you have minmal workspaces or data in notion this can be an option :P

.env

WIKIJS_URL="http://wikijsurllocal:portttt"
NOTION_MARKDOWN_DIR="./notion-markdown-export"
WIKIJS_TOKEN="JWTTOKEN"

libs

npm i axios form-data dotenv     
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
require('dotenv').config();

// Configuration from environment variables
const WIKI_URL = process.env.WIKIJS_URL || 'http://localhost:3000';
const API_TOKEN = process.env.WIKIJS_TOKEN || '';
const INPUT_DIR = process.env.NOTION_MARKDOWN_DIR || './notion-markdown-export2';

// Performance tuning
const CONCURRENT_ASSETS = 5; // Upload 5 assets at once
const CONCURRENT_PAGES = 3; // Create 3 pages at once
const DELAY_BETWEEN_BATCHES = 100; // Minimal delay between batches

class WikiJSImporter {
  constructor(wikiUrl, apiToken) {
    this.wikiUrl = wikiUrl.replace(/\/$/, '');
    this.apiToken = apiToken;
    this.headers = {
      'Authorization': `Bearer ${apiToken}`,
      'Content-Type': 'application/json'
    };
    this.stats = {
      pages: { success: 0, failed: 0, skipped: 0 },
      assets: { success: 0, failed: 0 }
    };
    this.assetMap = new Map(); // Maps old asset paths to new Wiki.js URLs
    this.pageIdToPath = new Map(); // Maps Notion page IDs to Wiki.js paths
    this.createdPages = new Set(); // Track created pages to avoid duplicates
  }

  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  /**
   * Clean up filename for Wiki.js (remove UUIDs, sanitize)
   */
  cleanFilename(filename) {
    return filename
      .replace(/_[a-f0-9-]{36}\.md$/, '') // Remove Notion UUID
      .replace(/_/g, '-') // Replace underscores with hyphens
      .replace(/[^a-zA-Z0-9-]/g, '-') // Remove special characters
      .replace(/-+/g, '-') // Collapse multiple hyphens
      .replace(/^-|-$/g, '') // Remove leading/trailing hyphens
      .toLowerCase()
      .substring(0, 100) // Limit length
      .replace(/\.md$/, ''); // Remove .md extension
  }

  /**
   * Extract title from markdown content
   */
  extractTitle(content) {
    const match = content.match(/^#\s+(.+)$/m);
    return match ? match[1].trim() : 'Untitled';
  }

  /**
   * Remove frontmatter from content
   */
  removeFrontmatter(content) {
    return content.replace(/^---\n[\s\S]*?\n---\n\n?/, '');
  }

  /**
   * Upload asset to Wiki.js
   */
  /**
   * Upload asset to Wiki.js
   */
   /**
   * Upload asset to Wiki.js
   */
  async uploadAsset(assetPath, workspaceDir) {
    try {
      const filename = path.basename(assetPath);
      const fileBuffer = fs.readFileSync(assetPath);

      const formData = new FormData();

      // 1) Metadata part – at least folderId is expected
      //    0 = root folder
      formData.append(
        'mediaUpload',
        JSON.stringify({ folderId: 0 }) // or another folderId if you want
      );

      // 2) Actual file
      formData.append('mediaUpload', fileBuffer, filename);

      const response = await axios.post(
        `${this.wikiUrl}/u`,
        formData,
        {
          headers: {
            ...formData.getHeaders(),
            'Authorization': `Bearer ${this.apiToken}`
          },
          timeout: 30000,
          maxContentLength: Infinity,
          maxBodyLength: Infinity
        }
      );

      const data = response.data;
      let wikiPath;

      // Handle the different ways Wiki.js might answer

      // Case: [{ path, url, ... }]
      if (Array.isArray(data) && data[0]) {
        wikiPath = data[0].url || data[0].path;
      }
      // Case: { succeeded: [ { path, url, ... } ], failed: [] }
      else if (Array.isArray(data.succeeded) && data.succeeded[0]) {
        wikiPath = data.succeeded[0].url || data.succeeded[0].path;
      }
      // Case: { path: "/uploads/..." }
      else if (data.path) {
        wikiPath = data.path;
      }

      if (!wikiPath) {
        console.error('Unexpected upload response:', JSON.stringify(data, null, 2));
        throw new Error('No usable media path in upload response');
      }

      this.stats.assets.success++;
      return wikiPath;
    } catch (error) {
      this.stats.assets.failed++;

      if (error.response) {
        console.error(
          'Asset upload failed for',
          assetPath,
          '- status:',
          error.response.status,
          'body:',
          JSON.stringify(error.response.data, null, 2)
        );
      } else {
        console.error(
          'Asset upload failed for',
          assetPath,
          '-',
          error.message
        );
      }

      return null;
    }
  }


  /**
   * Upload assets in parallel batches
   */
  async uploadAssets(workspaceDir) {
    const assetsDir = path.join(workspaceDir, 'assets');
    
    if (!fs.existsSync(assetsDir)) {
      console.log('  No assets directory found, skipping...');
      return;
    }

    const assetFiles = fs.readdirSync(assetsDir)
      .filter(f => fs.statSync(path.join(assetsDir, f)).isFile())
      .map(f => path.join(assetsDir, f));
    
    console.log(`  Uploading ${assetFiles.length} assets (${CONCURRENT_ASSETS} at a time)...`);

    // Process in batches
    for (let i = 0; i < assetFiles.length; i += CONCURRENT_ASSETS) {
      const batch = assetFiles.slice(i, i + CONCURRENT_ASSETS);
      const results = await Promise.allSettled(
        batch.map(assetPath => this.uploadAssetToMap(assetPath, workspaceDir))
      );
      
      // Log progress
      const processed = Math.min(i + CONCURRENT_ASSETS, assetFiles.length);
      console.log(`    Progress: ${processed}/${assetFiles.length} assets`);
      
      await this.delay(DELAY_BETWEEN_BATCHES);
    }
  }

  /**
   * Upload asset and add to map
   */
  async uploadAssetToMap(assetPath, workspaceDir) {
    const wikiPath = await this.uploadAsset(assetPath, workspaceDir);
    
    if (wikiPath) {
      const file = path.basename(assetPath);
      const relativePath = path.relative(workspaceDir, assetPath);
      this.assetMap.set(relativePath, wikiPath);
      this.assetMap.set(`../${relativePath}`, wikiPath);
      this.assetMap.set(`./${relativePath}`, wikiPath);
      this.assetMap.set(file, wikiPath);
    }
    
    return wikiPath;
  }

  /**
   * Fix asset paths in content
   */
  fixAssetPaths(content) {
    let fixedContent = content;

    fixedContent = fixedContent.replace(
      /!\[([^\]]*)\]\(\/uploads\/([^/]+\.\w+)\)/g,
      (match, alt, filename) => `![${alt}](/${filename})`
    );

    // Also fix regular links: [text](/uploads/file.pdf)
    fixedContent = fixedContent.replace(
      /\[([^\]]+)\]\(\/uploads\/([^/]+\.\w+)\)/g,
      (match, text, filename) => `[${text}](/${filename})`
    );


    // Fix image references: ![alt](../assets/image.png)
    fixedContent = fixedContent.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, imagePath) => {
      // Skip external URLs
      if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
        return match;
      }

      // Try exact match first
      if (this.assetMap.has(imagePath)) {
        return `![${alt}](${this.assetMap.get(imagePath)})`;
      }

      // Try to find by filename
      const filename = path.basename(imagePath);
      for (const [oldPath, newPath] of this.assetMap.entries()) {
        if (path.basename(oldPath) === filename) {
          return `![${alt}](${newPath})`;
        }
      }

      return match;
    });

    // Fix file/PDF/video links: [text](../assets/file.pdf)
    fixedContent = fixedContent.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, (match, text, linkPath) => {
      // Skip external URLs and special markers
      if (linkPath.startsWith('http://') || linkPath.startsWith('https://') || 
          linkPath.startsWith('`') || linkPath.startsWith('/')) {
        return match;
      }

      // Check if it looks like an asset path
      if (linkPath.includes('assets/') || linkPath.match(/\.(pdf|mp4|webm|zip|docx?|xlsx?|pptx?)$/i)) {
        // Try exact match
        if (this.assetMap.has(linkPath)) {
          return `[${text}](${this.assetMap.get(linkPath)})`;
        }

        // Try filename match
        const filename = path.basename(linkPath);
        for (const [oldPath, newPath] of this.assetMap.entries()) {
          if (path.basename(oldPath) === filename) {
            return `[${text}](${newPath})`;
          }
        }
      }

      return match;
    });

    // Fix child page references - convert UUIDs to proper links
    fixedContent = fixedContent.replace(/^## πŸ“„ Child Pages\n\n([\s\S]*?)(?=\n## |$)/m, (match, childSection) => {
      let fixedSection = childSection.replace(/- `([a-f0-9-]{36})`/g, (m, pageId) => {
        const pageInfo = this.pageIdToPath.get(pageId);
        if (pageInfo) {
          return `- [${pageInfo.title}](/${pageInfo.path})`;
        }
        return ''; // Remove if not found
      });
      // Only include section if there are actual links
      if (fixedSection.trim() && fixedSection.includes('[')) {
        return `## πŸ“„ Child Pages\n\n${fixedSection}`;
      }
      return ''; // Remove empty section
    });

    // Fix child database references
    fixedContent = fixedContent.replace(/^## πŸ—„οΈ Child Databases\n\n([\s\S]*?)(?=\n## |$)/m, (match, childSection) => {
      let fixedSection = childSection.replace(/- `([a-f0-9-]{36})`/g, (m, pageId) => {
        const pageInfo = this.pageIdToPath.get(pageId);
        if (pageInfo) {
          return `- [${pageInfo.title}](/${pageInfo.path})`;
        }
        return '';
      });
      if (fixedSection.trim() && fixedSection.includes('[')) {
        return `## πŸ—„οΈ Child Databases\n\n${fixedSection}`;
      }
      return '';
    });

    return fixedContent;
  }

  /**
   * Build complete page index from manifest
   */
  buildPageIndex(workspaceDir, workspacePrefix) {
    const manifestPath = path.join(workspaceDir, 'manifest.json');
    
    if (!fs.existsSync(manifestPath)) {
      console.log('  ⚠️  No manifest.json found, building index from files...');
      this.buildPageIndexFromFiles(workspaceDir, workspacePrefix);
      return;
    }

    try {
      const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
      
      // Index pages
      if (manifest.pages) {
        for (const [pageId, pageInfo] of Object.entries(manifest.pages)) {
          const cleanPath = this.cleanFilename(pageInfo.title || pageId);
          const fullPath = `${workspacePrefix}/${cleanPath}`;
          this.pageIdToPath.set(pageId, {
            path: fullPath,
            title: pageInfo.title || 'Untitled',
            parent: pageInfo.parent
          });
        }
      }

      // Index databases
      if (manifest.databases) {
        for (const [dbId, dbInfo] of Object.entries(manifest.databases)) {
          const cleanPath = this.cleanFilename(dbInfo.title || dbId);
          const fullPath = `${workspacePrefix}/${cleanPath}`;
          this.pageIdToPath.set(dbId, {
            path: fullPath,
            title: dbInfo.title || 'Untitled Database',
            parent: dbInfo.parent
          });
        }
      }

      console.log(`  βœ“ Indexed ${this.pageIdToPath.size} pages/databases from manifest`);
    } catch (error) {
      console.error('  ⚠️  Failed to parse manifest, using file-based index');
      this.buildPageIndexFromFiles(workspaceDir, workspacePrefix);
    }
  }

  /**
   * Fallback: build index by scanning files
   */
  buildPageIndexFromFiles(workspaceDir, workspacePrefix) {
    const scanDirectory = (dir) => {
      if (!fs.existsSync(dir)) return;
      
      const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
      
      for (const file of files) {
        const filePath = path.join(dir, file);
        const content = fs.readFileSync(filePath, 'utf8');
        const title = this.extractTitle(content);
        const filename = path.basename(file, '.md');
        
        // Extract Notion ID from filename
        const pageIdMatch = filename.match(/([a-f0-9-]{36})$/);
        if (pageIdMatch) {
          const pageId = pageIdMatch[1];
          const cleanPath = this.cleanFilename(filename);
          const fullPath = `${workspacePrefix}/${cleanPath}`;
          
          this.pageIdToPath.set(pageId, {
            path: fullPath,
            title: title
          });
        }
      }
    };

    scanDirectory(path.join(workspaceDir, 'pages'));
    scanDirectory(path.join(workspaceDir, 'databases'));
    
    console.log(`  βœ“ Indexed ${this.pageIdToPath.size} pages from files`);
  }

  /**
   * Create a page in Wiki.js
   */
  async createPage(pagePath, title, content) {
    // Check if already created
    if (this.createdPages.has(pagePath)) {
      this.stats.pages.skipped++;
      return true;
    }

    try {
      const mutation = `
        mutation Page {
          pages {
            create(
              content: ${JSON.stringify(content)},
              description: "",
              editor: "markdown",
              isPublished: true,
              isPrivate: false,
              locale: "en",
              path: ${JSON.stringify(pagePath)},
              publishEndDate: "",
              publishStartDate: "",
              tags: ["notion-import"],
              title: ${JSON.stringify(title)}
            ) {
              responseResult {
                succeeded
                errorCode
                message
              }
            }
          }
        }
      `;

      const response = await axios.post(
        `${this.wikiUrl}/graphql`,
        { query: mutation },
        {
          headers: this.headers,
          timeout: 30000, // Reduced timeout
          maxContentLength: Infinity,
          maxBodyLength: Infinity
        }
      );

      const result = response.data?.data?.pages?.create?.responseResult;
      
      if (result?.succeeded) {
        this.createdPages.add(pagePath);
        this.stats.pages.success++;
        return true;
      } else {
        throw new Error(result?.message || 'Unknown error');
      }
    } catch (error) {
      const errorMsg = error.response?.data?.errors?.[0]?.message || error.message;
      
      if (errorMsg.includes('already exists') || errorMsg.includes('duplicate')) {
        this.createdPages.add(pagePath);
        this.stats.pages.skipped++;
        return true;
      }
      
      this.stats.pages.failed++;
      return false;
    }
  }

  /**
   * Import a single markdown file
   */
  async importMarkdownFile(filePath, workspaceDir, workspacePrefix) {
    try {
      const rawContent = fs.readFileSync(filePath, 'utf8');
      const title = this.extractTitle(rawContent);
      let content = this.removeFrontmatter(rawContent);
      content = content.replace(/^#\s+.+$/m, '').trim();
      content = this.fixAssetPaths(content);
      
      const filename = path.basename(filePath, '.md');
      const cleanName = this.cleanFilename(filename);
      const pagePath = `${workspacePrefix}/${cleanName}`;
      
      await this.createPage(pagePath, title, content);
      
      return true;
    } catch (error) {
      console.error(`  βœ— Error: ${path.basename(filePath)} - ${error.message}`);
      this.stats.pages.failed++;
      return false;
    }
  }

  /**
   * Get workspace name from workspace_info.json or directory name
   */
  getWorkspaceName(workspaceDir) {
    const workspaceInfoPath = path.join(workspaceDir, 'workspace_info.json');
    const dirName = path.basename(workspaceDir);
    
    if (fs.existsSync(workspaceInfoPath)) {
      try {
        const info = JSON.parse(fs.readFileSync(workspaceInfoPath, 'utf8'));
        return `workspace-${info.workspaceIndex || dirName.replace('workspace_', '')}`;
      } catch (e) {
        // Fall through to default
      }
    }
    
    return dirName.replace('workspace_', 'workspace-');
  }

  /**
   * Import a workspace
   */
  async importWorkspace(workspaceDir) {
    const workspaceName = this.getWorkspaceName(workspaceDir);
    
    console.log(`\n${'='.repeat(60)}`);
    console.log(`Importing: ${path.basename(workspaceDir)}`);
    console.log(`${'='.repeat(60)}\n`);

    if (!fs.existsSync(workspaceDir)) {
      console.error(`βœ— Workspace directory not found: ${workspaceDir}`);
      return;
    }

    const startTime = Date.now();

    // Step 1: Upload assets in parallel
    console.log('πŸ“Ž Step 1: Uploading assets...');
    await this.uploadAssets(workspaceDir);

    // Step 2: Build page index
    console.log('\nπŸ“‹ Step 2: Building page index...');
    this.buildPageIndex(workspaceDir, workspaceName);

    // Step 3: Import pages in parallel batches
    console.log('\nπŸ“„ Step 3: Importing pages...');
    await this.importPagesInBatches(workspaceDir, workspaceName, 'pages');

    // Step 4: Import databases in parallel batches
    console.log('\nπŸ—„οΈ  Step 4: Importing databases...');
    await this.importPagesInBatches(workspaceDir, workspaceName, 'databases');

    const duration = ((Date.now() - startTime) / 1000).toFixed(1);
    console.log(`\n⏱️  Workspace imported in ${duration}s`);
  }

  /**
   * Import pages in parallel batches
   */
  async importPagesInBatches(workspaceDir, workspacePrefix, subDir) {
    const pagesDir = path.join(workspaceDir, subDir);
    
    if (!fs.existsSync(pagesDir)) {
      console.log(`  No ${subDir} directory found, skipping...`);
      return;
    }

    const pageFiles = fs.readdirSync(pagesDir)
      .filter(f => f.endsWith('.md'))
      .sort()
      .map(f => path.join(pagesDir, f));

    if (pageFiles.length === 0) {
      console.log(`  No ${subDir} found`);
      return;
    }

    console.log(`  Found ${pageFiles.length} ${subDir} to import\n`);

    // Process in parallel batches
    for (let i = 0; i < pageFiles.length; i += CONCURRENT_PAGES) {
      const batch = pageFiles.slice(i, i + CONCURRENT_PAGES);
      
      await Promise.allSettled(
        batch.map(pageFile => 
          this.importMarkdownFile(pageFile, workspaceDir, workspacePrefix)
        )
      );
      
      // Log progress
      const processed = Math.min(i + CONCURRENT_PAGES, pageFiles.length);
      console.log(`    Progress: ${processed}/${pageFiles.length} ${subDir}`);
      
      await this.delay(DELAY_BETWEEN_BATCHES);
    }
  }

  /**
   * Import all workspaces
   */
  async importAll(baseDir) {
    const entries = fs.readdirSync(baseDir, { withFileTypes: true });
    const workspaces = entries
      .filter(e => e.isDirectory() && e.name.startsWith('workspace_'))
      .sort((a, b) => a.name.localeCompare(b.name))
      .map(e => path.join(baseDir, e.name));

    if (workspaces.length === 0) {
      // Single workspace mode
      console.log('Single workspace detected, importing...');
      await this.importWorkspace(baseDir);
    } else {
      // Multiple workspaces
      console.log(`Found ${workspaces.length} workspace(s)\n`);
      
      for (const workspace of workspaces) {
        // Clear maps for each workspace
        this.assetMap.clear();
        this.pageIdToPath.clear();
        
        await this.importWorkspace(workspace);
      }
    }

    this.printSummary();
  }

  /**
   * Print import summary
   */
  printSummary() {
    console.log('\n' + '='.repeat(60));
    console.log('IMPORT SUMMARY');
    console.log('='.repeat(60));
    console.log(`\nπŸ“„ Pages:`);
    console.log(`  βœ“ Success: ${this.stats.pages.success}`);
    console.log(`  ⏭️  Skipped: ${this.stats.pages.skipped}`);
    console.log(`  βœ— Failed:  ${this.stats.pages.failed}`);
    console.log(`\nπŸ“Ž Assets:`);
    console.log(`  βœ“ Success: ${this.stats.assets.success}`);
    console.log(`  βœ— Failed:  ${this.stats.assets.failed}`);
    console.log(`\nπŸ“Š Total: ${this.stats.pages.success + this.stats.pages.skipped} pages, ${this.stats.assets.success} assets`);
    console.log('\n' + '='.repeat(60) + '\n');
  }

  /**
   * Test connection to Wiki.js
   */
  async testConnection() {
    try {
      console.log('Testing connection...');
      console.log(`  Target: ${this.wikiUrl}`);
      
      // HTTP health check
      const healthCheck = await axios.get(this.wikiUrl, {
        timeout: 10000,
        validateStatus: () => true,
        proxy: false,
        family: 4
      });
      console.log(`  βœ“ HTTP Response: ${healthCheck.status}`);

      // GraphQL API test
      const query = `
        query {
          pages {
            list(limit: 1) {
              id
            }
          }
        }
      `;

      const response = await axios.post(
        `${this.wikiUrl}/graphql`,
        { query },
        { 
          headers: this.headers,
          timeout: 15000,
          proxy: false,
          family: 4
        }
      );

      if (response.data.errors) {
        console.error('βœ— GraphQL API error:', response.data.errors[0].message);
        return false;
      }

      console.log('βœ“ Successfully connected to Wiki.js GraphQL API\n');
      return true;
    } catch (error) {
      console.error('\nβœ— Failed to connect:', error.message);
      if (error.code === 'EHOSTUNREACH' || error.code === 'ENETUNREACH') {
        console.error('\nπŸ’‘ Tip: Check macOS network permissions:');
        console.error('   System Settings β†’ Privacy & Security β†’ Local Network');
        console.error('   Make sure VS Code and Terminal are enabled\n');
      }
      return false;
    }
  }
}

/**
 * Main function
 */
async function main() {
  console.log('======================================');
  console.log('   Wiki.js Notion Import Tool        ');
  console.log('======================================\n');

  // Validate configuration
  if (!API_TOKEN) {
    console.error('❌ Error: WIKIJS_TOKEN not set!');
    console.error('\nPlease set environment variables:');
    console.error('  export WIKIJS_URL="http://your-wiki-url:port"');
    console.error('  export WIKIJS_TOKEN="your-api-token"');
    console.error('  export NOTION_MARKDOWN_DIR="./notion-markdown-export"\n');
    process.exit(1);
  }

  if (!fs.existsSync(INPUT_DIR)) {
    console.error(`❌ Error: Input directory not found: ${INPUT_DIR}\n`);
    process.exit(1);
  }

  console.log(`Wiki.js URL: ${WIKI_URL}`);
  console.log(`Input directory: ${INPUT_DIR}\n`);

  // Create importer instance
  const importer = new WikiJSImporter(WIKI_URL, API_TOKEN);

  // Test connection
  const connected = await importer.testConnection();
  if (!connected) {
    process.exit(1);
  }

  // Import all workspaces
  await importer.importAll(INPUT_DIR);
}

// Run if called directly
if (require.main === module) {
  main().catch(error => {
    console.error('\n❌ Fatal error:', error.message);
    process.exit(1);
  });
}

module.exports = WikiJSImporter;

@Sal7one
Copy link
Author

Sal7one commented Nov 16, 2025

delete wikijs pages and more :P

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
require('dotenv').config();

// Configuration
const WIKI_URL = process.env.WIKIJS_URL || 'http://localhost:3000';
const API_TOKEN = process.env.WIKIJS_TOKEN || '';
const INPUT_DIR = process.env.NOTION_MARKDOWN_DIR || './notion-markdown-export';

const DELAY_BETWEEN_REQUESTS = 500; // ms
const DELAY_BETWEEN_DELETES = 300; // ms

class WikiJSManager {
  constructor(wikiUrl, apiToken) {
    this.wikiUrl = wikiUrl.replace(/\/$/, '');
    this.apiToken = apiToken;
    this.headers = {
      'Authorization': `Bearer ${apiToken}`,
      'Content-Type': 'application/json'
    };
    this.stats = {
      deleted: 0,
      pages: { success: 0, failed: 0 },
      assets: { success: 0, failed: 0 }
    };
    this.assetMap = new Map();
    this.pageIdToPath = new Map();
  }

  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  /**
   * Get all pages from Wiki.js
   */
  async getAllPages() {
    try {
      const query = `
        query {
          pages {
            list {
              id
              path
              title
            }
          }
        }
      `;

      const response = await axios.post(
        `${this.wikiUrl}/graphql`,
        { query },
        { headers: this.headers, timeout: 30000 }
      );

      return response.data?.data?.pages?.list || [];
    } catch (error) {
      console.error('Failed to get pages:', error.message);
      return [];
    }
  }

  /**
   * Delete a single page
   */
  async deletePage(pageId) {
    try {
      const mutation = `
        mutation {
          pages {
            delete(id: ${pageId}) {
              responseResult {
                succeeded
                message
              }
            }
          }
        }
      `;

      const response = await axios.post(
        `${this.wikiUrl}/graphql`,
        { query: mutation },
        { headers: this.headers, timeout: 30000 }
      );

      const result = response.data?.data?.pages?.delete?.responseResult;
      return result?.succeeded || false;
    } catch (error) {
      console.error(`Failed to delete page ${pageId}:`, error.message);
      return false;
    }
  }

  /**
   * Delete all pages from Wiki.js
   */
  async deleteAllPages() {
    console.log('\nπŸ—‘οΈ  Deleting all existing pages...\n');

    const pages = await this.getAllPages();
    
    if (pages.length === 0) {
      console.log('  No pages found to delete.');
      return;
    }

    console.log(`  Found ${pages.length} pages to delete\n`);

    for (const page of pages) {
      const success = await this.deletePage(page.id);
      if (success) {
        console.log(`  βœ“ Deleted: ${page.title}`);
        this.stats.deleted++;
      } else {
        console.log(`  βœ— Failed: ${page.title}`);
      }
      await this.delay(DELAY_BETWEEN_DELETES);
    }

    console.log(`\n  Total deleted: ${this.stats.deleted} pages\n`);
  }

  /**
   * Clean filename for Wiki.js
   */
  cleanFilename(filename) {
    return filename
      .replace(/_[a-f0-9-]{36}\.md$/, '')
      .replace(/_/g, '-')
      .replace(/\.md$/, '');
  }

  /**
   * Extract title from markdown
   */
  extractTitle(content) {
    const match = content.match(/^#\s+(.+)$/m);
    return match ? match[1].trim() : 'Untitled';
  }

  /**
   * Remove frontmatter
   */
  removeFrontmatter(content) {
    return content.replace(/^---\n[\s\S]*?\n---\n\n?/, '');
  }

  /**
   * Upload asset to Wiki.js
   */
  async uploadAsset(assetPath) {
    try {
      const filename = path.basename(assetPath);
      const fileBuffer = fs.readFileSync(assetPath);
      
      const formData = new FormData();
      formData.append('mediaUpload', fileBuffer, filename);
      
      const response = await axios.post(
        `${this.wikiUrl}/u`,
        formData,
        {
          headers: {
            ...formData.getHeaders(),
            'Authorization': `Bearer ${this.apiToken}`
          },
          timeout: 60000,
          maxContentLength: Infinity,
          maxBodyLength: Infinity
        }
      );

      if (response.data && response.data.path) {
        this.stats.assets.success++;
        return response.data.path;
      }
      return null;
    } catch (error) {
      console.error(`    βœ— Failed to upload ${path.basename(assetPath)}:`, error.message);
      this.stats.assets.failed++;
      return null;
    }
  }

  /**
   * Upload all assets from workspace
   */
  async uploadWorkspaceAssets(workspaceDir) {
    const assetsDir = path.join(workspaceDir, 'assets');
    
    if (!fs.existsSync(assetsDir)) {
      return;
    }

    const assetFiles = fs.readdirSync(assetsDir);
    console.log(`  Uploading ${assetFiles.length} assets...`);

    for (const file of assetFiles) {
      const assetPath = path.join(assetsDir, file);
      const stat = fs.statSync(assetPath);
      
      if (stat.isFile()) {
        const wikiPath = await this.uploadAsset(assetPath);
        
        if (wikiPath) {
          const relativePath = path.relative(workspaceDir, assetPath);
          this.assetMap.set(relativePath, wikiPath);
          this.assetMap.set(`../${relativePath}`, wikiPath);
          this.assetMap.set(`./${relativePath}`, wikiPath);
          console.log(`    βœ“ ${file}`);
        }
      }
    }
  }

  /**
   * Fix asset paths in content
   */
  fixAssetPaths(content) {
    let fixedContent = content;

    // Fix images
    fixedContent = fixedContent.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, imagePath) => {
      if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
        return match;
      }

      for (const [oldPath, newPath] of this.assetMap.entries()) {
        if (imagePath.includes(path.basename(oldPath)) || oldPath.includes(path.basename(imagePath))) {
          return `![${alt}](${newPath})`;
        }
      }

      return match;
    });

    // Fix file links
    fixedContent = fixedContent.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, (match, text, linkPath) => {
      if (linkPath.startsWith('http://') || linkPath.startsWith('https://') || linkPath.startsWith('`')) {
        return match;
      }

      if (linkPath.includes('assets/') || linkPath.match(/\.(pdf|mp4|webm|zip|docx?|xlsx?)$/i)) {
        for (const [oldPath, newPath] of this.assetMap.entries()) {
          if (linkPath.includes(path.basename(oldPath)) || oldPath.includes(path.basename(linkPath))) {
            return `[${text}](${newPath})`;
          }
        }
      }

      return match;
    });

    return fixedContent;
  }

  /**
   * Combine all pages from a workspace into one
   */
  combineWorkspacePages(workspaceDir) {
    console.log(`  Combining pages from ${path.basename(workspaceDir)}...`);
    
    let combinedContent = '';
    const pagesDir = path.join(workspaceDir, 'pages');
    const databasesDir = path.join(workspaceDir, 'databases');
    
    // Process pages
    if (fs.existsSync(pagesDir)) {
      const pageFiles = fs.readdirSync(pagesDir)
        .filter(f => f.endsWith('.md'))
        .sort();

      console.log(`    Found ${pageFiles.length} pages`);

      for (const file of pageFiles) {
        const filePath = path.join(pagesDir, file);
        let content = fs.readFileSync(filePath, 'utf8');
        
        // Remove frontmatter
        content = this.removeFrontmatter(content);
        
        // Extract title
        const title = this.extractTitle(content);
        
        // Remove the H1 (we'll add it back)
        content = content.replace(/^#\s+.+$/m, '').trim();
        
        // Fix asset paths
        content = this.fixAssetPaths(content);
        
        // Remove child page/database sections (they're just references)
        content = content.replace(/\n---\n\n## πŸ“„ Child Pages[\s\S]*$/, '');
        content = content.replace(/\n## πŸ—„οΈ Child Databases[\s\S]*$/, '');
        
        // Add to combined content with clear separation
        combinedContent += `\n\n---\n\n# ${title}\n\n${content}\n`;
      }
    }

    // Process databases
    if (fs.existsSync(databasesDir)) {
      const dbFiles = fs.readdirSync(databasesDir)
        .filter(f => f.endsWith('.md'))
        .sort();

      console.log(`    Found ${dbFiles.length} databases`);

      for (const file of dbFiles) {
        const filePath = path.join(databasesDir, file);
        let content = fs.readFileSync(filePath, 'utf8');
        
        content = this.removeFrontmatter(content);
        const title = this.extractTitle(content);
        content = content.replace(/^#\s+.+$/m, '').trim();
        content = this.fixAssetPaths(content);
        
        combinedContent += `\n\n---\n\n# ${title}\n\n${content}\n`;
      }
    }

    return combinedContent.trim();
  }

  /**
   * Create a page in Wiki.js
   */
  async createPage(pagePath, title, content) {
    try {
      const mutation = `
        mutation Page {
          pages {
            create(
              content: ${JSON.stringify(content)},
              description: "Combined workspace from Notion export",
              editor: "markdown",
              isPublished: true,
              isPrivate: false,
              locale: "en",
              path: ${JSON.stringify(pagePath)},
              publishEndDate: "",
              publishStartDate: "",
              tags: ["notion-import", "workspace"],
              title: ${JSON.stringify(title)}
            ) {
              responseResult {
                succeeded
                errorCode
                message
              }
              page {
                id
                path
                title
              }
            }
          }
        }
      `;

      const response = await axios.post(
        `${this.wikiUrl}/graphql`,
        { query: mutation },
        {
          headers: this.headers,
          timeout: 60000,
          maxContentLength: Infinity,
          maxBodyLength: Infinity
        }
      );

      const result = response.data?.data?.pages?.create?.responseResult;
      
      if (result?.succeeded) {
        console.log(`  βœ“ Created: ${title}`);
        this.stats.pages.success++;
        return true;
      } else {
        throw new Error(result?.message || 'Unknown error');
      }
    } catch (error) {
      console.error(`  βœ— Failed: ${title}`);
      console.error(`    Error: ${error.response?.data?.errors?.[0]?.message || error.message}`);
      this.stats.pages.failed++;
      return false;
    }
  }

  /**
   * Import workspace as single combined page
   */
  async importWorkspaceAsSinglePage(workspaceDir) {
    const workspaceName = path.basename(workspaceDir);
    console.log(`\n${'='.repeat(60)}`);
    console.log(`Processing: ${workspaceName}`);
    console.log(`${'='.repeat(60)}\n`);

    // Clear asset map for this workspace
    this.assetMap.clear();

    // Upload assets
    console.log('πŸ“Ž Step 1: Uploading assets...');
    await this.uploadWorkspaceAssets(workspaceDir);

    // Combine all pages
    console.log('\nπŸ“„ Step 2: Combining pages...');
    const combinedContent = this.combineWorkspacePages(workspaceDir);

    if (!combinedContent) {
      console.log('  ⚠️  No content found in workspace');
      return;
    }

    // Get workspace info for title
    const workspaceInfoPath = path.join(workspaceDir, 'workspace_info.json');
    let workspaceIndex = workspaceName.replace('workspace_', '');
    let title = `Workspace ${workspaceIndex}`;

    if (fs.existsSync(workspaceInfoPath)) {
      try {
        const info = JSON.parse(fs.readFileSync(workspaceInfoPath, 'utf8'));
        workspaceIndex = info.workspaceIndex || workspaceIndex;
        title = `Workspace ${workspaceIndex}`;
      } catch (e) {
        // Use default title
      }
    }

    // Create single page
    console.log('\nπŸ“ Step 3: Creating combined page...');
    const pagePath = `workspace-${workspaceIndex}`;
    
    // Add table of contents at the top
    const tocContent = `# ${title}\n\n> πŸ“š Combined export from Notion workspace\n\n## Table of Contents\n\nThis page contains all content from this workspace. Use the heading links in the sidebar to navigate.\n\n${combinedContent}`;
    
    await this.createPage(pagePath, title, tocContent);
    await this.delay(DELAY_BETWEEN_REQUESTS);
  }

  /**
   * Import all workspaces as combined pages
   */
  async importAllAsCombined(baseDir) {
    const entries = fs.readdirSync(baseDir, { withFileTypes: true });
    const workspaces = entries
      .filter(e => e.isDirectory() && e.name.startsWith('workspace_'))
      .map(e => path.join(baseDir, e.name))
      .sort();

    if (workspaces.length === 0) {
      console.log('Single workspace detected, importing...');
      await this.importWorkspaceAsSinglePage(baseDir);
    } else {
      console.log(`Found ${workspaces.length} workspace(s)\n`);
      
      for (const workspace of workspaces) {
        await this.importWorkspaceAsSinglePage(workspace);
      }
    }

    this.printSummary();
  }

  /**
   * Print summary
   */
  printSummary() {
    console.log('\n' + '='.repeat(60));
    console.log('IMPORT SUMMARY');
    console.log('='.repeat(60));
    console.log(`\nπŸ—‘οΈ  Deleted: ${this.stats.deleted} pages`);
    console.log(`\nπŸ“„ Combined Pages:`);
    console.log(`  βœ“ Success: ${this.stats.pages.success}`);
    console.log(`  βœ— Failed:  ${this.stats.pages.failed}`);
    console.log(`\nπŸ“Ž Assets:`);
    console.log(`  βœ“ Success: ${this.stats.assets.success}`);
    console.log(`  βœ— Failed:  ${this.stats.assets.failed}`);
    console.log('\n' + '='.repeat(60) + '\n');
  }

  /**
   * Test connection
   */
  async testConnection() {
    try {
      console.log('Testing connection...');
      console.log(`  Target: ${this.wikiUrl}`);
      
      const healthCheck = await axios.get(this.wikiUrl, {
        timeout: 10000,
        validateStatus: () => true,
        proxy: false,
        family: 4
      });
      console.log(`  βœ“ HTTP Response: ${healthCheck.status}`);

      const query = `
        query {
          pages {
            list(limit: 1) {
              id
            }
          }
        }
      `;

      const response = await axios.post(
        `${this.wikiUrl}/graphql`,
        { query },
        { 
          headers: this.headers,
          timeout: 15000,
          proxy: false,
          family: 4
        }
      );

      if (response.data.errors) {
        console.error('βœ— GraphQL API error:', response.data.errors[0].message);
        return false;
      }

      console.log('βœ“ Successfully connected to Wiki.js GraphQL API\n');
      return true;
    } catch (error) {
      console.error('\nβœ— Failed to connect:', error.message);
      return false;
    }
  }
}

/**
 * Interactive prompt
 */
function promptInput(message) {
  return new Promise((resolve) => {
    const readline = require('readline');
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout
    });
    
    rl.question(message, (answer) => {
      rl.close();
      resolve(answer.trim().toLowerCase());
    });
  });
}

/**
 * Main function
 */
async function main() {
  console.log('======================================');
  console.log('   Wiki.js Clean & Import Tool       ');
  console.log('======================================\n');

  if (!API_TOKEN) {
    console.error('❌ Error: WIKIJS_TOKEN not set!\n');
    process.exit(1);
  }

  if (!fs.existsSync(INPUT_DIR)) {
    console.error(`❌ Error: Input directory not found: ${INPUT_DIR}\n`);
    process.exit(1);
  }

  console.log(`Wiki.js URL: ${WIKI_URL}`);
  console.log(`Input directory: ${INPUT_DIR}\n`);

  const manager = new WikiJSManager(WIKI_URL, API_TOKEN);

  // Test connection
  const connected = await manager.testConnection();
  if (!connected) {
    process.exit(1);
  }

  // Ask what to do
  console.log('What would you like to do?\n');
  console.log('  1) Delete all pages only');
  console.log('  2) Import workspaces as combined pages (keep existing)');
  console.log('  3) Delete all + Import as combined pages (fresh start)');
  console.log('  4) Cancel\n');

  const choice = await promptInput('Enter choice (1-4): ');

  if (choice === '1') {
    const confirm = await promptInput('⚠️  Delete ALL pages? This cannot be undone! (yes/no): ');
    if (confirm === 'yes') {
      await manager.deleteAllPages();
    } else {
      console.log('Cancelled.');
    }
  } else if (choice === '2') {
    await manager.importAllAsCombined(INPUT_DIR);
  } else if (choice === '3') {
    const confirm = await promptInput('⚠️  Delete ALL existing pages and import fresh? (yes/no): ');
    if (confirm === 'yes') {
      await manager.deleteAllPages();
      await manager.importAllAsCombined(INPUT_DIR);
    } else {
      console.log('Cancelled.');
    }
  } else {
    console.log('Cancelled.');
  }
}

if (require.main === module) {
  main().catch(error => {
    console.error('\n❌ Fatal error:', error.message);
    process.exit(1);
  });
}

module.exports = WikiJSManager;

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