@onozaty/growi-uploader 1.6.0 → 1.7.0

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
  ## 機能
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
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.0";
11
+ var version = "1.7.0";
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,6 +606,8 @@ 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
612
  const content = readFileSync(join(sourceDir, file.localPath), "utf-8");
528
613
  const result = await createOrUpdatePage(file, content, config.update);
@@ -541,62 +626,78 @@ const uploadFiles = async (files, sourceDir, config) => {
541
626
  if (config.verbose && result.error) console.error(formatDetailedError(result.error));
542
627
  }
543
628
  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
- }
629
+ let latestRevisionId = result.revisionId;
630
+ for (const attachment of file.attachments) {
631
+ if (attachment.isExternalReference) continue;
632
+ const attachmentResult = await uploadAttachment(attachment, result.pageId, sourceDir);
633
+ if (attachmentResult.success) {
634
+ stats.attachmentsUploaded++;
635
+ console.log(`[SUCCESS] ${attachment.localPath} ${file.growiPath} (attachment)`);
636
+ if (attachmentResult.attachmentId) {
637
+ attachment.attachmentId = attachmentResult.attachmentId;
638
+ attachmentMap.set(attachment.localPath, attachmentResult.attachmentId);
580
639
  }
640
+ if (attachmentResult.revisionId) latestRevisionId = attachmentResult.revisionId;
641
+ } else {
642
+ stats.attachmentErrors++;
643
+ console.error(`[ERROR] ${attachment.localPath} → ${file.growiPath} (${attachmentResult.errorMessage || "failed to upload attachment"})`);
644
+ if (config.verbose && attachmentResult.error) console.error(formatDetailedError(attachmentResult.error));
581
645
  }
582
646
  }
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
- }
647
+ if (latestRevisionId) pageResults.push({
648
+ file,
649
+ pageId: result.pageId,
650
+ revisionId: latestRevisionId,
651
+ content
652
+ });
653
+ } else if (file.attachments.length > 0) {
654
+ for (const attachment of file.attachments) if (!attachment.isExternalReference) {
655
+ console.log(`[SKIP] ${attachment.localPath} → ${file.growiPath} (attachment skipped)`);
656
+ stats.attachmentsSkipped++;
657
+ }
658
+ }
659
+ }
660
+ for (const pageResult of pageResults) {
661
+ const { file, pageId, content } = pageResult;
662
+ let currentContent = content;
663
+ let currentRevisionId = pageResult.revisionId;
664
+ const pageName = basename(file.localPath, ".md");
665
+ const localAttachments = file.attachments.filter((att) => !att.isExternalReference && att.attachmentId);
666
+ const externalAttachments = file.attachments.filter((att) => att.isExternalReference);
667
+ let hasReplacements = false;
668
+ if (localAttachments.length > 0) {
669
+ const { content: replacedContent, replaced } = replaceAttachmentLinks(currentContent, localAttachments, pageName);
670
+ if (replaced) {
671
+ currentContent = replacedContent;
672
+ hasReplacements = true;
673
+ }
674
+ }
675
+ if (externalAttachments.length > 0) {
676
+ const { content: replacedContent, replaced } = replaceExternalAttachmentLinks(currentContent, externalAttachments, attachmentMap);
677
+ if (replaced) {
678
+ currentContent = replacedContent;
679
+ hasReplacements = true;
680
+ }
681
+ }
682
+ const { content: linkedContent, replaced: linkReplaced } = replaceMarkdownExtension(currentContent, config.basePath);
683
+ if (linkReplaced) {
684
+ currentContent = linkedContent;
685
+ hasReplacements = true;
686
+ }
687
+ if (hasReplacements) {
688
+ const updateResult = await updatePageContent(pageId, currentRevisionId, currentContent);
689
+ if (updateResult.success && updateResult.revisionId) {
690
+ const replacementTypes = [];
691
+ if (localAttachments.some((att) => att.attachmentId)) replacementTypes.push("attachment links");
692
+ if (externalAttachments.length > 0) replacementTypes.push("external references");
693
+ if (linkReplaced) replacementTypes.push("page links");
694
+ console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (${replacementTypes.join(", ")} replaced)`);
695
+ currentRevisionId = updateResult.revisionId;
696
+ } else {
697
+ console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (failed to update links: ${updateResult.errorMessage || "unknown error"})`);
698
+ if (config.verbose && updateResult.error) console.error(formatDetailedError(updateResult.error));
699
+ stats.linkReplacementErrors++;
596
700
  }
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
701
  }
601
702
  }
602
703
  return stats;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onozaty/growi-uploader",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "A content uploader for GROWI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,8 +10,7 @@
10
10
  "dist/**/*"
11
11
  ],
12
12
  "scripts": {
13
- "build": "pnpm gen && tsdown",
14
- "prepublishOnly": "pnpm run build",
13
+ "build": "tsdown",
15
14
  "dev": "tsx src/index.ts",
16
15
  "gen": "orval",
17
16
  "lint": "eslint .",