GitHub(nextgroup2706/ken_nogi)は今後使わず自社Gitea運用に切替え。 NodeSrvは旧リポジトリの履歴を破棄しファイルのみ統合(Dokploy用サービスアカウントは 別途mygit-admin/NodeSrv.gitに履歴あり)。notepmエクスポート(12GB)とPleasanter インストーラzip(208MB)はサイズが大きいため.gitignoreで除外。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
577 lines
25 KiB
JavaScript
577 lines
25 KiB
JavaScript
#!/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/<hash>/, /page/<hash>)を解決するために、
|
||
// インポート対象が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しか自動生成しないため、このリンクは機能しない。
|
||
// 見出し直後に同名の<a name="...">を挿入し、既存リンクをそのまま機能させる。
|
||
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(`<a name="${anchor}"></a>`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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で同じ<li>に結合
|
||
// されてしまうため、リスト行自身への付与も必要になるケースがある。
|
||
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 (/^<img /.test(t)) return true;
|
||
if (/^<a name="[^"]*"><\/a>$/.test(t)) return true; // 見出しアンカー行(addHeadingAnchorsが挿入)
|
||
if (/^<details(\s+open)?>$/.test(t) || /^<\/?summary>$/.test(t) || t === "</details>") 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 `<details open>\n<summary>${filename.replace(/"/g, """)}</summary>\n\n${inner}\n\n</details>`;
|
||
}
|
||
|
||
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 = `<div style="max-height: 500px; overflow-y: auto; border: 1px solid #ddd; padding: 8px; margin: 8px 0;">\n\n{{office attachment="${filename.replace(/"/g, '\\"')}" filterStyles="false"/}}\n\n</div>`;
|
||
return wrapInDetails(filename, office);
|
||
}
|
||
if (VIDEO_EXTENSIONS.has(ext)) {
|
||
// mp4等は動画・音声どちらの用途もあるが(黒電話の着信音等)、<video>要素は音声専用ファイルでも
|
||
// 音声トラックだけ正常に再生できるため、拡張子だけで一律<video>タグを使う。
|
||
return wrapInDetails(filename, `<video controls style="max-width: 100%;" src="${url}"></video>`);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// スペース階層配列からREST APIのURLパス片(spaces/A/spaces/B/...)を組み立てる
|
||
function spacesUrlPath(spacePath) {
|
||
return spacePath.map((s) => `spaces/${encodeURIComponent(s)}`).join("/");
|
||
}
|
||
|
||
// スペース階層配列+ページ名からブラウザ表示URLを組み立てる(本文中のMarkdownリンクに
|
||
// そのまま埋め込まれるため、丸括弧まで含めて確実にエンコードするencodeUrlForMarkdownLinkを使う)
|
||
function viewUrl(spacePath, pageName) {
|
||
return `${XWIKI_BASE_URL}/wiki/${WIKI_ID}/view/${spacePath.map(encodeUrlForMarkdownLink).join("/")}/${encodeUrlForMarkdownLink(pageName)}`;
|
||
}
|
||
|
||
// --- ノート/ページの内部リンク解決用マップを、エクスポートルート全体から構築する ---
|
||
function buildLinkMaps(root) {
|
||
const noteHashToSpace = {}; // note hash -> ノートのスペース名(トップレベル)
|
||
const pageHashToPage = {}; // page hash -> { spacePath: [...], page: ページ名 }
|
||
|
||
const noteDirs = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory());
|
||
for (const nd of noteDirs) {
|
||
const noteHash = extractHash(nd.name);
|
||
const noteSpace = sanitizeForXWikiName(stripHash(nd.name));
|
||
if (noteHash) noteHashToSpace[noteHash] = noteSpace;
|
||
|
||
for (const { file, spacePath } of collectMdFiles(path.join(root, nd.name), [noteSpace])) {
|
||
const pageHash = extractHash(path.basename(file, ".md"));
|
||
if (!pageHash) continue;
|
||
const raw = fs.readFileSync(file, "utf8");
|
||
const { meta } = parseFrontmatter(raw);
|
||
const title = convertEmojiShortcodes(meta.title || stripHash(path.basename(file, ".md")));
|
||
pageHashToPage[pageHash] = { spacePath, page: sanitizeForXWikiName(title) };
|
||
}
|
||
}
|
||
|
||
return { noteHashToSpace, pageHashToPage };
|
||
}
|
||
|
||
function replaceInternalLinks(body, linkMaps) {
|
||
let out = body.replace(/https?:\/\/[a-zA-Z0-9.-]*notepm\.jp\/note\/([0-9a-f]{10})\/?/g, (m, hash) => {
|
||
const space = linkMaps.noteHashToSpace[hash];
|
||
return space ? `${XWIKI_BASE_URL}/wiki/${WIKI_ID}/view/${encodeUrlForMarkdownLink(space)}/` : m;
|
||
});
|
||
out = out.replace(/https?:\/\/[a-zA-Z0-9.-]*notepm\.jp\/page\/([0-9a-f]{10})\/?/g, (m, hash) => {
|
||
const target = linkMaps.pageHashToPage[hash];
|
||
return target ? viewUrl(target.spacePath, target.page) : m;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
async function xwikiRequest(method, urlPath, body, contentType) {
|
||
const res = await fetch(`${XWIKI_BASE_URL}${urlPath}`, {
|
||
method,
|
||
headers: {
|
||
Authorization: AUTH,
|
||
...(contentType ? { "Content-Type": contentType } : {}),
|
||
},
|
||
body,
|
||
});
|
||
return res;
|
||
}
|
||
|
||
// encodeURIComponentは`(`,`)`,`!`,`'`,`*`をエンコードしない(RFC3986非予約文字のため)。
|
||
// このURLをMarkdownの[text](url)構文にそのまま埋め込むと、URL中の生の丸括弧がリンクの
|
||
// 終端`)`と誤認識され構文が崩れる(実機のXWiki markdown/1.2レンダラーで確認済み)。
|
||
// Markdownリンクの宛先として使うURLは、このヘルパーで丸括弧まで含めて確実にエンコードする。
|
||
function encodeUrlForMarkdownLink(s) {
|
||
return encodeURIComponent(s).replace(/[()!'*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
|
||
}
|
||
|
||
function escapeXml(s) {
|
||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
|
||
async function importPage(mdFilePath, linkMaps, spacePath) {
|
||
const raw = fs.readFileSync(mdFilePath, "utf8");
|
||
const { meta, body } = parseFrontmatter(raw);
|
||
const title = convertEmojiShortcodes(meta.title || stripHash(path.basename(mdFilePath, ".md")));
|
||
const pageName = sanitizeForXWikiName(title);
|
||
const spacesPart = spacesUrlPath(spacePath);
|
||
|
||
const baseNoExt = mdFilePath.slice(0, -3);
|
||
const attachDir = `${baseNoExt}_添付`;
|
||
const attachments = (meta.attachments || []).map(parseAttachmentLine).filter(Boolean);
|
||
|
||
// NotePM原本は<img>タグの直前に空行が無いことが多く、CommonMarkが直前の段落と
|
||
// 結合してしまい「画像がテキストの右に回り込む」レイアウト崩れになる。<img>の前後に
|
||
// 空行を強制して独立したHTMLブロックとして認識させる。
|
||
let body0 = convertBulletLists(body);
|
||
body0 = addHeadingAnchors(body0);
|
||
body0 = addHardBreaks(body0);
|
||
body0 = body0.replace(/([^\n])\n(<img )/g, "$1\n\n$2");
|
||
body0 = body0.replace(/(<img [^\n]*>)\n([^\n])/g, "$1\n\n$2");
|
||
// 添付ファイルリンク等、"[text](url)"のみで構成される行が単一改行で連続していると、
|
||
// CommonMarkは同じ段落として結合し横並び表示になってしまう。そのような行の末尾に
|
||
// Markdownのhard break(半角スペース2つ)を付けて改行を保持する。
|
||
body0 = body0.replace(/^(\[[^\]]+\]\([^)]+\))[ \t]*$/gm, "$1 ");
|
||
body0 = convertEmojiShortcodes(body0);
|
||
body0 = replaceInternalLinks(body0, linkMaps);
|
||
|
||
const attachCount = attachments.length;
|
||
console.log(`\n[page] ${spacePath.join(" / ")} / ${pageName} (attachments: ${attachCount})`);
|
||
|
||
if (dryRun) {
|
||
for (const a of attachments) console.log(` - attach: ${a.filename}`);
|
||
return;
|
||
}
|
||
|
||
// 1. ページ本体を先に作成(空の本文で仮作成。添付ファイルはページが存在しないとアップロードできないため)
|
||
// --update-onlyの場合はページ・添付とも既に存在する前提でスキップする。
|
||
if (!updateOnly) {
|
||
const createXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<page xmlns="http://www.xwiki.org">
|
||
<title>${escapeXml(title)}</title>
|
||
<syntax>markdown/1.2</syntax>
|
||
<content></content>
|
||
</page>`;
|
||
const createRes = await xwikiRequest(
|
||
"PUT",
|
||
`/rest/wikis/${WIKI_ID}/${spacesPart}/pages/${encodeURIComponent(pageName)}`,
|
||
createXml,
|
||
"application/xml; charset=UTF-8"
|
||
);
|
||
if (createRes.status !== 201 && createRes.status !== 202) {
|
||
console.warn(` ! ページ作成失敗(${createRes.status})`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 2. 添付ファイルをアップロードし(update-onlyでは既存のためスキップ)、本文中のNotePM URLを
|
||
// XWiki添付ファイルURLへ置換。Office/PDFファイルはリンクの直後にプレビューマクロを追記する。
|
||
let newBody = body0;
|
||
if (fs.existsSync(attachDir)) {
|
||
for (const a of attachments) {
|
||
const localFile = path.join(attachDir, a.filename);
|
||
if (!fs.existsSync(localFile)) {
|
||
console.warn(` ! 添付ファイル欠損: ${localFile}`);
|
||
continue;
|
||
}
|
||
const encodedName = encodeURIComponent(a.filename);
|
||
if (!updateOnly) {
|
||
const data = fs.readFileSync(localFile);
|
||
const res = await xwikiRequest(
|
||
"PUT",
|
||
`/rest/wikis/${WIKI_ID}/${spacesPart}/pages/${encodeURIComponent(pageName)}/attachments/${encodedName}`,
|
||
data,
|
||
"application/octet-stream"
|
||
);
|
||
if (res.status !== 201 && res.status !== 202) {
|
||
console.warn(` ! 添付アップロード失敗(${res.status}): ${a.filename}`);
|
||
continue;
|
||
}
|
||
console.log(` - attached: ${a.filename}`);
|
||
}
|
||
const xwikiAttachUrl = `${XWIKI_BASE_URL}/wiki/${WIKI_ID}/download/${spacePath.map(encodeUrlForMarkdownLink).join("/")}/${encodeUrlForMarkdownLink(pageName)}/${encodeUrlForMarkdownLink(a.filename)}`;
|
||
newBody = newBody.split(a.url).join(xwikiAttachUrl);
|
||
|
||
const macro = attachmentMacroFor(a.filename, xwikiAttachUrl);
|
||
if (macro) {
|
||
const linkText = `[${a.filename}](${xwikiAttachUrl})`;
|
||
if (newBody.includes(linkText)) {
|
||
newBody = newBody.split(linkText).join(`${linkText}\n\n${macro}\n`);
|
||
console.log(` - preview macro added: ${a.filename}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 本文を確定内容で更新
|
||
const updateXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<page xmlns="http://www.xwiki.org">
|
||
<title>${escapeXml(title)}</title>
|
||
<syntax>markdown/1.2</syntax>
|
||
<content>${escapeXml(newBody)}</content>
|
||
</page>`;
|
||
const res = await xwikiRequest(
|
||
"PUT",
|
||
`/rest/wikis/${WIKI_ID}/${spacesPart}/pages/${encodeURIComponent(pageName)}`,
|
||
updateXml,
|
||
"application/xml; charset=UTF-8"
|
||
);
|
||
if (res.status !== 201 && res.status !== 202) {
|
||
console.warn(` ! ページ更新失敗(${res.status})`);
|
||
} else {
|
||
console.log(` - saved (${res.status})`);
|
||
}
|
||
}
|
||
|
||
// 各階層(ノート自体を含む)のスペースに、子ページ一覧を表示するWebHomeページを作成する。
|
||
// これが無いと「フォルダ名をクリック」した時に「ページが見つかりません」になる。
|
||
async function ensureWebHomePages(allSpacePaths) {
|
||
const uniquePaths = new Map(); // key: JSON文字列, value: spacePath配列
|
||
for (const spacePath of allSpacePaths) {
|
||
for (let i = 1; i <= spacePath.length; i++) {
|
||
const ancestor = spacePath.slice(0, i);
|
||
uniquePaths.set(JSON.stringify(ancestor), ancestor);
|
||
}
|
||
}
|
||
|
||
console.log(`\nWebHome作成対象スペース数: ${uniquePaths.size}`);
|
||
let created = 0;
|
||
let skipped = 0;
|
||
for (const spacePath of uniquePaths.values()) {
|
||
const spacesPart = spacesUrlPath(spacePath);
|
||
const checkRes = await xwikiRequest("GET", `/rest/wikis/${WIKI_ID}/${spacesPart}/pages/WebHome`);
|
||
if (checkRes.status === 200) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
if (dryRun) {
|
||
created++;
|
||
continue;
|
||
}
|
||
const title = spacePath[spacePath.length - 1];
|
||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<page xmlns="http://www.xwiki.org">
|
||
<title>${escapeXml(title)}</title>
|
||
<syntax>xwiki/2.1</syntax>
|
||
<content>{{children/}}</content>
|
||
</page>`;
|
||
const res = await xwikiRequest("PUT", `/rest/wikis/${WIKI_ID}/${spacesPart}/pages/WebHome`, xml, "application/xml; charset=UTF-8");
|
||
if (res.status === 201 || res.status === 202) {
|
||
created++;
|
||
} else {
|
||
console.warn(` ! WebHome作成失敗(${res.status}): ${spacePath.join(" / ")}`);
|
||
}
|
||
}
|
||
console.log(`WebHome: ${created}件作成${dryRun ? "予定" : ""}, ${skipped}件は既存のためスキップ`);
|
||
}
|
||
|
||
async function main() {
|
||
console.log(`リンクマップ構築元: ${rootDir}`);
|
||
const linkMaps = buildLinkMaps(rootDir);
|
||
console.log(`ノートマップ: ${Object.keys(linkMaps.noteHashToSpace).length}件, ページマップ: ${Object.keys(linkMaps.pageHashToPage).length}件`);
|
||
|
||
const noteTargets = [];
|
||
if (allMode) {
|
||
for (const nd of fs.readdirSync(rootDir, { withFileTypes: true }).filter((d) => d.isDirectory())) {
|
||
noteTargets.push({ dir: path.join(rootDir, nd.name), space: sanitizeForXWikiName(stripHash(nd.name)) });
|
||
}
|
||
} else {
|
||
noteTargets.push({ dir: noteDir, space: singleSpaceName });
|
||
}
|
||
|
||
let totalPages = 0;
|
||
let totalErrors = 0;
|
||
const allSpacePaths = [];
|
||
for (const target of noteTargets) {
|
||
const mdFiles = collectMdFiles(target.dir, [target.space]);
|
||
console.log(`\n=== ノート: ${target.space} (${mdFiles.length}ページ) ===`);
|
||
for (const { file, spacePath } of mdFiles) {
|
||
totalPages++;
|
||
allSpacePaths.push(spacePath);
|
||
try {
|
||
await importPage(file, linkMaps, spacePath);
|
||
} catch (e) {
|
||
totalErrors++;
|
||
console.error(` ! 例外: ${file}: ${e.message}`);
|
||
}
|
||
}
|
||
}
|
||
console.log(`\n完了: ${totalPages}ページ処理, ${totalErrors}件エラー${dryRun ? " [DRY RUN]" : ""}`);
|
||
|
||
await ensureWebHomePages(allSpacePaths);
|
||
}
|
||
|
||
main().catch((e) => {
|
||
console.error(e);
|
||
process.exit(1);
|
||
});
|