feature: convert downloaded file to cbz file & read cbz file
This commit is contained in:
parent
05bada5661
commit
4f0df88bc2
10 changed files with 294 additions and 36 deletions
|
|
@ -50,10 +50,10 @@ bool _isImageFile(String path) {
|
|||
}
|
||||
|
||||
bool _isArchiveFile(String path) {
|
||||
List<String> imageExtensions = ['.cbz', '.zip', 'cbt', 'tar'];
|
||||
List<String> archiveExtensions = ['.cbz', '.zip', 'cbt', 'tar'];
|
||||
String extension = path.toLowerCase();
|
||||
for (String imageExtension in imageExtensions) {
|
||||
if (extension.endsWith(imageExtension)) {
|
||||
for (String archiveExtension in archiveExtensions) {
|
||||
if (extension.endsWith(archiveExtension)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
lib/modules/manga/download/providers/convert_to_cbz.dart
Normal file
54
lib/modules/manga/download/providers/convert_to_cbz.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import 'dart:io';
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
part 'convert_to_cbz.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<String>> convertToCBZ(ConvertToCBZRef ref, String chapterDir,
|
||||
String mangaDir, String chapterName, List<String> pageList) async {
|
||||
Map<String, dynamic> data = {
|
||||
"chapterDir": chapterDir,
|
||||
"mangaDir": mangaDir,
|
||||
"chapterName": chapterName,
|
||||
"pageList": pageList
|
||||
};
|
||||
return compute(_convertToCBZ, data);
|
||||
}
|
||||
|
||||
List<String> _convertToCBZ(Map<String, dynamic> data) {
|
||||
List<String> imagesPaths = [];
|
||||
String chapterDir = data["chapterDir"]!;
|
||||
String mangaDir = data["mangaDir"]!;
|
||||
String chapterName = data["chapterName"]!;
|
||||
List<String> pageList = data["pageList"]!;
|
||||
|
||||
if (Directory(chapterDir).existsSync()) {
|
||||
List<FileSystemEntity> entities = Directory(chapterDir).listSync();
|
||||
for (FileSystemEntity entity in entities) {
|
||||
if (entity is File) {
|
||||
String path = entity.path;
|
||||
if (path.endsWith('.jpg')) {
|
||||
imagesPaths.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
imagesPaths.sort(
|
||||
(a, b) {
|
||||
return a.toString().compareTo(b.toString());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (imagesPaths.isNotEmpty && pageList.length == imagesPaths.length) {
|
||||
var encoder = ZipFileEncoder();
|
||||
encoder.create("$mangaDir/$chapterName.cbz");
|
||||
for (var path in imagesPaths) {
|
||||
encoder.addFile(File(path));
|
||||
}
|
||||
encoder.close();
|
||||
Directory(chapterDir).deleteSync(recursive: true);
|
||||
}
|
||||
|
||||
return imagesPaths;
|
||||
}
|
||||
137
lib/modules/manga/download/providers/convert_to_cbz.g.dart
Normal file
137
lib/modules/manga/download/providers/convert_to_cbz.g.dart
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'convert_to_cbz.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$convertToCBZHash() => r'b421d288e9cd1fca3079ccb5d5702ee2ad4cdfe3';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
typedef ConvertToCBZRef = AutoDisposeFutureProviderRef<List<String>>;
|
||||
|
||||
/// See also [convertToCBZ].
|
||||
@ProviderFor(convertToCBZ)
|
||||
const convertToCBZProvider = ConvertToCBZFamily();
|
||||
|
||||
/// See also [convertToCBZ].
|
||||
class ConvertToCBZFamily extends Family<AsyncValue<List<String>>> {
|
||||
/// See also [convertToCBZ].
|
||||
const ConvertToCBZFamily();
|
||||
|
||||
/// See also [convertToCBZ].
|
||||
ConvertToCBZProvider call(
|
||||
String chapterDir,
|
||||
String mangaDir,
|
||||
String chapterName,
|
||||
List<String> pageList,
|
||||
) {
|
||||
return ConvertToCBZProvider(
|
||||
chapterDir,
|
||||
mangaDir,
|
||||
chapterName,
|
||||
pageList,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ConvertToCBZProvider getProviderOverride(
|
||||
covariant ConvertToCBZProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.chapterDir,
|
||||
provider.mangaDir,
|
||||
provider.chapterName,
|
||||
provider.pageList,
|
||||
);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'convertToCBZProvider';
|
||||
}
|
||||
|
||||
/// See also [convertToCBZ].
|
||||
class ConvertToCBZProvider extends AutoDisposeFutureProvider<List<String>> {
|
||||
/// See also [convertToCBZ].
|
||||
ConvertToCBZProvider(
|
||||
this.chapterDir,
|
||||
this.mangaDir,
|
||||
this.chapterName,
|
||||
this.pageList,
|
||||
) : super.internal(
|
||||
(ref) => convertToCBZ(
|
||||
ref,
|
||||
chapterDir,
|
||||
mangaDir,
|
||||
chapterName,
|
||||
pageList,
|
||||
),
|
||||
from: convertToCBZProvider,
|
||||
name: r'convertToCBZProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$convertToCBZHash,
|
||||
dependencies: ConvertToCBZFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
ConvertToCBZFamily._allTransitiveDependencies,
|
||||
);
|
||||
|
||||
final String chapterDir;
|
||||
final String mangaDir;
|
||||
final String chapterName;
|
||||
final List<String> pageList;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ConvertToCBZProvider &&
|
||||
other.chapterDir == chapterDir &&
|
||||
other.mangaDir == mangaDir &&
|
||||
other.chapterName == chapterName &&
|
||||
other.pageList == pageList;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, chapterDir.hashCode);
|
||||
hash = _SystemHash.combine(hash, mangaDir.hashCode);
|
||||
hash = _SystemHash.combine(hash, chapterName.hashCode);
|
||||
hash = _SystemHash.combine(hash, pageList.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions
|
||||
|
|
@ -4,6 +4,7 @@ import 'package:isar/isar.dart';
|
|||
import 'package:mangayomi/main.dart';
|
||||
import 'package:mangayomi/models/chapter.dart';
|
||||
import 'package:mangayomi/models/download.dart';
|
||||
import 'package:mangayomi/modules/manga/download/providers/convert_to_cbz.dart';
|
||||
import 'package:mangayomi/modules/more/settings/downloads/providers/downloads_state_provider.dart';
|
||||
import 'package:mangayomi/providers/storage_provider.dart';
|
||||
import 'package:mangayomi/services/get_chapter_url.dart';
|
||||
|
|
@ -24,6 +25,7 @@ Future<List<String>> downloadChapter(
|
|||
final StorageProvider storageProvider = StorageProvider();
|
||||
await storageProvider.requestPermission();
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final mangaDir = await storageProvider.getMangaMainDirectory(chapter);
|
||||
bool onlyOnWifi = useWifi ?? ref.watch(onlyOnWifiStateProvider);
|
||||
Directory? path;
|
||||
bool isOk = false;
|
||||
|
|
@ -125,14 +127,21 @@ Future<List<String>> downloadChapter(
|
|||
taskIds: pageUrls,
|
||||
isStartDownload: false,
|
||||
chapterId: chapter.id);
|
||||
|
||||
await ref.watch(convertToCBZProvider(
|
||||
path.path, mangaDir!.path, chapter.name!, pageUrls)
|
||||
.future);
|
||||
isar.writeTxnSync(() {
|
||||
isar.downloads.putSync(model..chapter.value = chapter);
|
||||
});
|
||||
} else {
|
||||
await FileDownloader().downloadBatch(
|
||||
tasks,
|
||||
batchProgressCallback: (succeeded, failed) {
|
||||
batchProgressCallback: (succeeded, failed) async {
|
||||
if (succeeded == tasks.length) {
|
||||
await ref.watch(convertToCBZProvider(
|
||||
path!.path, mangaDir!.path, chapter.name!, pageUrls)
|
||||
.future);
|
||||
}
|
||||
bool isEmpty = isar.downloads
|
||||
.filter()
|
||||
.chapterIdEqualTo(chapter.id!)
|
||||
|
|
@ -165,12 +174,14 @@ Future<List<String>> downloadChapter(
|
|||
},
|
||||
taskProgressCallback: (taskProgress) async {
|
||||
if (taskProgress.progress == 1.0) {
|
||||
await File(
|
||||
"${tempDir.path}/${taskProgress.task.directory}/${taskProgress.task.filename}")
|
||||
.copy("${path!.path}/${taskProgress.task.filename}");
|
||||
await File(
|
||||
"${tempDir.path}/${taskProgress.task.directory}/${taskProgress.task.filename}")
|
||||
.delete();
|
||||
if (Platform.isAndroid) {
|
||||
await File(
|
||||
"${tempDir.path}/${taskProgress.task.directory}/${taskProgress.task.filename}")
|
||||
.copy("${path!.path}/${taskProgress.task.filename}");
|
||||
await File(
|
||||
"${tempDir.path}/${taskProgress.task.directory}/${taskProgress.task.filename}")
|
||||
.delete();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ part of 'download_provider.dart';
|
|||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$downloadChapterHash() => r'07e6c9782618562329005e15ce973061bf70d441';
|
||||
String _$downloadChapterHash() => r'b5c9bab624536bc4ad0e0c067773f5e591aa0cc1';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
|
@ -14,6 +15,7 @@ class ImageViewHorizontal extends ConsumerWidget {
|
|||
final String chapter;
|
||||
final Directory path;
|
||||
final bool isLocale;
|
||||
final Uint8List? localImage;
|
||||
final Widget? Function(ExtendedImageState state) loadStateChanged;
|
||||
final Function(ExtendedImageGestureState state) onDoubleTap;
|
||||
final GestureConfig Function(ExtendedImageState state)
|
||||
|
|
@ -31,20 +33,31 @@ class ImageViewHorizontal extends ConsumerWidget {
|
|||
required this.onDoubleTap,
|
||||
required this.initGestureConfigHandler,
|
||||
required this.isLocale,
|
||||
this.localImage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return isLocale
|
||||
? ExtendedImage.file(
|
||||
File("${path.path}" "${padIndex(index + 1)}.jpg"),
|
||||
clearMemoryCacheWhenDispose: true,
|
||||
enableMemoryCache: false,
|
||||
mode: ExtendedImageMode.gesture,
|
||||
initGestureConfigHandler: initGestureConfigHandler,
|
||||
onDoubleTap: onDoubleTap,
|
||||
loadStateChanged: loadStateChanged,
|
||||
)
|
||||
? localImage != null
|
||||
? ExtendedImage.memory(
|
||||
localImage!,
|
||||
clearMemoryCacheWhenDispose: true,
|
||||
enableMemoryCache: false,
|
||||
mode: ExtendedImageMode.gesture,
|
||||
initGestureConfigHandler: initGestureConfigHandler,
|
||||
onDoubleTap: onDoubleTap,
|
||||
loadStateChanged: loadStateChanged,
|
||||
)
|
||||
: ExtendedImage.file(
|
||||
File("${path.path}" "${padIndex(index + 1)}.jpg"),
|
||||
clearMemoryCacheWhenDispose: true,
|
||||
enableMemoryCache: false,
|
||||
mode: ExtendedImageMode.gesture,
|
||||
initGestureConfigHandler: initGestureConfigHandler,
|
||||
onDoubleTap: onDoubleTap,
|
||||
loadStateChanged: loadStateChanged,
|
||||
)
|
||||
: ExtendedImage.network(
|
||||
url,
|
||||
headers: ref.watch(headersProvider(source: source)),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
|
@ -17,6 +18,7 @@ class ImageViewVertical extends ConsumerWidget {
|
|||
final String source;
|
||||
final String chapter;
|
||||
final Directory path;
|
||||
final Uint8List? localImage;
|
||||
|
||||
const ImageViewVertical({
|
||||
super.key,
|
||||
|
|
@ -28,6 +30,7 @@ class ImageViewVertical extends ConsumerWidget {
|
|||
required this.source,
|
||||
required this.length,
|
||||
required this.isLocale,
|
||||
this.localImage,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -42,11 +45,18 @@ class ImageViewVertical extends ConsumerWidget {
|
|||
height: MediaQuery.of(context).padding.top,
|
||||
),
|
||||
isLocale
|
||||
? ExtendedImage.file(
|
||||
fit: BoxFit.contain,
|
||||
clearMemoryCacheWhenDispose: true,
|
||||
enableMemoryCache: false,
|
||||
File('${path.path}${padIndex(index + 1)}.jpg'))
|
||||
? localImage != null
|
||||
? ExtendedImage.memory(
|
||||
localImage!,
|
||||
fit: BoxFit.contain,
|
||||
clearMemoryCacheWhenDispose: true,
|
||||
enableMemoryCache: false,
|
||||
)
|
||||
: ExtendedImage.file(
|
||||
fit: BoxFit.contain,
|
||||
clearMemoryCacheWhenDispose: true,
|
||||
enableMemoryCache: false,
|
||||
File('${path.path}${padIndex(index + 1)}.jpg'))
|
||||
: ExtendedImage.network(url,
|
||||
headers: ref.watch(headersProvider(source: source)),
|
||||
handleLoadingProgress: true,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ class MangaReaderView extends ConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky,
|
||||
overlays: []);
|
||||
final chapterData = ref.watch(GetChapterUrlProvider(
|
||||
final chapterData = ref.watch(getChapterUrlProvider(
|
||||
chapter: chapter,
|
||||
));
|
||||
final readerController =
|
||||
|
|
@ -77,6 +77,7 @@ class MangaReaderView extends ConsumerWidget {
|
|||
readerController: readerController,
|
||||
isLocaleList: data.isLocaleList,
|
||||
chapter: chapter,
|
||||
localImages: data.localImages,
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) => Scaffold(
|
||||
|
|
@ -138,12 +139,14 @@ class MangaChapterPageGallery extends ConsumerStatefulWidget {
|
|||
required this.url,
|
||||
required this.readerController,
|
||||
required this.isLocaleList,
|
||||
required this.chapter});
|
||||
required this.chapter,
|
||||
required this.localImages});
|
||||
final ReaderController readerController;
|
||||
final Directory path;
|
||||
final List url;
|
||||
final List<bool> isLocaleList;
|
||||
final Chapter chapter;
|
||||
final List<Uint8List> localImages;
|
||||
|
||||
@override
|
||||
ConsumerState createState() {
|
||||
|
|
@ -921,6 +924,9 @@ class _MangaChapterPageGalleryState
|
|||
},
|
||||
onDoubleTap: () {},
|
||||
child: ImageViewVertical(
|
||||
localImage: widget.localImages.isNotEmpty
|
||||
? widget.localImages[index]
|
||||
: null,
|
||||
titleManga: widget.readerController.getMangaName(),
|
||||
source: widget.readerController
|
||||
.getSourceName()
|
||||
|
|
@ -960,6 +966,9 @@ class _MangaChapterPageGalleryState
|
|||
},
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return ImageViewHorizontal(
|
||||
localImage: widget.localImages.isNotEmpty
|
||||
? widget.localImages[index]
|
||||
: null,
|
||||
titleManga: widget.readerController.getMangaName(),
|
||||
source: widget.readerController
|
||||
.getSourceName()
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
// ignore_for_file: depend_o
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:mangayomi/main.dart';
|
||||
import 'package:mangayomi/models/chapter.dart';
|
||||
import 'package:mangayomi/models/settings.dart';
|
||||
import 'package:mangayomi/models/source.dart';
|
||||
import 'package:mangayomi/modules/local_reader/providers/local_reader_providers.dart';
|
||||
import 'package:mangayomi/providers/storage_provider.dart';
|
||||
import 'package:mangayomi/sources/multisrc/heancms/heancms.dart';
|
||||
import 'package:mangayomi/sources/multisrc/madara/src/madara.dart';
|
||||
|
|
@ -24,8 +26,12 @@ class GetChapterUrlModel {
|
|||
Directory? path;
|
||||
List<String> pageUrls = [];
|
||||
List<bool> isLocaleList = [];
|
||||
List<Uint8List> localImages = [];
|
||||
GetChapterUrlModel(
|
||||
{required this.path, required this.pageUrls, required this.isLocaleList});
|
||||
{required this.path,
|
||||
required this.pageUrls,
|
||||
required this.isLocaleList,
|
||||
required this.localImages});
|
||||
}
|
||||
|
||||
@riverpod
|
||||
|
|
@ -42,8 +48,11 @@ Future<GetChapterUrlModel> getChapterUrl(
|
|||
final isarPageUrls = settings!.chapterPageUrlsList!
|
||||
.where((element) => element.chapterId == chapter.id);
|
||||
final incognitoMode = ref.watch(incognitoModeStateProvider);
|
||||
path = await StorageProvider().getMangaChapterDirectory(chapter);
|
||||
final storageProvider = StorageProvider();
|
||||
path = await storageProvider.getMangaChapterDirectory(chapter);
|
||||
final mangaDirectory = await storageProvider.getMangaMainDirectory(chapter);
|
||||
|
||||
List<Uint8List> localImages = [];
|
||||
if (isarPageUrls.isNotEmpty &&
|
||||
isarPageUrls.first.urls != null &&
|
||||
isarPageUrls.first.urls!.isNotEmpty) {
|
||||
|
|
@ -105,6 +114,7 @@ Future<GetChapterUrlModel> getChapterUrl(
|
|||
else if (getMangaTypeSource(source) == TypeSource.heancms) {
|
||||
pageUrls = await HeanCms().getChapterUrl(chapter: chapter, ref: ref);
|
||||
}
|
||||
|
||||
/***********/
|
||||
/*madara*/
|
||||
/***********/
|
||||
|
|
@ -127,15 +137,29 @@ Future<GetChapterUrlModel> getChapterUrl(
|
|||
isar.writeTxnSync(() => isar.settings
|
||||
.putSync(settings..chapterPageUrlsList = chapterPageUrls));
|
||||
}
|
||||
for (var i = 0; i < pageUrls.length; i++) {
|
||||
if (await File("${path!.path}" "${padIndex(i + 1)}.jpg").exists()) {
|
||||
|
||||
if (await File("${mangaDirectory!.path}${chapter.name}.cbz").exists()) {
|
||||
final local = await ref.watch(getArchiveDataFromFileProvider(
|
||||
"${mangaDirectory.path}${chapter.name}.cbz")
|
||||
.future);
|
||||
for (var image in local.images!) {
|
||||
localImages.add(image.image!);
|
||||
isLocaleList.add(true);
|
||||
} else {
|
||||
isLocaleList.add(false);
|
||||
}
|
||||
} else {
|
||||
for (var i = 0; i < pageUrls.length; i++) {
|
||||
if (await File("${path!.path}" "${padIndex(i + 1)}.jpg").exists()) {
|
||||
isLocaleList.add(true);
|
||||
} else {
|
||||
isLocaleList.add(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GetChapterUrlModel(
|
||||
path: path, pageUrls: pageUrls, isLocaleList: isLocaleList);
|
||||
path: path,
|
||||
pageUrls: pageUrls,
|
||||
isLocaleList: isLocaleList,
|
||||
localImages: localImages);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ part of 'get_chapter_url.dart';
|
|||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$getChapterUrlHash() => r'2ac689860d288da631afbf39ab7ad2df7e2b99f3';
|
||||
String _$getChapterUrlHash() => r'37d070779ae79c31b82820c3a78af825db45fc1d';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
|
|
|||
Loading…
Reference in a new issue