diff --git a/README.md b/README.md index 72ee698..1067f69 100644 --- a/README.md +++ b/README.md @@ -31,3 +31,27 @@ chmod +x coding.phar sudo mv coding.phar /usr/local/bin/coding coding list ``` + +## Confluence 导入 CODING Wiki + +1. 浏览器访问 Confluence 空间,导出 HTML,获得一个 zip 压缩包。 + +![image](https://user-images.githubusercontent.com/4971414/127876158-8ab62714-e43f-4e20-8865-f8817f9264e1.png) + +2. 浏览器访问 CODING,创建个人令牌 + +![image](https://user-images.githubusercontent.com/4971414/127877027-68a3f58e-c253-4ba9-b4f9-68b6673582a3.png) + +3. 打开命令行,进入 zip 文件所在的目录,执行命令导入: + +```shell +cd ~/Downloads/ +docker run -it -v $(pwd):/root --env CODING_IMPORT_PROVIDER=Confluence \ + --env CODING_IMPORT_DATA_TYPE=HTML \ + --env CODING_IMPORT_DATA_PATH=./Confluence-space-export-231543-81.html.zip \ + --env CODING_TOKEN=foo \ + ecoding/coding-cli wiki:import +``` + +![image](https://user-images.githubusercontent.com/4971414/127878108-f778bfd6-fe7f-49f3-9590-9efd68404df5.png) + diff --git a/app/Commands/WikiImportCommand.php b/app/Commands/WikiImportCommand.php index 0fdcf65..8db8538 100644 --- a/app/Commands/WikiImportCommand.php +++ b/app/Commands/WikiImportCommand.php @@ -2,7 +2,6 @@ namespace App\Commands; -use App\Coding; use App\Coding\Disk; use App\Coding\Wiki; use Confluence\Content; @@ -11,6 +10,7 @@ use Illuminate\Support\Str; use LaravelFans\Confluence\Facades\Confluence; use LaravelZero\Framework\Commands\Command; +use ZipArchive; class WikiImportCommand extends Command { @@ -26,7 +26,7 @@ class WikiImportCommand extends Command protected $signature = 'wiki:import {--coding_import_provider= : 数据来源,如 Confluence、MediaWiki} {--coding_import_data_type= : 数据类型,如 HTML、API} - {--coding_import_data_path= : 空间导出的 HTML 目录,如 ./confluence/space1/} + {--coding_import_data_path= : 空间导出的 HTML zip 文件路径,如 ./Confluence-space-export-231543-81.html.zip} {--confluence_base_uri= : Confluence API URL,如 http://localhost:8090/confluence/rest/api/} {--confluence_username=} {--confluence_password=} @@ -135,15 +135,8 @@ private function handleConfluenceApi(): int private function handleConfluenceHtml(): int { - $dataPath = $this->option('coding_import_data_path'); - if (is_null($dataPath)) { - $dataPath = config('coding.import.data_path') ?? trim($this->ask( - '空间导出的 HTML 目录', - './confluence/space1/' - )); - } - $dataPath = str_ends_with($dataPath, '/index.html') ? substr($dataPath, 0, -10) : Str::finish($dataPath, '/'); - $filePath = $dataPath . 'index.html'; + $htmlDir = $this->unzipConfluenceHtml(); + $filePath = $htmlDir . 'index.html'; if (!file_exists($filePath)) { $this->error("文件不存在:$filePath"); return 1; @@ -171,7 +164,7 @@ private function handleConfluenceHtml(): int } $this->info('发现 ' . count($pages['tree']) . ' 个一级页面'); $this->info("开始导入 CODING:"); - $this->uploadConfluencePages($dataPath, $pages['tree'], $pages['titles']); + $this->uploadConfluencePages($htmlDir, $pages['tree'], $pages['titles']); } catch (\ErrorException $e) { $this->error($e->getMessage()); return 1; @@ -239,4 +232,31 @@ private function uploadConfluencePages(string $dataPath, array $tree, array $tit } } } + + private function unzipConfluenceHtml(): string + { + $dataPath = $this->option('coding_import_data_path'); + if (is_null($dataPath)) { + $dataPath = config('coding.import.data_path') ?? trim($this->ask( + '空间导出的 HTML zip 文件路径', + './confluence/space1.zip' + )); + } + + if (str_ends_with($dataPath, '.zip')) { + $zip = new ZipArchive(); + $zip->open($dataPath); + $tmpDir = sys_get_temp_dir() . '/confluence-' . Str::uuid(); + mkdir($tmpDir); + for ($i = 0; $i < $zip->numFiles; $i++) { + // HACK crash when zip include root path / + if ($zip->getNameIndex($i) != '/' && $zip->getNameIndex($i) != '__MACOSX/_') { + $zip->extractTo($tmpDir, [$zip->getNameIndex($i)]); + } + } + $zip->close(); + return $tmpDir . '/' . scandir($tmpDir, 1)[0] . '/'; + } + return str_ends_with($dataPath, '/index.html') ? substr($dataPath, 0, -10) : Str::finish($dataPath, '/'); + } } diff --git a/composer.lock b/composer.lock index 1353d7e..db3a6a5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3cbc0a3cdb813dda999bf3bd860217c3", + "content-hash": "cbce9cf01075f5140afc235370aa6a80", "packages": [ { "name": "brick/math", @@ -7294,6 +7294,7 @@ "platform": { "php": "^8.0", "ext-dom": "*", + "ext-fileinfo": "*", "ext-json": "*", "ext-libxml": "*", "ext-zip": "*" diff --git a/tests/Feature/WikiImportCommandTest.php b/tests/Feature/WikiImportCommandTest.php index 4257798..a2cf106 100755 --- a/tests/Feature/WikiImportCommandTest.php +++ b/tests/Feature/WikiImportCommandTest.php @@ -133,14 +133,14 @@ public function testHandleConfluenceHtmlFileNotExist() $this->artisan('wiki:import') ->expectsQuestion('数据来源?', 'Confluence') ->expectsQuestion('数据类型?', 'HTML') - ->expectsQuestion('空间导出的 HTML 目录', '~/Downloads/') + ->expectsQuestion('空间导出的 HTML zip 文件路径', '~/Downloads/') ->expectsOutput('文件不存在:~/Downloads/index.html') ->assertExitCode(1); $this->artisan('wiki:import') ->expectsQuestion('数据来源?', 'Confluence') ->expectsQuestion('数据类型?', 'HTML') - ->expectsQuestion('空间导出的 HTML 目录', '~/Downloads/index.html') + ->expectsQuestion('空间导出的 HTML zip 文件路径', '~/Downloads/index.html') ->expectsOutput('文件不存在:~/Downloads/index.html') ->assertExitCode(1); } @@ -177,7 +177,7 @@ public function testHandleConfluenceHtmlSuccess() $this->artisan('wiki:import') ->expectsQuestion('数据来源?', 'Confluence') ->expectsQuestion('数据类型?', 'HTML') - ->expectsQuestion('空间导出的 HTML 目录', $this->dataDir . 'confluence/space1/') + ->expectsQuestion('空间导出的 HTML zip 文件路径', $this->dataDir . 'confluence/space1/') ->expectsOutput('空间名称:空间 1') ->expectsOutput('空间标识:space1') ->expectsOutput('发现 2 个一级页面') @@ -210,4 +210,59 @@ public function testAskNothing() ->expectsOutput('文件不存在:/dev/null/index.html') ->assertExitCode(1); } + + public function testHandleConfluenceHtmlZipSuccess() + { + $codingToken = $this->faker->md5; + config(['coding.token' => $codingToken]); + $codingTeamDomain = $this->faker->domainWord; + config(['coding.team_domain' => $codingTeamDomain]); + $codingProjectUri = $this->faker->slug; + config(['coding.project_uri' => $codingProjectUri]); + + // 注意:不能使用 partialMock + // https://laracasts.com/discuss/channels/testing/this-partialmock-doesnt-call-the-constructor + $mock = \Mockery::mock(Wiki::class, [])->makePartial(); + $this->instance(Wiki::class, $mock); + + $mock->shouldReceive('createWikiByUploadZip')->times(5)->andReturn(json_decode( + file_get_contents($this->dataDir . 'coding/' . 'CreateWikiByZipResponse.json'), + true + )['Response']); + $mock->shouldReceive('getImportJobStatus')->times(5)->andReturn(json_decode( + file_get_contents($this->dataDir . 'coding/' . 'DescribeImportJobStatusResponse.json'), + true + )['Response']['Data']); + $mock->shouldReceive('updateWikiTitle')->times(5)->andReturn(true); + + + $mockDisk = \Mockery::mock(Disk::class, [])->makePartial(); + $this->instance(Disk::class, $mockDisk); + $mockDisk->shouldReceive('uploadAttachments')->times(5)->andReturn([]); + + $this->artisan('wiki:import') + ->expectsQuestion('数据来源?', 'Confluence') + ->expectsQuestion('数据类型?', 'HTML') + ->expectsQuestion( + '空间导出的 HTML zip 文件路径', + $this->dataDir . 'confluence/Confluence-space-export-231543-81.html.zip' + ) + ->expectsOutput('空间名称:空间 1') + ->expectsOutput('空间标识:space1') + ->expectsOutput('发现 1 个一级页面') + ->expectsOutput("开始导入 CODING:") + ->expectsOutput('标题:空间 1 Home') + ->expectsOutput('上传成功,正在处理,任务 ID:a12353fa-f45b-4af2-83db-666bf9f66615') + ->expectsOutput('发现 2 个子页面') + ->expectsOutput('标题:hello world') + ->expectsOutput('上传成功,正在处理,任务 ID:a12353fa-f45b-4af2-83db-666bf9f66615') + ->expectsOutput('发现 2 个子页面') + ->expectsOutput('标题:hello') + ->expectsOutput('上传成功,正在处理,任务 ID:a12353fa-f45b-4af2-83db-666bf9f66615') + ->expectsOutput('标题:world') + ->expectsOutput('上传成功,正在处理,任务 ID:a12353fa-f45b-4af2-83db-666bf9f66615') + ->expectsOutput('标题:你好世界') + ->expectsOutput('上传成功,正在处理,任务 ID:a12353fa-f45b-4af2-83db-666bf9f66615') + ->assertExitCode(0); + } } diff --git a/tests/data/confluence/Confluence-space-export-231543-81.html.zip b/tests/data/confluence/Confluence-space-export-231543-81.html.zip new file mode 100644 index 0000000..5c0e439 Binary files /dev/null and b/tests/data/confluence/Confluence-space-export-231543-81.html.zip differ