From f2b6ad465d7e9a87828ed7c3067cc7ebe9a1c614 Mon Sep 17 00:00:00 2001 From: lifegpc Date: Sat, 16 Sep 2023 13:14:23 +0800 Subject: [PATCH] Add download zip page --- lib/api/client.dart | 8 ++ lib/api/client.g.dart | 41 ++++++++ lib/components/gallery_basic_info.dart | 5 +- lib/components/gallery_info_desktop.dart | 13 ++- lib/dialog/dialog_page.dart | 40 ++++++++ lib/dialog/download_zip_page.dart | 117 +++++++++++++++++++++++ lib/l10n/app_en.arb | 5 +- lib/l10n/app_zh.arb | 5 +- lib/main.dart | 20 +++- lib/platform/path.dart | 23 +++++ lib/utils/download_zip.dart | 36 +++++++ windows/runner/CMakeLists.txt | 2 +- windows/runner/flutter_window.cpp | 113 ++++++++++++++++++++-- 13 files changed, 412 insertions(+), 16 deletions(-) create mode 100644 lib/dialog/dialog_page.dart create mode 100644 lib/dialog/download_zip_page.dart create mode 100644 lib/utils/download_zip.dart diff --git a/lib/api/client.dart b/lib/api/client.dart index 13885d2..d17450b 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -162,6 +162,14 @@ abstract class _EHApi { @GET('/tag/rows') Future>> getRowTags( {@CancelRequest() CancelToken? cancel}); + + @GET('/export/gallery/zip/{gid}') + @DioResponseType(ResponseType.stream) + Future exportGalleryZip(@Path("gid") int gid, + {@Query("jpn_title") bool? jpnTitle, + @Query("max_length") int? maxLength, + @Query("export_ad") bool? exportAd, + @CancelRequest() CancelToken? cancel}); } class EHApi extends __EHApi { diff --git a/lib/api/client.g.dart b/lib/api/client.g.dart index 2e516d3..4670297 100644 --- a/lib/api/client.g.dart +++ b/lib/api/client.g.dart @@ -647,6 +647,47 @@ class __EHApi implements _EHApi { return value; } + @override + Future> exportGalleryZip( + int gid, { + bool? jpnTitle, + int? maxLength, + bool? exportAd, + CancelToken? cancel, + }) async { + const _extra = {}; + final queryParameters = { + r'jpn_title': jpnTitle, + r'max_length': maxLength, + r'export_ad': exportAd, + }; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final Map? _data = null; + final _result = + await _dio.fetch(_setStreamType>(Options( + method: 'GET', + headers: _headers, + extra: _extra, + responseType: ResponseType.stream, + ) + .compose( + _dio.options, + '/export/gallery/zip/${gid}', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final value = _result.data; + final httpResponse = HttpResponse(value, _result); + return httpResponse; + } + RequestOptions _setStreamType(RequestOptions requestOptions) { if (T != dynamic && !(requestOptions.responseType == ResponseType.bytes || diff --git a/lib/components/gallery_basic_info.dart b/lib/components/gallery_basic_info.dart index 3348f21..872e66c 100644 --- a/lib/components/gallery_basic_info.dart +++ b/lib/components/gallery_basic_info.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; import '../api/gallery.dart'; import 'thumbnail.dart'; @@ -40,7 +41,9 @@ class GalleryBasicInfo extends StatelessWidget { onPressed: null, child: Text(AppLocalizations.of(context)!.read)), ElevatedButton( - onPressed: null, + onPressed: () { + context.push('/dialog/download/zip/${gMeta.gid}'); + }, child: Text(AppLocalizations.of(context)!.download)), ])) ])); diff --git a/lib/components/gallery_info_desktop.dart b/lib/components/gallery_info_desktop.dart index e4db358..db1f31a 100644 --- a/lib/components/gallery_info_desktop.dart +++ b/lib/components/gallery_info_desktop.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../api/gallery.dart'; import '../main.dart'; @@ -115,7 +116,17 @@ class GalleryInfoDesktop extends StatelessWidget { const VerticalDivider(indent: 10, endIndent: 10), Expanded(child: TagsPanel(gData.tags)), const VerticalDivider(indent: 10, endIndent: 10), - SizedBox(width: 150, child: Column(children: [])), + SizedBox( + width: 150, + child: Column(children: [ + ElevatedButton( + onPressed: () { + context.push( + '/dialog/download/zip/${gData.meta.gid}'); + }, + child: Text( + AppLocalizations.of(context)!.download)), + ])), ])), ], )) diff --git a/lib/dialog/dialog_page.dart b/lib/dialog/dialog_page.dart new file mode 100644 index 0000000..eca4b63 --- /dev/null +++ b/lib/dialog/dialog_page.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class DialogPage extends Page { + final Offset? anchorPoint; + final Color? barrierColor; + final bool barrierDismissible; + final String? barrierLabel; + final bool useSafeArea; + final CapturedThemes? themes; + final WidgetBuilder builder; + + const DialogPage({ + required this.builder, + this.anchorPoint, + this.barrierColor = Colors.black87, + this.barrierDismissible = true, + this.barrierLabel, + this.useSafeArea = true, + this.themes, + super.key, + super.name, + super.arguments, + super.restorationId, + }); + + @override + Route createRoute(BuildContext context) => DialogRoute( + context: context, + settings: this, + builder: (context) => Dialog( + child: builder(context), + ), + anchorPoint: anchorPoint, + barrierColor: barrierColor, + barrierDismissible: barrierDismissible, + barrierLabel: barrierLabel, + useSafeArea: useSafeArea, + themes: themes, + ); +} diff --git a/lib/dialog/download_zip_page.dart b/lib/dialog/download_zip_page.dart new file mode 100644 index 0000000..50c6607 --- /dev/null +++ b/lib/dialog/download_zip_page.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import '../globals.dart'; +import '../utils/download_zip.dart'; + +final _log = Logger("DownloadZipPage"); + +class DownloadZipPage extends StatefulWidget { + const DownloadZipPage(this.gid, {Key? key}) : super(key: key); + final int gid; + + @override + State createState() => _DownloadZipPage(); +} + +class _DownloadZipPage extends State { + bool useTitleJpn = false; + bool exportAd = false; + String maxLength = ""; + final _formKey = GlobalKey(); + @override + void initState() { + useTitleJpn = prefs.getBool("useTitleJpn") ?? false; + exportAd = prefs.getBool("exportAd") ?? false; + maxLength = prefs.getInt("maxZipFilenameLength")?.toString() ?? ""; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + final maxWidth = MediaQuery.of(context).size.width; + return Container( + padding: maxWidth < 400 + ? const EdgeInsets.symmetric(vertical: 20, horizontal: 5) + : const EdgeInsets.all(20), + width: maxWidth < 810 ? null : 800, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(10)), + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Center( + child: Text( + i18n.downloadAsZip, + style: Theme.of(context).textTheme.headlineSmall, + ))), + IconButton( + onPressed: () => context.canPop() + ? context.pop() + : context.go("/gallery/${widget.gid}"), + icon: const Icon(Icons.close), + ) + ], + ), + CheckboxMenuButton( + value: useTitleJpn, + onChanged: (u) { + if (u != null) { + setState(() { + useTitleJpn = u!; + }); + } + }, + child: Text(i18n.useTitleJpn)), + CheckboxMenuButton( + value: exportAd, + onChanged: (u) { + if (u != null) { + setState(() { + exportAd = u!; + }); + } + }, + child: Text(i18n.exportAd)), + Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TextFormField( + initialValue: maxLength, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + onChanged: (v) { + setState(() { + maxLength = v; + }); + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.maxZipFilenameLength), + )), + ElevatedButton( + onPressed: () { + downloadZip(widget.gid, + jpnTitle: useTitleJpn, + exportAd: exportAd, + maxLength: int.tryParse(maxLength)) + .catchError((err) { + _log.warning("Failed to download zip:", err); + }); + context.canPop() + ? context.pop() + : context.go("/gallery/${widget.gid}"); + }, + child: Text(i18n.download)) + ], + )))); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2cc8676..3a80404 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -79,5 +79,8 @@ "type": "String" } } - } + }, + "downloadAsZip": "Download as ZIP file", + "exportAd": "Export pages which marked as ads", + "maxZipFilenameLength": "Maximum length of filenames in Zip files" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 7c3d85d..dfeaf01 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -79,5 +79,8 @@ "type": "String" } } - } + }, + "downloadAsZip": "下载为ZIP文件", + "exportAd": "导出标记为广告的页面", + "maxZipFilenameLength": "Zip文件中文件名的最大长度" } diff --git a/lib/main.dart b/lib/main.dart index a5dde05..73e4441 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,8 @@ import 'package:logging/logging.dart'; import 'package:window_manager/window_manager.dart'; import 'api/client.dart'; import 'create_root_user.dart'; +import 'dialog/dialog_page.dart'; +import 'dialog/download_zip_page.dart'; import 'galleries.dart'; import 'gallery.dart'; import 'globals.dart'; @@ -87,7 +89,23 @@ final _router = GoRouter( GoRoute( path: "/gallery", redirect: (context, state) => "/galleries", - ) + ), + GoRoute( + path: '/dialog/download/zip/:gid', + pageBuilder: (context, state) => DialogPage( + key: state.pageKey, + builder: (context) { + return DownloadZipPage(int.parse(state.pathParameters["gid"]!)); + }), + redirect: (context, state) { + try { + int.parse(state.pathParameters["gid"]!); + return null; + } catch (e) { + _routerLog.warning("Failed to parse gid:", e); + return "/"; + } + }), ], ); diff --git a/lib/platform/path.dart b/lib/platform/path.dart index 1c01687..e8a8bcb 100644 --- a/lib/platform/path.dart +++ b/lib/platform/path.dart @@ -39,4 +39,27 @@ class Path { return _safChannel.invokeMethod( "saveFile", [filenameWithoutExtension, dir, mimeType, bytes]); } + + Future openFile(String filenameWithoutExtension, String mimeType, + {String dir = ""}) async { + final fd = await _safChannel.invokeMethod( + "openFile", [filenameWithoutExtension, dir, mimeType]); + return SAFFile(fd!); + } +} + +class SAFFile { + SAFFile(this._fd); + final int _fd; + bool _disposed = false; + Future write(Uint8List data) async { + if (_disposed) throw Exception("File already closed"); + return await Path._safChannel.invokeMethod("writeFile", [_fd, data]); + } + + Future dispose() async { + if (_disposed) return; + _disposed = true; + await Path._safChannel.invokeMethod("closeFile", [_fd]); + } } diff --git a/lib/utils/download_zip.dart b/lib/utils/download_zip.dart new file mode 100644 index 0000000..80c17f1 --- /dev/null +++ b/lib/utils/download_zip.dart @@ -0,0 +1,36 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import '../globals.dart'; +import '../platform/save_file.dart'; + +Future downloadZip(int gid, + {bool? jpnTitle, int? maxLength, bool? exportAd}) async { + final cancel = CancelToken(); + final re = await api.exportGalleryZip(gid, + jpnTitle: jpnTitle, + maxLength: maxLength, + exportAd: exportAd, + cancel: cancel); + final data = re.data as ResponseBody; + if (data.statusCode != 200) { + throw Exception("${data.statusCode} ${data.statusMessage}."); + } + final fileName = re.response.headers.value("content-disposition"); + final filenameWithoutExtension = fileName?.substring( + fileName.indexOf("filename=\"") + 10, fileName.lastIndexOf(".")) ?? + "$gid"; + try { + final f = await platformPath.openFile( + Uri.decodeComponent(filenameWithoutExtension), "application/zip"); + try { + await data.stream.forEach((data) { + f.write(data); + }); + } finally { + await f.dispose(); + } + } catch (e) { + cancel.cancel(); + rethrow; + } +} diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index ece282c..09cc65b 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.14) -project(runner LANGUAGES CXX) +project(runner) set(ENABLE_ICONV OFF CACHE BOOL "Libiconv is not needed.") add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../../utils" "${CMAKE_BINARY_DIR}/utils") diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index d28352b..e110618 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -10,6 +10,10 @@ #include "flutter/generated_plugin_registrant.h" +#include +#include +#include "err.h" +#include "fileop.h" #include "wchar_util.h" #define MAX_PATH_SIZE 32768 @@ -19,6 +23,22 @@ FlutterWindow::FlutterWindow(const flutter::DartProject& project) FlutterWindow::~FlutterWindow() {} +void updateDataFromMimeType(std::wstring& defExt, std::wstring& filter, std::string mimeType) { + if (mimeType == "image/jpeg") { + filter.append(std::wstring(L"JPEG File(*.jpg)\0*.jpg\0", 23)); + defExt = L"jpg"; + } else if (mimeType == "image/png") { + filter.append(std::wstring(L"PNG File(*.png)\0*.png\0", 22)); + defExt = L"png"; + } else if (mimeType == "image/gif") { + filter.append(std::wstring(L"GIF File(*.gif)\0*.gif\0", 22)); + defExt = L"gif"; + } else if (mimeType == "application/zip") { + filter.append(std::wstring(L"ZIP File(*.zip)\0*.zip\0", 22)); + defExt = L"zip"; + } +} + bool FlutterWindow::OnCreate() { if (!Win32Window::OnCreate()) { return false; @@ -87,16 +107,7 @@ bool FlutterWindow::OnCreate() { ofn.hwndOwner = Win32Window::GetHandle(); std::wstring filter; std::wstring defExt; - if (*mimeType == "image/jpeg") { - filter.append(std::wstring(L"JPEG File(*.jpg)\0*.jpg\0", 23)); - defExt = L"jpg"; - } else if (*mimeType == "image/png") { - filter.append(std::wstring(L"PNG File(*.png)\0*.png\0", 22)); - defExt = L"png"; - } else if (*mimeType == "image/gif") { - filter.append(std::wstring(L"GIF File(*.gif)\0*.gif\0", 22)); - defExt = L"gif"; - }; + updateDataFromMimeType(defExt, filter, *mimeType); filter.append(std::wstring(L"All Files\0*.*\0\0", 15)); ofn.lpstrFilter = filter.c_str(); ofn.lpstrDefExt = defExt.empty() ? nullptr : defExt.c_str(); @@ -119,6 +130,88 @@ bool FlutterWindow::OnCreate() { fwrite(data->data(), sizeof(uint8_t), data->size(), f); fclose(f); result->Success(); + } else if (call.method_name() == "closeFile") { + auto args = std::get_if(call.arguments()); + auto fd = std::get_if(&args->at(0)); + if (!fd) { + result->Error("INVALID_ARGUMENT", "Invalid arguments."); + return; + } + fileop::close(*fd); + result->Success(); + } else if (call.method_name() == "openFile") { + auto args = std::get_if(call.arguments()); + auto fileName = std::get_if(&args->at(0)); + auto dir = std::get_if(&args->at(1)); + auto mimeType = std::get_if(&args->at(2)); + if (!fileName || !dir || !mimeType) { + result->Error("INVALID_ARGUMENT", "Invalid arguments."); + return; + } + std::wstring wFileName; + if (!wchar_util::str_to_wstr(wFileName, *fileName, CP_UTF8)) { + result->Error("ERROR", "Failed to convert file name to wstring."); + return; + } + std::wstring wDir; + if (!dir->empty() && !wchar_util::str_to_wstr(wDir, *dir, CP_UTF8)) { + result->Error("ERROR", "Failed to convert dir to wstring."); + return; + } + OPENFILENAMEW ofn; + ZeroMemory(&ofn, sizeof(ofn)); + ofn.lStructSize = sizeof(ofn); + ofn.hwndOwner = Win32Window::GetHandle(); + std::wstring filter; + std::wstring defExt; + updateDataFromMimeType(defExt, filter, *mimeType); + filter.append(std::wstring(L"All Files\0*.*\0\0", 15)); + ofn.lpstrFilter = filter.c_str(); + ofn.lpstrDefExt = defExt.empty() ? nullptr : defExt.c_str(); + wchar_t wFileNameBuf[MAX_PATH_SIZE]; + memcpy(wFileNameBuf, wFileName.c_str(), (wFileName.size() + 1) * sizeof(wchar_t)); + ofn.lpstrFile = wFileNameBuf; + ofn.nMaxFile = MAX_PATH_SIZE; + ofn.lpstrInitialDir = wDir.empty() ? nullptr : wDir.c_str(); + ofn.Flags = OFN_DONTADDTORECENT | OFN_NONETWORKBUTTON | OFN_NOREADONLYRETURN | OFN_OVERWRITEPROMPT; + if (!GetSaveFileNameW(&ofn)) { + result->Error("ERROR", "Failed to get file name."); + return; + } + std::string fn; + if (!wchar_util::wstr_to_str(fn, wFileNameBuf, CP_UTF8)) { + result->Error("ERROR", "Failed to convert file name to UTF-8."); + return; + } + int fd = 0; + int e = fileop::open(fn, fd, _O_WRONLY | _O_BINARY | O_CREAT); + if (e) { + std::string errmsg; + if (!err::get_errno_message(errmsg, e)) { + errmsg = "Unknown error."; + } + result->Error("ERROR", "Failed to open file: " + errmsg); + return; + } + result->Success(fd); + } else if (call.method_name() == "writeFile") { + auto args = std::get_if(call.arguments()); + auto fd = std::get_if(&args->at(0)); + auto data = std::get_if>(&args->at(1)); + if (!fd || !data) { + result->Error("INVALID_ARGUMENT", "Invalid arguments."); + return; + } + int num = _write(*fd, data->data(), (unsigned int)data->size()); + if (num == -1) { + std::string errmsg; + if (!err::get_errno_message(errmsg, errno)) { + errmsg = "Unknown error."; + } + result->Error("ERROR", "Failed to write file:" + errmsg); + return; + } + result->Success(num); } else { result->NotImplemented(); }