From 87e8ff5049a4266491d4a8980b652f266a45a0f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AE=A1=E5=AE=9C=E5=B0=A7?= Date: Sun, 12 May 2019 17:33:31 +0800 Subject: [PATCH] add batch export for project --- .../Controllers/BatchExportController.php | 273 ++++++++++++++++++ app/Http/Controllers/DocumentController.php | 4 + app/Http/Controllers/ExportController.php | 18 +- app/Http/Controllers/ProjectController.php | 10 +- app/helpers.php | 13 +- composer.json | 1 + composer.lock | 120 +++++++- public/assets/css/pdf.css | 7 + .../views/components/doc-options.blade.php | 3 + .../page-menus-batch-export.blade.php | 65 +++++ resources/views/project/project.blade.php | 6 +- routes/web.php | 3 + 12 files changed, 515 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/BatchExportController.php create mode 100644 resources/views/components/page-menus-batch-export.blade.php diff --git a/app/Http/Controllers/BatchExportController.php b/app/Http/Controllers/BatchExportController.php new file mode 100644 index 00000000..48f8c74d --- /dev/null +++ b/app/Http/Controllers/BatchExportController.php @@ -0,0 +1,273 @@ + + */ + +namespace App\Http\Controllers; + + +use App\Policies\ProjectPolicy; +use App\Repositories\Document; +use App\Repositories\Project; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Http\Request; +use Mpdf\Mpdf; +use Log; +use SoapBox\Formatter\Formatter; +use ZipStream\Option\Archive; +use ZipStream\ZipStream; + +class BatchExportController extends Controller +{ + /** + * 最大处理超时时间 + */ + const TIMEOUT = 320; + + /** + * 批量导出文档 + * + * @param Request $request + * @param $project_id + * + * @throws \Illuminate\Validation\ValidationException + * @throws \Mpdf\MpdfException + * @throws \ZipStream\Exception\OverflowException + */ + public function batchExport(Request $request, $project_id) + { + $this->canExport($project_id); + + $this->validate( + $request, + [ + 'pid' => 'integer', + 'type' => 'required|in:pdf,raw' + ] + ); + + $pid = (int)$request->input('pid', 0); + $type = $request->input('type'); + + /** @var Project $project */ + $project = Project::where('id', $project_id)->firstOrFail(); + + /** @var Collection $documents */ + $documents = $project->pages; + $navigators = navigatorSort(navigator($project_id, 0)); + + if ($pid !== 0) { + $navigators = $this->filterNavigators($navigators, function (array $nav) use ($pid) { + return (int)$nav['id'] === $pid; + }); + } + + switch ($type) { + case 'pdf': + $this->exportPDF($navigators, $project, $documents); + break; + case 'raw': + $this->exportRaw($navigators, $project, $documents); + break; + } + } + + /** + * 过滤要导出的文档 + * + * @param array $navigators + * @param \Closure $filter + * + * @return array|mixed + */ + private function filterNavigators(array $navigators, \Closure $filter) + { + foreach ($navigators as $nav) { + if ($filter($nav)) { + return $nav['nodes'] ?? []; + } + } + + return []; + } + + /** + * Export to zip archive + * + * @param array $navigators + * @param Project $project + * @param Collection $documents + * + * @throws \ZipStream\Exception\OverflowException + */ + private function exportRaw(array $navigators, Project $project, Collection $documents) + { + set_time_limit(self::TIMEOUT); + + $options = new Archive(); + $options->setSendHttpHeaders(true); + + $zip = new ZipStream("{$project->name}.zip", $options); + $this->traverseNavigators( + $navigators, + function ($id, array $parents) use ($documents, $zip) { + /** @var Document $doc */ + $doc = $documents->where('id', $id)->first(); + + switch ($doc->type) { + case Document::TYPE_DOC: + $ext = 'md'; + $content = $doc->content; + break; + case Document::TYPE_SWAGGER: + $ext = 'yml'; + if (isJson($doc->content)) { + $formatter = Formatter::make($doc->content, Formatter::JSON); + $content = $formatter->toYaml(); + } else { + $content = $doc->content; + } + break; + default: + $ext = 'txt'; + $content = $doc->content; + } + + $path = collect($parents)->implode('name', '/'); + $filename = "{$path}/{$doc->title}.{$ext}"; + + $fp = fopen('php://memory', 'r+'); + fwrite($fp, $content); + rewind($fp); + $zip->addFileFromStream($filename, $fp); + }, + [] + ); + + $zip->finish(); + } + + /** + * Export to pdf document + * + * @param array $navigators + * @param Project $project + * @param Collection $documents + * + * @throws \Mpdf\MpdfException + */ + private function exportPDF(array $navigators, Project $project, Collection $documents) + { + set_time_limit(self::TIMEOUT); + + $mpdf = new Mpdf([ + 'mode' => 'utf-8', + 'tempDir' => sys_get_temp_dir(), + 'defaultfooterline' => false, + ]); + + $mpdf->allow_charset_conversion = true; + $mpdf->useAdobeCJK = true; + $mpdf->autoLangToFont = true; + $mpdf->autoScriptToLang = true; + $mpdf->author = $author ?? \Auth::user()->name ?? 'wizard'; + + $mpdf->SetFooter('{PAGENO} / {nbpg}'); + $mpdf->SetTitle($project->name); + + $header = ''; + $header .= ''; + $header .= ''; + $header .= ''; + $header .= ''; + $mpdf->WriteHTML($header); + + $pageNo = 1; + $this->traverseNavigators($navigators, + function ($id, array $parents) use ($documents, $mpdf, &$pageNo) { + if ($pageNo > 1) { + $mpdf->AddPage(); + } + + /** @var Document $doc */ + $doc = $documents->where('id', $id)->first(); + + $title = "* {$doc->title}"; + + if ($doc->type != Document::TYPE_DOC) { + $raw = "# {$title}\n\n暂不支持该类型的文档。"; + } else { + $raw = "# {$title}\n\n" . $doc->content; + } + + $html = (new \Parsedown())->text($raw); + $html = + "
{$html}
"; + + $mpdf->Bookmark($doc->title, count($parents)); + try { + $mpdf->WriteHTML($html); + } catch (\Exception $ex) { + Log::error('html_to_pdf_failed', [ + 'error' => $ex->getMessage(), + 'code' => $ex->getCode(), + 'doc' => [ + 'id' => $doc->id, + 'title' => $doc->title, + 'content' => $html, + ] + ]); + + $mpdf->WriteHTML('

部分文档生成失败

'); + } + + $pageNo++; + }, []); + + $mpdf->Output(); + } + + /** + * 遍历所有目录 + * + * @param array $navigators + * @param \Closure $callback + * @param array $parents + */ + private function traverseNavigators(array $navigators, \Closure $callback, array $parents = []) + { + foreach ($navigators as $nav) { + $callback($nav['id'], $parents); + + if (!empty($nav['nodes'])) { + array_push($parents, ['id' => $nav['id'], 'name' => $nav['name']]); + $this->traverseNavigators($nav['nodes'], $callback, $parents); + array_pop($parents); + } + } + } + + + /** + * 检查是否用户有导出权限 + * + * @param int $projectId + * + * @return Project + */ + private function canExport($projectId) + { + /** @var Project $project */ + $project = Project::findOrFail($projectId); + + $policy = new ProjectPolicy(); + if (!$policy->view(\Auth::user(), $project)) { + abort(404); + } + + return $project; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/DocumentController.php b/app/Http/Controllers/DocumentController.php index d1794312..03a71736 100644 --- a/app/Http/Controllers/DocumentController.php +++ b/app/Http/Controllers/DocumentController.php @@ -37,6 +37,7 @@ class DocumentController extends Controller * * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws \Illuminate\Validation\ValidationException */ public function newPage(Request $request, $id) { @@ -97,6 +98,7 @@ public function editPage($id, $page_id) * * @return array * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws \Illuminate\Validation\ValidationException */ public function newPageHandle(Request $request, $id) { @@ -170,6 +172,7 @@ public function newPageHandle(Request $request, $id) * * @return array|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws \Illuminate\Validation\ValidationException */ public function editPageHandle(Request $request, $id, $page_id) { @@ -264,6 +267,7 @@ public function editPageHandle(Request $request, $id, $page_id) * @param $page_id * * @return array + * @throws \Illuminate\Validation\ValidationException */ public function checkPageExpired(Request $request, $id, $page_id) { diff --git a/app/Http/Controllers/ExportController.php b/app/Http/Controllers/ExportController.php index 29c01b8a..fbd32b29 100644 --- a/app/Http/Controllers/ExportController.php +++ b/app/Http/Controllers/ExportController.php @@ -9,6 +9,7 @@ namespace App\Http\Controllers; use Illuminate\Http\Request; +use Log; use Mpdf\Mpdf; class ExportController extends Controller @@ -43,8 +44,6 @@ public function pdf(Request $request, $type) $title = $request->input('title'); $author = $request->input('author'); -// $html = (new \Parsedown())->text("# {$doc->title}\n\n" . $doc->content); - $mpdf = new Mpdf([ 'mode' => 'utf-8', 'tempDir' => sys_get_temp_dir() @@ -74,10 +73,21 @@ public function pdf(Request $request, $type) $header .= ''; $mpdf->WriteHTML($header); - $html = "
{$content}
"; $mpdf->Bookmark($title, 0); - $mpdf->WriteHTML($html); + try { + $mpdf->WriteHTML($html); + } catch (\Exception $ex) { + Log::error('html_to_pdf_failed', [ + 'error' => $ex->getMessage(), + 'code' => $ex->getCode(), + 'doc' => [ + 'content' => $html, + ] + ]); + + $mpdf->WriteHTML('

部分文档生成失败

'); + } $mpdf->Output(); } diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 5a547dd5..2c9b244f 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -70,6 +70,7 @@ public function home(Request $request) * * @return array * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws \Illuminate\Validation\ValidationException */ public function newProjectHandle(Request $request) { @@ -153,6 +154,7 @@ public function delete(Request $request, $id) * @param $id * * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + * @throws \Illuminate\Validation\ValidationException */ public function project(Request $request, $id) { @@ -217,6 +219,7 @@ public function project(Request $request, $id) * * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws \Illuminate\Validation\ValidationException */ public function setting(Request $request, $id) { @@ -274,6 +277,7 @@ public function setting(Request $request, $id) * * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws \Illuminate\Validation\ValidationException */ public function settingHandle(Request $request, $id) { @@ -319,7 +323,8 @@ public function settingHandle(Request $request, $id) * @param Request $request * @param Project $project * - * @return bool 如果返回true,说明执行了更新操作,false说明没有更新 + * @return bool + * @throws \Illuminate\Validation\ValidationException */ private function basicSettingHandle(Request $request, Project $project): bool { @@ -369,6 +374,7 @@ private function basicSettingHandle(Request $request, Project $project): bool * @param Project $project * * @return bool 如果返回true,说明执行了更新操作,false说明没有更新 + * @throws \Illuminate\Validation\ValidationException */ private function privilegeSettingHandle(Request $request, Project $project): bool { @@ -396,6 +402,7 @@ private function privilegeSettingHandle(Request $request, Project $project): boo * @param Project $project * * @return bool + * @throws \Throwable */ private function sortSettingHandle(Request $request, Project $project): bool { @@ -468,6 +475,7 @@ public function groupPrivilegeRevoke(Request $request, $id, $group_id) * @param $id * * @return array + * @throws \Illuminate\Validation\ValidationException */ public function favorite(Request $request, $id) { diff --git a/app/helpers.php b/app/helpers.php index 5b9b0af0..6b6d3691 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -57,6 +57,13 @@ function navigator( int $pageID = 0, $exclude = [] ) { + static $cached = []; + + $key = "{$projectID}:{$pageID}:" . implode(':', $exclude); + if (isset($cached[$key])) { + return $cached[$key]; + } + $pages = \App\Repositories\Document::where('project_id', $projectID)->select( 'id', 'pid', 'title', 'project_id', 'type', 'status', 'created_at', 'sort_level' )->orderBy('pid')->get(); @@ -90,9 +97,13 @@ function navigator( } } - return array_filter($navigators, function ($nav) { + $res = array_filter($navigators, function ($nav) { return $nav['pid'] === 0; }); + + $cached[$key] = $res; + + return $res; } /** diff --git a/composer.json b/composer.json index 7c237779..249bf01b 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "laravel/framework": "5.8.*", "laravel/tinker": "~1.0", "lcobucci/jwt": "^3.2", + "maennchen/zipstream-php": "^1.1", "mpdf/mpdf": "^8.0", "sebastian/diff": "1.4.3", "soapbox/laravel-formatter": "^3.0", diff --git a/composer.lock b/composer.lock index 7ca8eef2..9a14352b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "a4a9ecf1a6c378c0a3b2fe960271b36e", + "content-hash": "030df4fbb4eafad0dc6a352726e14bd7", "packages": [ { "name": "adldap2/adldap2", @@ -2074,6 +2074,73 @@ ], "time": "2019-03-30T13:22:34+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "272cab6f9b7a7e80248ec59a8d114ca197458623" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/272cab6f9b7a7e80248ec59a8d114ca197458623", + "reference": "272cab6f9b7a7e80248ec59a8d114ca197458623", + "shasum": "", + "mirrors": [ + { + "url": "https://dl.laravel-china.org/%package%/%reference%.%type%", + "preferred": true + } + ] + }, + "require": { + "ext-mbstring": "*", + "myclabs/php-enum": "^1.5", + "php": ">= 7.1", + "psr/http-message": "^1.0" + }, + "require-dev": { + "ext-zip": "*", + "guzzlehttp/guzzle": ">= 6.3", + "mikey179/vfsstream": "^1.6", + "phpunit/phpunit": ">= 7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "time": "2019-04-30T17:14:47+00:00" + }, { "name": "monolog/monolog", "version": "1.24.0", @@ -2343,6 +2410,57 @@ ], "time": "2019-04-07T13:18:21+00:00" }, + { + "name": "myclabs/php-enum", + "version": "1.7.1", + "source": { + "type": "git", + "url": "https://github.com/myclabs/php-enum.git", + "reference": "f46847626b8739de22e4ebc6b56010f317d4448d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/php-enum/zipball/f46847626b8739de22e4ebc6b56010f317d4448d", + "reference": "f46847626b8739de22e4ebc6b56010f317d4448d", + "shasum": "", + "mirrors": [ + { + "url": "https://dl.laravel-china.org/%package%/%reference%.%type%", + "preferred": true + } + ] + }, + "require": { + "ext-json": "*", + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|^5.7|^6.0", + "squizlabs/php_codesniffer": "1.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "MyCLabs\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP Enum contributors", + "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" + } + ], + "description": "PHP Enum implementation", + "homepage": "http://github.com/myclabs/php-enum", + "keywords": [ + "enum" + ], + "time": "2019-05-05T10:12:03+00:00" + }, { "name": "nesbot/carbon", "version": "2.17.1", diff --git a/public/assets/css/pdf.css b/public/assets/css/pdf.css index 4d6422d1..16c69118 100644 --- a/public/assets/css/pdf.css +++ b/public/assets/css/pdf.css @@ -27,4 +27,11 @@ .wz-pdf-content pre.prettyprint li.L1, li.L3, li.L5, li.L7, li.L9 { background: none; +} + +.pdf-error { + color: #ffffff; + background-color: #FF0000; + text-align: center; + font-size: 20px; } \ No newline at end of file diff --git a/resources/views/components/doc-options.blade.php b/resources/views/components/doc-options.blade.php index d983fe77..fdd85abe 100644 --- a/resources/views/components/doc-options.blade.php +++ b/resources/views/components/doc-options.blade.php @@ -1,4 +1,7 @@ @foreach($navbars as $nav) + @if (isset($excludeLeaf) && $excludeLeaf && empty($nav['nodes'])) + @continue + @endif @if(!empty($nav['nodes'])) @include('components.doc-options', ['navbars' => $nav['nodes'], 'level' => $level + 1]) diff --git a/resources/views/components/page-menus-batch-export.blade.php b/resources/views/components/page-menus-batch-export.blade.php new file mode 100644 index 00000000..78a6da0a --- /dev/null +++ b/resources/views/components/page-menus-batch-export.blade.php @@ -0,0 +1,65 @@ + + + + + +@push('script') + +@endpush diff --git a/resources/views/project/project.blade.php b/resources/views/project/project.blade.php index 1d56b8bd..773872b2 100644 --- a/resources/views/project/project.blade.php +++ b/resources/views/project/project.blade.php @@ -103,13 +103,17 @@ @else
-

{{ $project->name ?? '' }}

diff --git a/routes/web.php b/routes/web.php index 27602c91..04fda057 100644 --- a/routes/web.php +++ b/routes/web.php @@ -54,6 +54,9 @@ Route::post('/export/{type}.pdf', 'ExportController@pdf')->name('export:pdf'); Route::post('/export-file/{filename}', 'ExportController@download')->name('export:download'); + // 批量导出(项目) + Route::post('/project/{project_id}/export', 'BatchExportController@batchExport')->name('export:batch'); + Route::group(['prefix' => 'project', 'middleware' => 'share', 'as' => 'project:'], function () { // 项目分享 Route::get('/{id}/doc/{page_id}.json', 'DocumentController@getPageJSON')->name('doc:json');