@onozaty/growi-uploader 1.6.1 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # growi-uploader
2
2
 
3
- [English](README.md) | 日本語
4
-
5
3
  [![Test](https://github.com/onozaty/growi-uploader/actions/workflows/test.yaml/badge.svg)](https://github.com/onozaty/growi-uploader/actions/workflows/test.yaml)
6
4
  [![codecov](https://codecov.io/gh/onozaty/growi-uploader/graph/badge.svg?token=X0YN1OP5PB)](https://codecov.io/gh/onozaty/growi-uploader)
7
5
  [![npm version](https://badge.fury.io/js/@onozaty%2Fgrowi-uploader.svg)](https://www.npmjs.com/package/@onozaty/growi-uploader)
8
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
7
 
8
+ [English](README.md) | 日本語
9
+
10
10
  ローカルのMarkdownファイルと添付ファイルを[GROWI](https://growi.org/) Wikiに一括アップロードするCLIツールです。
11
11
 
12
12
  ## 機能
@@ -274,6 +274,48 @@ Download the [documentation](/attachment/68f3a3fa794f665ad2c0d2b3).
274
274
 
275
275
  両方の検出方法で、複数のリンク形式(`./`あり・なし)がサポートされています。
276
276
 
277
+ ### クロスページ参照
278
+
279
+ 命名規則に従うファイル(`BBB_attachment_*`)を別のページ(`AAA.md`)から参照している場合:
280
+
281
+ - 添付ファイルは所有者ページ(`/BBB`)にのみアップロードされます
282
+ - 参照元ページ(`AAA.md`)のリンクは添付ファイルのURL(`/attachment/{id}`)に置換されます
283
+
284
+ **例:**
285
+ ```
286
+ ファイル構成:
287
+ AAA.md (内容: ![](BBB_attachment_image.png))
288
+ BBB.md
289
+ BBB_attachment_image.png
290
+
291
+ 結果:
292
+ - BBB_attachment_image.png は /BBB ページにのみアップロード
293
+ - AAA.md 内のリンクは /attachment/{id} に置換
294
+ ```
295
+
296
+ ## ページリンクの変換
297
+
298
+ 他のMarkdownファイル(`.md`拡張子)へのリンクは自動的にGROWI形式に変換されます。
299
+
300
+ ### 対応パターン
301
+
302
+ ```markdown
303
+ # アップロード前(ローカル)
304
+ [ユーザーガイド](./guide.md)
305
+ [API概要](../api/overview.md)
306
+ [認証セクション](./api/auth.md#setup)
307
+
308
+ # アップロード後(GROWI上)
309
+ [ユーザーガイド](./guide)
310
+ [API概要](../api/overview)
311
+ [認証セクション](./api/auth#setup)
312
+ ```
313
+
314
+ - `.md`拡張子が除去されます
315
+ - 相対パスのプレフィックス(`./`、`../`)は維持されます(GROWIは相対リンクをサポート)
316
+ - アンカーリンク(`#section`)は維持されます
317
+ - 外部URL(`http://`、`https://`)は変更されません
318
+
277
319
  ## 高度な使い方
278
320
 
279
321
  ### 既存ページの更新
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # growi-uploader
2
2
 
3
- English | [日本語](README.ja.md)
4
-
5
3
  [![Test](https://github.com/onozaty/growi-uploader/actions/workflows/test.yaml/badge.svg)](https://github.com/onozaty/growi-uploader/actions/workflows/test.yaml)
6
4
  [![codecov](https://codecov.io/gh/onozaty/growi-uploader/graph/badge.svg?token=X0YN1OP5PB)](https://codecov.io/gh/onozaty/growi-uploader)
7
5
  [![npm version](https://badge.fury.io/js/@onozaty%2Fgrowi-uploader.svg)](https://www.npmjs.com/package/@onozaty/growi-uploader)
8
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
7
 
8
+ English | [日本語](README.ja.md)
9
+
10
10
  A CLI tool to batch upload local Markdown files and attachments to [GROWI](https://growi.org/) Wiki.
11
11
 
12
12
  ## Features
@@ -274,6 +274,48 @@ Download the [documentation](/attachment/68f3a3fa794f665ad2c0d2b3).
274
274
 
275
275
  Both detection methods support multiple link formats (with or without `./`).
276
276
 
277
+ ### Cross-Page References
278
+
279
+ When a file following the naming convention (`BBB_attachment_*`) is referenced from a different page (`AAA.md`):
280
+
281
+ - The attachment is uploaded only to its owner page (`/BBB`)
282
+ - Links in the referencing page (`AAA.md`) are replaced with the attachment URL (`/attachment/{id}`)
283
+
284
+ **Example:**
285
+ ```
286
+ File structure:
287
+ AAA.md (contains: ![](BBB_attachment_image.png))
288
+ BBB.md
289
+ BBB_attachment_image.png
290
+
291
+ Result:
292
+ - BBB_attachment_image.png is uploaded to /BBB page only
293
+ - The link in AAA.md is replaced with /attachment/{id}
294
+ ```
295
+
296
+ ## Page Link Conversion
297
+
298
+ Links to other Markdown files (`.md` extension) are automatically converted to GROWI format.
299
+
300
+ ### Supported Patterns
301
+
302
+ ```markdown
303
+ # Before upload (local)
304
+ [User Guide](./guide.md)
305
+ [API Overview](../api/overview.md)
306
+ [Auth Section](./api/auth.md#setup)
307
+
308
+ # After upload (on GROWI)
309
+ [User Guide](./guide)
310
+ [API Overview](../api/overview)
311
+ [Auth Section](./api/auth#setup)
312
+ ```
313
+
314
+ - The `.md` extension is removed
315
+ - Relative path prefixes (`./`, `../`) are preserved (GROWI supports relative links)
316
+ - Anchor links (`#section`) are preserved
317
+ - External URLs (`http://`, `https://`) are not modified
318
+
277
319
  ## Advanced Usage
278
320
 
279
321
  ### Update Existing Pages
package/dist/index.mjs CHANGED
@@ -8,7 +8,7 @@ import { lookup } from "mime-types";
8
8
  import { glob } from "glob";
9
9
 
10
10
  //#region package.json
11
- var version = "1.6.1";
11
+ var version = "1.7.1";
12
12
 
13
13
  //#endregion
14
14
  //#region src/config.ts
@@ -293,9 +293,25 @@ const processLinkPath = (linkPath, originalLinkPath, markdownFilePath, sourceDir
293
293
  else absolutePath = resolve(dirname(join(sourceDir, markdownFilePath)), decodedPath);
294
294
  if (!existsSync(absolutePath)) return null;
295
295
  const normalizedPath = relative(sourceDir, absolutePath).replace(/\\/g, "/");
296
+ const fileName = basename(normalizedPath);
297
+ const namingMatch = fileName.match(/^(.+)_attachment_/);
298
+ if (namingMatch && namingMatch[1]) {
299
+ const ownerPageName = namingMatch[1];
300
+ const attachmentDir = dirname(normalizedPath);
301
+ const currentMarkdownDir = dirname(markdownFilePath);
302
+ const currentPageName = basename(markdownFilePath, ".md");
303
+ return {
304
+ localPath: normalizedPath,
305
+ fileName,
306
+ detectionPattern: "link",
307
+ originalLinkPaths: [originalLinkPath],
308
+ isExternalReference: attachmentDir !== currentMarkdownDir || ownerPageName !== currentPageName,
309
+ ownerPageName
310
+ };
311
+ }
296
312
  return {
297
313
  localPath: normalizedPath,
298
- fileName: basename(normalizedPath),
314
+ fileName,
299
315
  detectionPattern: "link",
300
316
  originalLinkPaths: [originalLinkPath]
301
317
  };
@@ -315,7 +331,7 @@ const extractLinkedAttachments = (content, markdownFilePath, sourceDir) => {
315
331
  const attachments = [];
316
332
  const linkRegex = new RegExp([
317
333
  "!?",
318
- "\\[(?<alt>[^\\]]*)\\]",
334
+ "\\[(?<alt>(?:[^\\]\\\\]|\\\\.)*)\\]",
319
335
  "\\(",
320
336
  "(?:",
321
337
  "<(?<anglePath>[^>]+)>",
@@ -415,6 +431,56 @@ const escapeRegex = (str) => {
415
431
  return str.replace(/[.*+?^${}()|[\]\\<>]/g, "\\$&");
416
432
  };
417
433
  /**
434
+ * Build regex patterns from originalLinkPaths
435
+ * Generates escaped patterns including variations (./path <-> path)
436
+ */
437
+ const buildPatternsFromLinkPaths = (linkPaths) => {
438
+ const patterns = [];
439
+ for (const linkPath of linkPaths) {
440
+ const escapedPath = escapeRegex(linkPath);
441
+ patterns.push(escapedPath);
442
+ if (linkPath.startsWith("./")) {
443
+ const escapedWithoutDot = escapeRegex(linkPath.substring(2));
444
+ patterns.push(escapedWithoutDot);
445
+ } else if (!linkPath.startsWith("../") && !linkPath.startsWith("<")) patterns.push(`\\./${escapedPath}`);
446
+ }
447
+ return patterns;
448
+ };
449
+ /**
450
+ * Replace links in markdown content matching the given patterns with a target path
451
+ * Handles image links, regular links, and HTML img tags
452
+ *
453
+ * @param markdown Markdown content
454
+ * @param patterns Array of escaped regex patterns to match
455
+ * @param targetPath Path to replace with (e.g., /attachment/xxx)
456
+ * @returns Object with replaced content and whether any replacement occurred
457
+ */
458
+ const replaceLinksWithPatterns = (markdown, patterns, targetPath) => {
459
+ let result = markdown;
460
+ let replaced = false;
461
+ for (const pattern of patterns) {
462
+ const imgRegex = new RegExp(`!\\[((?:[^\\]\\\\]|\\\\.)*)\\]\\(${pattern}\\)`, "g");
463
+ if (imgRegex.test(result)) {
464
+ replaced = true;
465
+ result = result.replace(imgRegex, `![$1](${targetPath})`);
466
+ }
467
+ const linkRegex = new RegExp(`(?<!!)\\[((?:[^\\]\\\\]|\\\\.)*)\\]\\(${pattern}\\)`, "g");
468
+ if (linkRegex.test(result)) {
469
+ replaced = true;
470
+ result = result.replace(linkRegex, `[$1](${targetPath})`);
471
+ }
472
+ const imgTagRegex = new RegExp(`(<img\\s+[^>]*src=)(["'])${pattern}\\2([^>]*>)`, "gi");
473
+ if (imgTagRegex.test(result)) {
474
+ replaced = true;
475
+ result = result.replace(imgTagRegex, `$1$2${targetPath}$2$3`);
476
+ }
477
+ }
478
+ return {
479
+ content: result,
480
+ replaced
481
+ };
482
+ };
483
+ /**
418
484
  * Replace attachment links in Markdown content with GROWI format
419
485
  *
420
486
  * Supports two detection patterns:
@@ -437,30 +503,42 @@ const replaceAttachmentLinks = (markdown, attachments, pageName) => {
437
503
  const escapedFileName = escapeRegex(`${pageName}_attachment_${attachment.fileName}`);
438
504
  patterns.push(escapedFileName, `\\./${escapedFileName}`);
439
505
  }
440
- if (attachment.originalLinkPaths) for (const linkPath of attachment.originalLinkPaths) {
441
- const escapedPath = escapeRegex(linkPath);
442
- patterns.push(escapedPath);
443
- if (linkPath.startsWith("./")) {
444
- const escapedWithoutDot = escapeRegex(linkPath.substring(2));
445
- patterns.push(escapedWithoutDot);
446
- } else if (!linkPath.startsWith("../") && !linkPath.startsWith("<")) patterns.push(`\\./${escapedPath}`);
506
+ if (attachment.originalLinkPaths) patterns.push(...buildPatternsFromLinkPaths(attachment.originalLinkPaths));
507
+ const { content: replacedContent, replaced: wasReplaced } = replaceLinksWithPatterns(result, patterns, growiPath);
508
+ if (wasReplaced) {
509
+ result = replacedContent;
510
+ replaced = true;
447
511
  }
448
- for (const pattern of patterns) {
449
- const imgRegex = new RegExp(`!\\[([^\\]]*)\\]\\(${pattern}\\)`, "g");
450
- if (imgRegex.test(result)) {
451
- replaced = true;
452
- result = result.replace(imgRegex, `![$1](${growiPath})`);
453
- }
454
- const linkRegex = new RegExp(`(?<!!)\\[([^\\]]*)\\]\\(${pattern}\\)`, "g");
455
- if (linkRegex.test(result)) {
456
- replaced = true;
457
- result = result.replace(linkRegex, `[$1](${growiPath})`);
458
- }
459
- const imgTagRegex = new RegExp(`(<img\\s+[^>]*src=)(["'])${pattern}\\2([^>]*>)`, "gi");
460
- if (imgTagRegex.test(result)) {
461
- replaced = true;
462
- result = result.replace(imgTagRegex, `$1$2${growiPath}$2$3`);
463
- }
512
+ }
513
+ return {
514
+ content: result,
515
+ replaced
516
+ };
517
+ };
518
+ /**
519
+ * Replace external attachment links in Markdown content with GROWI format
520
+ *
521
+ * External attachments are files that belong to another page (detected via naming convention).
522
+ * This function resolves them using a global attachment map that contains all uploaded attachments.
523
+ *
524
+ * @param markdown Original Markdown content
525
+ * @param attachments List of external attachments (isExternalReference === true)
526
+ * @param attachmentMap Global map of localPath → attachmentId
527
+ * @returns Object with replaced content and whether any replacement occurred
528
+ */
529
+ const replaceExternalAttachmentLinks = (markdown, attachments, attachmentMap) => {
530
+ let result = markdown;
531
+ let replaced = false;
532
+ for (const attachment of attachments) {
533
+ if (!attachment.isExternalReference) continue;
534
+ const attachmentId = attachmentMap.get(attachment.localPath);
535
+ if (!attachmentId) continue;
536
+ const growiPath = `/attachment/${attachmentId}`;
537
+ const patterns = attachment.originalLinkPaths ? buildPatternsFromLinkPaths(attachment.originalLinkPaths) : [];
538
+ const { content: replacedContent, replaced: wasReplaced } = replaceLinksWithPatterns(result, patterns, growiPath);
539
+ if (wasReplaced) {
540
+ result = replacedContent;
541
+ replaced = true;
464
542
  }
465
543
  }
466
544
  return {
@@ -501,11 +579,16 @@ const replaceMarkdownExtension = (markdown, basePath = "/") => {
501
579
  /**
502
580
  * Upload Markdown files and their attachments to GROWI
503
581
  *
504
- * This function orchestrates a 4-stage upload process:
505
- * 1. Create or update page with original Markdown content
506
- * 2. Upload attachments
507
- * 3. Replace attachment links in Markdown and update page
508
- * 4. Replace .md extension in page links and update page
582
+ * This function orchestrates a 2-pass upload process:
583
+ *
584
+ * Pass 1: Page creation and attachment upload
585
+ * - Create or update page with original Markdown content
586
+ * - Upload attachments (skip external references)
587
+ * - Build global attachment map for cross-page reference resolution
588
+ *
589
+ * Pass 2: Link replacement
590
+ * - Replace attachment links (both local and external references)
591
+ * - Replace .md extension in page links
509
592
  *
510
593
  * @param files List of Markdown files to upload
511
594
  * @param sourceDir Source directory containing files
@@ -523,9 +606,10 @@ const uploadFiles = async (files, sourceDir, config) => {
523
606
  attachmentErrors: 0,
524
607
  linkReplacementErrors: 0
525
608
  };
609
+ const attachmentMap = /* @__PURE__ */ new Map();
610
+ const pageResults = [];
526
611
  for (const file of files) {
527
- const content = readFileSync(join(sourceDir, file.localPath), "utf-8");
528
- const result = await createOrUpdatePage(file, content, config.update);
612
+ const result = await createOrUpdatePage(file, readFileSync(join(sourceDir, file.localPath), "utf-8"), config.update);
529
613
  if (result.action === "created") {
530
614
  stats.pagesCreated++;
531
615
  console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (created)`);
@@ -541,62 +625,77 @@ const uploadFiles = async (files, sourceDir, config) => {
541
625
  if (config.verbose && result.error) console.error(formatDetailedError(result.error));
542
626
  }
543
627
  if (result.pageId && (result.action === "created" || result.action === "updated")) {
544
- let currentContent = content;
545
- let currentRevisionId = result.revisionId;
546
- if (file.attachments.length > 0) {
547
- let hasAttachments = false;
548
- let latestRevisionId = result.revisionId;
549
- for (const attachment of file.attachments) {
550
- const attachmentResult = await uploadAttachment(attachment, result.pageId, sourceDir);
551
- if (attachmentResult.success) {
552
- stats.attachmentsUploaded++;
553
- console.log(`[SUCCESS] ${attachment.localPath} → ${file.growiPath} (attachment)`);
554
- if (attachmentResult.attachmentId) {
555
- attachment.attachmentId = attachmentResult.attachmentId;
556
- hasAttachments = true;
557
- }
558
- if (attachmentResult.revisionId) latestRevisionId = attachmentResult.revisionId;
559
- } else {
560
- stats.attachmentErrors++;
561
- console.error(`[ERROR] ${attachment.localPath} → ${file.growiPath} (${attachmentResult.errorMessage || "failed to upload attachment"})`);
562
- if (config.verbose && attachmentResult.error) console.error(formatDetailedError(attachmentResult.error));
563
- }
564
- }
565
- if (hasAttachments && latestRevisionId) {
566
- currentRevisionId = latestRevisionId;
567
- const pageName = basename(file.localPath, ".md");
568
- const { content: replacedContent, replaced } = replaceAttachmentLinks(currentContent, file.attachments, pageName);
569
- if (replaced) {
570
- const updateResult = await updatePageContent(result.pageId, currentRevisionId, replacedContent);
571
- if (updateResult.success && updateResult.revisionId) {
572
- console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (attachment links replaced)`);
573
- currentContent = replacedContent;
574
- currentRevisionId = updateResult.revisionId;
575
- } else {
576
- console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (failed to update attachment links: ${updateResult.errorMessage || "unknown error"})`);
577
- if (config.verbose && updateResult.error) console.error(formatDetailedError(updateResult.error));
578
- stats.linkReplacementErrors++;
579
- }
628
+ let latestRevisionId = result.revisionId;
629
+ for (const attachment of file.attachments) {
630
+ if (attachment.isExternalReference) continue;
631
+ const attachmentResult = await uploadAttachment(attachment, result.pageId, sourceDir);
632
+ if (attachmentResult.success) {
633
+ stats.attachmentsUploaded++;
634
+ console.log(`[SUCCESS] ${attachment.localPath} ${file.growiPath} (attachment)`);
635
+ if (attachmentResult.attachmentId) {
636
+ attachment.attachmentId = attachmentResult.attachmentId;
637
+ attachmentMap.set(attachment.localPath, attachmentResult.attachmentId);
580
638
  }
639
+ if (attachmentResult.revisionId) latestRevisionId = attachmentResult.revisionId;
640
+ } else {
641
+ stats.attachmentErrors++;
642
+ console.error(`[ERROR] ${attachment.localPath} → ${file.growiPath} (${attachmentResult.errorMessage || "failed to upload attachment"})`);
643
+ if (config.verbose && attachmentResult.error) console.error(formatDetailedError(attachmentResult.error));
581
644
  }
582
645
  }
583
- if (currentRevisionId) {
584
- const { content: linkedContent, replaced: linkReplaced } = replaceMarkdownExtension(currentContent, config.basePath);
585
- if (linkReplaced) {
586
- const updateResult = await updatePageContent(result.pageId, currentRevisionId, linkedContent);
587
- if (updateResult.success && updateResult.revisionId) {
588
- console.log(`[SUCCESS] ${file.localPath} ${file.growiPath} (page links replaced)`);
589
- currentRevisionId = updateResult.revisionId;
590
- } else {
591
- console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (failed to update page links: ${updateResult.errorMessage || "unknown error"})`);
592
- if (config.verbose && updateResult.error) console.error(formatDetailedError(updateResult.error));
593
- stats.linkReplacementErrors++;
594
- }
595
- }
646
+ if (latestRevisionId) pageResults.push({
647
+ file,
648
+ pageId: result.pageId,
649
+ revisionId: latestRevisionId
650
+ });
651
+ } else if (file.attachments.length > 0) {
652
+ for (const attachment of file.attachments) if (!attachment.isExternalReference) {
653
+ console.log(`[SKIP] ${attachment.localPath} ${file.growiPath} (attachment skipped)`);
654
+ stats.attachmentsSkipped++;
655
+ }
656
+ }
657
+ }
658
+ for (const pageResult of pageResults) {
659
+ const { file, pageId } = pageResult;
660
+ let currentContent = readFileSync(join(sourceDir, file.localPath), "utf-8");
661
+ let currentRevisionId = pageResult.revisionId;
662
+ const pageName = basename(file.localPath, ".md");
663
+ const localAttachments = file.attachments.filter((att) => !att.isExternalReference && att.attachmentId);
664
+ const externalAttachments = file.attachments.filter((att) => att.isExternalReference);
665
+ let hasReplacements = false;
666
+ if (localAttachments.length > 0) {
667
+ const { content: replacedContent, replaced } = replaceAttachmentLinks(currentContent, localAttachments, pageName);
668
+ if (replaced) {
669
+ currentContent = replacedContent;
670
+ hasReplacements = true;
671
+ }
672
+ }
673
+ if (externalAttachments.length > 0) {
674
+ const { content: replacedContent, replaced } = replaceExternalAttachmentLinks(currentContent, externalAttachments, attachmentMap);
675
+ if (replaced) {
676
+ currentContent = replacedContent;
677
+ hasReplacements = true;
678
+ }
679
+ }
680
+ const { content: linkedContent, replaced: linkReplaced } = replaceMarkdownExtension(currentContent, config.basePath);
681
+ if (linkReplaced) {
682
+ currentContent = linkedContent;
683
+ hasReplacements = true;
684
+ }
685
+ if (hasReplacements) {
686
+ const updateResult = await updatePageContent(pageId, currentRevisionId, currentContent);
687
+ if (updateResult.success && updateResult.revisionId) {
688
+ const replacementTypes = [];
689
+ if (localAttachments.some((att) => att.attachmentId)) replacementTypes.push("attachment links");
690
+ if (externalAttachments.length > 0) replacementTypes.push("external references");
691
+ if (linkReplaced) replacementTypes.push("page links");
692
+ console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (${replacementTypes.join(", ")} replaced)`);
693
+ currentRevisionId = updateResult.revisionId;
694
+ } else {
695
+ console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (failed to update links: ${updateResult.errorMessage || "unknown error"})`);
696
+ if (config.verbose && updateResult.error) console.error(formatDetailedError(updateResult.error));
697
+ stats.linkReplacementErrors++;
596
698
  }
597
- } else if (file.attachments.length > 0) for (const attachment of file.attachments) {
598
- console.log(`[SKIP] ${attachment.localPath} → ${file.growiPath} (attachment skipped)`);
599
- stats.attachmentsSkipped++;
600
699
  }
601
700
  }
602
701
  return stats;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onozaty/growi-uploader",
3
- "version": "1.6.1",
3
+ "version": "1.7.1",
4
4
  "description": "A content uploader for GROWI",
5
5
  "type": "module",
6
6
  "bin": {