#!/usr/bin/env node // NotePMエクスポートデータ(md+frontmatter+添付)をXWikiへ一括インポートする。 // NotePMのサブフォルダ階層は、XWikiのネストされたスペース(Nested Pages)として再現する。 // // 使い方: node import-notepm.js <ノートディレクトリのパス> --root=<エクスポートルート全体> [--space=スペース名] [--dry-run] // または: node import-notepm.js --all --root=<エクスポートルート> [--dry-run] (全ノート一括) // // --root には全ノートを含むエクスポートルートディレクトリを指定する。ノート間・ページ間の // 内部リンク(https://xxx.notepm.jp/note//, /page/)を解決するために、 // インポート対象が1ノートだけでもルート全体を走査してリンクマップを構築する。 const fs = require("fs"); const path = require("path"); const envPath = path.join(__dirname, "..", ".env"); for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { const m = line.match(/^([A-Z_]+)=(.*)$/); if (m) process.env[m[1]] = m[2].replace(/^"|"$/g, ""); } const XWIKI_BASE_URL = process.env.XWIKI_BASE_URL; const AUTH = "Basic " + Buffer.from(`${process.env.XWIKI_ADMIN_USER}:${process.env.XWIKI_ADMIN_PASSWORD}`).toString("base64"); // NotePM移行データの実体はサブwiki "nexthd"。REST APIパス・閲覧/ダウンロードURLとも // wikiIdプレフィックスが無いとメインwiki(xwiki)扱いになり404になるため、必ず明示する。 const WIKI_ID = "nexthd"; const args = process.argv.slice(2); const noteDir = args.find((a) => !a.startsWith("--")); const dryRun = args.includes("--dry-run"); const allMode = args.includes("--all"); const updateOnly = args.includes("--update-only"); // 既存ページの本文だけ更新(ページ作成・添付再アップロードはしない) const spaceArg = args.find((a) => a.startsWith("--space=")); const rootArg = args.find((a) => a.startsWith("--root=")); const rootDir = rootArg ? rootArg.split("=")[1] : noteDir; if (!allMode && !noteDir) { console.error("使い方: node import-notepm.js <ノートディレクトリのパス> --root=<エクスポートルート> [--space=スペース名] [--dry-run]"); console.error(" または: node import-notepm.js --all --root=<エクスポートルート> [--dry-run] (全ノート一括)"); process.exit(1); } // NotePMのフォルダ/ファイル名は `表示名_ハッシュ10桁` の形式。ハッシュ部分を除去して表示名だけ取り出す。 const HASH_RE = /_([0-9a-f]{10})$/; function stripHash(name) { return name.replace(HASH_RE, ""); } function extractHash(name) { const m = name.match(HASH_RE); return m ? m[1] : null; } function sanitizeForXWikiName(name) { // XWikiのスペース/ページ名に使えない文字(. / : \ ~)を全角へ置換する return name .replace(/\./g, ".") .replace(/\//g, "/") .replace(/:/g, ":") .replace(/\\/g, "\") .replace(/~/g, "~") .trim(); } // サブフォルダも含めて.mdファイルを再帰的に収集する("_添付"フォルダは除外)。 // 戻り値は { file: 絶対パス, spacePath: [ノート名, サブフォルダ名, ...] } の配列。 // spacePathはそのファイルが属するネストされたスペースの階層(ページ自体の名前は含まない)。 function collectMdFiles(dir, spacePath) { const result = []; for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, ent.name); if (ent.isDirectory()) { if (ent.name.endsWith("_添付")) continue; result.push(...collectMdFiles(full, [...spacePath, sanitizeForXWikiName(stripHash(ent.name))])); } else if (ent.name.endsWith(".md")) { result.push({ file: full, spacePath }); } } return result; } const singleSpaceName = spaceArg ? spaceArg.split("=")[1] : (noteDir ? sanitizeForXWikiName(stripHash(path.basename(noteDir))) : null); // --- frontmatterパーサー(NotePMエクスポートの簡易YAML専用、汎用YAMLではない) --- function parseFrontmatter(raw) { const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); if (!m) return { meta: {}, body: raw }; const [, yamlBlock, body] = m; const meta = {}; const lines = yamlBlock.split("\n"); let i = 0; while (i < lines.length) { const line = lines[i]; const kv = line.match(/^([a-zA-Z_]+):\s?(.*)$/); if (kv) { const [, key, val] = kv; if (val.trim() === "" && lines[i + 1] && /^\s+-\s/.test(lines[i + 1])) { // リスト値(attachments等) const items = []; i++; while (i < lines.length && /^\s+-\s/.test(lines[i])) { items.push(lines[i].replace(/^\s+-\s/, "")); i++; } meta[key] = items; continue; } meta[key] = val.trim(); } i++; } return { meta, body }; } // Slack/GitHub風の絵文字ショートコードをUnicode絵文字に変換する(NotePMのタイトル・本文に頻出) const EMOJI_MAP = { smile: "😄", mag: "🔍", white_check_mark: "✅", sunglasses: "😎", star: "⭐", pencil: "✏️", pencil2: "📝", mega: "📣", lock: "🔒", bulb: "💡", beginner: "🔰", }; function convertEmojiShortcodes(text) { return text.replace(/:([a-z0-9_]+):/g, (m, name) => EMOJI_MAP[name] || m); } // NotePM独自の「・」箇条書き記法をCommonMark標準のリスト記法へ変換する。 // NotePMのインデントは全角スペースと半角スペースが混在し、半角の個数は項目ごとに不規則に // 増減する(コピペやエディタ挙動によるノイズ)一方、全角スペースの個数は論理的なネスト深さと // 一致している(実データで検証済み)。そのため深さ判定は「・」直前の全角スペース個数のみを // 使い、半角スペースは無視する。「・」の無い行(インデントのみの注記・説明文)は、それ単体の // インデント幅では深さを判定せず、常に直前の「・」項目の子(深さ+1)として扱う(NotePM側でも // 記号なし行は直前項目の説明として並列ではなく一段下にぶら下がる形で表示されているため)。 function convertBulletLists(text) { const lines = text.replace(/\r\n/g, "\n").split("\n"); const out = []; let block = null; let inCodeBlock = false; function flushBlock() { if (!block) return; const widths = [...block.bulletWidths].sort((a, b) => a - b); let lastDepth = 0; for (const it of block.items) { const depth = it.isBullet ? widths.indexOf(it.width) : lastDepth + 1; if (it.isBullet) lastDepth = depth; out.push(" ".repeat(depth) + "- " + it.content); } block = null; } for (const line of lines) { if (/^```/.test(line.trim())) { flushBlock(); inCodeBlock = !inCodeBlock; out.push(line); continue; } if (inCodeBlock) { out.push(line); continue; } if (line.trim() === "") { flushBlock(); out.push(line); continue; } const m = line.match(/^([  ]+)(.*)$/); if (m) { const [, indent, rest] = m; const fullWidthCount = [...indent].filter((c) => c === " ").length; const hasBullet = rest.startsWith("・"); const content = hasBullet ? rest.slice(1) : rest; if (hasBullet || block) { block = block || { bulletWidths: new Set(), items: [] }; if (hasBullet) block.bulletWidths.add(fullWidthCount); block.items.push({ isBullet: hasBullet, width: fullWidthCount, content }); continue; } } else if (line.startsWith("・")) { block = block || { bulletWidths: new Set(), items: [] }; block.bulletWidths.add(0); block.items.push({ isBullet: true, width: 0, content: line.slice(1) }); continue; } flushBlock(); out.push(line); } flushBlock(); return out.join("\n"); } // NotePM原本には見出しへの内部リンク(GitHub風の見出しスラグを想定、見出しテキストから半角括弧 // だけ除去したものをアンカー名にする書き方)が使われているページがある(黒電話操作方法等で確認)。 // XWiki側は見出しに人間可読でないハッシュidしか自動生成しないため、このリンクは機能しない。 // 見出し直後に同名のを挿入し、既存リンクをそのまま機能させる。 function addHeadingAnchors(text) { const lines = text.split("\n"); const out = []; const used = new Set(); let inCodeBlock = false; for (const line of lines) { if (/^```/.test(line.trim())) inCodeBlock = !inCodeBlock; out.push(line); if (!inCodeBlock) { const m = line.match(/^#{1,6}\s+(.+?)\s*$/); if (m) { const anchor = m[1].replace(/[()]/g, ""); if (anchor && !used.has(anchor)) { used.add(anchor); out.push(""); out.push(``); } } } } return out.join("\n"); } // NotePM原本は見た目の改行を単一の改行コードだけで表現しているが、CommonMarkは単一改行を // 段落内の折り返し(ソフトブレーク)として扱いスペース結合してしまうため、地の文の改行が // 消えて1行に連結される崩れが起きる(「・簡易積算は…」の続き行や「※」注記の複数行等で確認)。 // 空行区切りでない行の末尾にMarkdownのhard break(半角スペース2つ)を付けて改行を保持する。 // リスト行・見出し・テーブル・コードブロックは改行に構文上の意味があるため対象外とする。 // [text](url)単独行(添付リンクや外部リンク)はここでも対象に含める。以前は「別処理(単独リンク行への // hard break付与)で対応済み」として除外していたが、それは行自体へのhard break付与にしかならず、 // 「直前の行」の改行保持が漏れる不具合があった(「バッテリー」セクションでAmazonリンク行の直前行が // 結合される崩れとして発覚)。 function addHardBreaks(text) { const lines = text.split("\n"); const out = []; let inCodeBlock = false; // これらの行自体は改行に構文上の意味がある(見出し/テーブル/コードフェンス/img/見出しアンカー/ // 折りたたみタグ)ため、hard break付与の判定から常に除外する。リスト行(- 項目)はここでは除外しない。 // リスト項目の直後に空行なしで非リスト行が続くとCommonMarkのlazy continuationで同じ
  • に結合 // されてしまうため、リスト行自身への付与も必要になるケースがある。 function isStructural(line) { const t = line.trim(); if (t === "") return true; if (/^```/.test(t)) return true; if (/^#{1,6}\s/.test(t)) return true; if (/\|/.test(t)) return true; // テーブル記法 if (/^<\/a>$/.test(t)) return true; // 見出しアンカー行(addHeadingAnchorsが挿入) if (/^$/.test(t) || /^<\/?summary>$/.test(t) || t === "") return true; // 折りたたみ要素の開始/終了タグ if (/ {2,}$/.test(line)) return true; // 既にhard break済み return false; } const isListLine = (line) => /^\s*[-*+]\s/.test(line); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (/^```/.test(line.trim())) inCodeBlock = !inCodeBlock; const next = lines[i + 1]; // リスト項目同士が連続する場合は、行区切りだけで正しくパースされるため付与不要。 const bothListItems = isListLine(line) && isListLine(next || ""); if (!inCodeBlock && next !== undefined && !isStructural(line) && !isStructural(next) && !bothListItems) { out.push(line + " "); } else { out.push(line); } } return out.join("\n"); } // attachments行の "ファイル名 URL" を分解する(ファイル名にスペースを含む場合があるため、 // 最後の空白+httpの位置で区切る) function parseAttachmentLine(line) { const idx = line.lastIndexOf(" https://"); if (idx === -1) return null; return { filename: line.slice(0, idx), url: line.slice(idx + 1) }; } const OFFICE_EXTENSIONS = new Set(["doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp"]); const VIDEO_EXTENSIONS = new Set(["mp4", "mov", "avi", "webm", "mkv", "m4v"]); // プレビュー本体をdetails/summaryで包み、折りたたみ可能にする(既存の黒電話着信音セクション等と // 見た目を揃えるため、wiki全体に適用済みのdetails/summary用CSSがそのまま効く)。デフォルトは展開状態。 function wrapInDetails(filename, inner) { return `
    \n${filename.replace(/"/g, """)}\n\n${inner}\n\n
    `; } function attachmentMacroFor(filename, url) { const ext = filename.split(".").pop().toLowerCase(); if (ext === "pdf") { return wrapInDetails(filename, `{{pdfviewer file="${filename.replace(/"/g, '\\"')}"/}}`); } if (OFFICE_EXTENSIONS.has(ext)) { // officeマクロは変換結果をページ本文に直接インライン展開するため、そのままだと文書の // 長さ分だけページが縦に伸びてしまう。PDFビューア同様の「プレビューウィンドウ」に見せるため、 // 固定高さ・スクロール可能なdivで囲む。 const office = `
    \n\n{{office attachment="${filename.replace(/"/g, '\\"')}" filterStyles="false"/}}\n\n
    `; return wrapInDetails(filename, office); } if (VIDEO_EXTENSIONS.has(ext)) { // mp4等は動画・音声どちらの用途もあるが(黒電話の着信音等)、