Add download zip page

This commit is contained in:
2023-09-16 13:14:23 +08:00
parent 93a307b143
commit f2b6ad465d
13 changed files with 412 additions and 16 deletions

View File

@@ -162,6 +162,14 @@ abstract class _EHApi {
@GET('/tag/rows')
Future<ApiResult<List<Tag>>> getRowTags(
{@CancelRequest() CancelToken? cancel});
@GET('/export/gallery/zip/{gid}')
@DioResponseType(ResponseType.stream)
Future<HttpResponse> 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 {

View File

@@ -647,6 +647,47 @@ class __EHApi implements _EHApi {
return value;
}
@override
Future<HttpResponse<dynamic>> exportGalleryZip(
int gid, {
bool? jpnTitle,
int? maxLength,
bool? exportAd,
CancelToken? cancel,
}) async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{
r'jpn_title': jpnTitle,
r'max_length': maxLength,
r'export_ad': exportAd,
};
queryParameters.removeWhere((k, v) => v == null);
final _headers = <String, dynamic>{};
final Map<String, dynamic>? _data = null;
final _result =
await _dio.fetch(_setStreamType<HttpResponse<dynamic>>(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<T>(RequestOptions requestOptions) {
if (T != dynamic &&
!(requestOptions.responseType == ResponseType.bytes ||

View File

@@ -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)),
]))
]));

View File

@@ -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)),
])),
])),
],
))

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
class DialogPage<T> extends Page<T> {
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<T> createRoute(BuildContext context) => DialogRoute<T>(
context: context,
settings: this,
builder: (context) => Dialog(
child: builder(context),
),
anchorPoint: anchorPoint,
barrierColor: barrierColor,
barrierDismissible: barrierDismissible,
barrierLabel: barrierLabel,
useSafeArea: useSafeArea,
themes: themes,
);
}

View File

@@ -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<DownloadZipPage> createState() => _DownloadZipPage();
}
class _DownloadZipPage extends State<DownloadZipPage> {
bool useTitleJpn = false;
bool exportAd = false;
String maxLength = "";
final _formKey = GlobalKey<FormState>();
@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))
],
))));
}
}

View File

@@ -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"
}

View File

@@ -79,5 +79,8 @@
"type": "String"
}
}
}
},
"downloadAsZip": "下载为ZIP文件",
"exportAd": "导出标记为广告的页面",
"maxZipFilenameLength": "Zip文件中文件名的最大长度"
}

View File

@@ -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 "/";
}
}),
],
);

View File

@@ -39,4 +39,27 @@ class Path {
return _safChannel.invokeMethod(
"saveFile", [filenameWithoutExtension, dir, mimeType, bytes]);
}
Future<SAFFile> openFile(String filenameWithoutExtension, String mimeType,
{String dir = ""}) async {
final fd = await _safChannel.invokeMethod<int>(
"openFile", [filenameWithoutExtension, dir, mimeType]);
return SAFFile(fd!);
}
}
class SAFFile {
SAFFile(this._fd);
final int _fd;
bool _disposed = false;
Future<int> write(Uint8List data) async {
if (_disposed) throw Exception("File already closed");
return await Path._safChannel.invokeMethod("writeFile", [_fd, data]);
}
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
await Path._safChannel.invokeMethod("closeFile", [_fd]);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../globals.dart';
import '../platform/save_file.dart';
Future<void> 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;
}
}

View File

@@ -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")

View File

@@ -10,6 +10,10 @@
#include "flutter/generated_plugin_registrant.h"
#include <fcntl.h>
#include <io.h>
#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<flutter::EncodableList>(call.arguments());
auto fd = std::get_if<int>(&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<flutter::EncodableList>(call.arguments());
auto fileName = std::get_if<std::string>(&args->at(0));
auto dir = std::get_if<std::string>(&args->at(1));
auto mimeType = std::get_if<std::string>(&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<flutter::EncodableList>(call.arguments());
auto fd = std::get_if<int>(&args->at(0));
auto data = std::get_if<std::vector<uint8_t>>(&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();
}