diff --git a/.gitignore b/.gitignore index 3308461..8c58739 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ docs/ data/ public/* !public/.gitkeep +.tools/ +cxyz-android/.dart_tool/ +cxyz-android/.flutter-plugins-dependencies +cxyz-android/build/ diff --git a/cxyz-android/.gitignore b/cxyz-android/.gitignore new file mode 100644 index 0000000..7c169c1 --- /dev/null +++ b/cxyz-android/.gitignore @@ -0,0 +1,86 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ / Android Studio +*.iml +*.ipr +*.iws +.idea/ + +# VS Code (uncomment to ignore local workspace settings) +#.vscode/ + +# Flutter / Dart / Pub +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +.fvm/ +build/ +coverage/ +.widget_preview/ + +# Local analysis / temp HOME used by scripts and tooling +.tmp-home/ +.tmp/ + +# Symbolication / obfuscation +app.*.symbols +app.*.map.json + +# Android build outputs (also covered under android/) +/android/app/debug +/android/app/profile +/android/app/release +**/android/**/gradle-wrapper.jar +**/android/.gradle/ +**/android/captures/ +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/.cxx/ + +# Signing — never commit keystores or local key config +**/android/key.properties +**/*.keystore +**/*.jks +*.apk +*.aab +*.ap_ + +# Secrets / local env +.env +.env.* +!.env.example + +# Test / coverage extras +coverage/ +.test_coverage/ +*.lcov + +# Platform ephemeral (if added later) +**/ios/Pods/ +**/ios/.symlinks/ +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral/ +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* +**/macos/Flutter/ephemeral/ +**/linux/flutter/ephemeral/ +**/windows/flutter/ephemeral/ diff --git a/cxyz-android/.metadata b/cxyz-android/.metadata new file mode 100644 index 0000000..eae9680 --- /dev/null +++ b/cxyz-android/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "6655482ec06e547f90abf8ae7590466f4415978d" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 6655482ec06e547f90abf8ae7590466f4415978d + base_revision: 6655482ec06e547f90abf8ae7590466f4415978d + - platform: android + create_revision: 6655482ec06e547f90abf8ae7590466f4415978d + base_revision: 6655482ec06e547f90abf8ae7590466f4415978d + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/cxyz-android/README.md b/cxyz-android/README.md new file mode 100644 index 0000000..e2619b7 --- /dev/null +++ b/cxyz-android/README.md @@ -0,0 +1,91 @@ +# cxyz Android (Flutter) + +On-device manga/comic reader/downloader — Flutter reimplementation of [cxyz](../) adapters for Android. + +## Features (v1) + +- **Catalog**: source picker → per-source menus (not one shared tab bar) +- **Sources** (build-time): ComicLand, MangaRead, and/or MangaTown via `--dart-define=SOURCES=…` (or `SOURCES=all`) +- **Series**: chapter list, sync, multi-select / download-all (unread filter) +- **Reader**: gapless vertical webtoon + horizontal paged mode; progress + star +- **Library**: starred series only; **History**: recently read +- **Downloads**: running job + queue link; CBZ under public `Download/Comics/` +- **Settings**: RTL + WebDAV sync (progress, library, history) + +## Stack + +Flutter 3 · Riverpod · go_router · Dio · Drift (SQLite) · archive (CBZ) · cached_network_image · html + +## Setup + +Use a local Flutter SDK (this repo may use `../.tools/flutter-sdk` on the machine that cloned it): + +```bash +export PATH="/Users/Shared/LocalData/Projects/cxyz/.tools/bin:/Users/Shared/LocalData/Projects/cxyz/.tools/flutter-sdk/bin:$PATH" +export CI=true BOT=true + +cd cxyz-android +flutter pub get +dart run build_runner build --delete-conflicting-outputs +flutter run +``` + +Sideload APK only (not Play Store–friendly). + +## Build-time sources (`SOURCES`) + +Compile with Flutter `--dart-define`. Unset / invalid → **ComicLand only** (not all sources). + +Prefer the helper (copies a tagged filename and prints the path): + +```bash +./scripts/build_apk.sh # → …/cxyz-comicland-release.apk +./scripts/build_apk.sh mangaread # → …/cxyz-mangaread-release.apk +./scripts/build_apk.sh comicland,mangaread # → …/cxyz-comicland-mangaread-release.apk +./scripts/build_apk.sh all # → …/cxyz-all-release.apk +./scripts/build_apk.sh mangaread --debug # → …/cxyz-mangaread-debug.apk +``` + +Or call Flutter directly (always writes `app-release.apk` / `app-debug.apk`): + +```bash +flutter build apk +flutter build apk --dart-define=SOURCES=mangaread +flutter build apk --dart-define=SOURCES=comicland,mangaread,mangatown +flutter build apk --dart-define=SOURCES=all +flutter run --dart-define=SOURCES=all +``` + +With two or more sources enabled, Catalog opens a **source picker**; each source shows only its own menus (e.g. ComicLand: Newest / Popular / …; MangaRead: Latest / Search; MangaTown: Latest / Hot / Directory / …). A single-source build skips the picker and opens that source’s home. + +## ComicLand API + +Base: `https://api.comicland.org/api` + +| Endpoint | Use | +|---|---| +| `GET /comics/popular` | Popular shelf | +| `GET /comics?offset&limit` | Latest / browse | +| `GET /comic/search?q&offset&limit` | Search | +| `GET /comic/detail?slug=` | Series + chapters | +| `GET /chapter/pages_by_index?slug&index` | Page image URLs | + +CDN images need Referer/Origin `https://comicland.org/`. + +## MangaRead (HTML / Madara) + +Origin: `https://www.mangaread.org/` + +- Series: listing HTML `li.wp-manga-chapter`, fallback `POST …/ajax/chapters/` +- Pages: chapter HTML with mobile UA → `img.wp-manga-chapter-img` +- Catalog: Latest (`/manga/?m_orderby=latest`) + Search (`?s=&post_type=wp-manga`) +- Cloudflare: direct Dio only; HTTP 403 surfaces a clear error (no FlareSolverr on phone) + +## MangaTown (HTML / mobile) + +Origin: `https://m.mangatown.com` + +- Catalog: Latest (`/latest`), Hot (`/hot`, `?tviews.za` / `?wviews.za` / `?mviews.za`), Directory (`/directory/{demo}-{genre}-0-{status}-{letter}-0`), Search (`/search?name=`) +- Series: `/manga/{slug}` → `ul.detail-ch-list` +- Pages: one image per reader page (`#viewer img#image`); page N at `/manga/{slug}/c005/N.html` +- CDN images need Referer `https://m.mangatown.com/` diff --git a/cxyz-android/analysis_options.yaml b/cxyz-android/analysis_options.yaml new file mode 100644 index 0000000..841c177 --- /dev/null +++ b/cxyz-android/analysis_options.yaml @@ -0,0 +1,19 @@ +# Extra analysis options for Drift / Flutter. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - "**/*.g.dart" + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + errors: + invalid_annotation_target: ignore + +linter: + rules: + prefer_const_constructors: false diff --git a/cxyz-android/android/.gitignore b/cxyz-android/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/cxyz-android/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/cxyz-android/android/app/build.gradle.kts b/cxyz-android/android/app/build.gradle.kts new file mode 100644 index 0000000..392f8b7 --- /dev/null +++ b/cxyz-android/android/app/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "org.cxyz.cxyz_android" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "org.cxyz.cxyz_android" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 26 + targetSdk = flutter.targetSdkVersion + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/cxyz-android/android/app/src/debug/AndroidManifest.xml b/cxyz-android/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/cxyz-android/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/cxyz-android/android/app/src/main/AndroidManifest.xml b/cxyz-android/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8c1fbd7 --- /dev/null +++ b/cxyz-android/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cxyz-android/android/app/src/main/kotlin/org/cxyz/cxyz_android/MainActivity.kt b/cxyz-android/android/app/src/main/kotlin/org/cxyz/cxyz_android/MainActivity.kt new file mode 100644 index 0000000..6df3204 --- /dev/null +++ b/cxyz-android/android/app/src/main/kotlin/org/cxyz/cxyz_android/MainActivity.kt @@ -0,0 +1,153 @@ +package org.cxyz.cxyz_android + +import android.content.ContentValues +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream + +class MainActivity : FlutterActivity() { + private val channelName = "org.cxyz.cxyz_android/cbz_store" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) + .setMethodCallHandler { call, result -> + when (call.method) { + "saveCbzFromPath" -> { + val relativeDir = call.argument("relativeDir") + val displayName = call.argument("displayName") + val sourcePath = call.argument("sourcePath") + if (relativeDir == null || displayName == null || sourcePath == null) { + result.error( + "bad_args", + "relativeDir, displayName, sourcePath required", + null, + ) + return@setMethodCallHandler + } + try { + result.success(saveCbzFromPath(relativeDir, displayName, sourcePath)) + } catch (e: Exception) { + result.error("save_failed", e.message, null) + } + } + "readCbzToPath" -> { + val uriOrPath = call.argument("uri") + val destPath = call.argument("destPath") + if (uriOrPath == null || destPath == null) { + result.error("bad_args", "uri and destPath required", null) + return@setMethodCallHandler + } + try { + result.success(readCbzToPath(uriOrPath, destPath)) + } catch (e: Exception) { + result.error("read_failed", e.message, null) + } + } + "deleteCbz" -> { + val uriOrPath = call.argument("uri") + if (uriOrPath == null) { + result.error("bad_args", "uri required", null) + return@setMethodCallHandler + } + try { + result.success(deleteCbz(uriOrPath)) + } catch (e: Exception) { + result.error("delete_failed", e.message, null) + } + } + "sdkInt" -> result.success(Build.VERSION.SDK_INT) + else -> result.notImplemented() + } + } + } + + private fun saveCbzFromPath( + relativeDir: String, + displayName: String, + sourcePath: String, + ): String { + val source = File(sourcePath) + if (!source.exists()) { + throw IllegalStateException("Source file missing: $sourcePath") + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = applicationContext.contentResolver + val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/vnd.comicbook+zip") + put( + MediaStore.MediaColumns.RELATIVE_PATH, + "${Environment.DIRECTORY_DOWNLOADS}/$relativeDir", + ) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + val uri = resolver.insert(collection, values) + ?: throw IllegalStateException("MediaStore insert returned null") + resolver.openOutputStream(uri)?.use { out -> + FileInputStream(source).use { input -> input.copyTo(out) } + out.flush() + } ?: throw IllegalStateException("Could not open output stream for $uri") + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) + return uri.toString() + } + + val downloads = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val dir = File(downloads, relativeDir) + if (!dir.exists() && !dir.mkdirs()) { + throw IllegalStateException("Could not create ${dir.absolutePath}") + } + val file = File(dir, displayName) + FileInputStream(source).use { input -> + FileOutputStream(file).use { out -> + input.copyTo(out) + out.flush() + } + } + return file.absolutePath + } + + private fun readCbzToPath(uriOrPath: String, destPath: String): String { + val dest = File(destPath) + dest.parentFile?.mkdirs() + if (uriOrPath.startsWith("content://")) { + val uri = Uri.parse(uriOrPath) + applicationContext.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(dest).use { out -> + input.copyTo(out) + out.flush() + } + } ?: throw IllegalStateException("Could not open $uriOrPath") + } else { + File(uriOrPath).inputStream().use { input -> + FileOutputStream(dest).use { out -> + input.copyTo(out) + out.flush() + } + } + } + return dest.absolutePath + } + + private fun deleteCbz(uriOrPath: String): Boolean { + return if (uriOrPath.startsWith("content://")) { + val uri = Uri.parse(uriOrPath) + applicationContext.contentResolver.delete(uri, null, null) > 0 + } else { + val file = File(uriOrPath) + !file.exists() || file.delete() + } + } +} diff --git a/cxyz-android/android/app/src/main/res/drawable-v21/launch_background.xml b/cxyz-android/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/cxyz-android/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/cxyz-android/android/app/src/main/res/drawable/launch_background.xml b/cxyz-android/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/cxyz-android/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/cxyz-android/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/cxyz-android/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/cxyz-android/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/cxyz-android/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/cxyz-android/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/cxyz-android/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/cxyz-android/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/cxyz-android/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/cxyz-android/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/cxyz-android/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/cxyz-android/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/cxyz-android/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/cxyz-android/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/cxyz-android/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/cxyz-android/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/cxyz-android/android/app/src/main/res/values-night/styles.xml b/cxyz-android/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/cxyz-android/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/cxyz-android/android/app/src/main/res/values/styles.xml b/cxyz-android/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/cxyz-android/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/cxyz-android/android/app/src/profile/AndroidManifest.xml b/cxyz-android/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/cxyz-android/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/cxyz-android/android/build.gradle.kts b/cxyz-android/android/build.gradle.kts new file mode 100644 index 0000000..58d3009 --- /dev/null +++ b/cxyz-android/android/build.gradle.kts @@ -0,0 +1,59 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} + +// flutter_secure_storage 11 requests compileSdk 37; some SDKs install that +// platform as android-37.0, which AGP cannot resolve. Cap plugins at 36. +subprojects { + afterEvaluate { + val android = project.extensions.findByName("android") ?: return@afterEvaluate + try { + val method = android.javaClass.methods.firstOrNull { + it.name == "setCompileSdkVersion" || it.name == "setCompileSdk" + } + // Prefer the Int setter used by AGP 8+ + val setCompileSdk = android.javaClass.methods.firstOrNull { m -> + m.name == "setCompileSdk" && m.parameterTypes.size == 1 + } + if (setCompileSdk != null) { + val current = android.javaClass.methods + .firstOrNull { it.name == "getCompileSdk" && it.parameterCount == 0 } + ?.invoke(android) + val api = when (current) { + is Int -> current + is String -> current.toIntOrNull() ?: 0 + else -> 0 + } + if (api > 36) { + setCompileSdk.invoke(android, 36) + } + } else if (method != null) { + method.invoke(android, 36) + } + } catch (_: Exception) { + // Non-Android subprojects — ignore. + } + } +} + +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/cxyz-android/android/gradle.properties b/cxyz-android/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/cxyz-android/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/cxyz-android/android/gradle/wrapper/gradle-wrapper.properties b/cxyz-android/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a20f2c4 --- /dev/null +++ b/cxyz-android/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/cxyz-android/android/settings.gradle.kts b/cxyz-android/android/settings.gradle.kts new file mode 100644 index 0000000..b28021a --- /dev/null +++ b/cxyz-android/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/cxyz-android/assets/sources/comicland.png b/cxyz-android/assets/sources/comicland.png new file mode 100644 index 0000000..344c040 Binary files /dev/null and b/cxyz-android/assets/sources/comicland.png differ diff --git a/cxyz-android/assets/sources/mangaread.png b/cxyz-android/assets/sources/mangaread.png new file mode 100644 index 0000000..54b6c8e Binary files /dev/null and b/cxyz-android/assets/sources/mangaread.png differ diff --git a/cxyz-android/assets/sources/mangatown.png b/cxyz-android/assets/sources/mangatown.png new file mode 100644 index 0000000..2642b62 Binary files /dev/null and b/cxyz-android/assets/sources/mangatown.png differ diff --git a/cxyz-android/lib/main.dart b/cxyz-android/lib/main.dart new file mode 100644 index 0000000..dbcf40c --- /dev/null +++ b/cxyz-android/lib/main.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'providers/providers.dart'; +import 'router.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const ProviderScope(child: CxyzApp())); +} + +class CxyzApp extends ConsumerStatefulWidget { + const CxyzApp({super.key}); + + @override + ConsumerState createState() => _CxyzAppState(); +} + +class _CxyzAppState extends ConsumerState with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + ref.read(webDavSyncProvider).scheduleSync(); + } + } + + @override + Widget build(BuildContext context) { + final router = ref.watch(routerProvider); + return MaterialApp.router( + title: 'cxyz', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF1B6CA8), + brightness: Brightness.light, + ), + useMaterial3: true, + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF1B6CA8), + brightness: Brightness.dark, + ), + useMaterial3: true, + ), + routerConfig: router, + ); + } +} diff --git a/cxyz-android/lib/providers/providers.dart b/cxyz-android/lib/providers/providers.dart new file mode 100644 index 0000000..e86e65e --- /dev/null +++ b/cxyz-android/lib/providers/providers.dart @@ -0,0 +1,129 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/database.dart'; +import '../services/cbz_store.dart'; +import '../services/download_queue.dart'; +import '../services/image_headers.dart'; +import '../services/ntfy_notifier.dart'; +import '../services/peek_mode.dart'; +import '../services/reader_direction.dart'; +import '../services/webdav_sync.dart'; +import '../sources/registry.dart'; + +final sourceRegistryProvider = Provider((ref) { + return SourceRegistry(); +}); + +final databaseProvider = Provider((ref) { + final db = AppDatabase(); + ref.onDispose(db.close); + return db; +}); + +final cbzStoreProvider = Provider((ref) => CbzStore()); + +final ntfyNotifierProvider = Provider((ref) { + return NtfyNotifier(store: ref.watch(cbzStoreProvider)); +}); + +final downloadQueueProvider = ChangeNotifierProvider((ref) { + final queue = DownloadQueue( + db: ref.watch(databaseProvider), + sources: ref.watch(sourceRegistryProvider), + store: ref.watch(cbzStoreProvider), + ntfy: ref.watch(ntfyNotifierProvider), + ); + // Clear leftover queue rows for chapters already on disk (e.g. older builds). + unawaited(queue.reconcileStaleJobs()); + return queue; +}); + +/// Per-request headers come from [ComicCover] / reader via `httpHeaders`. +final imageCacheManagerProvider = Provider((ref) { + return HeaderCacheManager(const {}); +}); + +final libraryTitlesProvider = StreamProvider>((ref) { + final db = ref.watch(databaseProvider); + return (db.select(db.titles) + ..where((t) => t.inLibrary.equals(true)) + ..orderBy([(t) => OrderingTerm.asc(t.title)])) + .watch(); +}); + +final historyTitlesProvider = StreamProvider>((ref) { + final db = ref.watch(databaseProvider); + return (db.select(db.titles) + ..where((t) => t.lastReadAt.isNotNull()) + ..orderBy([(t) => OrderingTerm.desc(t.lastReadAt)])) + .watch(); +}); + +final queueJobsProvider = StreamProvider>((ref) { + final db = ref.watch(databaseProvider); + return db.watchQueueJobs(); +}); + +final downloadedSeriesProvider = + StreamProvider>((ref) { + final db = ref.watch(databaseProvider); + return db.watchDownloadedSeries(); +}); + +class PeekModeNotifier extends StateNotifier { + PeekModeNotifier() : super(false) { + _load(); + } + + Future _load() async { + state = await PeekMode.load(); + } + + Future toggle() async { + state = !state; + await PeekMode.save(state); + } + + Future setEnabled(bool enabled) async { + state = enabled; + await PeekMode.save(enabled); + } +} + +final peekModeProvider = + StateNotifierProvider((ref) => PeekModeNotifier()); + +class ReadingDirectionNotifier extends StateNotifier { + ReadingDirectionNotifier() : super(ReadingDirection.ltr) { + _load(); + } + + Future _load() async { + state = await ReaderDirection.load(); + } + + Future setDirection(ReadingDirection direction) async { + state = direction; + await ReaderDirection.save(direction); + } + + Future toggle() async { + await setDirection( + state == ReadingDirection.ltr + ? ReadingDirection.rtl + : ReadingDirection.ltr, + ); + } +} + +final readingDirectionProvider = + StateNotifierProvider( + (ref) => ReadingDirectionNotifier(), +); + +final webDavSyncProvider = Provider((ref) { + return WebDavSyncService(ref.watch(databaseProvider)); +}); diff --git a/cxyz-android/lib/router.dart b/cxyz-android/lib/router.dart new file mode 100644 index 0000000..024c9ce --- /dev/null +++ b/cxyz-android/lib/router.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../services/reader_direction.dart'; +import '../sources/source.dart'; +import '../sources/source_flags.dart'; +import '../ui/catalog/source_picker_page.dart'; +import '../ui/catalog_page.dart'; +import '../ui/downloads_page.dart'; +import '../ui/history_page.dart'; +import '../ui/library_page.dart'; +import '../ui/reader_page.dart'; +import '../ui/settings_page.dart'; +import '../ui/shell.dart'; +import '../ui/title_page.dart'; + +final _rootKey = GlobalKey(); + +SourceId? _parseSourceId(String? raw) { + if (raw == null) return null; + return SourceId.tryParse(raw); +} + +CatalogMenuKind? _parseMenu(String? raw) { + if (raw == null) return null; + for (final k in CatalogMenuKind.values) { + if (k.pathSegment == raw) return k; + } + return null; +} + +final routerProvider = Provider((ref) { + return GoRouter( + navigatorKey: _rootKey, + initialLocation: '/catalog', + routes: [ + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) { + return AppShell(navigationShell: navigationShell); + }, + branches: [ + StatefulShellBranch( + routes: [ + // Sibling routes (not nested under picker) so back stack is clean. + GoRoute( + path: '/catalog', + redirect: (context, state) { + final enabled = enabledSourceIds(); + if (enabled.length == 1) { + return '/catalog/${enabled.first.name}'; + } + return null; + }, + builder: (context, state) => const SourcePickerPage(), + ), + GoRoute( + path: '/catalog/:sourceId', + builder: (context, state) { + final id = _parseSourceId(state.pathParameters['sourceId']); + if (id == null) { + return const Scaffold( + body: Center(child: Text('Unknown source')), + ); + } + final single = enabledSourceIds().length == 1; + return SourceHomePage( + sourceId: id, + embeddedAsRoot: single, + ); + }, + routes: [ + GoRoute( + path: ':menu', + builder: (context, state) { + final id = + _parseSourceId(state.pathParameters['sourceId']); + final menu = _parseMenu(state.pathParameters['menu']); + if (id == null || menu == null) { + return const Scaffold( + body: Center(child: Text('Unknown catalog feed')), + ); + } + return CatalogFeedPage(sourceId: id, menuKind: menu); + }, + ), + ], + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/library', + builder: (context, state) => const LibraryPage(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/history', + builder: (context, state) => const HistoryPage(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/downloads', + builder: (context, state) => const DownloadsPage(), + routes: [ + GoRoute( + path: 'queue', + builder: (context, state) => const DownloadsQueuePage(), + ), + ], + ), + ], + ), + ], + ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), + GoRoute( + path: '/title/:id', + builder: (context, state) { + final id = int.parse(state.pathParameters['id']!); + return TitlePage(titleId: id); + }, + ), + GoRoute( + path: '/read/:titleId/:chapterId', + builder: (context, state) { + final titleId = int.parse(state.pathParameters['titleId']!); + final chapterId = int.parse(state.pathParameters['chapterId']!); + final modeParam = state.uri.queryParameters['mode']; + ReaderMode? initialMode; + if (modeParam == 'paged') { + initialMode = ReaderMode.paged; + } else if (modeParam == 'webtoon') { + initialMode = ReaderMode.webtoon; + } + return ReaderPage( + titleId: titleId, + chapterId: chapterId, + initialMode: initialMode, + ); + }, + ), + ], + ); +}); diff --git a/cxyz-android/lib/services/cbz_store.dart b/cxyz-android/lib/services/cbz_store.dart new file mode 100644 index 0000000..0c0ebf6 --- /dev/null +++ b/cxyz-android/lib/services/cbz_store.dart @@ -0,0 +1,388 @@ +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../sources/comicland/urls.dart'; +import '../sources/source.dart'; + +/// Saves CBZs to public Download/Comics via a native MediaStore channel. +/// +/// Transfers use temp file paths (not byte arrays) to avoid binder size limits. +class PublicCbzStore { + PublicCbzStore({MethodChannel? channel}) + : _channel = channel ?? + const MethodChannel('org.cxyz.cxyz_android/cbz_store'); + + final MethodChannel _channel; + int? _sdkInt; + + Future sdkInt() async { + if (_sdkInt != null) return _sdkInt!; + try { + final v = await _channel.invokeMethod('sdkInt'); + _sdkInt = v ?? 30; + } catch (_) { + _sdkInt = 30; + } + return _sdkInt!; + } + + /// Request WRITE_EXTERNAL_STORAGE on API 26–28 only. + Future ensureWritePermission() async { + if (!Platform.isAndroid) return; + final sdk = await sdkInt(); + if (sdk >= 29) return; + final status = await Permission.storage.request(); + if (!status.isGranted) { + throw StateError('Storage permission is required to save CBZ files'); + } + } + + Future saveCbzFromPath({ + required String seriesDirName, + required String displayName, + required String sourcePath, + }) async { + await ensureWritePermission(); + final relativeDir = 'Comics/$seriesDirName'; + final uri = await _channel.invokeMethod('saveCbzFromPath', { + 'relativeDir': relativeDir, + 'displayName': displayName, + 'sourcePath': sourcePath, + }); + if (uri == null || uri.isEmpty) { + throw StateError('Native saveCbzFromPath returned empty uri'); + } + return uri; + } + + Future readCbz(String uriOrPath) async { + if (!uriOrPath.startsWith('content://')) { + return File(uriOrPath).readAsBytes(); + } + final tmp = await File( + p.join( + Directory.systemTemp.path, + 'cxyz-read-${DateTime.now().millisecondsSinceEpoch}.cbz', + ), + ).create(); + try { + await _channel.invokeMethod('readCbzToPath', { + 'uri': uriOrPath, + 'destPath': tmp.path, + }); + return await tmp.readAsBytes(); + } finally { + try { + await tmp.delete(); + } catch (_) {} + } + } + + Future deleteCbz(String uriOrPath) async { + if (uriOrPath.startsWith('content://')) { + await _channel.invokeMethod('deleteCbz', {'uri': uriOrPath}); + return; + } + final file = File(uriOrPath); + if (await file.exists()) { + await file.delete(); + } + } +} + +class CbzStore { + CbzStore({Dio? dio, PublicCbzStore? publicStore}) + : _dio = dio ?? Dio(), + _public = publicStore ?? PublicCbzStore(); + + final Dio _dio; + final PublicCbzStore _public; + + Future comicsRoot() async { + final docs = await getApplicationDocumentsDirectory(); + final dir = Directory(p.join(docs.path, 'Comics')); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + Future cacheRoot() async { + final cache = await getTemporaryDirectory(); + final dir = Directory(p.join(cache.path, 'chapter_cache')); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + String cbzDisplayName({ + required String chapterUrl, + String? chapterNumber, + String? label, + }) { + return transferLinkBasename( + chapterNumber, + label, + '${sanitizeFsStem(stemForComicLandChapterCbz(chapterUrl))}.cbz', + ); + } + + Future resolveCacheDir({ + required String comicTitle, + required String chapterKey, + }) async { + final root = await cacheRoot(); + final dir = Directory( + p.join(root.path, sanitizeComicDirName(comicTitle), sanitizeFsStem(chapterKey)), + ); + return dir.path; + } + + String extensionFromContentType(String? contentType) { + if (contentType == null || contentType.isEmpty) return ''; + final ct = contentType.split(';').first.trim().toLowerCase(); + const map = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/pjpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', + 'image/avif': '.avif', + }; + return map[ct] ?? ''; + } + + String extensionFromImageUrl(String imageUrl) { + try { + final ext = p.extension(Uri.parse(imageUrl).path).toLowerCase(); + if (ext.isNotEmpty && + ext.length <= 8 && + RegExp(r'^\.[a-z0-9]+$').hasMatch(ext)) { + return ext; + } + } catch (_) {} + return ''; + } + + /// Download images → pack CBZ → extract to cache. Returns paths and sizes. + /// [cbzPath] may be a content:// URI (MediaStore) or a filesystem path. + Future< + ({ + String cbzPath, + String cacheDir, + int imageCount, + int rawBytes, + int cbzBytes, + String? firstImagePath, + int firstImageBytes, + })> downloadChapter({ + required ComicSource source, + required String comicTitle, + required ChapterLink chapter, + void Function(double progress, String message)? onProgress, + bool Function()? isCancelled, + }) async { + onProgress?.call(0.02, 'Fetching…'); + final urls = await source.fetchPageUrls(chapter.url); + if (urls.isEmpty) { + throw StateError('No chapter images were found for ${chapter.url}'); + } + + final tmp = await Directory.systemTemp.createTemp('cxyz-chapter-'); + try { + final saved = []; + var rawBytes = 0; + for (var i = 0; i < urls.length; i++) { + if (isCancelled?.call() == true) { + throw StateError('Download cancelled'); + } + final imageUrl = urls[i]; + onProgress?.call( + 0.05 + (0.7 * (i / urls.length)), + 'Downloading…', + ); + final res = await _dio.get>( + imageUrl, + options: Options( + responseType: ResponseType.bytes, + headers: source.imageHeaders, + receiveTimeout: const Duration(seconds: 120), + validateStatus: (s) => s != null && s >= 200 && s < 300, + ), + ); + var ext = extensionFromImageUrl(imageUrl); + if (ext.isEmpty) { + ext = extensionFromContentType(res.headers.value('content-type')); + if (ext.isEmpty) ext = '.bin'; + } + final filename = 'page-${(i + 1).toString().padLeft(4, '0')}$ext'; + final file = File(p.join(tmp.path, filename)); + final data = Uint8List.fromList(res.data ?? const []); + await file.writeAsBytes(data, flush: true); + rawBytes += data.length; + saved.add(file); + } + + onProgress?.call(0.8, 'Packing…'); + final archive = Archive(); + for (final file in saved) { + final bytes = await file.readAsBytes(); + archive.addFile( + ArchiveFile(p.basename(file.path), bytes.length, bytes), + ); + } + final encoded = ZipEncoder().encode(archive); + final zipBytes = Uint8List.fromList(encoded); + final cbzBytes = zipBytes.length; + + final packed = File(p.join(tmp.path, 'chapter.cbz')); + await packed.writeAsBytes(zipBytes, flush: true); + + final seriesDir = sanitizeComicDirName(comicTitle); + final displayName = cbzDisplayName( + chapterUrl: chapter.url, + chapterNumber: chapter.chapterNumber, + label: chapter.title, + ); + + String cbzPath; + if (Platform.isAndroid) { + cbzPath = await _public.saveCbzFromPath( + seriesDirName: seriesDir, + displayName: displayName, + sourcePath: packed.path, + ); + } else { + // Desktop / tests: keep writing under app documents. + final root = await comicsRoot(); + final titleDir = Directory(p.join(root.path, seriesDir)); + if (!await titleDir.exists()) { + await titleDir.create(recursive: true); + } + cbzPath = p.join(titleDir.path, displayName); + await packed.copy(cbzPath); + } + + onProgress?.call(0.9, 'Extracting…'); + final cacheDir = await resolveCacheDir( + comicTitle: comicTitle, + chapterKey: chapter.chapterKey, + ); + final extracted = await extractBytesToCache(zipBytes, cacheDir); + extracted.sort(); + final firstImagePath = extracted.isEmpty ? null : extracted.first; + final firstImageBytes = firstImagePath == null + ? 0 + : await File(firstImagePath).length(); + + onProgress?.call(1.0, 'Done'); + return ( + cbzPath: cbzPath, + cacheDir: cacheDir, + imageCount: saved.length, + rawBytes: rawBytes, + cbzBytes: cbzBytes, + firstImagePath: firstImagePath, + firstImageBytes: firstImageBytes, + ); + } finally { + try { + await tmp.delete(recursive: true); + } catch (_) {} + } + } + + Future> extractBytesToCache( + Uint8List bytes, + String cacheDirPath, + ) async { + final dir = Directory(cacheDirPath); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + await dir.create(recursive: true); + + final archive = ZipDecoder().decodeBytes(bytes); + final out = []; + for (final file in archive.files) { + if (!file.isFile) continue; + final name = p.basename(file.name); + if (name.isEmpty || name.startsWith('.')) continue; + final outPath = p.join(cacheDirPath, name); + final outFile = File(outPath); + await outFile.writeAsBytes(file.content as List, flush: true); + out.add(outPath); + } + out.sort(); + return out; + } + + Future> extractCbzToCache(String cbzPath, String cacheDirPath) async { + final bytes = await readCbzBytes(cbzPath); + return extractBytesToCache(bytes, cacheDirPath); + } + + Future readCbzBytes(String cbzPath) async { + if (cbzPath.startsWith('content://')) { + return _public.readCbz(cbzPath); + } + return File(cbzPath).readAsBytes(); + } + + Future> pagesFromCacheOrCbz({ + required String? cacheDir, + required String? cbzPath, + required String comicTitle, + required String chapterKey, + }) async { + if (cacheDir != null) { + final dir = Directory(cacheDir); + if (await dir.exists()) { + final files = await dir + .list() + .where((e) => e is File) + .map((e) => e.path) + .toList(); + files.sort(); + if (files.isNotEmpty) return files; + } + } + if (cbzPath != null && cbzPath.isNotEmpty) { + final exists = cbzPath.startsWith('content://') || + await File(cbzPath).exists(); + if (exists) { + final target = cacheDir ?? + await resolveCacheDir(comicTitle: comicTitle, chapterKey: chapterKey); + return extractCbzToCache(cbzPath, target); + } + } + return const []; + } + + Future deleteDownload({ + String? cbzPath, + String? cacheDir, + }) async { + if (cacheDir != null) { + try { + final dir = Directory(cacheDir); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } catch (_) {} + } + if (cbzPath != null && cbzPath.isNotEmpty) { + try { + await _public.deleteCbz(cbzPath); + } catch (_) {} + } + } +} diff --git a/cxyz-android/lib/services/download_queue.dart b/cxyz-android/lib/services/download_queue.dart new file mode 100644 index 0000000..1606863 --- /dev/null +++ b/cxyz-android/lib/services/download_queue.dart @@ -0,0 +1,443 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; + +import '../data/database.dart'; +import '../sources/registry.dart'; +import '../sources/source.dart'; +import 'cbz_store.dart'; +import 'ntfy_notifier.dart'; + +class DownloadQueue extends ChangeNotifier { + DownloadQueue({ + required AppDatabase db, + required SourceRegistry sources, + required this._store, + NtfyNotifier? ntfy, + }) : _db = db, + _sources = sources, + _ntfy = ntfy ?? NtfyNotifier(store: _store); + + final AppDatabase _db; + final SourceRegistry _sources; + final CbzStore _store; + final NtfyNotifier _ntfy; + + final Queue _chapterIds = Queue(); + final Set _cancelled = {}; + /// chapterId → jobId for open jobs created at enqueue. + final Map _jobByChapter = {}; + bool _running = false; + int? _activeChapterId; + double _progress = 0; + String _message = ''; + int _sessionOk = 0; + int _sessionFailed = 0; + bool _sessionHadWork = false; + DateTime? _sessionStartedAt; + final List> _pendingNtfy = []; + + int? get activeChapterId => _activeChapterId; + double get progress => _progress; + String get message => _message; + bool get isRunning => _running; + int get queuedCount => _chapterIds.length; + + String _label(String seriesTitle, String chapterLabel) => + '$seriesTitle — $chapterLabel'; + + Future enqueueChapters(Iterable chapterIds) async { + for (final id in chapterIds) { + if (_chapterIds.contains(id) || id == _activeChapterId) continue; + _cancelled.remove(id); + + final chapter = await _db.getChapter(id); + if (chapter == null) continue; + final title = await _db.getTitle(chapter.titleId); + if (title == null) continue; + + _chapterIds.add(id); + await (_db.update(_db.chapters)..where((c) => c.id.equals(id))).write( + const ChaptersCompanion( + status: Value('pending'), + error: Value(null), + ), + ); + + final existing = await _db.findOpenJobForChapter(id); + late final int jobId; + if (existing != null) { + jobId = existing.id; + await (_db.update(_db.jobs)..where((j) => j.id.equals(jobId))).write( + JobsCompanion( + titleId: Value(title.id), + chapterId: Value(id), + status: const Value('queued'), + progress: const Value(0), + message: Value(_label(title.title, chapter.label)), + finishedAt: const Value(null), + ), + ); + } else { + jobId = await _db.into(_db.jobs).insert( + JobsCompanion.insert( + type: 'download', + titleId: Value(title.id), + chapterId: Value(id), + status: const Value('queued'), + message: Value(_label(title.title, chapter.label)), + ), + ); + } + _jobByChapter[id] = jobId; + } + notifyListeners(); + unawaited(_pump()); + } + + /// Cancel / dismiss a queue row for [chapterId]. + /// + /// Always closes open jobs. Only resets chapter status when it was still + /// pending/downloading/failed — never wipes an already-downloaded chapter. + Future cancelChapter(int chapterId) async { + _cancelled.add(chapterId); + _chapterIds.removeWhere((id) => id == chapterId); + _jobByChapter.remove(chapterId); + + await (_db.update(_db.jobs) + ..where( + (j) => + j.chapterId.equals(chapterId) & + j.status.isIn(['queued', 'running', 'failed']), + )) + .write( + JobsCompanion( + status: const Value('done'), + message: const Value('Cancelled'), + finishedAt: Value(DateTime.now()), + ), + ); + + final chapter = await _db.getChapter(chapterId); + if (chapter != null && chapter.status != 'downloaded') { + await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))) + .write( + const ChaptersCompanion(status: Value('pending'), error: Value(null)), + ); + } + notifyListeners(); + } + + Future retryChapter(int chapterId) async { + await enqueueChapters([chapterId]); + } + + /// Remove offline download: delete CBZ + cache, reset chapter to pending. + Future deleteDownload(int chapterId) async { + final chapter = await _db.getChapter(chapterId); + if (chapter == null) return; + await _store.deleteDownload( + cbzPath: chapter.cbzPath, + cacheDir: chapter.cacheDir, + ); + await _closeOpenJobs(chapterId, message: 'Deleted'); + await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write( + const ChaptersCompanion( + status: Value('pending'), + cbzPath: Value(null), + cacheDir: Value(null), + downloadedAt: Value(null), + error: Value(null), + ), + ); + } + + /// Mark all open jobs for [chapterId] as done (dismisses Queue rows). + Future _closeOpenJobs( + int chapterId, { + required String message, + double? progress, + }) async { + await (_db.update(_db.jobs) + ..where( + (j) => + j.chapterId.equals(chapterId) & + j.status.isIn(['queued', 'running', 'failed']), + )) + .write( + JobsCompanion( + status: const Value('done'), + progress: progress != null ? Value(progress) : const Value.absent(), + message: Value(message), + finishedAt: Value(DateTime.now()), + ), + ); + } + + /// Drop stale queue rows for chapters that are already downloaded. + Future reconcileStaleJobs() async { + final open = await (_db.select(_db.jobs) + ..where((j) => j.status.isIn(['queued', 'running', 'failed']))) + .get(); + for (final job in open) { + final chapterId = job.chapterId; + if (chapterId == null) continue; + final chapter = await _db.getChapter(chapterId); + if (chapter?.status == 'downloaded') { + await _closeOpenJobs(chapterId, message: chapter!.label, progress: 1); + } + } + } + + Future _pump() async { + if (_running) return; + _running = true; + _sessionOk = 0; + _sessionFailed = 0; + _sessionHadWork = false; + _sessionStartedAt = DateTime.now(); + _pendingNtfy.clear(); + notifyListeners(); + try { + while (_chapterIds.isNotEmpty) { + final chapterId = _chapterIds.removeFirst(); + if (_cancelled.contains(chapterId)) { + await _closeOpenJobs(chapterId, message: 'Cancelled'); + final chapter = await _db.getChapter(chapterId); + if (chapter != null && chapter.status != 'downloaded') { + await (_db.update(_db.chapters) + ..where((c) => c.id.equals(chapterId))) + .write( + const ChaptersCompanion( + status: Value('pending'), + error: Value(null), + ), + ); + } + _jobByChapter.remove(chapterId); + continue; + } + _sessionHadWork = true; + await _downloadOne(chapterId); + } + } finally { + final hadWork = _sessionHadWork; + final ok = _sessionOk; + final failed = _sessionFailed; + final started = _sessionStartedAt; + final pending = List>.from(_pendingNtfy); + _pendingNtfy.clear(); + _running = false; + _activeChapterId = null; + _progress = 0; + _message = ''; + _sessionOk = 0; + _sessionFailed = 0; + _sessionHadWork = false; + _sessionStartedAt = null; + notifyListeners(); + if (hadWork) { + // Per-chapter completed/failed webhooks first, then queue-empty. + await Future.wait(pending); + final duration = + started == null ? null : DateTime.now().difference(started); + await _ntfy.queueDrained(ok: ok, failed: failed, duration: duration); + } + } + } + + Future _downloadOne(int chapterId) async { + _activeChapterId = chapterId; + _progress = 0; + + final chapter = await _db.getChapter(chapterId); + if (chapter == null) { + await _closeOpenJobs(chapterId, message: 'Chapter missing'); + return; + } + final title = await _db.getTitle(chapter.titleId); + if (title == null) { + await _closeOpenJobs(chapterId, message: 'Series missing'); + return; + } + + final label = _label(title.title, chapter.label); + _message = label; + notifyListeners(); + + var jobId = _jobByChapter[chapterId]; + if (jobId == null) { + final existing = await _db.findOpenJobForChapter(chapterId); + if (existing != null) { + jobId = existing.id; + } else { + jobId = await _db.into(_db.jobs).insert( + JobsCompanion.insert( + type: 'download', + titleId: Value(title.id), + chapterId: Value(chapterId), + status: const Value('running'), + message: Value(label), + ), + ); + } + _jobByChapter[chapterId] = jobId; + } + + await (_db.update(_db.jobs)..where((j) => j.id.equals(jobId!))).write( + JobsCompanion( + titleId: Value(title.id), + status: const Value('running'), + progress: const Value(0), + message: Value(label), + finishedAt: const Value(null), + ), + ); + + await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write( + const ChaptersCompanion(status: Value('downloading'), error: Value(null)), + ); + + try { + final source = _sources.get(SourceId.values.byName(title.source)); + final link = ChapterLink( + url: chapter.url, + title: chapter.label, + chapterKey: chapter.chapterKey, + chapterNumber: chapter.chapterNumber, + ); + final result = await _store.downloadChapter( + source: source, + comicTitle: title.title, + chapter: link, + onProgress: (p, _) { + _progress = p; + _message = label; + notifyListeners(); + unawaited( + (_db.update(_db.jobs)..where((j) => j.id.equals(jobId!))).write( + JobsCompanion(progress: Value(p), message: Value(label)), + ), + ); + }, + isCancelled: () => _cancelled.contains(chapterId), + ); + + await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write( + ChaptersCompanion( + status: const Value('downloaded'), + cbzPath: Value(result.cbzPath), + cacheDir: Value(result.cacheDir), + downloadedAt: Value(DateTime.now()), + error: const Value(null), + ), + ); + // Close every open job for this chapter (covers stale duplicates). + await _closeOpenJobs(chapterId, message: label, progress: 1); + _sessionOk++; + _pendingNtfy.add( + _ntfy.chapterCompleted( + ChapterCompletedPayload( + seriesTitle: title.title, + chapterLabel: chapter.label, + imageCount: result.imageCount, + rawBytes: result.rawBytes, + cbzBytes: result.cbzBytes, + cbzPath: result.cbzPath, + cacheDir: result.cacheDir, + coverUrl: title.coverUrl, + ), + ), + ); + } catch (e) { + final cancelled = _cancelled.contains(chapterId); + await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write( + ChaptersCompanion( + status: Value(cancelled ? 'pending' : 'failed'), + error: Value(cancelled ? null : e.toString()), + ), + ); + await (_db.update(_db.jobs)..where((j) => j.id.equals(jobId!))).write( + JobsCompanion( + status: Value(cancelled ? 'done' : 'failed'), + message: Value(cancelled ? 'Cancelled' : label), + finishedAt: Value(DateTime.now()), + ), + ); + if (!cancelled) { + _sessionFailed++; + _pendingNtfy.add( + _ntfy.jobFailed( + seriesTitle: title.title, + chapterLabel: chapter.label, + error: e.toString(), + ), + ); + } + } finally { + _cancelled.remove(chapterId); + _jobByChapter.remove(chapterId); + } + } + + /// Upsert series into library from a remote [SeriesDetail]. + Future upsertSeries(SeriesDetail series) async { + final existing = await _db.findTitleByUrl(series.listingUrl); + late final int titleId; + if (existing == null) { + titleId = await _db.into(_db.titles).insert( + TitlesCompanion.insert( + source: series.source.id, + url: series.listingUrl, + slug: series.slug, + title: series.title, + coverUrl: Value(series.coverUrl), + description: Value(series.description), + lastCheckedAt: Value(DateTime.now()), + ), + ); + } else { + titleId = existing.id; + await (_db.update(_db.titles)..where((t) => t.id.equals(titleId))).write( + TitlesCompanion( + title: Value(series.title), + coverUrl: Value(series.coverUrl), + description: Value(series.description), + lastCheckedAt: Value(DateTime.now()), + ), + ); + } + + for (final ch in series.chapters) { + final rows = await (_db.select(_db.chapters) + ..where( + (c) => + c.titleId.equals(titleId) & c.chapterKey.equals(ch.chapterKey), + )) + .get(); + if (rows.isEmpty) { + await _db.into(_db.chapters).insert( + ChaptersCompanion.insert( + titleId: titleId, + chapterKey: ch.chapterKey, + chapterNumber: Value(ch.chapterNumber), + label: ch.title, + url: ch.url, + ), + ); + } else { + await (_db.update(_db.chapters)..where((c) => c.id.equals(rows.first.id))) + .write( + ChaptersCompanion( + label: Value(ch.title), + url: Value(ch.url), + chapterNumber: Value(ch.chapterNumber), + ), + ); + } + } + return titleId; + } +} diff --git a/cxyz-android/lib/services/image_headers.dart b/cxyz-android/lib/services/image_headers.dart new file mode 100644 index 0000000..2edbd43 --- /dev/null +++ b/cxyz-android/lib/services/image_headers.dart @@ -0,0 +1,31 @@ +import 'package:flutter/painting.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +/// Cache manager that attaches ComicLand CDN Referer/Origin headers. +class HeaderCacheManager extends CacheManager { + HeaderCacheManager(this.headers) + : super( + Config( + 'cxyzHeaderCache', + fileService: _HeaderHttpFileService(headers), + ), + ); + + final Map headers; +} + +class _HeaderHttpFileService extends HttpFileService { + _HeaderHttpFileService(this.headers); + + final Map headers; + + @override + Future get(String url, {Map? headers}) { + final merged = {...this.headers, ...?headers}; + return super.get(url, headers: merged); + } +} + +ImageProvider headerNetworkImage(String url, Map headers) { + return NetworkImage(url, headers: headers); +} diff --git a/cxyz-android/lib/services/ntfy_notifier.dart b/cxyz-android/lib/services/ntfy_notifier.dart new file mode 100644 index 0000000..3e32276 --- /dev/null +++ b/cxyz-android/lib/services/ntfy_notifier.dart @@ -0,0 +1,419 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as p; + +import 'cbz_store.dart'; +import 'ntfy_settings.dart'; + +const int _kMaxCbzAttachBytes = 15 * 1024 * 1024; +const int _kPreviewMaxEdge = 720; +const int _kPreviewMaxBytes = 250 * 1024; + +/// Encode for HTTP headers (Dart rejects non-ASCII / CR/LF). +/// ntfy decodes RFC 2047 (`=?UTF-8?B?…?=`). +String encodeNtfyHeader(String value) { + final flat = value.replaceAll(RegExp(r'[\r\n]+'), '\n').trim(); + if (flat.isEmpty) return flat; + final needsEncode = + flat.codeUnits.any((c) => c < 0x20 || c > 0x7e); + if (!needsEncode) return flat; + final b64 = base64.encode(utf8.encode(flat)); + return '=?UTF-8?B?$b64?='; +} + +class ChapterCompletedPayload { + const ChapterCompletedPayload({ + required this.seriesTitle, + required this.chapterLabel, + required this.imageCount, + required this.rawBytes, + required this.cbzBytes, + this.cbzPath, + this.cacheDir, + this.coverUrl, + }); + + final String seriesTitle; + final String chapterLabel; + final int imageCount; + final int rawBytes; + final int cbzBytes; + final String? cbzPath; + final String? cacheDir; + final String? coverUrl; +} + +/// Fire-and-forget ntfy publisher for download queue events. +class NtfyNotifier { + NtfyNotifier({Dio? dio, CbzStore? store}) + : _dio = dio ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 20), + receiveTimeout: const Duration(seconds: 60), + sendTimeout: const Duration(seconds: 120), + validateStatus: (s) => s != null && s >= 200 && s < 300, + ), + ), + _store = store ?? CbzStore(); + + final Dio _dio; + final CbzStore _store; + + static String formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + final kb = bytes / 1024; + if (kb < 1024) return '${kb.toStringAsFixed(kb < 10 ? 1 : 0)} KB'; + final mb = kb / 1024; + if (mb < 1024) return '${mb.toStringAsFixed(mb < 10 ? 1 : 0)} MB'; + final gb = mb / 1024; + return '${gb.toStringAsFixed(2)} GB'; + } + + static String completedTitle(String series, String chapter) => + 'Downloaded · $series · $chapter'; + + static String failedTitle(String series, String chapter) => + 'Download failed · $series · $chapter'; + + static String drainedTitle({required int ok, required int failed}) => + 'Queue finished · $ok downloaded · $failed failed'; + + static String completedBody({ + required int imageCount, + required int rawBytes, + required int cbzBytes, + String? note, + }) { + final saved = rawBytes > 0 + ? (((rawBytes - cbzBytes) / rawBytes) * 100).clamp(0, 100).round() + : 0; + final lines = [ + 'Pages: $imageCount', + 'Images: ${formatBytes(rawBytes)}', + 'CBZ: ${formatBytes(cbzBytes)}${rawBytes > 0 ? ' (saved $saved%)' : ''}', + ]; + if (note != null && note.isNotEmpty) lines.add(note); + return lines.join('\n'); + } + + Future chapterCompleted(ChapterCompletedPayload payload) async { + final settings = await NtfySettings.load(); + if (!settings.enabled) return; + final url = settings.urlForCompleted; + if (url == null) return; + + final title = completedTitle(payload.seriesTitle, payload.chapterLabel); + var note = ''; + Uint8List? attachBytes; + String? filename; + String? attachUrl; + + switch (settings.attachmentMode) { + case NtfyAttachmentMode.none: + break; + case NtfyAttachmentMode.cover: + final cover = payload.coverUrl?.trim(); + if (cover == null || cover.isEmpty) { + note = 'Attachment skipped (no cover URL)'; + } else { + attachUrl = cover; + } + break; + case NtfyAttachmentMode.firstImage: + final preview = await _buildFirstPagePreview(payload.cacheDir); + if (preview == null) { + note = 'Preview resize failed'; + } else { + attachBytes = preview; + filename = 'preview.jpg'; + } + break; + case NtfyAttachmentMode.cbz: + final path = payload.cbzPath; + if (path == null || path.isEmpty) { + note = 'Attachment skipped (no CBZ path)'; + } else if (payload.cbzBytes > _kMaxCbzAttachBytes) { + note = 'Attachment skipped (15 MB limit)'; + } else { + try { + attachBytes = await _store.readCbzBytes(path); + if (attachBytes.length > _kMaxCbzAttachBytes) { + attachBytes = null; + note = 'Attachment skipped (15 MB limit)'; + } else { + filename = _cbzFilename(payload.chapterLabel); + } + } catch (_) { + note = 'Attachment skipped (read failed)'; + } + } + } + + final body = completedBody( + imageCount: payload.imageCount, + rawBytes: payload.rawBytes, + cbzBytes: payload.cbzBytes, + note: note.isEmpty ? null : note, + ); + + try { + if (attachBytes != null && filename != null) { + await _putAttachment( + url: url, + title: title, + message: body, + tags: 'white_check_mark', + priority: '3', + filename: filename, + bytes: attachBytes, + token: settings.authToken, + ); + } else if (attachUrl != null) { + await _postText( + url: url, + title: title, + message: body, + tags: 'white_check_mark', + priority: '3', + token: settings.authToken, + attachUrl: attachUrl, + ); + } else { + await _postText( + url: url, + title: title, + message: body, + tags: 'white_check_mark', + priority: '3', + token: settings.authToken, + ); + } + } catch (e, st) { + debugPrint('ntfy chapterCompleted failed: $e\n$st'); + } + } + + Future jobFailed({ + required String seriesTitle, + required String chapterLabel, + required String error, + }) async { + final settings = await NtfySettings.load(); + if (!settings.enabled) return; + final url = settings.urlForFailed; + if (url == null) return; + + final trimmed = error.trim(); + final snippet = + trimmed.length > 500 ? '${trimmed.substring(0, 500)}…' : trimmed; + try { + await _postText( + url: url, + title: failedTitle(seriesTitle, chapterLabel), + message: snippet.isEmpty ? 'Unknown error' : snippet, + tags: 'x', + priority: '4', + token: settings.authToken, + ); + } catch (e, st) { + debugPrint('ntfy jobFailed failed: $e\n$st'); + } + } + + Future queueDrained({ + required int ok, + required int failed, + Duration? duration, + }) async { + final settings = await NtfySettings.load(); + if (!settings.enabled) return; + final url = settings.urlForDrained; + if (url == null) return; + + final lines = []; + if (duration != null) { + lines.add('Finished in ${_formatDuration(duration)}'); + } + lines.add('$ok downloaded · $failed failed'); + try { + await _postText( + url: url, + title: drainedTitle(ok: ok, failed: failed), + message: lines.join('\n'), + tags: 'inbox_tray', + priority: '3', + token: settings.authToken, + ); + } catch (e, st) { + debugPrint('ntfy queueDrained failed: $e\n$st'); + } + } + + /// Send a sample notification to [url] (for Settings Test buttons). + Future testTopic(String url, {required String kind}) async { + final settings = await NtfySettings.load(); + final trimmed = url.trim(); + if (trimmed.isEmpty) { + throw StateError('URL is empty'); + } + switch (kind) { + case 'completed': + await _postText( + url: trimmed, + title: completedTitle('Sample Series', 'Chapter 1'), + message: completedBody( + imageCount: 12, + rawBytes: 5 * 1024 * 1024, + cbzBytes: 4 * 1024 * 1024, + note: 'Test notification (${settings.attachmentMode.label})', + ), + tags: 'white_check_mark', + priority: '3', + token: settings.authToken, + ); + case 'failed': + await _postText( + url: trimmed, + title: failedTitle('Sample Series', 'Chapter 2'), + message: 'Sample error for testing', + tags: 'x', + priority: '4', + token: settings.authToken, + ); + case 'drained': + await _postText( + url: trimmed, + title: drainedTitle(ok: 3, failed: 1), + message: 'Finished in 1m 5s\n3 downloaded · 1 failed', + tags: 'inbox_tray', + priority: '3', + token: settings.authToken, + ); + default: + throw StateError('Unknown test kind: $kind'); + } + } + + Future _postText({ + required String url, + required String title, + required String message, + required String tags, + required String priority, + required String token, + String? attachUrl, + }) async { + await _dio.post( + url, + data: message, + options: Options( + headers: { + 'Title': encodeNtfyHeader(title), + 'Tags': tags, + 'Priority': priority, + if (attachUrl != null && attachUrl.isNotEmpty) + 'Attach': encodeNtfyHeader(attachUrl), + if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}', + }, + contentType: 'text/plain; charset=utf-8', + ), + ); + } + + Future _putAttachment({ + required String url, + required String title, + required String message, + required String tags, + required String priority, + required String filename, + required Uint8List bytes, + required String token, + }) async { + await _dio.put( + url, + data: bytes, + options: Options( + headers: { + 'Title': encodeNtfyHeader(title), + // Multiline body must be header-safe (RFC 2047), not raw newlines. + 'Message': encodeNtfyHeader(message), + 'Tags': tags, + 'Priority': priority, + 'Filename': encodeNtfyHeader(filename), + if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}', + }, + contentType: 'application/octet-stream', + ), + ); + } + + Future _buildFirstPagePreview(String? cacheDir) async { + if (cacheDir == null || cacheDir.isEmpty) return null; + final dir = Directory(cacheDir); + if (!await dir.exists()) return null; + final files = await dir + .list() + .where((e) => e is File) + .cast() + .toList(); + files.sort((a, b) => p.basename(a.path).compareTo(p.basename(b.path))); + if (files.isEmpty) return null; + final bytes = await files.first.readAsBytes(); + return compute(_resizePreviewIsolate, bytes); + } + + static String _cbzFilename(String chapterLabel) { + final stem = chapterLabel + .trim() + .replaceAll(RegExp(r'[^a-zA-Z0-9._-]+'), '_') + .replaceAll(RegExp(r'^_+|_+$'), ''); + final name = stem.isEmpty ? 'chapter' : stem; + return name.toLowerCase().endsWith('.cbz') ? name : '$name.cbz'; + } + + static String _formatDuration(Duration d) { + final s = d.inSeconds; + if (s < 60) return '${s}s'; + final m = s ~/ 60; + final rem = s % 60; + if (m < 60) return rem == 0 ? '${m}m' : '${m}m ${rem}s'; + final h = m ~/ 60; + final rm = m % 60; + return rm == 0 ? '${h}h' : '${h}h ${rm}m'; + } +} + +/// Isolate entry: decode → max edge 720 → JPEG ≤ 250 KB. +Uint8List? _resizePreviewIsolate(Uint8List input) { + return resizePreviewForTest(input); +} + +/// Visible for unit tests. +Uint8List? resizePreviewForTest(Uint8List input) { + final decoded = img.decodeImage(input); + if (decoded == null) return null; + var image = decoded; + final longest = image.width > image.height ? image.width : image.height; + if (longest > _kPreviewMaxEdge) { + if (image.width >= image.height) { + image = img.copyResize(image, width: _kPreviewMaxEdge); + } else { + image = img.copyResize(image, height: _kPreviewMaxEdge); + } + } + var quality = 75; + Uint8List? best; + while (quality >= 40) { + final encoded = Uint8List.fromList(img.encodeJpg(image, quality: quality)); + best = encoded; + if (encoded.length <= _kPreviewMaxBytes) return encoded; + quality -= 15; + } + return best; +} diff --git a/cxyz-android/lib/services/ntfy_settings.dart b/cxyz-android/lib/services/ntfy_settings.dart new file mode 100644 index 0000000..4a1e9c8 --- /dev/null +++ b/cxyz-android/lib/services/ntfy_settings.dart @@ -0,0 +1,100 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// What to attach on chapter-completed ntfy notifications. +enum NtfyAttachmentMode { + none, + cover, + firstImage, + cbz; + + String get storageValue => name; + + static NtfyAttachmentMode fromStorage(String? raw) { + for (final m in NtfyAttachmentMode.values) { + if (m.name == raw) return m; + } + return NtfyAttachmentMode.none; + } + + String get label { + switch (this) { + case NtfyAttachmentMode.none: + return 'None'; + case NtfyAttachmentMode.cover: + return 'Series cover'; + case NtfyAttachmentMode.firstImage: + return 'First page'; + case NtfyAttachmentMode.cbz: + return 'CBZ file'; + } + } +} + +class NtfySettings { + static const _kEnabled = 'ntfy_enabled'; + static const _kCompletedUrl = 'ntfy_completed_url'; + static const _kFailedUrl = 'ntfy_failed_url'; + static const _kDrainedUrl = 'ntfy_drained_url'; + static const _kAttachment = 'ntfy_attachment_mode'; + static const _tokenKey = 'ntfy_auth_token'; + + static const _secure = FlutterSecureStorage(); + + final bool enabled; + final String completedUrl; + final String failedUrl; + final String drainedUrl; + final NtfyAttachmentMode attachmentMode; + final String authToken; + + const NtfySettings({ + required this.enabled, + required this.completedUrl, + required this.failedUrl, + required this.drainedUrl, + required this.attachmentMode, + required this.authToken, + }); + + String? get urlForCompleted => _nonEmpty(completedUrl); + String? get urlForFailed => _nonEmpty(failedUrl); + String? get urlForDrained => _nonEmpty(drainedUrl); + + static String? _nonEmpty(String raw) { + final t = raw.trim(); + return t.isEmpty ? null : t; + } + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final token = await _secure.read(key: _tokenKey) ?? ''; + return NtfySettings( + enabled: prefs.getBool(_kEnabled) ?? false, + completedUrl: prefs.getString(_kCompletedUrl) ?? '', + failedUrl: prefs.getString(_kFailedUrl) ?? '', + drainedUrl: prefs.getString(_kDrainedUrl) ?? '', + attachmentMode: NtfyAttachmentMode.fromStorage( + prefs.getString(_kAttachment), + ), + authToken: token, + ); + } + + static Future save({ + required bool enabled, + required String completedUrl, + required String failedUrl, + required String drainedUrl, + required NtfyAttachmentMode attachmentMode, + required String authToken, + }) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kEnabled, enabled); + await prefs.setString(_kCompletedUrl, completedUrl.trim()); + await prefs.setString(_kFailedUrl, failedUrl.trim()); + await prefs.setString(_kDrainedUrl, drainedUrl.trim()); + await prefs.setString(_kAttachment, attachmentMode.storageValue); + await _secure.write(key: _tokenKey, value: authToken); + } +} diff --git a/cxyz-android/lib/services/peek_mode.dart b/cxyz-android/lib/services/peek_mode.dart new file mode 100644 index 0000000..1ddaf7f --- /dev/null +++ b/cxyz-android/lib/services/peek_mode.dart @@ -0,0 +1,16 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Privacy mode: hide/blur comic covers in lists. +class PeekMode { + static const prefKey = 'peek_mode_enabled'; + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? false; + } + + static Future save(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, enabled); + } +} diff --git a/cxyz-android/lib/services/reader_direction.dart b/cxyz-android/lib/services/reader_direction.dart new file mode 100644 index 0000000..59dcc1c --- /dev/null +++ b/cxyz-android/lib/services/reader_direction.dart @@ -0,0 +1,39 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Vertical continuous scroll vs horizontal page turns. +enum ReaderMode { + webtoon, + paged; + + String get storageValue => this == ReaderMode.paged ? 'paged' : 'webtoon'; + + static ReaderMode fromStorage(String? raw) { + return raw == 'paged' ? ReaderMode.paged : ReaderMode.webtoon; + } +} + +/// Global / per-title paged-reader tap/swipe direction. +enum ReadingDirection { + ltr, + rtl; + + String get storageValue => name; + + static ReadingDirection fromStorage(String? raw) { + return raw == 'rtl' ? ReadingDirection.rtl : ReadingDirection.ltr; + } +} + +class ReaderDirection { + static const prefKey = 'reader_reading_direction'; + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return ReadingDirection.fromStorage(prefs.getString(prefKey)); + } + + static Future save(ReadingDirection direction) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefKey, direction.storageValue); + } +} diff --git a/cxyz-android/lib/services/reader_zoom.dart b/cxyz-android/lib/services/reader_zoom.dart new file mode 100644 index 0000000..8b6e18f --- /dev/null +++ b/cxyz-android/lib/services/reader_zoom.dart @@ -0,0 +1,32 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Layout zoom for the in-app reader (`pageWidth = screenWidth * scale`). +class ReaderZoom { + static const prefKey = 'reader_zoom_scale'; + static const double min = 0.4; + static const double max = 1.5; + static const double step = 0.1; + static const double defaultScale = 1.0; + + static double clamp(double value) { + final stepped = (value / step).round() * step; + return stepped.clamp(min, max); + } + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getDouble(prefKey); + if (raw == null) return defaultScale; + return clamp(raw); + } + + static Future save(double scale) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(prefKey, clamp(scale)); + } + + static String percentLabel(double scale) { + final pct = (clamp(scale) * 100).round(); + return '$pct%'; + } +} diff --git a/cxyz-android/lib/services/source_reader_defaults.dart b/cxyz-android/lib/services/source_reader_defaults.dart new file mode 100644 index 0000000..99f2363 --- /dev/null +++ b/cxyz-android/lib/services/source_reader_defaults.dart @@ -0,0 +1,53 @@ +import '../sources/source.dart'; +import 'reader_direction.dart'; + +/// Built-in reader prefs per catalog source (used when a title has no override). +class SourceReaderDefaults { + const SourceReaderDefaults._(); + + /// ComicLand → vertical webtoon; MangaRead / MangaTown → horizontal paged. + static ReaderMode modeFor(String sourceId) { + switch (SourceId.tryParse(sourceId)) { + case SourceId.mangaread: + case SourceId.mangatown: + return ReaderMode.paged; + case SourceId.comicland: + case null: + return ReaderMode.webtoon; + } + } + + /// MangaRead / MangaTown → RTL; ComicLand falls back to global Settings. + static ReadingDirection? directionFor(String sourceId) { + switch (SourceId.tryParse(sourceId)) { + case SourceId.mangaread: + case SourceId.mangatown: + return ReadingDirection.rtl; + case SourceId.comicland: + case null: + return null; + } + } + + /// Resolve stored title mode, else [modeFor]. + static ReaderMode resolveMode(String sourceId, String? storedMode) { + final raw = storedMode?.trim() ?? ''; + if (raw == 'paged' || raw == 'webtoon') { + return ReaderMode.fromStorage(raw); + } + return modeFor(sourceId); + } + + /// Resolve stored title direction, else [directionFor], else [globalFallback]. + static ReadingDirection resolveDirection( + String sourceId, + String? storedDirection, { + required ReadingDirection globalFallback, + }) { + final raw = storedDirection?.trim() ?? ''; + if (raw == 'ltr' || raw == 'rtl') { + return ReadingDirection.fromStorage(raw); + } + return directionFor(sourceId) ?? globalFallback; + } +} diff --git a/cxyz-android/lib/services/webdav_settings.dart b/cxyz-android/lib/services/webdav_settings.dart new file mode 100644 index 0000000..c3ef738 --- /dev/null +++ b/cxyz-android/lib/services/webdav_settings.dart @@ -0,0 +1,92 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class WebDavSettings { + static const _kEnabled = 'webdav_enabled'; + static const _kUrl = 'webdav_url'; + static const _kUsername = 'webdav_username'; + static const _kPath = 'webdav_path'; + static const _kLastSync = 'webdav_last_sync'; + static const _kLastError = 'webdav_last_error'; + static const _kLastStatus = 'webdav_last_status'; + static const _passwordKey = 'webdav_password'; + + static const defaultPath = '/cxyz/progress.json'; + + static const _secure = FlutterSecureStorage(); + + final bool enabled; + final String url; + final String username; + final String password; + final String path; + final DateTime? lastSyncAt; + final String? lastError; + final String? lastStatus; + + const WebDavSettings({ + required this.enabled, + required this.url, + required this.username, + required this.password, + required this.path, + this.lastSyncAt, + this.lastError, + this.lastStatus, + }); + + bool get isConfigured => + url.trim().isNotEmpty && username.trim().isNotEmpty; + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final password = await _secure.read(key: _passwordKey) ?? ''; + final lastSyncRaw = prefs.getString(_kLastSync); + return WebDavSettings( + enabled: prefs.getBool(_kEnabled) ?? false, + url: prefs.getString(_kUrl) ?? '', + username: prefs.getString(_kUsername) ?? '', + password: password, + path: prefs.getString(_kPath) ?? defaultPath, + lastSyncAt: + lastSyncRaw == null ? null : DateTime.tryParse(lastSyncRaw), + lastError: prefs.getString(_kLastError), + lastStatus: prefs.getString(_kLastStatus), + ); + } + + static Future save({ + required bool enabled, + required String url, + required String username, + required String password, + required String path, + }) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kEnabled, enabled); + await prefs.setString(_kUrl, url.trim()); + await prefs.setString(_kUsername, username.trim()); + await prefs.setString( + _kPath, + path.trim().isEmpty ? defaultPath : path.trim(), + ); + await _secure.write(key: _passwordKey, value: password); + } + + static Future setSyncResult({ + String? status, + String? error, + DateTime? syncedAt, + }) async { + final prefs = await SharedPreferences.getInstance(); + if (status != null) await prefs.setString(_kLastStatus, status); + if (error != null) { + await prefs.setString(_kLastError, error); + } else { + await prefs.remove(_kLastError); + } + if (syncedAt != null) { + await prefs.setString(_kLastSync, syncedAt.toUtc().toIso8601String()); + } + } +} diff --git a/cxyz-android/lib/services/webdav_sync.dart b/cxyz-android/lib/services/webdav_sync.dart new file mode 100644 index 0000000..6a4f6bd --- /dev/null +++ b/cxyz-android/lib/services/webdav_sync.dart @@ -0,0 +1,474 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:drift/drift.dart'; + +import '../data/database.dart'; +import 'webdav_settings.dart'; + +class WebDavSyncService { + WebDavSyncService(this._db); + + final AppDatabase _db; + bool _busy = false; + Timer? _debounce; + + void scheduleSync() { + _debounce?.cancel(); + _debounce = Timer(const Duration(seconds: 10), () { + unawaited(sync()); + }); + } + + Future testConnection() async { + final settings = await WebDavSettings.load(); + if (!settings.isConfigured) { + throw StateError('Enter WebDAV URL and username first'); + } + final dio = _dio(settings); + final url = _fileUrl(settings); + try { + final res = await dio.request( + url, + options: Options( + method: 'PROPFIND', + headers: {'Depth': '0'}, + validateStatus: (s) => s != null && s < 500, + ), + ); + // Some hosts reject PROPFIND; try GET as fallback. + if (res.statusCode == 404 || res.statusCode == 405 || res.statusCode == 501) { + final get = await dio.get>( + url, + options: Options( + responseType: ResponseType.bytes, + validateStatus: (s) => s != null && (s == 200 || s == 404), + ), + ); + if (get.statusCode == 200 || get.statusCode == 404) { + return 'Connection OK'; + } + throw StateError('HTTP ${get.statusCode}'); + } + if (res.statusCode != null && res.statusCode! >= 200 && res.statusCode! < 300) { + return 'Connection OK'; + } + if (res.statusCode == 401 || res.statusCode == 403) { + throw StateError('Auth failed (HTTP ${res.statusCode})'); + } + // Parent exists even if file missing + if (res.statusCode == 404) return 'Connection OK (file not created yet)'; + throw StateError('HTTP ${res.statusCode}'); + } on DioException catch (e) { + throw StateError(e.message ?? e.toString()); + } + } + + Future sync() async { + if (_busy) return; + final settings = await WebDavSettings.load(); + if (!settings.enabled || !settings.isConfigured) return; + _busy = true; + try { + await _syncOnce(settings); + await WebDavSettings.setSyncResult( + status: 'Synced', + error: null, + syncedAt: DateTime.now(), + ); + } catch (e) { + await WebDavSettings.setSyncResult( + status: 'Failed', + error: e.toString(), + ); + rethrow; + } finally { + _busy = false; + } + } + + Future _syncOnce(WebDavSettings settings, {int attempt = 0}) async { + final dio = _dio(settings); + final url = _fileUrl(settings); + + String? etag; + Map remote = { + 'updatedAt': null, + 'titles': [], + 'chapters': [], + }; + + final get = await dio.get>( + url, + options: Options( + responseType: ResponseType.bytes, + validateStatus: (s) => s != null && (s == 200 || s == 404), + ), + ); + if (get.statusCode == 200 && get.data != null) { + etag = get.headers.value('etag'); + final text = utf8.decode(get.data!); + if (text.trim().isNotEmpty) { + remote = jsonDecode(text) as Map; + } + } + + final localBundle = await _exportLocal(); + final merged = _merge(remote, localBundle); + await _applyRemote(merged); + + final body = utf8.encode(const JsonEncoder.withIndent(' ').convert(merged)); + final headers = { + 'Content-Type': 'application/json', + }; + if (etag != null && etag.isNotEmpty) { + headers['If-Match'] = etag; + } else if (get.statusCode == 404) { + // Create only if absent + headers['If-None-Match'] = '*'; + } + + try { + final put = await dio.put>( + url, + data: body, + options: Options( + headers: headers, + validateStatus: (s) => + s != null && ((s >= 200 && s < 300) || s == 412), + ), + ); + if (put.statusCode == 412) { + if (attempt >= 2) { + throw StateError('Sync conflict: too many retries'); + } + await _syncOnce(settings, attempt: attempt + 1); + } + } on DioException catch (e) { + // Weak hosts may ignore precondition; retry once without If-Match. + if (attempt == 0 && etag != null) { + await dio.put( + url, + data: body, + options: Options(headers: {'Content-Type': 'application/json'}), + ); + } else { + throw StateError(e.message ?? e.toString()); + } + } + } + + Dio _dio(WebDavSettings settings) { + return Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 20), + receiveTimeout: const Duration(seconds: 30), + headers: { + 'Authorization': + 'Basic ${base64Encode(utf8.encode('${settings.username}:${settings.password}'))}', + }, + ), + ); + } + + String _fileUrl(WebDavSettings settings) { + final base = settings.url.trim().replaceAll(RegExp(r'/+$'), ''); + var path = settings.path.trim().isEmpty + ? WebDavSettings.defaultPath + : settings.path.trim(); + if (!path.startsWith('/')) path = '/$path'; + return '$base$path'; + } + + Future> _exportLocal() async { + final titles = await _db.select(_db.titles).get(); + final chapters = await _db.select(_db.chapters).get(); + final titleById = {for (final t in titles) t.id: t}; + + final titleMaps = >[]; + for (final t in titles) { + String? chapterKey; + if (t.lastReadChapterId != null) { + final ch = await _db.getChapter(t.lastReadChapterId!); + chapterKey = ch?.chapterKey; + } + titleMaps.add({ + 'source': t.source, + 'url': t.url, + 'slug': t.slug, + 'title': t.title, + 'coverUrl': t.coverUrl, + 'inLibrary': t.inLibrary, + 'readerMode': t.readerMode, + 'readingDirection': t.readingDirection, + 'lastReadChapterKey': chapterKey, + 'lastReadAt': t.lastReadAt?.toUtc().toIso8601String(), + 'libraryUpdatedAt': t.libraryUpdatedAt?.toUtc().toIso8601String(), + }); + } + + final chapterMaps = >[]; + for (final c in chapters) { + final t = titleById[c.titleId]; + if (t == null) continue; + if (c.lastReadAt == null && c.lastPage == 0 && !c.completed) continue; + chapterMaps.add({ + 'source': t.source, + 'seriesUrl': t.url, + 'chapterKey': c.chapterKey, + 'label': c.label, + 'url': c.url, + 'chapterNumber': c.chapterNumber, + 'lastPage': c.lastPage, + 'pageCount': c.pageCount, + 'completed': c.completed, + 'lastReadAt': c.lastReadAt?.toUtc().toIso8601String(), + }); + } + + return { + 'updatedAt': DateTime.now().toUtc().toIso8601String(), + 'titles': titleMaps, + 'chapters': chapterMaps, + }; + } + + Map _merge( + Map remote, + Map local, + ) { + final remoteTitles = _asMapList(remote['titles']); + final localTitles = _asMapList(local['titles']); + final titles = >{}; + for (final t in [...remoteTitles, ...localTitles]) { + final key = '${t['source']}|${t['url']}'; + final existing = titles[key]; + if (existing == null) { + titles[key] = Map.from(t); + continue; + } + titles[key] = _mergeTitle(existing, t); + } + + final remoteChapters = _asMapList(remote['chapters']); + final localChapters = _asMapList(local['chapters']); + final chapters = >{}; + for (final c in [...remoteChapters, ...localChapters]) { + final key = '${c['source']}|${c['seriesUrl']}|${c['chapterKey']}'; + final existing = chapters[key]; + if (existing == null) { + chapters[key] = Map.from(c); + continue; + } + chapters[key] = _newerBy(existing, c, 'lastReadAt'); + } + + return { + 'updatedAt': DateTime.now().toUtc().toIso8601String(), + 'titles': titles.values.toList(), + 'chapters': chapters.values.toList(), + }; + } + + Map _mergeTitle( + Map a, + Map b, + ) { + final out = Map.from(a); + // Metadata: prefer non-empty + for (final field in ['title', 'coverUrl', 'slug']) { + final bv = b[field]; + if ((out[field] == null || (out[field] as String?)?.isEmpty == true) && + bv is String && + bv.isNotEmpty) { + out[field] = bv; + } + } + // History / continue by lastReadAt + final newerRead = _newerBy(a, b, 'lastReadAt'); + out['lastReadAt'] = newerRead['lastReadAt']; + out['lastReadChapterKey'] = newerRead['lastReadChapterKey']; + out['readerMode'] = newerRead['readerMode'] ?? out['readerMode']; + out['readingDirection'] = + newerRead['readingDirection'] ?? out['readingDirection']; + // Library star by libraryUpdatedAt + final newerLib = _newerBy(a, b, 'libraryUpdatedAt'); + out['inLibrary'] = newerLib['inLibrary'] ?? false; + out['libraryUpdatedAt'] = newerLib['libraryUpdatedAt']; + return out; + } + + Map _newerBy( + Map a, + Map b, + String field, + ) { + final at = _parseTime(a[field]); + final bt = _parseTime(b[field]); + if (bt == null) return a; + if (at == null) return b; + return bt.isAfter(at) ? b : a; + } + + DateTime? _parseTime(dynamic v) { + if (v is! String || v.isEmpty) return null; + return DateTime.tryParse(v)?.toUtc(); + } + + List> _asMapList(dynamic v) { + if (v is! List) return const []; + return v + .whereType() + .map((e) => Map.from(e)) + .toList(); + } + + Future _applyRemote(Map merged) async { + final titleMaps = _asMapList(merged['titles']); + final chapterMaps = _asMapList(merged['chapters']); + + for (final t in titleMaps) { + final source = t['source'] as String? ?? ''; + final url = t['url'] as String? ?? ''; + if (source.isEmpty || url.isEmpty) continue; + final existing = await _db.findTitleBySourceUrl(source, url); + final lastReadAt = _parseTime(t['lastReadAt']); + final libraryUpdatedAt = _parseTime(t['libraryUpdatedAt']); + final inLibrary = t['inLibrary'] == true; + late final int titleId; + if (existing == null) { + titleId = await _db.into(_db.titles).insert( + TitlesCompanion.insert( + source: source, + url: url, + slug: (t['slug'] as String?) ?? '', + title: (t['title'] as String?) ?? url, + coverUrl: Value(t['coverUrl'] as String?), + readerMode: Value( + (t['readerMode'] as String?) ?? '', + ), + readingDirection: Value(t['readingDirection'] as String?), + lastReadAt: Value(lastReadAt), + inLibrary: Value(inLibrary), + libraryUpdatedAt: Value(libraryUpdatedAt), + ), + ); + } else { + titleId = existing.id; + await (_db.update(_db.titles)..where((x) => x.id.equals(titleId))) + .write( + TitlesCompanion( + title: Value( + (t['title'] as String?)?.isNotEmpty == true + ? t['title'] as String + : existing.title, + ), + coverUrl: Value( + (t['coverUrl'] as String?) ?? existing.coverUrl, + ), + slug: Value( + (t['slug'] as String?)?.isNotEmpty == true + ? t['slug'] as String + : existing.slug, + ), + readerMode: Value( + (t['readerMode'] as String?) ?? existing.readerMode, + ), + readingDirection: Value( + (t['readingDirection'] as String?) ?? existing.readingDirection, + ), + lastReadAt: Value(lastReadAt ?? existing.lastReadAt), + inLibrary: Value(inLibrary), + libraryUpdatedAt: Value(libraryUpdatedAt ?? existing.libraryUpdatedAt), + ), + ); + } + + final chapterKey = t['lastReadChapterKey'] as String?; + if (chapterKey != null && chapterKey.isNotEmpty) { + final rows = await (_db.select(_db.chapters) + ..where( + (c) => + c.titleId.equals(titleId) & c.chapterKey.equals(chapterKey), + )) + .get(); + if (rows.isNotEmpty) { + await (_db.update(_db.titles)..where((x) => x.id.equals(titleId))) + .write( + TitlesCompanion(lastReadChapterId: Value(rows.first.id)), + ); + } + } + } + + for (final c in chapterMaps) { + final source = c['source'] as String? ?? ''; + final seriesUrl = c['seriesUrl'] as String? ?? ''; + final chapterKey = c['chapterKey'] as String? ?? ''; + if (source.isEmpty || seriesUrl.isEmpty || chapterKey.isEmpty) continue; + var title = await _db.findTitleBySourceUrl(source, seriesUrl); + if (title == null) { + final titleId = await _db.into(_db.titles).insert( + TitlesCompanion.insert( + source: source, + url: seriesUrl, + slug: '', + title: seriesUrl, + ), + ); + title = await _db.getTitle(titleId); + } + if (title == null) continue; + + final rows = await (_db.select(_db.chapters) + ..where( + (ch) => + ch.titleId.equals(title!.id) & + ch.chapterKey.equals(chapterKey), + )) + .get(); + final lastReadAt = _parseTime(c['lastReadAt']); + final lastPage = (c['lastPage'] as num?)?.toInt() ?? 0; + final pageCount = (c['pageCount'] as num?)?.toInt() ?? 0; + final completed = c['completed'] == true; + if (rows.isEmpty) { + await _db.into(_db.chapters).insert( + ChaptersCompanion.insert( + titleId: title.id, + chapterKey: chapterKey, + label: (c['label'] as String?) ?? chapterKey, + url: (c['url'] as String?) ?? seriesUrl, + chapterNumber: Value(c['chapterNumber'] as String?), + lastPage: Value(lastPage), + pageCount: Value(pageCount), + completed: Value(completed), + lastReadAt: Value(lastReadAt), + ), + ); + } else { + final local = rows.first; + final remoteTime = lastReadAt; + final localTime = local.lastReadAt; + final takeRemote = remoteTime != null && + (localTime == null || remoteTime.isAfter(localTime)); + if (takeRemote) { + await (_db.update(_db.chapters)..where((x) => x.id.equals(local.id))) + .write( + ChaptersCompanion( + lastPage: Value(lastPage), + pageCount: Value(pageCount > 0 ? pageCount : local.pageCount), + completed: Value(completed), + lastReadAt: Value(lastReadAt), + label: Value( + (c['label'] as String?)?.isNotEmpty == true + ? c['label'] as String + : local.label, + ), + ), + ); + } + } + } + } +} diff --git a/cxyz-android/lib/sources/comicland/comicland_source.dart b/cxyz-android/lib/sources/comicland/comicland_source.dart new file mode 100644 index 0000000..ee02658 --- /dev/null +++ b/cxyz-android/lib/sources/comicland/comicland_source.dart @@ -0,0 +1,235 @@ +import 'package:dio/dio.dart'; + +import '../source.dart'; +import 'constants.dart'; +import 'urls.dart'; + +/// ComicLand JSON adapter (port of cxyz `src/comicland/*`). +class ComicLandSource implements ComicSource { + ComicLandSource({Dio? dio}) + : _dio = dio ?? + Dio( + BaseOptions( + baseUrl: comicLandApiBase, + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 60), + headers: comicLandApiHeaders, + responseType: ResponseType.json, + validateStatus: (s) => s != null && s >= 200 && s < 300, + ), + ); + + final Dio _dio; + + @override + SourceId get id => SourceId.comicland; + + @override + String get displayName => 'ComicLand'; + + @override + String get blurb => + 'Newest, popular, official, uncensored, recommended, and search via ComicLand API'; + + @override + List get catalogMenus => const [ + CatalogMenu(kind: CatalogMenuKind.latest, title: 'Newest'), + CatalogMenu(kind: CatalogMenuKind.popular, title: 'Popular'), + CatalogMenu(kind: CatalogMenuKind.official, title: 'Official'), + CatalogMenu(kind: CatalogMenuKind.uncensored, title: 'Uncensored'), + CatalogMenu(kind: CatalogMenuKind.recommended, title: 'Recommended'), + CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'), + ]; + + @override + Map get imageHeaders => Map.unmodifiable(comicLandCdnHeaders); + + Map _unwrap(dynamic payload, String label) { + if (payload is! Map) { + throw StateError('ComicLand $label: empty response'); + } + final map = Map.from(payload); + final code = map['code']; + if (code != 0) { + final msg = (map['message'] as String?)?.trim(); + throw StateError( + 'ComicLand $label failed: ${msg?.isNotEmpty == true ? msg : 'code $code'}', + ); + } + final data = map['data']; + if (data is! Map) { + throw StateError('ComicLand $label: missing data'); + } + return Map.from(data); + } + + Future> _get( + String path, { + Map? query, + String label = 'request', + }) async { + final res = await _dio.get(path, queryParameters: query); + return _unwrap(res.data, label); + } + + List _listFrom(Map data, {String listKey = 'list'}) { + final raw = data[listKey]; + if (raw is! List) return const []; + return raw + .whereType() + .map((e) => catalogFromJson(Map.from(e))) + .toList(); + } + + @override + Future> loadCatalog( + CatalogMenuKind kind, { + String query = '', + int offset = 0, + int limit = 30, + CatalogBrowseParams? browse, + }) async { + switch (kind) { + case CatalogMenuKind.hotToday: + case CatalogMenuKind.hotWeek: + case CatalogMenuKind.hotMonth: + case CatalogMenuKind.directory: + throw UnsupportedError('ComicLand has no ${kind.name} catalog feed'); + case CatalogMenuKind.popular: + final data = await _get('/comics/popular', label: 'comics/popular'); + final all = _listFrom(data); + if (offset == 0 && all.length <= limit) return all; + final end = (offset + limit).clamp(0, all.length); + if (offset >= all.length) return const []; + return all.sublist(offset, end); + case CatalogMenuKind.latest: + final data = await _get( + '/comics', + query: {'offset': offset, 'limit': limit, 'status': 'ongoing'}, + label: 'comics/newest', + ); + return _listFrom(data); + case CatalogMenuKind.official: + final data = await _get( + '/comics/official', + query: {'offset': offset, 'limit': limit}, + label: 'comics/official', + ); + return _listFrom(data); + case CatalogMenuKind.uncensored: + final data = await _get( + '/comic/search', + query: {'q': 'uncensored', 'offset': offset, 'limit': limit}, + label: 'comic/search/uncensored', + ); + return _listFrom(data, listKey: 'items'); + case CatalogMenuKind.recommended: + final data = await _get( + '/comics', + query: {'offset': offset, 'limit': limit}, + label: 'comics/recommended', + ); + return _listFrom(data); + case CatalogMenuKind.search: + final q = query.trim(); + if (q.length < 2) return const []; + final data = await _get( + '/comic/search', + query: {'q': q, 'offset': offset, 'limit': limit}, + label: 'comic/search', + ); + return _listFrom(data, listKey: 'items'); + } + } + + @override + Future fetchSeries(String listingUrl) async { + final parts = parseComicLandUrl(listingUrl); + final data = await _get( + '/comic/detail', + query: {'slug': parts.slug}, + label: 'comic/detail', + ); + + final title = (data['title'] as String?)?.trim().isNotEmpty == true + ? (data['title'] as String).trim() + : parts.slug; + + final rawChapters = data['chapters']; + final chapters = []; + if (rawChapters is List) { + for (final row in rawChapters) { + if (row is! Map) continue; + final map = Map.from(row); + final index = _chapterIndexString(map['chapter_index']); + if (index == null) continue; + final label = (map['title'] as String?)?.trim().replaceAll(RegExp(r'\s+'), ' '); + final chapterTitle = + (label != null && label.isNotEmpty) ? label : 'Chapter $index'; + final url = chapterUrlFromParts(parts.origin, parts.slug, index); + chapters.add( + ChapterLink( + url: url, + title: chapterTitle, + chapterKey: chapterKeyFromUrl(url), + chapterNumber: chapterNumberFromTitle(chapterTitle) ?? index, + ), + ); + } + } + + chapters.sort((a, b) { + final na = double.tryParse(Uri.parse(a.url).pathSegments.last); + final nb = double.tryParse(Uri.parse(b.url).pathSegments.last); + if (na != null && nb != null && na != nb) return na.compareTo(nb); + return a.url.compareTo(b.url); + }); + + final listing = listingUrlForSlug(parts.slug); + return SeriesDetail( + source: SourceId.comicland, + listingUrl: listing, + slug: parts.slug, + title: title, + coverUrl: data['cover_url'] as String?, + description: data['description'] as String?, + chapters: chapters, + ); + } + + @override + Future> fetchPageUrls(String chapterUrl) async { + final parts = parseComicLandUrl(chapterUrl); + if (parts.chapterIndex == null) { + throw StateError('ComicLand chapter URL is missing an index: $chapterUrl'); + } + final data = await _get( + '/chapter/pages_by_index', + query: {'slug': parts.slug, 'index': parts.chapterIndex}, + label: 'chapter/pages_by_index', + ); + final pages = data['pages']; + if (pages is! List) return const []; + final urls = []; + for (final raw in pages) { + if (raw is! String || raw.trim().isEmpty) continue; + try { + urls.add(Uri.parse(raw.trim()).toString()); + } catch (_) {} + } + return urls; + } + + String? _chapterIndexString(dynamic rawIndex) { + if (rawIndex is num && rawIndex.isFinite) { + if (rawIndex == rawIndex.roundToDouble()) { + return rawIndex.toInt().toString(); + } + return rawIndex.toString(); + } + if (rawIndex is String && rawIndex.trim().isNotEmpty) { + return rawIndex.trim(); + } + return null; + } +} diff --git a/cxyz-android/lib/sources/comicland/constants.dart b/cxyz-android/lib/sources/comicland/constants.dart new file mode 100644 index 0000000..acf8af0 --- /dev/null +++ b/cxyz-android/lib/sources/comicland/constants.dart @@ -0,0 +1,33 @@ +// ComicLand API / CDN constants (port of cxyz `src/comicland/constants.js`). + +const comicLandHosts = ['comicland.org', 'www.comicland.org']; + +const comicLandOrigin = 'https://comicland.org/'; + +const comicLandApiOrigin = 'https://api.comicland.org'; + +/// Axios-style base used by the SPA: `https://api.comicland.org/api`. +const comicLandApiBase = '$comicLandApiOrigin/api'; + +const comicLandDefaultListing = + 'https://comicland.org/comic/the-delinquent-girl'; + +const comicLandUserAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + +const comicLandApiHeaders = { + 'Accept': 'application/json,text/plain,*/*', + 'Accept-Language': 'en-US,en;q=0.9', + 'Origin': 'https://comicland.org', + 'Referer': comicLandOrigin, + 'User-Agent': comicLandUserAgent, +}; + +const comicLandCdnHeaders = { + 'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Referer': comicLandOrigin, + 'Origin': 'https://comicland.org', + 'User-Agent': comicLandUserAgent, +}; diff --git a/cxyz-android/lib/sources/comicland/urls.dart b/cxyz-android/lib/sources/comicland/urls.dart new file mode 100644 index 0000000..0de0cb3 --- /dev/null +++ b/cxyz-android/lib/sources/comicland/urls.dart @@ -0,0 +1,149 @@ +import '../source.dart'; +import 'constants.dart'; + +class ComicLandUrlParts { + const ComicLandUrlParts({ + required this.origin, + required this.slug, + this.chapterIndex, + }); + + final String origin; + final String slug; + final String? chapterIndex; +} + +bool isLikelyComicLandSite(String url) { + try { + final host = Uri.parse(url).host.toLowerCase(); + return comicLandHosts.contains(host) || host.contains('comicland'); + } catch (_) { + return false; + } +} + +String seriesSlugFromListingUrl(String pageUrl) { + try { + final parts = Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList(); + final comicIdx = parts.indexOf('comic'); + if (comicIdx != -1 && comicIdx + 1 < parts.length) { + return parts[comicIdx + 1]; + } + } catch (_) {} + return 'comic'; +} + +ComicLandUrlParts parseComicLandUrl(String pageUrl) { + final u = Uri.parse(pageUrl); + final parts = u.pathSegments.where((s) => s.isNotEmpty).toList(); + final comicIdx = parts.indexOf('comic'); + final slug = + comicIdx != -1 && comicIdx + 1 < parts.length ? parts[comicIdx + 1] : 'comic'; + String? chapterIndex; + final chapterIdx = parts.indexOf('chapter'); + if (chapterIdx != -1 && chapterIdx + 1 < parts.length) { + chapterIndex = parts[chapterIdx + 1]; + } + return ComicLandUrlParts( + origin: '${u.scheme}://${u.host}', + slug: slug, + chapterIndex: chapterIndex, + ); +} + +String chapterUrlFromParts(String siteOrigin, String slug, Object chapterIndex) { + final origin = siteOrigin.replaceAll(RegExp(r'/+$'), ''); + return '$origin/comic/$slug/chapter/$chapterIndex'; +} + +String listingUrlForSlug(String slug) => + '${comicLandOrigin.replaceAll(RegExp(r'/+$'), '')}/comic/$slug'; + +String chapterKeyFromUrl(String chapterUrl) { + try { + final segs = Uri.parse(chapterUrl).pathSegments.where((s) => s.isNotEmpty).toList(); + return segs.isEmpty ? 'chapter' : segs.last; + } catch (_) { + return 'chapter'; + } +} + +String? chapterNumberFromTitle(String title) { + final m = RegExp(r'^Chapter\s+([\d.]+)', caseSensitive: false) + .firstMatch(title.trim().replaceAll(RegExp(r'\s+'), ' ')); + return m?.group(1); +} + +String sanitizeFsStem(String stem) { + final cleaned = stem + .replaceAll(RegExp(r'[^a-z0-9._-]+', caseSensitive: false), '_') + .replaceAll(RegExp(r'^_+|_+$'), ''); + if (cleaned.isEmpty) return 'chapter'; + return cleaned.length > 180 ? cleaned.substring(0, 180) : cleaned; +} + +String sanitizeComicDirName(String name) { + final collapsed = name.trim().replaceAll(RegExp(r'\s+'), ' '); + final cleaned = String.fromCharCodes( + collapsed.runes.where((code) { + if (code < 32 || code == 127) return false; + final ch = String.fromCharCode(code); + return !RegExp(r'[<>:"/\\|?*]').hasMatch(ch); + }), + ).replaceAll(RegExp(r'\.+$'), '').trim(); + if (cleaned.isEmpty) return 'comic'; + return cleaned.length > 180 ? cleaned.substring(0, 180) : cleaned; +} + +String stemForComicLandChapterCbz(String chapterPageUrl) { + try { + final segs = + Uri.parse(chapterPageUrl).pathSegments.where((s) => s.isNotEmpty).toList(); + final comicIdx = segs.indexOf('comic'); + if (comicIdx != -1 && + segs.length >= comicIdx + 4 && + segs[comicIdx + 2] == 'chapter') { + return '${segs[comicIdx + 1]}-chapter-${segs[comicIdx + 3]}'; + } + if (segs.length >= 2) { + return '${segs[segs.length - 2]}-${segs.last}'; + } + return segs.isEmpty ? 'chapter' : segs.last; + } catch (_) { + return 'chapter'; + } +} + +String transferLinkBasename(String? chapterNumber, String? label, String? cbzPath) { + final candidates = [chapterNumber, label] + .whereType() + .where((s) => s.trim().isNotEmpty); + for (final raw in candidates) { + final text = raw.trim(); + final m = RegExp(r'(\d+)((?:\.\d+)*)([a-zA-Z]*)').firstMatch(text); + if (m != null) { + final padded = m.group(1)!.padLeft(3, '0'); + return 'chapter $padded${m.group(2) ?? ''}${m.group(3) ?? ''}.cbz'; + } + } + if (cbzPath != null && cbzPath.isNotEmpty) { + final base = cbzPath.split('/').last; + if (base.isNotEmpty) return base; + } + return 'chapter unknown.cbz'; +} + +CatalogComic catalogFromJson(Map row) { + final slug = (row['slug'] as String?)?.trim() ?? 'comic'; + return CatalogComic( + source: SourceId.comicland, + slug: slug, + title: (row['title'] as String?)?.trim().isNotEmpty == true + ? (row['title'] as String).trim() + : slug, + listingUrl: listingUrlForSlug(slug), + coverUrl: row['cover_url'] as String?, + description: row['description'] as String?, + rating: (row['rating'] is num) ? (row['rating'] as num).toDouble() : null, + ); +} diff --git a/cxyz-android/lib/sources/mangaread/constants.dart b/cxyz-android/lib/sources/mangaread/constants.dart new file mode 100644 index 0000000..d60b22f --- /dev/null +++ b/cxyz-android/lib/sources/mangaread/constants.dart @@ -0,0 +1,32 @@ +/// MangaRead / Madara constants (port of cxyz mangaread fetch helpers). +library; +const mangaReadHosts = ['www.mangaread.org', 'mangaread.org']; + +const mangaReadOrigin = 'https://www.mangaread.org/'; + +const mangaReadDesktopUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + +const mangaReadMobileUa = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 ' + '(KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1'; + +const mangaReadHtmlHeaders = { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': mangaReadDesktopUa, +}; + +const mangaReadReaderHeaders = { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': mangaReadMobileUa, +}; + +const mangaReadCdnHeaders = { + 'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Referer': mangaReadOrigin, + 'User-Agent': mangaReadDesktopUa, +}; diff --git a/cxyz-android/lib/sources/mangaread/mangaread_source.dart b/cxyz-android/lib/sources/mangaread/mangaread_source.dart new file mode 100644 index 0000000..db411b8 --- /dev/null +++ b/cxyz-android/lib/sources/mangaread/mangaread_source.dart @@ -0,0 +1,328 @@ +import 'package:dio/dio.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; + +import '../source.dart'; +import 'constants.dart'; +import 'urls.dart'; + +/// MangaRead / Madara HTML adapter (port of cxyz `mangareadFetchChapters.js`). +class MangaReadSource implements ComicSource { + MangaReadSource({Dio? dio}) + : _dio = dio ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 60), + headers: mangaReadHtmlHeaders, + responseType: ResponseType.plain, + validateStatus: (s) => s != null && s >= 200 && s < 300, + ), + ); + + final Dio _dio; + + @override + SourceId get id => SourceId.mangaread; + + @override + String get displayName => 'MangaRead'; + + @override + String get blurb => 'Latest and search via mangaread.org (Madara HTML)'; + + @override + List get catalogMenus => const [ + CatalogMenu(kind: CatalogMenuKind.latest, title: 'Latest'), + CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'), + ]; + + @override + Map get imageHeaders => Map.unmodifiable(mangaReadCdnHeaders); + + Never _cloudflareOrRethrow(DioException e, String label) { + final code = e.response?.statusCode; + if (code == 403) { + throw StateError( + 'MangaRead $label blocked (HTTP 403 / Cloudflare). ' + 'Direct fetch is not available right now.', + ); + } + throw StateError('MangaRead $label failed: ${e.message ?? e}'); + } + + Future _getHtml( + String url, { + Map? headers, + String label = 'request', + }) async { + try { + final res = await _dio.get( + url, + options: Options(headers: headers ?? mangaReadHtmlHeaders), + ); + return res.data ?? ''; + } on DioException catch (e) { + _cloudflareOrRethrow(e, label); + } + } + + Future _postAjaxChapters(String mangaBaseUrl, String refererUrl) async { + final base = mangaBaseUrl.endsWith('/') ? mangaBaseUrl : '$mangaBaseUrl/'; + final ajaxUrl = Uri.parse(base).resolve('ajax/chapters/').toString(); + final origin = Uri.parse(refererUrl).origin; + try { + final res = await _dio.post( + ajaxUrl, + data: '', + options: Options( + headers: { + ...mangaReadHtmlHeaders, + 'Accept': '*/*', + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'Referer': refererUrl, + 'Origin': origin, + 'X-Requested-With': 'XMLHttpRequest', + }, + responseType: ResponseType.plain, + ), + ); + return res.data ?? ''; + } on DioException catch (e) { + _cloudflareOrRethrow(e, 'ajax/chapters'); + } + } + + @override + Future> loadCatalog( + CatalogMenuKind kind, { + String query = '', + int offset = 0, + int limit = 30, + CatalogBrowseParams? browse, + }) async { + switch (kind) { + case CatalogMenuKind.popular: + throw UnsupportedError('MangaRead has no popular catalog feed'); + case CatalogMenuKind.official: + throw UnsupportedError('MangaRead has no official catalog feed'); + case CatalogMenuKind.uncensored: + throw UnsupportedError('MangaRead has no uncensored catalog feed'); + case CatalogMenuKind.recommended: + throw UnsupportedError('MangaRead has no recommended catalog feed'); + case CatalogMenuKind.hotToday: + case CatalogMenuKind.hotWeek: + case CatalogMenuKind.hotMonth: + case CatalogMenuKind.directory: + throw UnsupportedError('MangaRead has no ${kind.name} catalog feed'); + case CatalogMenuKind.latest: + // Madara HTML pages are ~12 titles each. Callers pass offset as + // (pageIndex * theirLimit); map that to a 1-based site page. + final page = (offset ~/ (limit > 0 ? limit : 12)) + 1; + final url = page <= 1 + ? '${mangaReadOrigin}manga/?m_orderby=latest' + : '${mangaReadOrigin}manga/page/$page/?m_orderby=latest'; + final html = await _getHtml(url, label: 'latest'); + return _parseCatalogCards(html, baseHref: url); + case CatalogMenuKind.search: + final q = query.trim(); + if (q.length < 2) return const []; + // Same page mapping as latest; Madara search paginates via /page/N/. + final page = (offset ~/ (limit > 0 ? limit : 12)) + 1; + final uri = page <= 1 + ? Uri.parse(mangaReadOrigin).replace( + queryParameters: { + 's': q, + 'post_type': 'wp-manga', + }, + ) + : Uri.parse( + '${mangaReadOrigin}page/$page/', + ).replace( + queryParameters: { + 's': q, + 'post_type': 'wp-manga', + }, + ); + final html = await _getHtml(uri.toString(), label: 'search'); + return _parseCatalogCards(html, baseHref: uri.toString()); + } + } + + List _parseCatalogCards(String html, {required String baseHref}) { + final doc = html_parser.parse(html); + final cards = [ + ...doc.querySelectorAll('.page-item-detail'), + ...doc.querySelectorAll('.c-tabs-item__content'), + ]; + final seen = {}; + final out = []; + + for (final card in cards) { + final titleA = card.querySelector('.post-title a') ?? + card.querySelector('h3 a') ?? + card.querySelector('h5 a') ?? + card.querySelector('a'); + if (titleA == null) continue; + final href = titleA.attributes['href']?.trim(); + if (href == null || href.isEmpty) continue; + final listingUrl = absoluteUrl(href, baseHref); + if (!listingUrl.contains('/manga/')) continue; + final slug = mangaSlugFromUrl(listingUrl); + if (slug == 'comic' || !seen.add(slug)) continue; + + final title = titleA.text.trim().replaceAll(RegExp(r'\s+'), ' '); + if (title.isEmpty) continue; + + String? coverUrl; + final img = card.querySelector('img'); + if (img != null) { + coverUrl = _pickImageSrc(img); + if (coverUrl != null) { + coverUrl = absoluteUrl(coverUrl, baseHref); + } + } + + out.add( + CatalogComic( + source: SourceId.mangaread, + slug: slug, + title: title, + listingUrl: listingUrlForSlug(slug), + coverUrl: coverUrl, + ), + ); + } + return out; + } + + String? _pickImageSrc(dom.Element img) { + for (final attr in [ + 'data-src', + 'data-lazy-src', + 'data-original', + 'data-full-url', + 'src', + ]) { + final raw = img.attributes[attr]?.trim(); + if (raw != null && raw.isNotEmpty && !raw.startsWith('data:')) { + return raw; + } + } + return null; + } + + List _extractChapters(String html, String baseHref) { + final doc = html_parser.parse(html); + final items = doc.querySelectorAll('li.wp-manga-chapter'); + final chapters = []; + for (final li in items) { + final a = li.querySelector('a'); + if (a == null) continue; + final href = a.attributes['href']?.trim(); + if (href == null || href.isEmpty) continue; + final title = a.text.trim().replaceAll(RegExp(r'\s+'), ' '); + final url = absoluteUrl(href, baseHref); + chapters.add( + ChapterLink( + url: url, + title: title.isNotEmpty ? title : 'Chapter', + chapterKey: chapterKeyFromUrl(url), + chapterNumber: chapterNumberFromTitle(title), + ), + ); + } + return chapters; + } + + String? _extractTitle(String html) { + final doc = html_parser.parse(html); + final og = doc.querySelector('meta[property="og:title"]'); + final ogContent = og?.attributes['content']?.trim(); + if (ogContent != null && ogContent.isNotEmpty) { + return ogContent.replaceAll(RegExp(r'\s+'), ' '); + } + final h1 = doc.querySelector('.profile-manga .post-title h1'); + final h1Text = h1?.text.trim().replaceAll(RegExp(r'\s+'), ' '); + if (h1Text != null && h1Text.isNotEmpty) return h1Text; + final titleEl = doc.querySelector('title'); + final titleText = titleEl?.text.trim() ?? ''; + final m = RegExp(r'^Read\s+(.+?)\s+-\s+manga', caseSensitive: false) + .firstMatch(titleText); + if (m != null) { + return m.group(1)!.trim().replaceAll(RegExp(r'\s+'), ' '); + } + return null; + } + + String? _extractCover(String html, String baseHref) { + final doc = html_parser.parse(html); + final og = doc.querySelector('meta[property="og:image"]'); + final ogUrl = og?.attributes['content']?.trim(); + if (ogUrl != null && ogUrl.isNotEmpty) { + return absoluteUrl(ogUrl, baseHref); + } + final img = doc.querySelector('.summary_image img') ?? + doc.querySelector('.tab-summary img'); + if (img == null) return null; + final raw = _pickImageSrc(img); + if (raw == null) return null; + return absoluteUrl(raw, baseHref); + } + + @override + Future fetchSeries(String listingUrl) async { + final base = mangaBaseUrlFromListingUrl(listingUrl); + final html = await _getHtml(base, label: 'series'); + var chapters = _extractChapters(html, base); + if (chapters.isEmpty) { + final ajaxHtml = await _postAjaxChapters(base, listingUrl); + chapters = _extractChapters(ajaxHtml, base); + } + + // Madara lists newest first; store oldest → newest for consistent numbering. + if (chapters.length > 1) { + chapters = chapters.reversed.toList(growable: false); + } + + final slug = mangaSlugFromUrl(base); + final title = _extractTitle(html) ?? slug; + return SeriesDetail( + source: SourceId.mangaread, + listingUrl: base, + slug: slug, + title: title, + coverUrl: _extractCover(html, base), + chapters: chapters, + ); + } + + List _collectPageUrls(String html, String baseHref) { + final doc = html_parser.parse(html); + final imgs = doc.querySelectorAll('img.wp-manga-chapter-img'); + final urls = []; + for (final img in imgs) { + final raw = _pickImageSrc(img); + if (raw == null) continue; + try { + urls.add(absoluteUrl(raw, baseHref)); + } catch (_) {} + } + return urls; + } + + @override + Future> fetchPageUrls(String chapterUrl) async { + var html = await _getHtml( + chapterUrl, + headers: mangaReadReaderHeaders, + label: 'chapter (mobile)', + ); + var urls = _collectPageUrls(html, chapterUrl); + if (urls.isEmpty) { + html = await _getHtml(chapterUrl, label: 'chapter (desktop)'); + urls = _collectPageUrls(html, chapterUrl); + } + return urls; + } +} diff --git a/cxyz-android/lib/sources/mangaread/urls.dart b/cxyz-android/lib/sources/mangaread/urls.dart new file mode 100644 index 0000000..a23e61b --- /dev/null +++ b/cxyz-android/lib/sources/mangaread/urls.dart @@ -0,0 +1,47 @@ +String mangaSlugFromUrl(String pageUrl) { + try { + final parts = Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList(); + final mi = parts.indexOf('manga'); + if (mi != -1 && mi + 1 < parts.length) { + return parts[mi + 1]; + } + } catch (_) {} + return 'comic'; +} + +String mangaBaseUrlFromListingUrl(String pageUrl) { + final u = Uri.parse(pageUrl); + final parts = u.pathSegments.where((s) => s.isNotEmpty).toList(); + final mi = parts.indexOf('manga'); + if (mi == -1 || mi + 1 >= parts.length) { + throw StateError( + 'Could not find /manga/{slug}/ in URL; use a manga or chapter link from MangaRead.', + ); + } + final slug = parts[mi + 1]; + return Uri(scheme: u.scheme, host: u.host, path: '/manga/$slug/').toString(); +} + +String listingUrlForSlug(String slug, {String origin = 'https://www.mangaread.org'}) { + final base = origin.replaceAll(RegExp(r'/+$'), ''); + return '$base/manga/$slug/'; +} + +String chapterKeyFromUrl(String chapterUrl) { + try { + final segs = Uri.parse(chapterUrl).pathSegments.where((s) => s.isNotEmpty).toList(); + return segs.isEmpty ? 'chapter' : segs.last; + } catch (_) { + return 'chapter'; + } +} + +String? chapterNumberFromTitle(String title) { + final m = RegExp(r'(?:Chapter|Ch\.?)\s*([\d.]+)', caseSensitive: false) + .firstMatch(title.trim().replaceAll(RegExp(r'\s+'), ' ')); + return m?.group(1); +} + +String absoluteUrl(String href, String baseHref) { + return Uri.parse(baseHref).resolve(href.trim()).toString(); +} diff --git a/cxyz-android/lib/sources/mangatown/constants.dart b/cxyz-android/lib/sources/mangatown/constants.dart new file mode 100644 index 0000000..a026e6b --- /dev/null +++ b/cxyz-android/lib/sources/mangatown/constants.dart @@ -0,0 +1,24 @@ +/// MangaTown mobile (`m.mangatown.com`) constants. +library; + +const mangaTownOrigin = 'https://m.mangatown.com'; + +const mangaTownMobileUa = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 ' + '(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + +const mangaTownHtmlHeaders = { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': mangaTownMobileUa, +}; + +const mangaTownImageHeaders = { + 'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Referer': '$mangaTownOrigin/', + 'User-Agent': mangaTownMobileUa, +}; + +/// Site catalog page size (Latest / Hot / Directory). +const mangaTownCatalogPageSize = 20; diff --git a/cxyz-android/lib/sources/mangatown/mangatown_source.dart b/cxyz-android/lib/sources/mangatown/mangatown_source.dart new file mode 100644 index 0000000..31890f4 --- /dev/null +++ b/cxyz-android/lib/sources/mangatown/mangatown_source.dart @@ -0,0 +1,307 @@ +import 'package:dio/dio.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; + +import '../source.dart'; +import 'constants.dart'; +import 'urls.dart'; + +/// MangaTown mobile HTML adapter (`m.mangatown.com`). +class MangaTownSource implements ComicSource { + MangaTownSource({Dio? dio}) + : _dio = dio ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 60), + headers: mangaTownHtmlHeaders, + responseType: ResponseType.plain, + validateStatus: (s) => s != null && s >= 200 && s < 300, + ), + ); + + final Dio _dio; + + static const _pageFetchConcurrency = 4; + + @override + SourceId get id => SourceId.mangatown; + + @override + String get displayName => 'MangaTown'; + + @override + String get blurb => + 'Latest, hot rankings, directory filters, and search via m.mangatown.com'; + + @override + List get catalogMenus => const [ + CatalogMenu(kind: CatalogMenuKind.latest, title: 'Latest'), + CatalogMenu(kind: CatalogMenuKind.popular, title: 'Hot'), + CatalogMenu(kind: CatalogMenuKind.hotToday, title: 'Hot Today'), + CatalogMenu(kind: CatalogMenuKind.hotWeek, title: 'Hot Week'), + CatalogMenu(kind: CatalogMenuKind.hotMonth, title: 'Hot Month'), + CatalogMenu(kind: CatalogMenuKind.directory, title: 'Directory'), + CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'), + ]; + + @override + Map get imageHeaders => + Map.unmodifiable(mangaTownImageHeaders); + + Future _getHtml( + String url, { + Map? headers, + String label = 'request', + }) async { + try { + final res = await _dio.get( + url, + options: Options(headers: headers ?? mangaTownHtmlHeaders), + ); + return res.data ?? ''; + } on DioException catch (e) { + throw StateError('MangaTown $label failed: ${e.message ?? e}'); + } + } + + /// CatalogFeedPage uses caller `limit` as page stride (like MangaRead). + int _sitePage(int offset, int limit) { + final stride = limit > 0 ? limit : mangaTownCatalogPageSize; + return (offset ~/ stride) + 1; + } + + @override + Future> loadCatalog( + CatalogMenuKind kind, { + String query = '', + int offset = 0, + int limit = 30, + CatalogBrowseParams? browse, + }) async { + final page = _sitePage(offset, limit); + late final String url; + switch (kind) { + case CatalogMenuKind.official: + case CatalogMenuKind.uncensored: + case CatalogMenuKind.recommended: + throw UnsupportedError('MangaTown has no ${kind.name} catalog feed'); + case CatalogMenuKind.latest: + url = catalogLatestUrl(page: page); + case CatalogMenuKind.popular: + case CatalogMenuKind.hotToday: + case CatalogMenuKind.hotWeek: + case CatalogMenuKind.hotMonth: + final period = hotPeriodForMenu(kind)!; + url = catalogHotUrl(period: period, page: page); + case CatalogMenuKind.directory: + url = directoryUrl(browse ?? const CatalogBrowseParams(), page: page); + case CatalogMenuKind.search: + final q = query.trim(); + if (q.length < 2) return const []; + url = catalogSearchUrl(query: q, page: page); + } + + final html = await _getHtml(url, label: kind.name); + return _parseCatalogCards(html, baseHref: url); + } + + List _parseCatalogCards(String html, {required String baseHref}) { + final doc = html_parser.parse(html); + final posts = doc.querySelectorAll('ul.post-list li div.post'); + final seen = {}; + final out = []; + + for (final post in posts) { + final coverA = post.querySelector('a.manga-cover'); + final href = coverA?.attributes['href']?.trim() ?? + post.querySelector('a[href*="/manga/"]')?.attributes['href']?.trim(); + if (href == null || href.isEmpty) continue; + final listingUrl = absoluteUrl(href, baseHref); + if (!listingUrl.contains('/manga/')) continue; + final slug = mangaSlugFromUrl(listingUrl); + if (slug == 'comic' || !seen.add(slug)) continue; + + var title = coverA?.attributes['rel']?.trim() ?? ''; + if (title.isEmpty) { + title = post + .querySelector('.post-info .title') + ?.text + .trim() + .replaceAll(RegExp(r'\s+'), ' ') ?? + ''; + } + if (title.isEmpty) title = slug; + + String? coverUrl; + final img = post.querySelector('img'); + final rawSrc = img?.attributes['src']?.trim(); + if (rawSrc != null && rawSrc.isNotEmpty && !rawSrc.startsWith('data:')) { + coverUrl = absoluteUrl(rawSrc, baseHref); + } + + out.add( + CatalogComic( + source: SourceId.mangatown, + slug: slug, + title: title, + listingUrl: listingUrlForSlug(slug), + coverUrl: coverUrl, + ), + ); + } + return out; + } + + String? _extractTitle(String html) { + final doc = html_parser.parse(html); + final t = doc + .querySelector('.manga-detail-top .title') + ?.text + .trim() + .replaceAll(RegExp(r'\s+'), ' '); + if (t != null && t.isNotEmpty) return t; + final titleEl = doc.querySelector('title')?.text.trim() ?? ''; + final m = RegExp( + r'Read\s+(.+?)\s+Manga', + caseSensitive: false, + ).firstMatch(titleEl); + return m?.group(1)?.trim(); + } + + String? _extractCover(String html, String baseHref) { + final doc = html_parser.parse(html); + final img = doc.querySelector('img.detail-cover'); + final raw = img?.attributes['src']?.trim(); + if (raw == null || raw.isEmpty) return null; + return absoluteUrl(raw, baseHref); + } + + String? _extractDescription(String html) { + final doc = html_parser.parse(html); + final show = doc.querySelector('#show'); + if (show != null) { + final clone = show.clone(true); + clone.querySelectorAll('a').forEach((a) => a.remove()); + final text = clone.text.trim().replaceAll(RegExp(r'\s+'), ' '); + if (text.isNotEmpty) return text; + } + final hide = doc.querySelector('#hide'); + if (hide != null) { + final clone = hide.clone(true); + clone.querySelectorAll('a').forEach((a) => a.remove()); + final text = clone.text.trim().replaceAll(RegExp(r'\s+'), ' '); + if (text.isNotEmpty) return text; + } + return null; + } + + List _extractChapters(String html, String baseHref) { + final doc = html_parser.parse(html); + final items = doc.querySelectorAll('ul.detail-ch-list li a'); + final chapters = []; + for (final a in items) { + final href = a.attributes['href']?.trim(); + if (href == null || href.isEmpty) continue; + final clone = a.clone(true); + clone.querySelectorAll('span').forEach((s) => s.remove()); + final title = clone.text.trim().replaceAll(RegExp(r'\s+'), ' '); + final url = absoluteUrl(href, baseHref); + final key = chapterKeyFromUrl(url); + chapters.add( + ChapterLink( + url: chapterBaseUrl(url), + title: title.isNotEmpty ? title : key, + chapterKey: key, + chapterNumber: chapterNumberFromTitle(title), + ), + ); + } + return chapters; + } + + @override + Future fetchSeries(String listingUrl) async { + final base = mangaBaseUrlFromListingUrl(listingUrl); + final html = await _getHtml(base, label: 'series'); + var chapters = _extractChapters(html, base); + if (chapters.length > 1) { + chapters = chapters.reversed.toList(growable: false); + } + final slug = mangaSlugFromUrl(base); + return SeriesDetail( + source: SourceId.mangatown, + listingUrl: listingUrlForSlug(slug), + slug: slug, + title: _extractTitle(html) ?? slug, + coverUrl: _extractCover(html, base), + description: _extractDescription(html), + chapters: chapters, + ); + } + + int _pageCountFromHtml(String html) { + final doc = html_parser.parse(html); + final options = doc.querySelectorAll('select.index-page option'); + if (options.isNotEmpty) return options.length; + final selects = doc.querySelectorAll('.ch-select select option'); + if (selects.isNotEmpty) return selects.length; + final pager = doc.querySelector('#pager')?.text ?? ''; + final m = RegExp(r'/\s*(\d+)').firstMatch(pager); + if (m != null) { + return int.tryParse(m.group(1)!) ?? 1; + } + return 1; + } + + String? _imageFromReaderHtml(String html, String baseHref) { + final doc = html_parser.parse(html); + dom.Element? img = + doc.querySelector('#viewer img#image') ?? doc.querySelector('img#image'); + final raw = img?.attributes['src']?.trim(); + if (raw == null || raw.isEmpty || raw.startsWith('data:')) return null; + return absoluteUrl(raw, baseHref); + } + + Future> _fetchPagesParallel( + String chapterBase, + int fromPage, + int total, + ) async { + if (fromPage > total) return const []; + final count = total - fromPage + 1; + final urls = List.filled(count, null); + var next = fromPage; + Future worker() async { + while (true) { + final i = next; + next++; + if (i > total) return; + final pageUrl = chapterPageUrl(chapterBase, i); + final html = await _getHtml(pageUrl, label: 'chapter page $i'); + urls[i - fromPage] = _imageFromReaderHtml(html, pageUrl); + } + } + + final workers = List.generate( + _pageFetchConcurrency.clamp(1, count), + (_) => worker(), + ); + await Future.wait(workers); + return urls.whereType().toList(growable: false); + } + + @override + Future> fetchPageUrls(String chapterUrl) async { + final base = chapterBaseUrl(chapterUrl); + final firstHtml = await _getHtml(base, label: 'chapter'); + final total = _pageCountFromHtml(firstHtml); + final first = _imageFromReaderHtml(firstHtml, base); + if (total <= 1) { + return first == null ? const [] : [first]; + } + final rest = await _fetchPagesParallel(base, 2, total); + if (first == null) return rest; + return [first, ...rest]; + } +} diff --git a/cxyz-android/lib/sources/mangatown/urls.dart b/cxyz-android/lib/sources/mangatown/urls.dart new file mode 100644 index 0000000..81767d1 --- /dev/null +++ b/cxyz-android/lib/sources/mangatown/urls.dart @@ -0,0 +1,245 @@ +import '../source.dart'; +import 'constants.dart'; + +/// Hot ranking period for `/hot` feeds. +enum MangaTownHotPeriod { + total, + today, + week, + month, +} + +String? hotPeriodQuery(MangaTownHotPeriod period) { + switch (period) { + case MangaTownHotPeriod.total: + return null; + case MangaTownHotPeriod.today: + return 'tviews.za'; + case MangaTownHotPeriod.week: + return 'wviews.za'; + case MangaTownHotPeriod.month: + return 'mviews.za'; + } +} + +MangaTownHotPeriod? hotPeriodForMenu(CatalogMenuKind kind) { + switch (kind) { + case CatalogMenuKind.popular: + return MangaTownHotPeriod.total; + case CatalogMenuKind.hotToday: + return MangaTownHotPeriod.today; + case CatalogMenuKind.hotWeek: + return MangaTownHotPeriod.week; + case CatalogMenuKind.hotMonth: + return MangaTownHotPeriod.month; + default: + return null; + } +} + +/// Page 1 uses the bare path; page 2+ appends `/{n}.html`. +String _pagedPath(String path, int page) { + final base = path.replaceAll(RegExp(r'/+$'), ''); + if (page <= 1) return base; + return '$base/$page.html'; +} + +Uri _withOptionalQuery(String path, String? queryToken) { + final uri = Uri.parse('$mangaTownOrigin$path'); + if (queryToken == null || queryToken.isEmpty) return uri; + // Site uses bare tokens like `?wviews.za` (no `=` value). + return Uri.parse('${uri.toString()}?$queryToken'); +} + +String catalogLatestUrl({int page = 1}) { + return _withOptionalQuery(_pagedPath('/latest', page), null).toString(); +} + +String catalogHotUrl({ + MangaTownHotPeriod period = MangaTownHotPeriod.total, + int page = 1, +}) { + return _withOptionalQuery( + _pagedPath('/hot', page), + hotPeriodQuery(period), + ).toString(); +} + +/// `/directory/{demographic}-{genre}-0-{status}-{initial}-0` +String directoryUrl( + CatalogBrowseParams browse, { + int page = 1, +}) { + final demo = _seg(browse.demographic); + final genre = _seg(browse.genre); + final status = _seg(browse.status); + final initial = _seg(browse.initial); + final path = _pagedPath( + '/directory/$demo-$genre-0-$status-$initial-0', + page, + ); + return '$mangaTownOrigin$path'; +} + +String catalogSearchUrl({required String query, int page = 1}) { + final path = _pagedPath('/search', page); + return Uri.parse('$mangaTownOrigin$path').replace( + queryParameters: {'name': query}, + ).toString(); +} + +String _seg(String raw) { + final t = raw.trim(); + if (t.isEmpty) return '0'; + return t; +} + +String mangaSlugFromUrl(String pageUrl) { + try { + final parts = + Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList(); + final mi = parts.indexOf('manga'); + if (mi != -1 && mi + 1 < parts.length) { + return parts[mi + 1]; + } + } catch (_) {} + return 'comic'; +} + +String listingUrlForSlug(String slug, {String origin = mangaTownOrigin}) { + final base = origin.replaceAll(RegExp(r'/+$'), ''); + return '$base/manga/$slug'; +} + +String mangaBaseUrlFromListingUrl(String pageUrl) { + final u = Uri.parse(pageUrl); + final parts = u.pathSegments.where((s) => s.isNotEmpty).toList(); + final mi = parts.indexOf('manga'); + if (mi == -1 || mi + 1 >= parts.length) { + throw StateError( + 'Could not find /manga/{slug}/ in URL; use a manga or chapter link from MangaTown.', + ); + } + final slug = parts[mi + 1]; + return Uri(scheme: u.scheme, host: u.host, path: '/manga/$slug').toString(); +} + +/// Strip reader page suffixes like `/2.html` so we keep `/manga/{slug}/c005/`. +String chapterBaseUrl(String chapterUrl) { + final u = Uri.parse(chapterUrl); + final parts = u.pathSegments.where((s) => s.isNotEmpty).toList(); + if (parts.isEmpty) return chapterUrl; + final last = parts.last; + if (RegExp(r'^\d+\.html$', caseSensitive: false).hasMatch(last)) { + parts.removeLast(); + } + final path = '/${parts.join('/')}/'; + return Uri(scheme: u.scheme, host: u.host, path: path).toString(); +} + +/// 1-based reader page URL under a chapter base. +String chapterPageUrl(String chapterBase, int pageIndex) { + final base = chapterBaseUrl(chapterBase); + if (pageIndex <= 1) return base; + final trimmed = base.replaceAll(RegExp(r'/+$'), ''); + return '$trimmed/$pageIndex.html'; +} + +String chapterKeyFromUrl(String chapterUrl) { + try { + final segs = Uri.parse(chapterBaseUrl(chapterUrl)) + .pathSegments + .where((s) => s.isNotEmpty) + .toList(); + return segs.isEmpty ? 'chapter' : segs.last; + } catch (_) { + return 'chapter'; + } +} + +String? chapterNumberFromTitle(String title) { + final cleaned = title.trim().replaceAll(RegExp(r'\s+'), ' '); + final m = RegExp( + r'(?:Chapter|Ch\.?|C\.?)\s*([\d.]+)', + caseSensitive: false, + ).firstMatch(cleaned); + return m?.group(1); +} + +String absoluteUrl(String href, String baseHref) { + final resolved = Uri.parse(baseHref).resolve(href.trim()).toString(); + if (resolved.startsWith('//')) { + return 'https:$resolved'; + } + return resolved; +} + +/// Demographics offered by MangaTown directory filters. +const mangaTownDemographics = >[ + MapEntry('0', 'All'), + MapEntry('josei', 'Josei'), + MapEntry('seinen', 'Seinen'), + MapEntry('shoujo', 'Shoujo'), + MapEntry('shoujo_ai', 'Shoujo Ai'), + MapEntry('shounen', 'Shounen'), + MapEntry('shounen_ai', 'Shounen Ai'), + MapEntry('yaoi', 'Yaoi'), + MapEntry('yuri', 'Yuri'), +]; + +/// Genre slug → label for directory filters. +const mangaTownGenres = >[ + MapEntry('0', 'All'), + MapEntry('4_koma', '4 Koma'), + MapEntry('action', 'Action'), + MapEntry('adventure', 'Adventure'), + MapEntry('comedy', 'Comedy'), + MapEntry('cooking', 'Cooking'), + MapEntry('doujinshi', 'Doujinshi'), + MapEntry('drama', 'Drama'), + MapEntry('ecchi', 'Ecchi'), + MapEntry('fantasy', 'Fantasy'), + MapEntry('gender_bender', 'Gender Bender'), + MapEntry('harem', 'Harem'), + MapEntry('historical', 'Historical'), + MapEntry('horror', 'Horror'), + MapEntry('martial_arts', 'Martial Arts'), + MapEntry('mature', 'Mature'), + MapEntry('mecha', 'Mecha'), + MapEntry('music', 'Music'), + MapEntry('mystery', 'Mystery'), + MapEntry('one_shot', 'One Shot'), + MapEntry('psychological', 'Psychological'), + MapEntry('reverse_harem', 'Reverse Harem'), + MapEntry('romance', 'Romance'), + MapEntry('school_life', 'School Life'), + MapEntry('sci_fi', 'Sci Fi'), + MapEntry('shotacon', 'Shotacon'), + MapEntry('slice_of_life', 'Slice Of Life'), + MapEntry('smut', 'Smut'), + MapEntry('sports', 'Sports'), + MapEntry('supernatural', 'Supernatural'), + MapEntry('suspense', 'Suspense'), + MapEntry('tragedy', 'Tragedy'), + MapEntry('vampire', 'Vampire'), + MapEntry('webtoons', 'Webtoons'), + MapEntry('youkai', 'Youkai'), +]; + +const mangaTownStatuses = >[ + MapEntry('0', 'All'), + MapEntry('ongoing', 'Ongoing'), + MapEntry('new', 'New'), + MapEntry('completed', 'Completed'), +]; + +/// A–Z plus `0-9`. +List> mangaTownInitials() { + final out = >[const MapEntry('0', 'All')]; + for (var c = 0; c < 26; c++) { + final letter = String.fromCharCode('a'.codeUnitAt(0) + c); + out.add(MapEntry(letter, letter.toUpperCase())); + } + out.add(const MapEntry('0-9', '0-9')); + return out; +} diff --git a/cxyz-android/lib/sources/registry.dart b/cxyz-android/lib/sources/registry.dart new file mode 100644 index 0000000..016a46d --- /dev/null +++ b/cxyz-android/lib/sources/registry.dart @@ -0,0 +1,66 @@ +import 'comicland/comicland_source.dart'; +import 'mangaread/mangaread_source.dart'; +import 'mangatown/mangatown_source.dart'; +import 'source.dart'; +import 'source_flags.dart'; + +class SourceRegistry { + SourceRegistry({ + Set? enabled, + ComicSource? comicLand, + ComicSource? mangaRead, + ComicSource? mangaTown, + }) : _sources = _build( + enabled ?? enabledSourceIds(), + comicLand: comicLand, + mangaRead: mangaRead, + mangaTown: mangaTown, + ); + + final Map _sources; + + static Map _build( + Set enabled, { + ComicSource? comicLand, + ComicSource? mangaRead, + ComicSource? mangaTown, + }) { + final map = {}; + for (final id in enabled) { + switch (id) { + case SourceId.comicland: + map[id] = comicLand ?? ComicLandSource(); + case SourceId.mangaread: + map[id] = mangaRead ?? MangaReadSource(); + case SourceId.mangatown: + map[id] = mangaTown ?? MangaTownSource(); + } + } + if (map.isEmpty) { + map[SourceId.comicland] = comicLand ?? ComicLandSource(); + } + return map; + } + + List get all { + // Stable order matching SourceId declaration. + return SourceId.values + .where(_sources.containsKey) + .map((id) => _sources[id]!) + .toList(growable: false); + } + + bool get hasMultiple => _sources.length > 1; + + ComicSource get(SourceId id) { + final s = _sources[id]; + if (s == null) { + throw StateError('Source not enabled in this build: $id'); + } + return s; + } + + bool isEnabled(SourceId id) => _sources.containsKey(id); + + ComicSource get defaultSource => all.first; +} diff --git a/cxyz-android/lib/sources/source.dart b/cxyz-android/lib/sources/source.dart new file mode 100644 index 0000000..3a9d91a --- /dev/null +++ b/cxyz-android/lib/sources/source.dart @@ -0,0 +1,176 @@ +// Shared models and source-adapter contract (port of cxyz `sources.js`). + +enum SourceId { + comicland, + mangaread, + mangatown; + + String get id => name; + + String get label { + switch (this) { + case SourceId.comicland: + return 'ComicLand'; + case SourceId.mangaread: + return 'MangaRead'; + case SourceId.mangatown: + return 'MangaTown'; + } + } + + /// Bundled favicon asset (not fetched at runtime). + String get logoAsset => 'assets/sources/$name.png'; + + static SourceId? tryParse(String raw) { + final key = raw.trim().toLowerCase(); + for (final id in SourceId.values) { + if (id.name == key) return id; + } + return null; + } +} + +/// Browse menus a source may expose on its Catalog home. +enum CatalogMenuKind { + popular, + latest, + official, + uncensored, + recommended, + search, + hotToday, + hotWeek, + hotMonth, + directory; + + String get pathSegment => name; +} + +/// Optional directory filters (MangaTown). Segment `0` means All. +class CatalogBrowseParams { + const CatalogBrowseParams({ + this.demographic = '0', + this.genre = '0', + this.status = '0', + this.initial = '0', + }); + + final String demographic; + final String genre; + final String status; + final String initial; + + CatalogBrowseParams copyWith({ + String? demographic, + String? genre, + String? status, + String? initial, + }) { + return CatalogBrowseParams( + demographic: demographic ?? this.demographic, + genre: genre ?? this.genre, + status: status ?? this.status, + initial: initial ?? this.initial, + ); + } + + @override + bool operator ==(Object other) { + return other is CatalogBrowseParams && + other.demographic == demographic && + other.genre == genre && + other.status == status && + other.initial == initial; + } + + @override + int get hashCode => Object.hash(demographic, genre, status, initial); +} + +class CatalogMenu { + const CatalogMenu({required this.kind, required this.title}); + + final CatalogMenuKind kind; + final String title; +} + +class CatalogComic { + const CatalogComic({ + required this.source, + required this.slug, + required this.title, + required this.listingUrl, + this.coverUrl, + this.description, + this.rating, + }); + + final SourceId source; + final String slug; + final String title; + final String listingUrl; + final String? coverUrl; + final String? description; + final double? rating; +} + +class ChapterLink { + const ChapterLink({ + required this.url, + required this.title, + required this.chapterKey, + this.chapterNumber, + }); + + final String url; + final String title; + final String chapterKey; + final String? chapterNumber; +} + +class SeriesDetail { + const SeriesDetail({ + required this.source, + required this.listingUrl, + required this.slug, + required this.title, + required this.chapters, + this.coverUrl, + this.description, + }); + + final SourceId source; + final String listingUrl; + final String slug; + final String title; + final String? coverUrl; + final String? description; + final List chapters; +} + +/// Site adapter: per-source catalog menus + series + page URLs. +abstract class ComicSource { + SourceId get id; + + String get displayName; + + String get blurb; + + /// Menus on this source’s Catalog home. Omit unsupported feeds (e.g. no search). + List get catalogMenus; + + /// Headers for page/CDN image requests. + Map get imageHeaders; + + Future> loadCatalog( + CatalogMenuKind kind, { + String query = '', + int offset = 0, + int limit = 30, + CatalogBrowseParams? browse, + }); + + Future fetchSeries(String listingUrl); + + Future> fetchPageUrls(String chapterUrl); +} diff --git a/cxyz-android/lib/sources/source_flags.dart b/cxyz-android/lib/sources/source_flags.dart new file mode 100644 index 0000000..d0e8132 --- /dev/null +++ b/cxyz-android/lib/sources/source_flags.dart @@ -0,0 +1,37 @@ +import 'source.dart'; + +/// Compile-time source gate: `--dart-define=SOURCES=comicland,mangaread,mangatown` +/// or `--dart-define=SOURCES=all`. +/// +/// Unset / invalid → ComicLand only. Default is **not** all sources. +const String kSourcesDefine = String.fromEnvironment( + 'SOURCES', + defaultValue: 'comicland', +); + +/// Parses [raw] into enabled [SourceId]s. +/// +/// - Token `all` (case-insensitive) enables every [SourceId]. +/// - Unknown tokens ignored. +/// - Empty result falls back to `{SourceId.comicland}`. +Set parseEnabledSourceIds([String raw = kSourcesDefine]) { + final parts = raw.split(',').map((p) => p.trim().toLowerCase()).where( + (p) => p.isNotEmpty, + ); + for (final part in parts) { + if (part == 'all') { + return SourceId.values.toSet(); + } + } + final out = {}; + for (final part in parts) { + final id = SourceId.tryParse(part); + if (id != null) out.add(id); + } + if (out.isEmpty) { + return {SourceId.comicland}; + } + return out; +} + +Set enabledSourceIds() => parseEnabledSourceIds(); diff --git a/cxyz-android/lib/ui/catalog/mangatown_directory_filters.dart b/cxyz-android/lib/ui/catalog/mangatown_directory_filters.dart new file mode 100644 index 0000000..5a53239 --- /dev/null +++ b/cxyz-android/lib/ui/catalog/mangatown_directory_filters.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +import '../../sources/mangatown/urls.dart'; +import '../../sources/source.dart'; + +/// MangaTown Directory filter bar (demographic / genre / status / initial). +class MangaTownDirectoryFilters extends StatelessWidget { + const MangaTownDirectoryFilters({ + super.key, + required this.value, + required this.onChanged, + }); + + final CatalogBrowseParams value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final initials = mangaTownInitials(); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: _FilterDropdown( + label: 'Demographic', + value: value.demographic, + entries: mangaTownDemographics, + onChanged: (v) => onChanged(value.copyWith(demographic: v)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _FilterDropdown( + label: 'Status', + value: value.status, + entries: mangaTownStatuses, + onChanged: (v) => onChanged(value.copyWith(status: v)), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _FilterDropdown( + label: 'Genre', + value: value.genre, + entries: mangaTownGenres, + onChanged: (v) => onChanged(value.copyWith(genre: v)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _FilterDropdown( + label: 'Letter', + value: value.initial, + entries: initials, + onChanged: (v) => onChanged(value.copyWith(initial: v)), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _FilterDropdown extends StatelessWidget { + const _FilterDropdown({ + required this.label, + required this.value, + required this.entries, + required this.onChanged, + }); + + final String label; + final String value; + final List> entries; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final known = entries.any((e) => e.key == value) ? value : '0'; + return InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + isDense: true, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: known, + items: [ + for (final e in entries) + DropdownMenuItem(value: e.key, child: Text(e.value)), + ], + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ), + ); + } +} diff --git a/cxyz-android/lib/ui/catalog/source_picker_page.dart b/cxyz-android/lib/ui/catalog/source_picker_page.dart new file mode 100644 index 0000000..0d1b711 --- /dev/null +++ b/cxyz-android/lib/ui/catalog/source_picker_page.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../providers/providers.dart'; +import '../../sources/source.dart'; +import '../widgets/comic_cover.dart'; +import '../widgets/source_logo.dart'; + +/// Catalog tab root helpers live in the router redirect; picker is [SourcePickerPage]. + +const _kLastCatalogSource = 'catalog_last_source_id'; + +Future saveLastCatalogSource(SourceId id) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kLastCatalogSource, id.name); +} + +Future loadLastCatalogSource() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_kLastCatalogSource); + if (raw == null) return null; + return SourceId.tryParse(raw); +} + +class SourcePickerPage extends ConsumerStatefulWidget { + const SourcePickerPage({super.key}); + + @override + ConsumerState createState() => _SourcePickerPageState(); +} + +class _SourcePickerPageState extends ConsumerState { + SourceId? _last; + + @override + void initState() { + super.initState(); + loadLastCatalogSource().then((id) { + if (!mounted) return; + setState(() => _last = id); + }); + } + + @override + Widget build(BuildContext context) { + final registry = ref.watch(sourceRegistryProvider); + final sources = registry.all; + final last = _last; + final continueSource = + last != null && registry.isEnabled(last) ? registry.get(last) : null; + + return Scaffold( + appBar: AppBar( + title: const Text('Catalog'), + actions: [ + const PeekModeToggleButton(), + IconButton( + tooltip: 'Settings', + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.push('/settings'), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + if (continueSource != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Align( + alignment: Alignment.centerLeft, + child: ActionChip( + avatar: SourceLogo(sourceId: continueSource.id, size: 18), + label: Text('Continue · ${continueSource.displayName}'), + onPressed: () => _open(continueSource.id), + ), + ), + ), + for (final s in sources) + ListTile( + leading: SourceLogo(sourceId: s.id), + title: Text(s.displayName), + subtitle: Text(s.blurb), + trailing: const Icon(Icons.chevron_right), + onTap: () => _open(s.id), + ), + ], + ), + ); + } + + void _open(SourceId id) { + saveLastCatalogSource(id); + context.push('/catalog/${id.name}'); + } +} + +/// Lists [ComicSource.catalogMenus] for one enabled source. +class SourceHomePage extends ConsumerWidget { + const SourceHomePage({ + super.key, + required this.sourceId, + this.embeddedAsRoot = false, + }); + + final SourceId sourceId; + final bool embeddedAsRoot; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final registry = ref.watch(sourceRegistryProvider); + if (!registry.isEnabled(sourceId)) { + return Scaffold( + appBar: AppBar(title: const Text('Catalog')), + body: const Center(child: Text('Source not enabled in this build.')), + ); + } + final source = registry.get(sourceId); + final menus = source.catalogMenus; + + return Scaffold( + appBar: AppBar( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SourceLogo(sourceId: sourceId, size: 24), + const SizedBox(width: 8), + Text(source.displayName), + ], + ), + leading: embeddedAsRoot + ? null + : IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/catalog'); + } + }, + ), + actions: [ + const PeekModeToggleButton(), + IconButton( + tooltip: 'Settings', + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.push('/settings'), + ), + ], + ), + body: ListView.separated( + itemCount: menus.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, i) { + final menu = menus[i]; + return ListTile( + leading: Icon(_iconFor(menu.kind)), + title: Text(menu.title), + trailing: const Icon(Icons.chevron_right), + onTap: () { + saveLastCatalogSource(sourceId); + context.push('/catalog/${sourceId.name}/${menu.kind.pathSegment}'); + }, + ); + }, + ), + ); + } + + IconData _iconFor(CatalogMenuKind kind) { + switch (kind) { + case CatalogMenuKind.popular: + return Icons.local_fire_department_outlined; + case CatalogMenuKind.latest: + return Icons.schedule_outlined; + case CatalogMenuKind.official: + return Icons.verified_outlined; + case CatalogMenuKind.uncensored: + return Icons.visibility_outlined; + case CatalogMenuKind.recommended: + return Icons.thumb_up_outlined; + case CatalogMenuKind.search: + return Icons.search; + case CatalogMenuKind.hotToday: + return Icons.wb_sunny_outlined; + case CatalogMenuKind.hotWeek: + return Icons.date_range_outlined; + case CatalogMenuKind.hotMonth: + return Icons.calendar_month_outlined; + case CatalogMenuKind.directory: + return Icons.grid_view_outlined; + } + } +} diff --git a/cxyz-android/lib/ui/catalog_page.dart b/cxyz-android/lib/ui/catalog_page.dart new file mode 100644 index 0000000..8cc560a --- /dev/null +++ b/cxyz-android/lib/ui/catalog_page.dart @@ -0,0 +1,272 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../providers/providers.dart'; +import '../sources/source.dart'; +import 'catalog/mangatown_directory_filters.dart'; +import 'widgets/comic_cover.dart'; + +/// Catalog feed for one `(source, menuKind)` — grid + search field when needed. +class CatalogFeedPage extends ConsumerStatefulWidget { + const CatalogFeedPage({ + super.key, + required this.sourceId, + required this.menuKind, + }); + + final SourceId sourceId; + final CatalogMenuKind menuKind; + + @override + ConsumerState createState() => _CatalogFeedPageState(); +} + +class _CatalogFeedPageState extends ConsumerState { + final _searchCtrl = TextEditingController(); + List _items = const []; + bool _loading = true; + String? _error; + String _lastQuery = ''; + /// Next 0-based page index (mapped to `offset: page * _pageSize` for sources). + int _page = 0; + static const _pageSize = 30; + bool _hasMore = true; + bool _loadingMore = false; + CatalogBrowseParams _browse = const CatalogBrowseParams(); + + ComicSource get _source => + ref.read(sourceRegistryProvider).get(widget.sourceId); + + bool get _showDirectoryFilters => + widget.sourceId == SourceId.mangatown && + widget.menuKind == CatalogMenuKind.directory; + + String get _menuTitle { + final menus = _source.catalogMenus; + for (final m in menus) { + if (m.kind == widget.menuKind) return m.title; + } + return widget.menuKind.name; + } + + @override + void initState() { + super.initState(); + if (widget.menuKind != CatalogMenuKind.search) { + _load(reset: true); + } else { + _loading = false; + } + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + Future _load({required bool reset}) async { + if (reset) { + setState(() { + _loading = true; + _error = null; + _page = 0; + _hasMore = true; + }); + } else { + if (!_hasMore || _loadingMore) return; + setState(() => _loadingMore = true); + } + + final pageIndex = reset ? 0 : _page; + try { + final list = await _source.loadCatalog( + widget.menuKind, + query: _lastQuery, + offset: pageIndex * _pageSize, + limit: _pageSize, + browse: _showDirectoryFilters ? _browse : null, + ); + if (!mounted) return; + setState(() { + if (reset) { + _items = list; + } else { + // Drop accidental overlaps if a source re-served the same page. + final seen = _items.map((c) => c.listingUrl).toSet(); + _items = [ + ..._items, + ...list.where((c) => seen.add(c.listingUrl)), + ]; + } + _page = pageIndex + 1; + // Madara (and similar) may return < limit per HTML page; keep going + // until a page comes back empty. + _hasMore = list.isNotEmpty; + _loading = false; + _loadingMore = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + _loadingMore = false; + }); + } + } + + Future _runSearch(String q) async { + final query = q.trim(); + if (query.length < 2) { + setState(() { + _items = const []; + _lastQuery = ''; + _error = null; + }); + return; + } + _lastQuery = query; + await _load(reset: true); + } + + void _onBrowseChanged(CatalogBrowseParams next) { + if (next == _browse) return; + setState(() => _browse = next); + _load(reset: true); + } + + Future _openComic(CatalogComic comic) async { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + SnackBar(content: Text('Loading ${comic.title}…')), + ); + try { + final source = ref.read(sourceRegistryProvider).get(comic.source); + final queue = ref.read(downloadQueueProvider); + final series = await source.fetchSeries(comic.listingUrl); + final titleId = await queue.upsertSeries(series); + if (!mounted) return; + messenger.hideCurrentSnackBar(); + context.push('/title/$titleId'); + } catch (e) { + if (!mounted) return; + messenger.hideCurrentSnackBar(); + messenger.showSnackBar(SnackBar(content: Text('Failed: $e'))); + } + } + + Widget _grid() { + if (_loading && _items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (_items.isEmpty) { + final empty = widget.menuKind == CatalogMenuKind.search + ? 'Type at least 2 characters to search' + : 'No comics found'; + return Center(child: Text(empty)); + } + return RefreshIndicator( + onRefresh: () => _load(reset: true), + child: NotificationListener( + onNotification: (n) { + if (n.metrics.pixels > n.metrics.maxScrollExtent - 400) { + _load(reset: false); + } + return false; + }, + child: GridView.builder( + padding: const EdgeInsets.all(12), + gridDelegate: (() { + final tablet = + MediaQuery.sizeOf(context).shortestSide >= 600; + return SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: tablet ? 5 : 3, + childAspectRatio: tablet ? 0.58 : 0.55, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ); + })(), + itemCount: _items.length + (_loadingMore ? 1 : 0), + itemBuilder: (context, i) { + if (i >= _items.length) { + return const Center(child: CircularProgressIndicator()); + } + final c = _items[i]; + final tablet = + MediaQuery.sizeOf(context).shortestSide >= 600; + return ComicGridTile( + comic: c, + onTap: () => _openComic(c), + titleMaxLines: tablet ? 1 : 2, + ); + }, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('${_source.displayName} · $_menuTitle'), + actions: [ + const PeekModeToggleButton(), + IconButton( + tooltip: 'Settings', + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.push('/settings'), + ), + ], + ), + body: Column( + children: [ + if (_error != null) + MaterialBanner( + content: Text(_error!, maxLines: 3, overflow: TextOverflow.ellipsis), + actions: [ + TextButton( + onPressed: () => setState(() => _error = null), + child: const Text('Dismiss'), + ), + ], + ), + if (widget.menuKind == CatalogMenuKind.search) + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: TextField( + controller: _searchCtrl, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search comics…', + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchCtrl.clear(); + _runSearch(''); + }, + ), + ), + onChanged: (v) { + Future.delayed(const Duration(milliseconds: 250), () { + if (_searchCtrl.text == v) _runSearch(v); + }); + }, + onSubmitted: _runSearch, + ), + ), + if (_showDirectoryFilters) + MangaTownDirectoryFilters( + value: _browse, + onChanged: _onBrowseChanged, + ), + Expanded(child: _grid()), + ], + ), + ); + } +} diff --git a/cxyz-android/lib/ui/downloads_page.dart b/cxyz-android/lib/ui/downloads_page.dart new file mode 100644 index 0000000..2ee4e30 --- /dev/null +++ b/cxyz-android/lib/ui/downloads_page.dart @@ -0,0 +1,320 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../data/database.dart'; +import '../providers/providers.dart'; +import '../sources/source.dart'; +import 'widgets/comic_cover.dart'; + +class DownloadsPage extends ConsumerWidget { + const DownloadsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final queue = ref.watch(downloadQueueProvider); + final queueAsync = ref.watch(queueJobsProvider); + final downloadedAsync = ref.watch(downloadedSeriesProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Downloads')), + body: queueAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e')), + data: (queueJobs) { + return downloadedAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e')), + data: (groups) { + final queued = + queueJobs.where((j) => j.status == 'queued').length; + final failed = + queueJobs.where((j) => j.status == 'failed').length; + final pendingTotal = queued + failed; + final hasDownloads = groups.isNotEmpty; + final showRunning = queue.isRunning; + if (!showRunning && !hasDownloads && pendingTotal == 0) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'No downloads yet.\nSelect chapters on a series and tap Download.', + textAlign: TextAlign.center, + ), + ), + ); + } + + final queueLabel = failed > 0 + ? 'Queue ($pendingTotal · $failed failed)' + : 'Queue ($pendingTotal)'; + + return CustomScrollView( + slivers: [ + if (showRunning) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + queue.message.isEmpty + ? 'Downloading…' + : queue.message, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: queue.progress.clamp(0.0, 1.0), + ), + ], + ), + ), + ), + if (pendingTotal > 0) + SliverToBoxAdapter( + child: ListTile( + leading: const Icon(Icons.queue), + title: Text(queueLabel), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/downloads/queue'), + ), + ), + if (groups.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'Downloaded', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + SliverList.builder( + itemCount: groups.length, + itemBuilder: (context, i) { + return _DownloadedSeriesTile(group: groups[i]); + }, + ), + ], + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + }, + ); + }, + ), + ); + } +} + +class DownloadsQueuePage extends ConsumerWidget { + const DownloadsQueuePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final queueAsync = ref.watch(queueJobsProvider); + return Scaffold( + appBar: AppBar(title: const Text('Queue')), + body: queueAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e')), + data: (jobs) { + final list = jobs + .where((j) => j.status == 'queued' || j.status == 'failed') + .toList(); + if (list.isEmpty) { + return const Center(child: Text('Queue is empty.')); + } + return ListView.builder( + itemCount: list.length, + itemBuilder: (context, i) => _QueueJobTile(job: list[i]), + ); + }, + ), + ); + } +} + +class _QueueJobTile extends ConsumerWidget { + const _QueueJobTile({required this.job}); + + final QueueJobView job; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final statusLabel = switch (job.status) { + 'running' => 'Downloading', + 'queued' => 'Queued', + 'failed' => 'Failed', + _ => job.status, + }; + + return ListTile( + leading: Icon(_iconFor(job.status)), + title: Text( + job.seriesTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${job.chapterLabel} · $statusLabel', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (job.status == 'failed' && job.error != null) ...[ + const SizedBox(height: 2), + Text( + job.error!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ], + ], + ), + isThreeLine: job.status == 'failed', + trailing: job.status == 'failed' + ? IconButton( + tooltip: 'Retry', + icon: const Icon(Icons.refresh), + onPressed: job.chapterId == null + ? null + : () => ref + .read(downloadQueueProvider) + .retryChapter(job.chapterId!), + ) + : job.status == 'queued' + ? IconButton( + tooltip: 'Cancel', + icon: const Icon(Icons.cancel_outlined), + onPressed: job.chapterId == null + ? null + : () => ref + .read(downloadQueueProvider) + .cancelChapter(job.chapterId!), + ) + : null, + onTap: job.titleId == null + ? null + : () => context.push('/title/${job.titleId}'), + ); + } + + IconData _iconFor(String status) { + switch (status) { + case 'failed': + return Icons.error_outline; + case 'queued': + return Icons.hourglass_empty; + default: + return Icons.download_outlined; + } + } +} + +class _DownloadedSeriesTile extends ConsumerWidget { + const _DownloadedSeriesTile({required this.group}); + + final DownloadedSeriesGroup group; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sourceId = SourceId.tryParse(group.source); + return ExpansionTile( + leading: SizedBox( + width: 40, + height: 56, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: ComicCover(url: group.coverUrl, sourceId: sourceId), + ), + ), + title: Text( + group.seriesTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${group.chapters.length} chapter${group.chapters.length == 1 ? '' : 's'}', + ), + children: [ + for (final ch in group.chapters) + Dismissible( + key: ValueKey('dl-${ch.chapterId}'), + direction: DismissDirection.endToStart, + background: Container( + color: Theme.of(context).colorScheme.error, + alignment: Alignment.centerRight, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: const Icon(Icons.delete_outline, color: Colors.white), + ), + confirmDismiss: (_) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete download?'), + content: Text( + 'Remove “${ch.chapterLabel}” from Downloads and delete the CBZ file.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Delete'), + ), + ], + ), + ); + return ok == true; + }, + onDismissed: (_) { + ref.read(downloadQueueProvider).deleteDownload(ch.chapterId); + }, + child: ListTile( + contentPadding: const EdgeInsets.only(left: 72, right: 8), + title: Text(ch.chapterLabel), + subtitle: ch.downloadedAt == null + ? null + : Text(_formatDate(ch.downloadedAt!)), + trailing: PopupMenuButton( + onSelected: (v) { + if (v == 'delete') { + ref + .read(downloadQueueProvider) + .deleteDownload(ch.chapterId); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'delete', child: Text('Delete')), + ], + ), + onTap: () { + context.push('/read/${ch.titleId}/${ch.chapterId}'); + }, + ), + ), + ], + ); + } + + String _formatDate(DateTime dt) { + final local = dt.toLocal(); + final y = local.year.toString().padLeft(4, '0'); + final m = local.month.toString().padLeft(2, '0'); + final d = local.day.toString().padLeft(2, '0'); + return 'Downloaded $y-$m-$d'; + } +} diff --git a/cxyz-android/lib/ui/history_page.dart b/cxyz-android/lib/ui/history_page.dart new file mode 100644 index 0000000..cebc8e1 --- /dev/null +++ b/cxyz-android/lib/ui/history_page.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart' hide Title; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../providers/providers.dart'; +import '../sources/source.dart'; +import 'widgets/comic_cover.dart'; + +class HistoryPage extends ConsumerWidget { + const HistoryPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final titlesAsync = ref.watch(historyTitlesProvider); + return Scaffold( + appBar: AppBar(title: const Text('History')), + body: titlesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (titles) { + if (titles.isEmpty) { + return const Center( + child: Text('Chapters you read will show up here.'), + ); + } + return ListView.separated( + itemCount: titles.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, i) { + final t = titles[i]; + final sourceId = SourceId.tryParse(t.source); + final label = sourceId?.label ?? t.source; + final when = t.lastReadAt; + final subtitle = when == null + ? label + : '$label · ${_formatWhen(when)}'; + return ListTile( + leading: SizedBox( + width: 48, + height: 64, + child: ComicCover(url: t.coverUrl, sourceId: sourceId), + ), + title: Text(t.title), + subtitle: Text(subtitle), + onTap: () => context.push('/title/${t.id}'), + ); + }, + ); + }, + ), + ); + } + + String _formatWhen(DateTime dt) { + final local = dt.toLocal(); + final y = local.year.toString().padLeft(4, '0'); + final m = local.month.toString().padLeft(2, '0'); + final d = local.day.toString().padLeft(2, '0'); + final hh = local.hour.toString().padLeft(2, '0'); + final mm = local.minute.toString().padLeft(2, '0'); + return '$y-$m-$d $hh:$mm'; + } +} diff --git a/cxyz-android/lib/ui/library_page.dart b/cxyz-android/lib/ui/library_page.dart new file mode 100644 index 0000000..30b9b2a --- /dev/null +++ b/cxyz-android/lib/ui/library_page.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart' hide Title; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../providers/providers.dart'; +import '../sources/source.dart'; +import 'widgets/comic_cover.dart'; + +class LibraryPage extends ConsumerWidget { + const LibraryPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final titlesAsync = ref.watch(libraryTitlesProvider); + return Scaffold( + appBar: AppBar(title: const Text('Library')), + body: titlesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (titles) { + if (titles.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Star a series on its title page to add it to your Library.', + textAlign: TextAlign.center, + ), + ), + ); + } + return ListView.separated( + itemCount: titles.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, i) { + final t = titles[i]; + final sourceId = SourceId.tryParse(t.source); + final label = sourceId?.label ?? t.source; + return ListTile( + leading: SizedBox( + width: 48, + height: 64, + child: ComicCover(url: t.coverUrl, sourceId: sourceId), + ), + title: Text(t.title), + subtitle: Text(label), + trailing: const Icon(Icons.star, size: 18), + onTap: () => context.push('/title/${t.id}'), + ); + }, + ); + }, + ), + ); + } +} diff --git a/cxyz-android/lib/ui/reader_page.dart b/cxyz-android/lib/ui/reader_page.dart new file mode 100644 index 0000000..86e011f --- /dev/null +++ b/cxyz-android/lib/ui/reader_page.dart @@ -0,0 +1,1114 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:drift/drift.dart' hide Column; +import 'package:flutter/material.dart' hide Title; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../data/database.dart'; +import '../providers/providers.dart'; +import '../services/reader_direction.dart'; +import '../services/reader_zoom.dart'; +import '../services/source_reader_defaults.dart'; +import '../sources/source.dart'; + +/// Approximate height of the under-image end panel (webtoon scroll math). +const double _kEndPanelHeight = 320; + +class ReaderPage extends ConsumerStatefulWidget { + const ReaderPage({ + super.key, + required this.titleId, + required this.chapterId, + this.initialMode, + }); + + final int titleId; + final int chapterId; + /// If null, mode is loaded from the title's saved preference. + final ReaderMode? initialMode; + + @override + ConsumerState createState() => _ReaderPageState(); +} + +class _ReaderPageState extends ConsumerState { + ReaderMode _mode = ReaderMode.webtoon; + ReadingDirection _direction = ReadingDirection.ltr; + Title? _title; + Chapter? _chapter; + List _allChapters = const []; + List _pages = const []; + bool _loading = true; + bool _offline = false; + bool _uiVisible = true; + String? _error; + late final PageController _pageController; + final ScrollController _scrollController = ScrollController(); + final ScrollController _hScrollController = ScrollController(); + final GlobalKey _webtoonListKey = GlobalKey(); + List _pageKeys = const []; + int _pageIndex = 0; + bool _viewingEndPanel = false; + double _zoomScale = ReaderZoom.defaultScale; + Timer? _saveDebounce; + bool _restoringScroll = false; + int _restoreAttempts = 0; + /// Content Y of the current page top — used to compensate when images above grow. + double? _anchorContentY; + bool _userScrolling = false; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + _scrollController.addListener(_onWebtoonScroll); + _loadZoom(); + _load(); + } + + @override + void dispose() { + _saveDebounce?.cancel(); + // Best-effort; navigation paths await [_flushProgress] before leaving. + unawaited(_saveProgress()); + _scrollController.removeListener(_onWebtoonScroll); + _pageController.dispose(); + _scrollController.dispose(); + _hScrollController.dispose(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); + } + + Chapter? get _nextChapter { + final chapter = _chapter; + if (chapter == null || _allChapters.isEmpty) return null; + final idx = _allChapters.indexWhere((c) => c.id == chapter.id); + if (idx < 0 || idx + 1 >= _allChapters.length) return null; + return _allChapters[idx + 1]; + } + + bool get _isLastChapter => _nextChapter == null; + + Future _loadZoom() async { + final scale = await ReaderZoom.load(); + if (!mounted) return; + setState(() => _zoomScale = scale); + } + + Future _setZoom(double next) async { + final clamped = ReaderZoom.clamp(next); + if ((clamped - _zoomScale).abs() < 0.001) return; + setState(() => _zoomScale = clamped); + await ReaderZoom.save(clamped); + } + + void _zoomOut() => _setZoom(_zoomScale - ReaderZoom.step); + + void _zoomIn() => _setZoom(_zoomScale + ReaderZoom.step); + + void _scheduleSave() { + _saveDebounce?.cancel(); + _saveDebounce = Timer(const Duration(milliseconds: 400), () { + unawaited(_saveProgress()); + }); + } + + /// Cancel debounce and persist immediately (call before leaving the reader). + Future _flushProgress() async { + _saveDebounce?.cancel(); + _saveDebounce = null; + await _saveProgress(); + } + + Future _saveProgress() async { + final chapter = _chapter; + if (chapter == null) return; + final db = ref.read(databaseProvider); + if (_pages.isEmpty) { + // Pages not loaded yet — still bump the series continue pointer. + final now = DateTime.now(); + await (db.update(db.titles)..where((t) => t.id.equals(widget.titleId))) + .write( + TitlesCompanion( + lastReadChapterId: Value(chapter.id), + lastReadAt: Value(now), + readerMode: Value(_mode.storageValue), + readingDirection: Value(_direction.storageValue), + ), + ); + await (db.update(db.chapters)..where((c) => c.id.equals(chapter.id))) + .write(ChaptersCompanion(lastReadAt: Value(now))); + ref.read(webDavSyncProvider).scheduleSync(); + return; + } + await db.saveReadingProgress( + titleId: widget.titleId, + chapterId: chapter.id, + lastPage: _pageIndex.clamp(0, _pages.length - 1), + pageCount: _pages.length, + readerMode: _mode.storageValue, + readingDirection: _direction.storageValue, + ); + ref.read(webDavSyncProvider).scheduleSync(); + } + + Future _leaveReader(void Function() navigate) async { + await _flushProgress(); + if (!mounted) return; + navigate(); + } + + Future _setMode(ReaderMode mode) async { + if (_mode == mode) return; + setState(() { + _mode = mode; + _viewingEndPanel = false; + }); + await ref.read(databaseProvider).saveReaderPrefs( + titleId: widget.titleId, + readerMode: mode.storageValue, + readingDirection: _direction.storageValue, + ); + ref.read(webDavSyncProvider).scheduleSync(); + _scheduleSave(); + if (mode == ReaderMode.paged) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_pageController.hasClients) { + _pageController.jumpToPage(_pageIndex); + } + }); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) => _restoreWebtoonScroll()); + } + } + + Future _setDirection(ReadingDirection direction) async { + if (_direction == direction) return; + setState(() => _direction = direction); + await ref.read(databaseProvider).saveReaderPrefs( + titleId: widget.titleId, + readerMode: _mode.storageValue, + readingDirection: direction.storageValue, + ); + ref.read(webDavSyncProvider).scheduleSync(); + } + + void _onWebtoonScroll() { + if (_restoringScroll || !_scrollController.hasClients || _pages.isEmpty) { + return; + } + // Metrics can change when images above the viewport finish loading. + // Re-anchor first so the same page stays under the viewport. + if (!_userScrolling) { + _reanchorWebtoonIfNeeded(); + } + _updateWebtoonPageFromVisibility(updateAnchor: _userScrolling); + } + + double? _pageContentY(int index) { + if (!_scrollController.hasClients || + index < 0 || + index >= _pageKeys.length) { + return null; + } + final listCtx = _webtoonListKey.currentContext; + final pageCtx = _pageKeys[index].currentContext; + if (listCtx == null || pageCtx == null) return null; + final listBox = listCtx.findRenderObject() as RenderBox?; + final pageBox = pageCtx.findRenderObject() as RenderBox?; + if (listBox == null || pageBox == null || !listBox.hasSize || !pageBox.hasSize) { + return null; + } + final listTop = listBox.localToGlobal(Offset.zero).dy; + final pageTop = pageBox.localToGlobal(Offset.zero).dy; + return _scrollController.offset + (pageTop - listTop); + } + + void _captureWebtoonAnchor() { + _anchorContentY = _pageContentY(_pageIndex); + } + + /// When placeholders expand above the current page, keep that page glued in place. + void _reanchorWebtoonIfNeeded() { + if (_restoringScroll || + !_scrollController.hasClients || + _anchorContentY == null || + _pages.isEmpty) { + return; + } + final currentY = _pageContentY(_pageIndex); + if (currentY == null) return; + final delta = currentY - _anchorContentY!; + if (delta.abs() < 1.0) return; + + _restoringScroll = true; + final pos = _scrollController.position; + final next = (pos.pixels + delta).clamp(0.0, pos.maxScrollExtent); + _scrollController.jumpTo(next); + _restoringScroll = false; + _anchorContentY = _pageContentY(_pageIndex) ?? (_anchorContentY! + delta); + } + + /// Pick the page with the largest visible overlap in the viewport. + /// Avoids ratio-of-maxScrollExtent, which jumps as images finish loading. + void _updateWebtoonPageFromVisibility({bool updateAnchor = true}) { + if (!mounted || _pages.isEmpty) return; + + final listCtx = _webtoonListKey.currentContext; + if (listCtx == null) return; + final listBox = listCtx.findRenderObject() as RenderBox?; + if (listBox == null || !listBox.hasSize) return; + + final listTop = listBox.localToGlobal(Offset.zero).dy; + final viewportTop = listTop; + final viewportBottom = listTop + listBox.size.height; + + var bestIndex = _pageIndex; + var bestVisible = 0.0; + var anyBuilt = false; + + for (var i = 0; i < _pageKeys.length; i++) { + final ctx = _pageKeys[i].currentContext; + if (ctx == null) continue; + final box = ctx.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) continue; + anyBuilt = true; + + final top = box.localToGlobal(Offset.zero).dy; + final bottom = top + box.size.height; + final visibleTop = math.max(top, viewportTop); + final visibleBottom = math.min(bottom, viewportBottom); + final visible = math.max(0.0, visibleBottom - visibleTop); + if (visible > bestVisible) { + bestVisible = visible; + bestIndex = i; + } + } + + if (!anyBuilt) return; + + final pos = _scrollController.position; + final atEnd = pos.maxScrollExtent > 0 && + pos.pixels >= pos.maxScrollExtent - 24; + + if (bestIndex != _pageIndex || atEnd != _viewingEndPanel) { + setState(() { + _pageIndex = bestIndex; + _viewingEndPanel = atEnd; + }); + _scheduleSave(); + } + if (updateAnchor) _captureWebtoonAnchor(); + } + + void _restoreWebtoonScroll() { + if (!_scrollController.hasClients || _pages.isEmpty) return; + if (_pageIndex <= 0) { + _captureWebtoonAnchor(); + return; + } + if (_pageIndex >= _pageKeys.length) return; + + final key = _pageKeys[_pageIndex]; + final ctx = key.currentContext; + if (ctx == null) { + // ListView.builder has not mounted this index yet — nudge toward it. + if (_restoreAttempts < 16) { + _restoreAttempts++; + final pos = _scrollController.position; + // Prefer measured average height of built pages; fall back to placeholder. + var sampleH = 0.0; + var sampleN = 0; + for (final k in _pageKeys) { + final c = k.currentContext; + if (c == null) continue; + final box = c.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) continue; + sampleH += box.size.height; + sampleN++; + } + final estH = sampleN > 0 ? sampleH / sampleN : 240.0; + final target = (_pageIndex * estH).clamp(0.0, pos.maxScrollExtent); + _restoringScroll = true; + _scrollController.jumpTo(target); + _restoringScroll = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _mode == ReaderMode.webtoon) _restoreWebtoonScroll(); + }); + } + return; + } + + _restoringScroll = true; + _restoreAttempts = 0; + Scrollable.ensureVisible( + ctx, + alignment: 0.0, + duration: Duration.zero, + ).whenComplete(() { + _restoringScroll = false; + if (mounted) { + _captureWebtoonAnchor(); + _updateWebtoonPageFromVisibility(updateAnchor: false); + } + }); + } + + void _revealEndPanel() { + if (_pages.isEmpty) return; + setState(() { + _pageIndex = _pages.length - 1; + _viewingEndPanel = true; + }); + _scheduleSave(); + if (_mode == ReaderMode.paged) { + if (_pageController.hasClients) { + _pageController.animateToPage( + _pages.length, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + } else if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final db = ref.read(databaseProvider); + final title = await db.getTitle(widget.titleId); + final chapter = await db.getChapter(widget.chapterId); + if (title == null || chapter == null) { + throw StateError('Chapter not found'); + } + final chapters = await db.listChapters(widget.titleId); + chapters.sort((a, b) { + final na = double.tryParse(a.chapterNumber ?? '') ?? 0; + final nb = double.tryParse(b.chapterNumber ?? '') ?? 0; + return na.compareTo(nb); + }); + + List pages = const []; + var offline = false; + if (chapter.status == 'downloaded') { + final store = ref.read(cbzStoreProvider); + pages = await store.pagesFromCacheOrCbz( + cacheDir: chapter.cacheDir, + cbzPath: chapter.cbzPath, + comicTitle: title.title, + chapterKey: chapter.chapterKey, + ); + offline = pages.isNotEmpty; + if (offline && chapter.cacheDir == null) { + final cacheDir = await store.resolveCacheDir( + comicTitle: title.title, + chapterKey: chapter.chapterKey, + ); + await (db.update(db.chapters)..where((c) => c.id.equals(chapter.id))) + .write(ChaptersCompanion(cacheDir: Value(cacheDir))); + } + } + + if (!offline) { + final source = + ref.read(sourceRegistryProvider).get(SourceId.values.byName(title.source)); + pages = await source.fetchPageUrls(chapter.url); + } + + final globalDir = ref.read(readingDirectionProvider); + final mode = widget.initialMode ?? + SourceReaderDefaults.resolveMode(title.source, title.readerMode); + final direction = SourceReaderDefaults.resolveDirection( + title.source, + title.readingDirection, + globalFallback: globalDir, + ); + final pageIndex = + chapter.lastPage.clamp(0, pages.isEmpty ? 0 : pages.length - 1); + + if (!mounted) return; + setState(() { + _title = title; + _chapter = chapter; + _allChapters = chapters; + _pages = pages; + _pageKeys = List.generate(pages.length, (_) => GlobalKey()); + _offline = offline; + _mode = mode; + _direction = direction; + _pageIndex = pageIndex; + _viewingEndPanel = false; + _restoreAttempts = 0; + _anchorContentY = null; + _loading = false; + }); + + unawaited(_saveProgress()); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_mode == ReaderMode.paged && _pageIndex > 0) { + if (_pageController.hasClients) { + _pageController.jumpToPage(_pageIndex); + } + } else if (_mode == ReaderMode.webtoon && _pageIndex > 0) { + _restoreWebtoonScroll(); + } else if (_mode == ReaderMode.webtoon) { + _captureWebtoonAnchor(); + } + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + void _toggleUi() { + setState(() => _uiVisible = !_uiVisible); + if (_uiVisible) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } + } + + /// Reading-order previous page (respects LTR/RTL for which edge, not chapter). + void _readingPrevPage() { + if (_viewingEndPanel) { + setState(() => _viewingEndPanel = false); + if (_mode == ReaderMode.paged && _pageController.hasClients) { + _pageController.animateToPage( + _pages.length - 1, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + return; + } + if (_pageIndex <= 0) return; + if (_mode == ReaderMode.paged && _pageController.hasClients) { + _pageController.previousPage( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + } + + /// Reading-order next page; on last image reveals end panel. + void _readingNextPage() { + if (_viewingEndPanel) return; + if (_pages.isEmpty) return; + if (_pageIndex >= _pages.length - 1) { + _revealEndPanel(); + return; + } + if (_mode == ReaderMode.paged && _pageController.hasClients) { + _pageController.nextPage( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + } + + Future _goChapter(int delta) async { + final chapter = _chapter; + if (chapter == null || _allChapters.isEmpty) return; + final idx = _allChapters.indexWhere((c) => c.id == chapter.id); + if (idx < 0) return; + final next = idx + delta; + if (next < 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('First chapter')), + ); + return; + } + if (next >= _allChapters.length) { + _revealEndPanel(); + return; + } + await _flushProgress(); + if (!mounted) return; + context.pushReplacement( + '/read/${widget.titleId}/${_allChapters[next].id}', + ); + } + + Future _openNextChapter() async { + final next = _nextChapter; + if (next == null) return; + await _flushProgress(); + if (!mounted) return; + context.pushReplacement('/read/${widget.titleId}/${next.id}'); + } + + Future _backToSeries() async { + final nav = Navigator.of(context); + final titleId = widget.titleId; + final canPop = nav.canPop(); + await _flushProgress(); + if (!mounted) return; + // Prefer pop so the existing TitlePage can reload via its push().then. + if (canPop) { + nav.pop(); + } else { + context.go('/title/$titleId'); + } + } + + Future _goLibrary() => _leaveReader(() => context.go('/library')); + + Future _goHome() => _leaveReader(() => context.go('/catalog')); + + double _pageWidth(BuildContext context) { + final screenW = MediaQuery.sizeOf(context).width; + return math.max(1.0, screenW * _zoomScale); + } + + Widget _pageImage( + String src, { + required bool offline, + required double pageWidth, + }) { + if (offline) { + return Image.file( + File(src), + fit: BoxFit.fitWidth, + width: pageWidth, + filterQuality: FilterQuality.medium, + gaplessPlayback: true, + ); + } + final cache = ref.watch(imageCacheManagerProvider); + final title = _title; + Map headers = const {}; + if (title != null) { + final sid = SourceId.tryParse(title.source); + if (sid != null) { + final registry = ref.watch(sourceRegistryProvider); + if (registry.isEnabled(sid)) { + headers = registry.get(sid).imageHeaders; + } + } + } + return CachedNetworkImage( + imageUrl: src, + cacheManager: cache, + httpHeaders: headers, + fit: BoxFit.fitWidth, + width: pageWidth, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholder: (context, url) => SizedBox( + width: pageWidth, + height: 240, + child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + errorWidget: (context, url, error) => SizedBox( + width: pageWidth, + height: 120, + child: const Center(child: Icon(Icons.broken_image_outlined)), + ), + ); + } + + Widget _endOfChapterPanel() { + final next = _nextChapter; + return Material( + color: Colors.black, + child: SizedBox( + width: double.infinity, + height: _kEndPanelHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: next != null + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'End of ${_chapter?.label ?? 'chapter'}', + style: const TextStyle(color: Colors.white70, fontSize: 14), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _openNextChapter, + icon: const Icon(Icons.skip_next), + label: Text('Next chapter · ${next.label}'), + ), + ], + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'You’ve read the last chapter', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + _title?.title ?? '', + style: const TextStyle(color: Colors.white70, fontSize: 14), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 20), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + FilledButton( + onPressed: _backToSeries, + child: const Text('Back to series'), + ), + OutlinedButton( + onPressed: _goLibrary, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white54), + ), + child: const Text('Library'), + ), + OutlinedButton( + onPressed: _goHome, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white54), + ), + child: const Text('Home'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _webtoon() { + final pageWidth = _pageWidth(context); + final list = NotificationListener( + onNotification: (notification) { + if (notification is ScrollStartNotification && + notification.dragDetails != null) { + _userScrolling = true; + } else if (notification is ScrollEndNotification) { + _userScrolling = false; + _updateWebtoonPageFromVisibility(updateAnchor: true); + } else if (notification is UserScrollNotification) { + // Keep flag in sync with user-driven scroll activity. + _userScrolling = notification.direction != ScrollDirection.idle; + if (!_userScrolling) _captureWebtoonAnchor(); + } else if (notification is ScrollMetricsNotification && !_userScrolling) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_userScrolling) _reanchorWebtoonIfNeeded(); + }); + } + return false; + }, + child: ListView.builder( + controller: _scrollController, + padding: EdgeInsets.zero, + cacheExtent: 2000, + itemCount: _pages.isEmpty ? 0 : _pages.length + 1, + itemBuilder: (context, i) { + if (i >= _pages.length) { + return _endOfChapterPanel(); + } + return KeyedSubtree( + key: _pageKeys[i], + child: Align( + alignment: Alignment.topCenter, + child: _pageImage( + _pages[i], + offline: _offline, + pageWidth: pageWidth, + ), + ), + ); + }, + ), + ); + + final wrapped = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggleUi, + child: SizedBox.expand( + key: _webtoonListKey, + child: list, + ), + ); + + if (_zoomScale <= 1.0) { + return wrapped; + } + + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + controller: _hScrollController, + scrollDirection: Axis.horizontal, + child: SizedBox( + width: pageWidth, + height: constraints.maxHeight, + child: wrapped, + ), + ); + }, + ); + } + + Widget _paged() { + final pageWidth = _pageWidth(context); + final rtl = _direction == ReadingDirection.rtl; + return Stack( + children: [ + PageView.builder( + controller: _pageController, + reverse: rtl, + itemCount: _pages.isEmpty ? 0 : _pages.length + 1, + onPageChanged: (i) { + if (i >= _pages.length) { + setState(() { + _pageIndex = _pages.length - 1; + _viewingEndPanel = true; + }); + } else { + setState(() { + _pageIndex = i; + _viewingEndPanel = false; + }); + } + _scheduleSave(); + }, + itemBuilder: (context, i) { + if (i >= _pages.length) { + return _endOfChapterPanel(); + } + return InteractiveViewer( + minScale: 1, + maxScale: 4, + child: Center( + child: _pageImage( + _pages[i], + offline: _offline, + pageWidth: pageWidth, + ), + ), + ); + }, + ), + // Edge tap zones (screen %). Hidden on end panel so buttons stay tappable. + if (!_viewingEndPanel) + Positioned.fill( + child: Row( + children: [ + Expanded( + flex: 35, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: rtl ? _readingNextPage : _readingPrevPage, + ), + ), + Expanded( + flex: 30, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _toggleUi, + ), + ), + Expanded( + flex: 35, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: rtl ? _readingPrevPage : _readingNextPage, + ), + ), + ], + ), + ), + ], + ); + } + + List _zoomControls(BuildContext context) { + final atMin = _zoomScale <= ReaderZoom.min + 0.001; + final atMax = _zoomScale >= ReaderZoom.max - 0.001; + return [ + IconButton( + tooltip: 'Zoom out', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + onPressed: atMin ? null : _zoomOut, + icon: Icon( + Icons.remove, + color: atMin ? Colors.white38 : Colors.white, + ), + ), + Text( + ReaderZoom.percentLabel(_zoomScale), + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + IconButton( + tooltip: 'Zoom in', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + onPressed: atMax ? null : _zoomIn, + icon: Icon( + Icons.add, + color: atMax ? Colors.white38 : Colors.white, + ), + ), + ]; + } + + @override + Widget build(BuildContext context) { + final direction = _direction; + final rtl = direction == ReadingDirection.rtl; + final completed = + _pages.isNotEmpty && _pageIndex >= _pages.length - 1; + final pageLabel = _pages.isEmpty + ? '0/0' + : '${_pageIndex + 1}/${_pages.length}'; + + // Chevron actions follow reading direction (match edge taps). + final onChevronLeft = rtl ? _readingNextPage : _readingPrevPage; + final onChevronRight = rtl ? _readingPrevPage : _readingNextPage; + final canChevronLeft = rtl + ? !_viewingEndPanel && _pages.isNotEmpty + : _viewingEndPanel || _pageIndex > 0; + final canChevronRight = rtl + ? _viewingEndPanel || _pageIndex > 0 + : !_viewingEndPanel && _pages.isNotEmpty; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final nav = Navigator.of(context); + await _flushProgress(); + if (!mounted) return; + // Force pop after flush; canPop is false so system back lands here. + nav.pop(result); + }, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + if (_loading) + const Center(child: CircularProgressIndicator()) + else if (_error != null) + Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(_error!, style: const TextStyle(color: Colors.white)), + ), + ) + else + _mode == ReaderMode.webtoon ? _webtoon() : _paged(), + if (_uiVisible) + SafeArea( + child: Column( + children: [ + Material( + color: Colors.black54, + child: ListTile( + textColor: Colors.white, + iconColor: Colors.white, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: Text( + _chapter?.label ?? 'Reader', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${_title?.title ?? ''} · ${_offline ? 'Offline' : 'Online'} · ' + '$pageLabel' + '${completed || _viewingEndPanel ? ' · Done' : ''}' + '${_viewingEndPanel ? ' · End' : ''}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ..._zoomControls(context), + IconButton( + tooltip: (_title?.inLibrary ?? false) + ? 'Remove from Library' + : 'Add to Library', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + onPressed: _title == null + ? null + : () async { + final t = _title!; + final next = !t.inLibrary; + await ref + .read(databaseProvider) + .setInLibrary(t.id, next); + ref.read(webDavSyncProvider).scheduleSync(); + final refreshed = await ref + .read(databaseProvider) + .getTitle(t.id); + if (!mounted) return; + setState(() => _title = refreshed); + }, + icon: Icon( + (_title?.inLibrary ?? false) + ? Icons.star + : Icons.star_border, + color: Colors.white, + ), + ), + IconButton( + tooltip: 'Vertical (webtoon)', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + onPressed: () => _setMode(ReaderMode.webtoon), + icon: Icon( + Icons.view_day, + color: _mode == ReaderMode.webtoon + ? Theme.of(context).colorScheme.primary + : Colors.white, + ), + ), + IconButton( + tooltip: 'Horizontal (paged)', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + onPressed: () => _setMode(ReaderMode.paged), + icon: Icon( + Icons.auto_stories, + color: _mode == ReaderMode.paged + ? Theme.of(context).colorScheme.primary + : Colors.white, + ), + ), + IconButton( + tooltip: 'Left → Right', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + onPressed: () => + _setDirection(ReadingDirection.ltr), + icon: Icon( + Icons.format_textdirection_l_to_r, + color: _direction == ReadingDirection.ltr + ? Theme.of(context).colorScheme.primary + : Colors.white, + ), + ), + IconButton( + tooltip: 'Right → Left', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + onPressed: () => + _setDirection(ReadingDirection.rtl), + icon: Icon( + Icons.format_textdirection_r_to_l, + color: _direction == ReadingDirection.rtl + ? Theme.of(context).colorScheme.primary + : Colors.white, + ), + ), + ], + ), + ), + ), + const Spacer(), + Material( + color: Colors.black54, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + color: Colors.white, + onPressed: () => _goChapter(-1), + icon: const Icon(Icons.skip_previous), + tooltip: 'Previous chapter', + ), + if (_mode == ReaderMode.paged) ...[ + IconButton( + color: Colors.white, + onPressed: canChevronLeft ? onChevronLeft : null, + icon: const Icon(Icons.chevron_left), + tooltip: rtl ? 'Next page' : 'Previous page', + ), + IconButton( + color: Colors.white, + onPressed: canChevronRight ? onChevronRight : null, + icon: const Icon(Icons.chevron_right), + tooltip: rtl ? 'Previous page' : 'Next page', + ), + ], + IconButton( + color: Colors.white, + onPressed: _isLastChapter + ? _revealEndPanel + : () => _goChapter(1), + icon: const Icon(Icons.skip_next), + tooltip: 'Next chapter', + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/cxyz-android/lib/ui/settings_page.dart b/cxyz-android/lib/ui/settings_page.dart new file mode 100644 index 0000000..8bdfc69 --- /dev/null +++ b/cxyz-android/lib/ui/settings_page.dart @@ -0,0 +1,397 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/providers.dart'; +import '../services/ntfy_settings.dart'; +import '../services/reader_direction.dart'; +import '../services/webdav_settings.dart'; + +class SettingsPage extends ConsumerStatefulWidget { + const SettingsPage({super.key}); + + @override + ConsumerState createState() => _SettingsPageState(); +} + +class _SettingsPageState extends ConsumerState { + final _urlCtrl = TextEditingController(); + final _userCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + final _pathCtrl = TextEditingController(); + + final _ntfyCompletedCtrl = TextEditingController(); + final _ntfyFailedCtrl = TextEditingController(); + final _ntfyDrainedCtrl = TextEditingController(); + final _ntfyTokenCtrl = TextEditingController(); + + bool _enabled = false; + bool _ntfyEnabled = false; + NtfyAttachmentMode _ntfyAttachment = NtfyAttachmentMode.none; + bool _loading = true; + bool _busy = false; + String? _status; + String? _error; + DateTime? _lastSync; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _urlCtrl.dispose(); + _userCtrl.dispose(); + _passCtrl.dispose(); + _pathCtrl.dispose(); + _ntfyCompletedCtrl.dispose(); + _ntfyFailedCtrl.dispose(); + _ntfyDrainedCtrl.dispose(); + _ntfyTokenCtrl.dispose(); + super.dispose(); + } + + Future _load() async { + final s = await WebDavSettings.load(); + final n = await NtfySettings.load(); + if (!mounted) return; + setState(() { + _enabled = s.enabled; + _urlCtrl.text = s.url; + _userCtrl.text = s.username; + _passCtrl.text = s.password; + _pathCtrl.text = s.path; + _status = s.lastStatus; + _error = s.lastError; + _lastSync = s.lastSyncAt; + _ntfyEnabled = n.enabled; + _ntfyCompletedCtrl.text = n.completedUrl; + _ntfyFailedCtrl.text = n.failedUrl; + _ntfyDrainedCtrl.text = n.drainedUrl; + _ntfyTokenCtrl.text = n.authToken; + _ntfyAttachment = n.attachmentMode; + _loading = false; + }); + } + + Future _save() async { + await WebDavSettings.save( + enabled: _enabled, + url: _urlCtrl.text, + username: _userCtrl.text, + password: _passCtrl.text, + path: _pathCtrl.text, + ); + await NtfySettings.save( + enabled: _ntfyEnabled, + completedUrl: _ntfyCompletedCtrl.text, + failedUrl: _ntfyFailedCtrl.text, + drainedUrl: _ntfyDrainedCtrl.text, + attachmentMode: _ntfyAttachment, + authToken: _ntfyTokenCtrl.text, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Settings saved')), + ); + } + + Future _test() async { + setState(() => _busy = true); + try { + await _save(); + final msg = await ref.read(webDavSyncProvider).testConnection(); + if (!mounted) return; + setState(() { + _status = msg; + _error = null; + }); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } catch (e) { + if (!mounted) return; + setState(() { + _status = 'Test failed'; + _error = e.toString(); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Test failed: $e')), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _syncNow() async { + setState(() => _busy = true); + try { + await _save(); + await ref.read(webDavSyncProvider).sync(); + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sync finished')), + ); + } catch (e) { + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Sync failed: $e')), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _testNtfy(String kind, TextEditingController ctrl) async { + setState(() => _busy = true); + try { + await _save(); + await ref.read(ntfyNotifierProvider).testTopic(ctrl.text, kind: kind); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Test notification sent')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('ntfy test failed: $e')), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Widget _ntfyUrlRow({ + required String label, + required TextEditingController controller, + required String kind, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: controller, + decoration: InputDecoration( + labelText: label, + hintText: 'https://ntfy.sh/mytopic', + ), + keyboardType: TextInputType.url, + autocorrect: false, + enabled: _ntfyEnabled, + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: !_ntfyEnabled || _busy + ? null + : () => _testNtfy(kind, controller), + child: const Text('Test'), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final direction = ref.watch(readingDirectionProvider); + if (_loading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + actions: [ + TextButton( + onPressed: _busy ? null : _save, + child: const Text('Save'), + ), + ], + ), + body: SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('Reader', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text( + 'Fallback page direction when a title has no saved preference ' + '(ComicLand). MangaRead / MangaTown default to Right → Left; ' + 'overrides are saved per title and sync via WebDAV.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: ReadingDirection.ltr, + label: Text('Left → Right'), + icon: Icon(Icons.format_textdirection_l_to_r), + ), + ButtonSegment( + value: ReadingDirection.rtl, + label: Text('Right → Left'), + icon: Icon(Icons.format_textdirection_r_to_l), + ), + ], + selected: {direction}, + onSelectionChanged: (s) { + ref.read(readingDirectionProvider.notifier).setDirection(s.first); + }, + ), + const SizedBox(height: 24), + Text('WebDAV sync', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text( + 'Syncs reading progress, reader mode/direction, Library stars, ' + 'and History across devices.', + style: Theme.of(context).textTheme.bodySmall, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enable sync'), + value: _enabled, + onChanged: (v) => setState(() => _enabled = v), + ), + TextField( + controller: _urlCtrl, + decoration: const InputDecoration( + labelText: 'WebDAV URL', + hintText: 'https://dav.example.com/remote.php/dav/files/user', + ), + keyboardType: TextInputType.url, + autocorrect: false, + ), + TextField( + controller: _userCtrl, + decoration: const InputDecoration(labelText: 'Username'), + autocorrect: false, + ), + TextField( + controller: _passCtrl, + decoration: const InputDecoration(labelText: 'Password'), + obscureText: true, + autocorrect: false, + ), + TextField( + controller: _pathCtrl, + decoration: const InputDecoration( + labelText: 'Remote file path', + hintText: WebDavSettings.defaultPath, + ), + autocorrect: false, + ), + const SizedBox(height: 12), + if (_status != null) + Text('Status: $_status', style: Theme.of(context).textTheme.bodySmall), + if (_lastSync != null) + Text( + 'Last sync: ${_lastSync!.toLocal()}', + style: Theme.of(context).textTheme.bodySmall, + ), + if (_error != null) + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 12), + Row( + children: [ + FilledButton( + onPressed: _busy ? null : _test, + child: const Text('Test connection'), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: _busy || !_enabled ? null : _syncNow, + child: const Text('Sync now'), + ), + ], + ), + const SizedBox(height: 32), + Text( + 'Notifications (ntfy)', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + 'Send download events to ntfy topics. Leave a URL empty to disable that event.', + style: Theme.of(context).textTheme.bodySmall, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enable notifications'), + value: _ntfyEnabled, + onChanged: (v) => setState(() => _ntfyEnabled = v), + ), + _ntfyUrlRow( + label: 'Chapter completed URL', + controller: _ntfyCompletedCtrl, + kind: 'completed', + ), + _ntfyUrlRow( + label: 'Job failed URL', + controller: _ntfyFailedCtrl, + kind: 'failed', + ), + _ntfyUrlRow( + label: 'Queue drained URL', + controller: _ntfyDrainedCtrl, + kind: 'drained', + ), + const SizedBox(height: 8), + Text( + 'Attachment on complete', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + DropdownMenu( + initialSelection: _ntfyAttachment, + enabled: _ntfyEnabled, + width: double.infinity, + dropdownMenuEntries: [ + for (final m in NtfyAttachmentMode.values) + DropdownMenuEntry(value: m, label: m.label), + ], + onSelected: !_ntfyEnabled + ? null + : (v) { + if (v != null) setState(() => _ntfyAttachment = v); + }, + ), + const SizedBox(height: 4), + Text( + 'Cover uses the series cover URL. First page is resized to ≤720px / ≤250 KB. CBZ skips if over 15 MB.', + style: Theme.of(context).textTheme.bodySmall, + ), + TextField( + controller: _ntfyTokenCtrl, + decoration: const InputDecoration( + labelText: 'Auth token (optional)', + hintText: 'Bearer token for private topics', + ), + obscureText: true, + autocorrect: false, + enabled: _ntfyEnabled, + ), + if (_busy) ...[ + const SizedBox(height: 16), + const LinearProgressIndicator(), + ], + const SizedBox(height: 32), + Text('About', style: Theme.of(context).textTheme.titleMedium), + const ListTile( + contentPadding: EdgeInsets.zero, + title: Text('Version'), + subtitle: Text('1.0'), + ), + ], + ), + ), + ); + } +} diff --git a/cxyz-android/lib/ui/shell.dart b/cxyz-android/lib/ui/shell.dart new file mode 100644 index 0000000..ff0463a --- /dev/null +++ b/cxyz-android/lib/ui/shell.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class AppShell extends StatelessWidget { + const AppShell({super.key, required this.navigationShell}); + + final StatefulNavigationShell navigationShell; + + void _onTap(int index) { + navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: navigationShell, + bottomNavigationBar: NavigationBar( + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: _onTap, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.explore_outlined), + selectedIcon: Icon(Icons.explore), + label: 'Catalog', + ), + NavigationDestination( + icon: Icon(Icons.collections_bookmark_outlined), + selectedIcon: Icon(Icons.collections_bookmark), + label: 'Library', + ), + NavigationDestination( + icon: Icon(Icons.history_outlined), + selectedIcon: Icon(Icons.history), + label: 'History', + ), + NavigationDestination( + icon: Icon(Icons.download_outlined), + selectedIcon: Icon(Icons.download), + label: 'Downloads', + ), + ], + ), + ); + } +} diff --git a/cxyz-android/lib/ui/title_page.dart b/cxyz-android/lib/ui/title_page.dart new file mode 100644 index 0000000..698e346 --- /dev/null +++ b/cxyz-android/lib/ui/title_page.dart @@ -0,0 +1,451 @@ +import 'package:flutter/material.dart' hide Title; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../data/database.dart'; +import '../providers/providers.dart'; +import '../sources/source.dart'; +import 'widgets/comic_cover.dart'; +import 'widgets/source_logo.dart'; + +class TitlePage extends ConsumerStatefulWidget { + const TitlePage({super.key, required this.titleId}); + + final int titleId; + + @override + ConsumerState createState() => _TitlePageState(); +} + +class _TitlePageState extends ConsumerState { + Title? _title; + List _chapters = const []; + final Set _selected = {}; + bool _newestFirst = true; + bool _loading = true; + bool _syncing = false; + String? _error; + + @override + void initState() { + super.initState(); + _reload(); + } + + List _sortedReadingOrder(List chapters) { + final copy = [...chapters]; + copy.sort((a, b) { + final na = double.tryParse(a.chapterNumber ?? '') ?? 0; + final nb = double.tryParse(b.chapterNumber ?? '') ?? 0; + return na.compareTo(nb); + }); + return copy; + } + + Future _reload({bool showLoading = true}) async { + if (showLoading) { + setState(() { + _loading = true; + _error = null; + }); + } + final db = ref.read(databaseProvider); + final title = await db.getTitle(widget.titleId); + final chapters = + await db.listChapters(widget.titleId, newestFirst: _newestFirst); + chapters.sort((a, b) { + final na = double.tryParse(a.chapterNumber ?? '') ?? 0; + final nb = double.tryParse(b.chapterNumber ?? '') ?? 0; + return _newestFirst ? nb.compareTo(na) : na.compareTo(nb); + }); + if (!mounted) return; + setState(() { + _title = title; + _chapters = chapters; + _loading = false; + }); + } + + Future _sync() async { + final title = _title; + if (title == null) return; + setState(() => _syncing = true); + try { + final source = ref.read(sourceRegistryProvider).get( + SourceId.values.byName(title.source), + ); + final series = await source.fetchSeries(title.url); + await ref.read(downloadQueueProvider).upsertSeries(series); + await _reload(); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _syncing = false); + } + } + + Future _downloadSelected() async { + if (_selected.isEmpty) return; + await ref.read(downloadQueueProvider).enqueueChapters(_selected); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Queued ${_selected.length} chapter(s)')), + ); + setState(() => _selected.clear()); + await _reload(); + } + + Future _downloadAll() async { + if (_chapters.isEmpty) return; + var unreadOnly = true; + final confirmed = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setLocal) { + final ids = _chapters.where((c) { + if (c.status == 'downloaded') return false; + if (unreadOnly && c.completed) return false; + return true; + }).map((c) => c.id).toList(); + return AlertDialog( + title: const Text('Download all'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ids.isEmpty + ? 'No chapters match the current filter.' + : 'Queue ${ids.length} chapter(s) for download?', + ), + const SizedBox(height: 12), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Download unread only'), + subtitle: const Text( + 'Skip chapters already marked completed', + ), + value: unreadOnly, + onChanged: (v) => setLocal(() => unreadOnly = v ?? true), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: ids.isEmpty + ? null + : () => Navigator.pop(ctx, true), + child: const Text('Download'), + ), + ], + ); + }, + ); + }, + ); + if (confirmed != true || !mounted) return; + final ids = _chapters.where((c) { + if (c.status == 'downloaded') return false; + if (unreadOnly && c.completed) return false; + return true; + }).map((c) => c.id); + await ref.read(downloadQueueProvider).enqueueChapters(ids); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Queued ${ids.length} chapter(s)')), + ); + await _reload(); + } + + bool get _hasReadingHistory { + final title = _title; + if (title?.lastReadChapterId != null) return true; + return _chapters.any((c) => c.lastReadAt != null || c.lastPage > 0 || c.completed); + } + + Chapter? get _continueChapter { + final title = _title; + if (title?.lastReadChapterId != null) { + final match = + _chapters.where((c) => c.id == title!.lastReadChapterId).firstOrNull; + if (match != null) return match; + } + Chapter? best; + for (final c in _chapters) { + if (c.lastReadAt == null && c.lastPage == 0 && !c.completed) continue; + if (best == null) { + best = c; + continue; + } + final a = c.lastReadAt; + final b = best.lastReadAt; + if (a != null && (b == null || a.isAfter(b))) best = c; + } + return best; + } + + Chapter? get _firstChapter { + final ordered = _sortedReadingOrder(_chapters); + return ordered.isEmpty ? null : ordered.first; + } + + Future _toggleInLibrary() async { + final title = _title; + if (title == null) return; + final next = !title.inLibrary; + await ref.read(databaseProvider).setInLibrary(title.id, next); + ref.read(webDavSyncProvider).scheduleSync(); + await _reload(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(next ? 'Added to Library' : 'Removed from Library'), + ), + ); + } + + void _openChapter(Chapter chapter) { + context.push('/read/${widget.titleId}/${chapter.id}').then((_) async { + if (!mounted) return; + await _reload(showLoading: false); + }); + } + + void _onReadOrContinue() { + if (_hasReadingHistory) { + final ch = _continueChapter ?? _firstChapter; + if (ch != null) _openChapter(ch); + } else { + final ch = _firstChapter; + if (ch != null) _openChapter(ch); + } + } + + Color? _statusColor(BuildContext context, Chapter ch) { + final scheme = Theme.of(context).colorScheme; + if (ch.completed) return scheme.primary; + if (ch.status == 'downloaded') return scheme.tertiary; + if (ch.status == 'downloading') return scheme.tertiary; + if (ch.status == 'failed') return scheme.error; + return null; + } + + String _chapterSubtitle(Chapter ch) { + final parts = []; + if (ch.completed) { + parts.add('Completed'); + } else if (ch.pageCount > 0 && (ch.lastPage > 0 || ch.lastReadAt != null)) { + parts.add('Page ${ch.lastPage + 1}/${ch.pageCount}'); + } else if (ch.lastReadAt != null || ch.lastPage > 0) { + parts.add('Reading'); + } + if (ch.status == 'downloaded') { + parts.add('Downloaded'); + } else if (ch.status == 'downloading') { + parts.add('Downloading'); + } else if (ch.status == 'failed') { + parts.add('Failed'); + } + return parts.isEmpty ? 'Unread' : parts.join(' · '); + } + + @override + Widget build(BuildContext context) { + final title = _title; + final canRead = _chapters.isNotEmpty; + final continueLabel = _hasReadingHistory ? 'Continue' : 'Read first chapter'; + final continueChapter = + _hasReadingHistory ? (_continueChapter ?? _firstChapter) : _firstChapter; + + return Scaffold( + appBar: AppBar( + title: Text(title?.title ?? 'Title'), + actions: [ + if (title != null) + IconButton( + tooltip: title.inLibrary ? 'Remove from Library' : 'Add to Library', + onPressed: _toggleInLibrary, + icon: Icon(title.inLibrary ? Icons.star : Icons.star_border), + ), + IconButton( + tooltip: 'Download all', + onPressed: _chapters.isEmpty ? null : _downloadAll, + icon: const Icon(Icons.download_for_offline_outlined), + ), + IconButton( + tooltip: _newestFirst ? 'Oldest first' : 'Newest first', + onPressed: () async { + setState(() => _newestFirst = !_newestFirst); + await _reload(); + }, + icon: Icon(_newestFirst ? Icons.arrow_downward : Icons.arrow_upward), + ), + IconButton( + tooltip: 'Sync chapters', + onPressed: _syncing ? null : _sync, + icon: _syncing + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.sync), + ), + ], + ), + floatingActionButton: _selected.isEmpty + ? null + : FloatingActionButton.extended( + onPressed: _downloadSelected, + icon: const Icon(Icons.download), + label: Text('Download ${_selected.length}'), + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : title == null + ? const Center(child: Text('Title not found')) + : Column( + children: [ + if (_error != null) + MaterialBanner( + content: Text(_error!), + actions: [ + TextButton( + onPressed: () => setState(() => _error = null), + child: const Text('Dismiss'), + ), + ], + ), + Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 96, + height: 128, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ComicCover( + url: title.coverUrl, + sourceId: SourceId.tryParse(title.source), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Row( + children: [ + if (SourceId.tryParse(title.source) + case final sourceId?) + SourceLogo( + sourceId: sourceId, + size: 16, + borderRadius: 4, + ), + if (SourceId.tryParse(title.source) != null) + const SizedBox(width: 6), + Expanded( + child: Text( + '${SourceId.tryParse(title.source)?.label ?? title.source} · ${_chapters.length} chapters', + style: + Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + if (title.description != null) ...[ + const SizedBox(height: 8), + Text( + title.description!, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + if (canRead) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _onReadOrContinue, + icon: Icon( + _hasReadingHistory + ? Icons.play_arrow + : Icons.menu_book, + ), + label: Text( + continueChapter == null + ? continueLabel + : _hasReadingHistory + ? 'Continue · ${continueChapter.label}' + : continueLabel, + ), + ), + ], + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + itemCount: _chapters.length, + itemBuilder: (context, i) { + final ch = _chapters[i]; + final selected = _selected.contains(ch.id); + return ListTile( + leading: Checkbox( + value: selected, + onChanged: (v) { + setState(() { + if (v == true) { + _selected.add(ch.id); + } else { + _selected.remove(ch.id); + } + }); + }, + ), + title: Text(ch.label), + subtitle: Text(_chapterSubtitle(ch)), + trailing: Icon( + ch.completed + ? Icons.check_circle + : ch.status == 'downloaded' + ? Icons.offline_pin + : Icons.chevron_right, + color: _statusColor(context, ch), + ), + onTap: () => _openChapter(ch), + onLongPress: () { + setState(() { + if (selected) { + _selected.remove(ch.id); + } else { + _selected.add(ch.id); + } + }); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/cxyz-android/lib/ui/widgets/comic_cover.dart b/cxyz-android/lib/ui/widgets/comic_cover.dart new file mode 100644 index 0000000..f76aa63 --- /dev/null +++ b/cxyz-android/lib/ui/widgets/comic_cover.dart @@ -0,0 +1,178 @@ +import 'dart:ui'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/providers.dart'; +import '../../sources/source.dart'; + +class ComicCover extends ConsumerStatefulWidget { + const ComicCover({ + super.key, + required this.url, + this.sourceId, + this.width, + this.height, + this.fit = BoxFit.cover, + }); + + final String? url; + final SourceId? sourceId; + final double? width; + final double? height; + final BoxFit fit; + + @override + ConsumerState createState() => _ComicCoverState(); +} + +class _ComicCoverState extends ConsumerState { + bool _revealed = false; + + void _setRevealed(bool value) { + if (_revealed == value) return; + setState(() => _revealed = value); + } + + @override + Widget build(BuildContext context) { + final peek = ref.watch(peekModeProvider); + final cache = ref.watch(imageCacheManagerProvider); + Map headers = const {}; + final sid = widget.sourceId; + if (sid != null) { + final registry = ref.watch(sourceRegistryProvider); + if (registry.isEnabled(sid)) { + headers = registry.get(sid).imageHeaders; + } + } + if (widget.url == null || widget.url!.isEmpty) { + return Container( + width: widget.width, + height: widget.height, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.menu_book_outlined), + ); + } + + final image = CachedNetworkImage( + imageUrl: widget.url!, + cacheManager: cache, + httpHeaders: headers, + width: widget.width, + height: widget.height, + fit: widget.fit, + placeholder: (context, url) => Container( + width: widget.width, + height: widget.height, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + width: widget.width, + height: widget.height, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.broken_image_outlined), + ), + ); + + if (!peek) return image; + + if (_revealed) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPressEnd: (_) => _setRevealed(false), + onLongPressCancel: () => _setRevealed(false), + child: image, + ); + } + + // Heavy blur + dark veil so covers are unreadable in public. + return GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPressStart: (_) => _setRevealed(true), + child: SizedBox( + width: widget.width, + height: widget.height, + child: Stack( + fit: StackFit.expand, + children: [ + ClipRect( + child: ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: 36, + sigmaY: 36, + tileMode: TileMode.clamp, + ), + child: image, + ), + ), + ColoredBox( + color: Colors.black.withValues(alpha: 0.45), + child: const Center( + child: Icon( + Icons.visibility_off, + color: Colors.white54, + size: 28, + ), + ), + ), + ], + ), + ), + ); + } +} + +class PeekModeToggleButton extends ConsumerWidget { + const PeekModeToggleButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final peek = ref.watch(peekModeProvider); + return IconButton( + tooltip: peek ? 'Peek mode on (covers hidden)' : 'Peek mode off', + onPressed: () => ref.read(peekModeProvider.notifier).toggle(), + icon: Icon(peek ? Icons.visibility_off : Icons.visibility), + ); + } +} + +class ComicGridTile extends StatelessWidget { + const ComicGridTile({ + super.key, + required this.comic, + required this.onTap, + this.titleMaxLines = 2, + }); + + final CatalogComic comic; + final VoidCallback onTap; + final int titleMaxLines; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ComicCover(url: comic.coverUrl, sourceId: comic.source), + ), + ), + const SizedBox(height: 6), + Text( + comic.title, + maxLines: titleMaxLines, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ); + } +} diff --git a/cxyz-android/lib/ui/widgets/source_logo.dart b/cxyz-android/lib/ui/widgets/source_logo.dart new file mode 100644 index 0000000..bef5f04 --- /dev/null +++ b/cxyz-android/lib/ui/widgets/source_logo.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../../sources/source.dart'; + +/// Bundled source favicon from [SourceId.logoAsset]. +class SourceLogo extends StatelessWidget { + const SourceLogo({ + super.key, + required this.sourceId, + this.size = 32, + this.borderRadius = 6, + }); + + final SourceId sourceId; + final double size; + final double borderRadius; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: Image.asset( + sourceId.logoAsset, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.public, + size: size * 0.75, + ), + ), + ); + } +} diff --git a/cxyz-android/pubspec.lock b/cxyz-android/pubspec.lock new file mode 100644 index 0000000..8bbbed8 --- /dev/null +++ b/cxyz-android/pubspec.lock @@ -0,0 +1,1090 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "1b0e6a07425a3e460666e88bf1c949ccc7bb0116ad562ce94a1eca60fe820725" + url: "https://pub.dev" + source: hosted + version: "103.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "61c04d0c1bfed555c681ea079519933f071a5a026578ff73c4ff0df2d3462e5e" + url: "https://pub.dev" + source: hosted + version: "13.3.0" + archive: + dependency: "direct main" + description: + name: archive + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 + url: "https://pub.dev" + source: hosted + version: "4.2.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: b94f5da9ed3d081fd4ecc426c260998b5f4f4eb2f6d89b7e5472edef0b2b2a1b + url: "https://pub.dev" + source: hosted + version: "4.0.10" + build_config: + dependency: transitive + description: + name: build_config + sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8" + url: "https://pub.dev" + source: hosted + version: "1.3.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6" + url: "https://pub.dev" + source: hosted + version: "4.1.5" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "90363d438bd9f84a4d5bbb83352251f57d2d9d771bc95a44e6a33fe25fa52774" + url: "https://pub.dev" + source: hosted + version: "2.16.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" + url: "https://pub.dev" + source: hosted + version: "8.12.7" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: "71e43f1976c3eae2d9468a5b4d6600bf8cae61508b87f27fa1799228935d48ab" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "3f88fc9c96c568d631356507355a2d1e983ee000c9ac008bdcb39b5bf53ce777" + url: "https://pub.dev" + source: hosted + version: "3.1.12" + dio: + dependency: "direct main" + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.dev" + source: hosted + version: "5.11.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + drift: + dependency: "direct main" + description: + name: drift + sha256: "3a3f1f6f905037d7426e4c445854139fd6a3d592135f7c96d7931682b73d16f4" + url: "https://pub.dev" + source: hosted + version: "2.34.3" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "735aad3c34215805c66bd518c8812fa4f83b5534b2c13c4092c970c04a7e9983" + url: "https://pub.dev" + source: hosted + version: "2.34.5" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: "direct main" + description: + name: flutter_cache_manager + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" + url: "https://pub.dev" + source: hosted + version: "3.4.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "15e8c8fe269fdf7d469b23008ab3df521c8b826ed345820532364c31bdebace6" + url: "https://pub.dev" + source: hosted + version: "11.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: ac6d76a752de0cd738334eb4b21743fc4943f449f5b6e308f18838b048c02ac0 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea + url: "https://pub.dev" + source: hosted + version: "17.5.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + html: + dependency: "direct main" + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" + url: "https://pub.dev" + source: hosted + version: "4.9.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.dev" + source: hosted + version: "0.19.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 + url: "https://pub.dev" + source: hosted + version: "12.0.3" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 + url: "https://pub.dev" + source: hosted + version: "9.6.1" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23 + url: "https://pub.dev" + source: hosted + version: "4.4.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" + source: hosted + version: "0.2.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.dev" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 + url: "https://pub.dev" + source: hosted + version: "4.2.4" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c" + url: "https://pub.dev" + source: hosted + version: "3.5.2" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454" + url: "https://pub.dev" + source: hosted + version: "0.6.0+eol" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "772bb2f6f5bce0631a60f26b57d6e8882e1105c22345b05c163ee6ee5d7ba32d" + url: "https://pub.dev" + source: hosted + version: "0.45.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" + source: hosted + version: "3.4.1+2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.13.1 <4.0.0" + flutter: ">=3.44.0" diff --git a/cxyz-android/pubspec.yaml b/cxyz-android/pubspec.yaml new file mode 100644 index 0000000..8c9a997 --- /dev/null +++ b/cxyz-android/pubspec.yaml @@ -0,0 +1,104 @@ +name: cxyz_android +description: On-device ComicLand manga/comic reader and downloader (cxyz Android). +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.1+1 + +environment: + sdk: ^3.13.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + flutter_riverpod: ^2.6.1 + go_router: ^17.5.0 + dio: ^5.11.0 + path_provider: ^2.1.6 + path: ^1.9.1 + archive: ^4.2.0 + cached_network_image: ^3.4.1 + flutter_cache_manager: ^3.4.2 + drift: ^2.34.3 + sqlite3_flutter_libs: ^0.6.0+eol + shared_preferences: ^2.5.5 + html: ^0.15.6 + permission_handler: ^12.0.1 + flutter_secure_storage: ^11.0.0 + image: ^4.9.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + drift_dev: ^2.34.5 + build_runner: ^2.16.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - assets/sources/ + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/cxyz-android/scripts/build_apk.sh b/cxyz-android/scripts/build_apk.sh new file mode 100755 index 0000000..1d00890 --- /dev/null +++ b/cxyz-android/scripts/build_apk.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Build cxyz Android APK and copy to a SOURCES-tagged filename. +# +# Usage: +# ./scripts/build_apk.sh +# ./scripts/build_apk.sh mangaread +# ./scripts/build_apk.sh all +# ./scripts/build_apk.sh comicland,mangaread,mangatown +# ./scripts/build_apk.sh mangaread --debug +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +SOURCES="${1:-comicland}" +MODE="release" +if [[ "${2:-}" == "--debug" ]] || [[ "${1:-}" == "--debug" ]]; then + MODE="debug" + if [[ "${1:-}" == "--debug" ]]; then + SOURCES="comicland" + fi +fi + +# Normalize tag for filename: comicland,mangaread → comicland-mangaread +SOURCES_TAG="$(echo "$SOURCES" | tr '[:upper:]' '[:lower:]' | tr ',' '-' | tr -cd 'a-z0-9-')" +if [[ -z "$SOURCES_TAG" ]]; then + SOURCES_TAG="comicland" + SOURCES="comicland" +fi + +REPO_ROOT="$(cd "$ROOT/.." && pwd)" +export PATH="${REPO_ROOT}/.tools/bin:${REPO_ROOT}/.tools/flutter-sdk/bin:${PATH:-}" +export CI=true BOT=true + +echo "Building APK (SOURCES=${SOURCES}, mode=${MODE})…" + +BUILD_ARGS=(build apk --dart-define="SOURCES=${SOURCES}") +if [[ "$MODE" == "debug" ]]; then + BUILD_ARGS+=(--debug) +fi + +flutter "${BUILD_ARGS[@]}" + +SRC_APK="build/app/outputs/flutter-apk/app-${MODE}.apk" +OUT_NAME="cxyz-${SOURCES_TAG}-${MODE}.apk" +OUT_APK="build/app/outputs/flutter-apk/${OUT_NAME}" + +if [[ ! -f "$SRC_APK" ]]; then + echo "error: expected APK missing: $SRC_APK" >&2 + exit 1 +fi + +cp -f "$SRC_APK" "$OUT_APK" + +echo +echo "APK: ${ROOT}/${OUT_APK}" +echo " (also ${SRC_APK})" diff --git a/cxyz-android/test/comicland_urls_test.dart b/cxyz-android/test/comicland_urls_test.dart new file mode 100644 index 0000000..75d61a5 --- /dev/null +++ b/cxyz-android/test/comicland_urls_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:cxyz_android/sources/comicland/urls.dart'; + +void main() { + test('parseComicLandUrl extracts slug and chapter', () { + final parts = parseComicLandUrl( + 'https://comicland.org/comic/the-delinquent-girl/chapter/12', + ); + expect(parts.slug, 'the-delinquent-girl'); + expect(parts.chapterIndex, '12'); + }); + + test('transferLinkBasename pads chapter numbers', () { + expect(transferLinkBasename('12', 'Chapter 12', null), 'chapter 012.cbz'); + expect(transferLinkBasename('1.5', null, null), 'chapter 001.5.cbz'); + }); + + test('sanitizeComicDirName strips illegal chars', () { + expect(sanitizeComicDirName('A/B:C*'), 'ABC'); + }); +} diff --git a/cxyz-android/test/mangaread_catalog_test.dart b/cxyz-android/test/mangaread_catalog_test.dart new file mode 100644 index 0000000..e8ceee9 --- /dev/null +++ b/cxyz-android/test/mangaread_catalog_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:cxyz_android/sources/mangaread/mangaread_source.dart'; +import 'package:cxyz_android/sources/source.dart'; + +void main() { + test('latest pagination maps offset/limit to distinct site pages', () async { + final source = MangaReadSource(); + final page1 = await source.loadCatalog( + CatalogMenuKind.latest, + offset: 0, + limit: 30, + ); + final page2 = await source.loadCatalog( + CatalogMenuKind.latest, + offset: 30, + limit: 30, + ); + + expect(page1, isNotEmpty); + expect(page2, isNotEmpty); + expect(page1.first.slug, isNot(page2.first.slug)); + final overlap = page1.map((c) => c.slug).toSet().intersection( + page2.map((c) => c.slug).toSet(), + ); + expect(overlap, isEmpty); + }, timeout: const Timeout(Duration(seconds: 60))); +} diff --git a/cxyz-android/test/mangatown_catalog_test.dart b/cxyz-android/test/mangatown_catalog_test.dart new file mode 100644 index 0000000..086ffbd --- /dev/null +++ b/cxyz-android/test/mangatown_catalog_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:cxyz_android/sources/mangatown/mangatown_source.dart'; +import 'package:cxyz_android/sources/source.dart'; + +@Tags(['network']) +void main() { + test( + 'latest pagination: offset 0 and 30 yield different first slugs', + () async { + final source = MangaTownSource(); + final first = await source.loadCatalog( + CatalogMenuKind.latest, + offset: 0, + limit: 30, + ); + final second = await source.loadCatalog( + CatalogMenuKind.latest, + offset: 30, + limit: 30, + ); + expect(first, isNotEmpty); + expect(second, isNotEmpty); + expect(first.first.slug, isNot(second.first.slug)); + final firstSlugs = first.map((c) => c.slug).toSet(); + expect( + second.any((c) => firstSlugs.contains(c.slug)), + isFalse, + reason: 'page 2 should not overlap page 1', + ); + }, + timeout: const Timeout(Duration(seconds: 90)), + ); +} diff --git a/cxyz-android/test/mangatown_urls_test.dart b/cxyz-android/test/mangatown_urls_test.dart new file mode 100644 index 0000000..8d32219 --- /dev/null +++ b/cxyz-android/test/mangatown_urls_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:cxyz_android/sources/mangatown/urls.dart'; +import 'package:cxyz_android/sources/source.dart'; + +void main() { + group('mangatown catalog URLs', () { + test('latest page 1 and 2', () { + expect(catalogLatestUrl(), 'https://m.mangatown.com/latest'); + expect(catalogLatestUrl(page: 2), 'https://m.mangatown.com/latest/2.html'); + }); + + test('hot total and period queries with pagination', () { + expect(catalogHotUrl(), 'https://m.mangatown.com/hot'); + expect( + catalogHotUrl(page: 2), + 'https://m.mangatown.com/hot/2.html', + ); + expect( + catalogHotUrl(period: MangaTownHotPeriod.today), + 'https://m.mangatown.com/hot?tviews.za', + ); + expect( + catalogHotUrl(period: MangaTownHotPeriod.week, page: 2), + 'https://m.mangatown.com/hot/2.html?wviews.za', + ); + expect( + catalogHotUrl(period: MangaTownHotPeriod.month, page: 2), + 'https://m.mangatown.com/hot/2.html?mviews.za', + ); + }); + + test('directory default and filters', () { + expect( + directoryUrl(const CatalogBrowseParams()), + 'https://m.mangatown.com/directory/0-0-0-0-0-0', + ); + expect( + directoryUrl( + const CatalogBrowseParams( + demographic: 'seinen', + genre: 'action', + status: 'ongoing', + initial: 'a', + ), + page: 2, + ), + 'https://m.mangatown.com/directory/seinen-action-0-ongoing-a-0/2.html', + ); + }); + + test('search URL', () { + expect( + catalogSearchUrl(query: 'naruto'), + 'https://m.mangatown.com/search?name=naruto', + ); + expect( + catalogSearchUrl(query: 'naruto', page: 2), + 'https://m.mangatown.com/search/2.html?name=naruto', + ); + }); + }); + + group('mangatown series/chapter helpers', () { + test('mangaSlugFromUrl and listingUrlForSlug', () { + expect( + mangaSlugFromUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/'), + 'ao_no_souyoku', + ); + expect( + listingUrlForSlug('ao_no_souyoku'), + 'https://m.mangatown.com/manga/ao_no_souyoku', + ); + }); + + test('chapterBaseUrl and chapterPageUrl', () { + expect( + chapterBaseUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/3.html'), + 'https://m.mangatown.com/manga/ao_no_souyoku/c005/', + ); + expect( + chapterPageUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/', 1), + 'https://m.mangatown.com/manga/ao_no_souyoku/c005/', + ); + expect( + chapterPageUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/', 5), + 'https://m.mangatown.com/manga/ao_no_souyoku/c005/5.html', + ); + }); + + test('chapterKeyFromUrl and chapterNumberFromTitle', () { + expect( + chapterKeyFromUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/'), + 'c005', + ); + expect(chapterNumberFromTitle('C.5'), '5'); + expect(chapterNumberFromTitle('C.031'), '031'); + }); + + test('absoluteUrl upgrades protocol-relative', () { + expect( + absoluteUrl('//zjcdn.mangahere.org/a.jpg', 'https://m.mangatown.com/'), + 'https://zjcdn.mangahere.org/a.jpg', + ); + }); + + test('hotPeriodForMenu mapping', () { + expect(hotPeriodForMenu(CatalogMenuKind.popular), MangaTownHotPeriod.total); + expect(hotPeriodForMenu(CatalogMenuKind.hotToday), MangaTownHotPeriod.today); + expect(hotPeriodForMenu(CatalogMenuKind.hotWeek), MangaTownHotPeriod.week); + expect(hotPeriodForMenu(CatalogMenuKind.hotMonth), MangaTownHotPeriod.month); + expect(hotPeriodForMenu(CatalogMenuKind.latest), isNull); + }); + }); +} diff --git a/cxyz-android/test/ntfy_drain_test.dart b/cxyz-android/test/ntfy_drain_test.dart new file mode 100644 index 0000000..1df01a6 --- /dev/null +++ b/cxyz-android/test/ntfy_drain_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; + +/// Mirrors DownloadQueue session drain gating used in `_pump`. +bool shouldNotifyQueueDrained({ + required bool sessionHadWork, + required int queuedRemaining, +}) { + return sessionHadWork && queuedRemaining == 0; +} + +void main() { + group('queue drained gating', () { + test('does not fire when no work was processed', () { + expect( + shouldNotifyQueueDrained(sessionHadWork: false, queuedRemaining: 0), + isFalse, + ); + }); + + test('fires when work ran and queue is empty', () { + expect( + shouldNotifyQueueDrained(sessionHadWork: true, queuedRemaining: 0), + isTrue, + ); + }); + + test('does not fire while items remain', () { + expect( + shouldNotifyQueueDrained(sessionHadWork: true, queuedRemaining: 2), + isFalse, + ); + }); + }); +} diff --git a/cxyz-android/test/ntfy_notifier_test.dart b/cxyz-android/test/ntfy_notifier_test.dart new file mode 100644 index 0000000..c35e27d --- /dev/null +++ b/cxyz-android/test/ntfy_notifier_test.dart @@ -0,0 +1,115 @@ +import 'dart:typed_data'; + +import 'package:cxyz_android/services/ntfy_notifier.dart'; +import 'package:cxyz_android/services/ntfy_settings.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; + +void main() { + group('NtfyNotifier.formatBytes', () { + test('formats B KB MB', () { + expect(NtfyNotifier.formatBytes(500), '500 B'); + expect(NtfyNotifier.formatBytes(1536), '1.5 KB'); + expect(NtfyNotifier.formatBytes(5 * 1024 * 1024), '5.0 MB'); + }); + }); + + group('payload titles and body', () { + test('completed title is scannable', () { + expect( + NtfyNotifier.completedTitle('One Piece', 'Chapter 1100'), + 'Downloaded · One Piece · Chapter 1100', + ); + }); + + test('failed title includes series and chapter', () { + expect( + NtfyNotifier.failedTitle('One Piece', 'Chapter 1100'), + 'Download failed · One Piece · Chapter 1100', + ); + }); + + test('drained title includes counts', () { + expect( + NtfyNotifier.drainedTitle(ok: 5, failed: 1), + 'Queue finished · 5 downloaded · 1 failed', + ); + }); + + test('completed body includes sizes and savings', () { + final body = NtfyNotifier.completedBody( + imageCount: 36, + rawBytes: 10 * 1024 * 1024, + cbzBytes: 8 * 1024 * 1024, + ); + expect(body, contains('Pages: 36')); + expect(body, contains('Images:')); + expect(body, contains('CBZ:')); + expect(body, contains('saved 20%')); + }); + + test('completed body appends note', () { + final body = NtfyNotifier.completedBody( + imageCount: 1, + rawBytes: 100, + cbzBytes: 90, + note: 'Attachment skipped (15 MB limit)', + ); + expect(body, contains('Attachment skipped (15 MB limit)')); + }); + }); + + group('NtfyAttachmentMode', () { + test('fromStorage defaults to none', () { + expect(NtfyAttachmentMode.fromStorage(null), NtfyAttachmentMode.none); + expect(NtfyAttachmentMode.fromStorage('nope'), NtfyAttachmentMode.none); + expect( + NtfyAttachmentMode.fromStorage('firstImage'), + NtfyAttachmentMode.firstImage, + ); + }); + }); + + group('preview resize', () { + test('resizePreviewIsolate shrinks large image under 250 KB', () { + final big = img.Image(width: 2000, height: 3000); + img.fill(big, color: img.ColorRgb8(40, 80, 120)); + // Add noise so JPEG does not compress to near-zero. + for (var y = 0; y < big.height; y += 17) { + for (var x = 0; x < big.width; x += 13) { + big.setPixelRgb(x, y, (x * 3) % 255, (y * 5) % 255, 90); + } + } + final raw = Uint8List.fromList(img.encodeJpg(big, quality: 95)); + expect(raw.length, greaterThan(250 * 1024)); + + final out = resizePreviewForTest(raw); + expect(out, isNotNull); + expect(out!.length, lessThanOrEqualTo(250 * 1024)); + + final decoded = img.decodeImage(out)!; + final longest = + decoded.width > decoded.height ? decoded.width : decoded.height; + expect(longest, lessThanOrEqualTo(720)); + }); + }); + + group('encodeNtfyHeader', () { + test('leaves plain ASCII unchanged', () { + expect(encodeNtfyHeader('Downloaded Chapter 1'), 'Downloaded Chapter 1'); + }); + + test('RFC 2047-encodes middle-dot titles', () { + final encoded = encodeNtfyHeader('Downloaded · Series · Ch 1'); + expect(encoded.startsWith('=?UTF-8?B?'), isTrue); + expect(encoded.endsWith('?='), isTrue); + expect(encoded.codeUnits.every((c) => c >= 0x20 && c <= 0x7e), isTrue); + }); + + test('RFC 2047-encodes multiline message', () { + final encoded = encodeNtfyHeader('Pages: 3\nImages: 1 MB\nCBZ: 0.9 MB'); + expect(encoded.startsWith('=?UTF-8?B?'), isTrue); + expect(encoded.contains('\n'), isFalse); + }); + }); +} diff --git a/cxyz-android/test/reader_zoom_test.dart b/cxyz-android/test/reader_zoom_test.dart new file mode 100644 index 0000000..1ef26b5 --- /dev/null +++ b/cxyz-android/test/reader_zoom_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:cxyz_android/services/reader_zoom.dart'; + +void main() { + test('clamp stays in range and snaps to step', () { + expect(ReaderZoom.clamp(0.1), ReaderZoom.min); + expect(ReaderZoom.clamp(3.0), ReaderZoom.max); + expect(ReaderZoom.clamp(0.66), closeTo(0.7, 0.001)); + expect(ReaderZoom.clamp(1.0), 1.0); + }); + + test('percentLabel formats', () { + expect(ReaderZoom.percentLabel(0.7), '70%'); + expect(ReaderZoom.percentLabel(1.0), '100%'); + }); +} diff --git a/cxyz-android/test/source_flags_test.dart b/cxyz-android/test/source_flags_test.dart new file mode 100644 index 0000000..8920dad --- /dev/null +++ b/cxyz-android/test/source_flags_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:cxyz_android/sources/source.dart'; +import 'package:cxyz_android/sources/source_flags.dart'; +import 'package:cxyz_android/sources/comicland/comicland_source.dart'; +import 'package:cxyz_android/sources/mangaread/mangaread_source.dart'; +import 'package:cxyz_android/sources/mangaread/urls.dart'; +import 'package:cxyz_android/sources/mangatown/mangatown_source.dart'; +import 'package:cxyz_android/sources/registry.dart'; + +void main() { + group('parseEnabledSourceIds', () { + test('default comicland token', () { + expect(parseEnabledSourceIds('comicland'), {SourceId.comicland}); + }); + + test('comma-separated both', () { + expect( + parseEnabledSourceIds('comicland,mangaread'), + {SourceId.comicland, SourceId.mangaread}, + ); + }); + + test('mangaread only', () { + expect(parseEnabledSourceIds('mangaread'), {SourceId.mangaread}); + }); + + test('all enables every SourceId', () { + expect(parseEnabledSourceIds('all'), SourceId.values.toSet()); + expect(parseEnabledSourceIds('ALL'), SourceId.values.toSet()); + expect(parseEnabledSourceIds('all,mangaread'), SourceId.values.toSet()); + }); + + test('mangatown token', () { + expect(parseEnabledSourceIds('mangatown'), {SourceId.mangatown}); + }); + + test('ignores unknown and falls back when empty', () { + expect(parseEnabledSourceIds(''), {SourceId.comicland}); + expect(parseEnabledSourceIds('nope,also-bad'), {SourceId.comicland}); + }); + + test('trims and is case-insensitive', () { + expect( + parseEnabledSourceIds(' MangaRead , ComicLand '), + {SourceId.mangaread, SourceId.comicland}, + ); + }); + }); + + group('SourceRegistry gating', () { + test('registers only enabled ids', () { + final reg = SourceRegistry( + enabled: {SourceId.mangaread}, + mangaRead: MangaReadSource(), + ); + expect(reg.all.map((s) => s.id), [SourceId.mangaread]); + expect(reg.hasMultiple, isFalse); + }); + + test('dual sources preserve declaration order', () { + final reg = SourceRegistry( + enabled: {SourceId.mangaread, SourceId.comicland}, + comicLand: ComicLandSource(), + mangaRead: MangaReadSource(), + ); + expect(reg.all.map((s) => s.id).toList(), [ + SourceId.comicland, + SourceId.mangaread, + ]); + expect(reg.hasMultiple, isTrue); + }); + + test('all three sources preserve declaration order', () { + final reg = SourceRegistry( + enabled: { + SourceId.mangatown, + SourceId.mangaread, + SourceId.comicland, + }, + comicLand: ComicLandSource(), + mangaRead: MangaReadSource(), + mangaTown: MangaTownSource(), + ); + expect(reg.all.map((s) => s.id).toList(), [ + SourceId.comicland, + SourceId.mangaread, + SourceId.mangatown, + ]); + }); + }); + + group('catalogMenus capabilities', () { + test('ComicLand exposes newest, popular, official, uncensored, recommended, search', () { + final kinds = + ComicLandSource().catalogMenus.map((m) => m.kind).toList(); + expect(kinds, [ + CatalogMenuKind.latest, + CatalogMenuKind.popular, + CatalogMenuKind.official, + CatalogMenuKind.uncensored, + CatalogMenuKind.recommended, + CatalogMenuKind.search, + ]); + expect( + ComicLandSource().catalogMenus.first.title, + 'Newest', + ); + }); + + test('MangaRead exposes latest and search only', () { + final kinds = + MangaReadSource().catalogMenus.map((m) => m.kind).toList(); + expect(kinds, [ + CatalogMenuKind.latest, + CatalogMenuKind.search, + ]); + expect(kinds.contains(CatalogMenuKind.popular), isFalse); + }); + + test('MangaTown exposes latest, hot variants, directory, search', () { + final kinds = + MangaTownSource().catalogMenus.map((m) => m.kind).toList(); + expect(kinds, [ + CatalogMenuKind.latest, + CatalogMenuKind.popular, + CatalogMenuKind.hotToday, + CatalogMenuKind.hotWeek, + CatalogMenuKind.hotMonth, + CatalogMenuKind.directory, + CatalogMenuKind.search, + ]); + }); + }); + + group('mangaread urls', () { + test('mangaSlugFromUrl', () { + expect( + mangaSlugFromUrl( + 'https://www.mangaread.org/manga/the-chaebeols-youngest-son/chapter-560/', + ), + 'the-chaebeols-youngest-son', + ); + }); + + test('mangaBaseUrlFromListingUrl', () { + expect( + mangaBaseUrlFromListingUrl( + 'https://www.mangaread.org/manga/foo-bar/chapter-1/', + ), + 'https://www.mangaread.org/manga/foo-bar/', + ); + }); + + test('listingUrlForSlug', () { + expect( + listingUrlForSlug('foo-bar'), + 'https://www.mangaread.org/manga/foo-bar/', + ); + }); + + test('chapterNumberFromTitle', () { + expect(chapterNumberFromTitle('Chapter 560'), '560'); + expect(chapterNumberFromTitle('Ch. 12.5'), '12.5'); + }); + }); +} diff --git a/cxyz-android/test/source_reader_defaults_test.dart b/cxyz-android/test/source_reader_defaults_test.dart new file mode 100644 index 0000000..f4efbd6 --- /dev/null +++ b/cxyz-android/test/source_reader_defaults_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:cxyz_android/services/reader_direction.dart'; +import 'package:cxyz_android/services/source_reader_defaults.dart'; + +void main() { + group('SourceReaderDefaults.modeFor', () { + test('comicland is webtoon', () { + expect(SourceReaderDefaults.modeFor('comicland'), ReaderMode.webtoon); + }); + + test('mangaread and mangatown are paged', () { + expect(SourceReaderDefaults.modeFor('mangaread'), ReaderMode.paged); + expect(SourceReaderDefaults.modeFor('mangatown'), ReaderMode.paged); + }); + }); + + group('SourceReaderDefaults.directionFor', () { + test('mangaread and mangatown are rtl', () { + expect( + SourceReaderDefaults.directionFor('mangaread'), + ReadingDirection.rtl, + ); + expect( + SourceReaderDefaults.directionFor('mangatown'), + ReadingDirection.rtl, + ); + }); + + test('comicland has no built-in direction', () { + expect(SourceReaderDefaults.directionFor('comicland'), isNull); + }); + }); + + group('SourceReaderDefaults.resolveMode', () { + test('stored override wins', () { + expect( + SourceReaderDefaults.resolveMode('mangatown', 'webtoon'), + ReaderMode.webtoon, + ); + expect( + SourceReaderDefaults.resolveMode('comicland', 'paged'), + ReaderMode.paged, + ); + }); + + test('empty / unknown falls back to source default', () { + expect( + SourceReaderDefaults.resolveMode('mangatown', ''), + ReaderMode.paged, + ); + expect( + SourceReaderDefaults.resolveMode('mangatown', null), + ReaderMode.paged, + ); + expect( + SourceReaderDefaults.resolveMode('comicland', ''), + ReaderMode.webtoon, + ); + }); + }); + + group('SourceReaderDefaults.resolveDirection', () { + test('stored override wins', () { + expect( + SourceReaderDefaults.resolveDirection( + 'mangatown', + 'ltr', + globalFallback: ReadingDirection.rtl, + ), + ReadingDirection.ltr, + ); + }); + + test('source default when unset', () { + expect( + SourceReaderDefaults.resolveDirection( + 'mangaread', + null, + globalFallback: ReadingDirection.ltr, + ), + ReadingDirection.rtl, + ); + }); + + test('global fallback for comicland when unset', () { + expect( + SourceReaderDefaults.resolveDirection( + 'comicland', + null, + globalFallback: ReadingDirection.rtl, + ), + ReadingDirection.rtl, + ); + expect( + SourceReaderDefaults.resolveDirection( + 'comicland', + '', + globalFallback: ReadingDirection.ltr, + ), + ReadingDirection.ltr, + ); + }); + }); +} diff --git a/cxyz-android/test/widget_test.dart b/cxyz-android/test/widget_test.dart new file mode 100644 index 0000000..1505df1 --- /dev/null +++ b/cxyz-android/test/widget_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:cxyz_android/main.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +void main() { + testWidgets('App builds', (tester) async { + await tester.pumpWidget(const ProviderScope(child: CxyzApp())); + // Catalog app bar title from ComicLand source. + expect(find.text('ComicLand'), findsOneWidget); + }); +} diff --git a/package.json b/package.json index 7753833..1a0ffbf 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,9 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "docs": "jsdoc -c jsdoc.conf.json", - "mangaread:fetch": "node src/mangareadFetchChapters.js", - "manhwasusu:fetch": "node src/manhwasusu/fetchChapters.cli.js", - "manhwasusu:chapter-cbz": "node src/manhwasusu/downloadChapterCbz.js", - "manhwasusu:chapters-cbz": "node src/manhwasusu/downloadChaptersQueue.js", - "mangaread:chapter-cbz": "node src/downloadChapterCbz.js", - "mangaread:chapters-cbz": "node src/downloadChaptersQueue.js", + "fetch": "node src/cli/fetch.js", + "chapter-cbz": "node src/cli/chapter-cbz.js", + "chapters-cbz": "node src/cli/chapters-cbz.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..78fee0d --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2379 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fastify/cookie': + specifier: ^11.1.2 + version: 11.1.2 + '@fastify/static': + specifier: ^10.1.2 + version: 10.1.3 + '@picocss/pico': + specifier: ^2.1.1 + version: 2.1.1 + adm-zip: + specifier: ^0.5.17 + version: 0.5.18 + axios: + specifier: ^1.16.1 + version: 1.19.0(debug@4.4.3) + better-sqlite3: + specifier: ^13.0.2 + version: 13.0.3 + fastify: + specifier: ^5.11.0 + version: 5.12.1 + node-cron: + specifier: ^4.6.0 + version: 4.6.0 + node-html-parser: + specifier: ^7.1.0 + version: 7.1.0 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.9.0) + '@sveltejs/vite-plugin-svelte': + specifier: ^7.2.0 + version: 7.3.0(svelte@5.56.10(@typescript-eslint/types@8.67.0))(vite@8.2.2) + eslint: + specifier: ^10.4.0 + version: 10.9.0 + eslint-plugin-jsdoc: + specifier: ^62.9.0 + version: 62.9.0(eslint@10.9.0) + globals: + specifier: ^17.6.0 + version: 17.11.0 + jsdoc: + specifier: ^4.0.5 + version: 4.0.5 + svelte: + specifier: ^5.56.8 + version: 5.56.10(@typescript-eslint/types@8.67.0) + vite: + specifier: ^8.2.0 + version: 8.2.2 + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@es-joy/jsdoccomment@0.86.0': + resolution: {integrity: sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@fastify/accept-negotiator@2.1.0': + resolution: {integrity: sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==} + + '@fastify/ajv-compiler@4.0.6': + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + + '@fastify/cookie@11.1.2': + resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + + '@fastify/forwarded@3.0.2': + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.1': + resolution: {integrity: sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==} + + '@fastify/static@10.1.3': + resolution: {integrity: sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jsdoc/salty@0.2.12': + resolution: {integrity: sha512-TuB0x50EoAvEX/UEWITd8Mkn3WhiTjSvbTMCLj0BhsQEl5iUzjXdA0bETEVpTk+5TGTLR6QktI9H4hLviVeaAQ==} + engines: {node: '>=v12.0.0'} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@oxc-project/types@0.146.0': + resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + + '@picocss/pico@2.1.1': + resolution: {integrity: sha512-kIDugA7Ps4U+2BHxiNHmvgPIQDWPDU4IeU6TNRdvXQM1uZX+FibqDQT2xUOnnO2yq/LUHcwnGlu1hvf4KfXnMg==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@rolldown/binding-android-arm-eabi@1.2.5': + resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.5': + resolution: {integrity: sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.5': + resolution: {integrity: sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.5': + resolution: {integrity: sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.5': + resolution: {integrity: sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + resolution: {integrity: sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + resolution: {integrity: sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.5': + resolution: {integrity: sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + resolution: {integrity: sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + resolution: {integrity: sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.5': + resolution: {integrity: sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.5': + resolution: {integrity: sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.5': + resolution: {integrity: sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + resolution: {integrity: sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.5': + resolution: {integrity: sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + + '@sveltejs/acorn-typescript@1.0.13': + resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/vite-plugin-svelte@7.3.0': + resolution: {integrity: sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.2.0': + resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + adm-zip@0.5.18: + resolution: {integrity: sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==} + engines: {node: '>=12.0'} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + are-docs-informative@0.0.2: + resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + better-sqlite3@13.0.3: + resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==} + engines: {node: '>=22'} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + catharsis@0.9.0: + resolution: {integrity: sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==} + engines: {node: '>= 10'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + comment-parser@1.4.6: + resolution: {integrity: sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==} + engines: {node: '>= 12.0.0'} + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.9.1: + resolution: {integrity: sha512-+17vil3EVQRzvtDJSFuTWEb8XJRvXqAiV3qZyQWD398QeXUa6CxsUyMdD1fxzEhUrd4FojitFz7lhIHBTlV4fw==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-jsdoc@62.9.0: + resolution: {integrity: sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.9.0: + resolution: {integrity: sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrap@2.3.6: + resolution: {integrity: sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + + fast-uri@4.1.2: + resolution: {integrity: sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==} + + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + + fastify@5.12.1: + resolution: {integrity: sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-my-way@9.9.0: + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} + engines: {node: '>=20'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js2xmlparser@4.0.2: + resolution: {integrity: sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==} + + jsdoc-type-pratt-parser@7.2.0: + resolution: {integrity: sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==} + engines: {node: '>=20.0.0'} + + jsdoc@4.0.5: + resolution: {integrity: sha512-P4C6MWP9yIlMiK8nwoZvxN84vb6MsnXcHuy7XzVOvQoCizWX5JFCBsWIIWKXBltpoRZXddUOVQmCTOZt9yDj9g==} + engines: {node: '>=12.0.0'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + klaw@3.0.0: + resolution: {integrity: sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.2.2: + resolution: {integrity: sha512-veT/+7iXrXzT39XnEN4lOxtNl72dMgJ8Lp+5Bd6YcMSWpb0n0MjBM8Uuooi6jgJr8dhUW2swQgBmoZVMni5SVg==} + + markdown-it-anchor@8.6.7: + resolution: {integrity: sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==} + peerDependencies: + '@types/markdown-it': '*' + markdown-it: '*' + + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} + hasBin: true + + marked@4.3.0: + resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} + engines: {node: '>= 12'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + + node-cron@4.6.0: + resolution: {integrity: sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==} + engines: {node: '>=20'} + + node-html-parser@7.1.0: + resolution: {integrity: sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-deep-merge@2.0.1: + resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + requizzle@0.2.4: + resolution: {integrity: sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==} + + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.2.5: + resolution: {integrity: sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@4.0.0: + resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + svelte@5.56.10: + resolution: {integrity: sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g==} + engines: {node: '>=18'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + xmlcreate@2.0.4: + resolution: {integrity: sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@es-joy/jsdoccomment@0.86.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.67.0 + comment-parser: 1.4.6 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.2.0 + + '@es-joy/resolve.exports@1.2.0': {} + + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.0)': + dependencies: + eslint: 10.9.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.9.0)': + optionalDependencies: + eslint: 10.9.0 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@fastify/accept-negotiator@2.1.0': {} + + '@fastify/ajv-compiler@4.0.6': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.2 + + '@fastify/cookie@11.1.2': + dependencies: + cookie: 2.0.1 + fastify-plugin: 6.0.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/forwarded@3.0.2': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.2 + ipaddr.js: 2.5.0 + + '@fastify/send@4.1.1': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@10.1.3': + dependencies: + '@fastify/accept-negotiator': 2.1.0 + '@fastify/error': 4.2.0 + '@fastify/send': 4.1.1 + content-disposition: 2.0.1 + fastify-plugin: 6.0.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jsdoc/salty@0.2.12': + dependencies: + lodash: 4.18.1 + + '@lukeed/ms@2.0.2': {} + + '@oxc-project/types@0.146.0': {} + + '@picocss/pico@2.1.1': {} + + '@pinojs/redact@0.4.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.5': + optional: true + + '@rolldown/binding-android-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-x64@1.2.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.5': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sindresorhus/base62@1.0.0': {} + + '@sveltejs/acorn-typescript@1.0.13(acorn@8.18.0)': + dependencies: + acorn: 8.18.0 + + '@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10(@typescript-eslint/types@8.67.0))(vite@8.2.2)': + dependencies: + deepmerge: 4.3.1 + magic-string: 1.2.2 + obug: 2.1.4 + svelte: 5.56.10(@typescript-eslint/types@8.67.0) + vite: 8.2.2 + vitefu: 1.1.3(vite@8.2.2) + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.2.0': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + + '@types/trusted-types@2.0.7': {} + + '@typescript-eslint/types@8.67.0': {} + + abstract-logging@2.0.1: {} + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + adm-zip@0.5.18: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + are-docs-informative@0.0.2: {} + + argparse@2.0.1: {} + + aria-query@5.3.1: {} + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + axios@1.19.0(debug@4.4.3): + dependencies: + follow-redirects: 1.16.0(debug@4.4.3) + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + axobject-query@4.1.0: {} + + balanced-match@4.0.4: {} + + better-sqlite3@13.0.3: + dependencies: + node-addon-api: 8.9.2 + + bluebird@3.7.2: {} + + boolbase@1.0.0: {} + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + catharsis@0.9.0: + dependencies: + lodash: 4.18.1 + + clsx@2.1.1: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + comment-parser@1.4.6: {} + + content-disposition@2.0.1: {} + + cookie@1.1.1: {} + + cookie@2.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devalue@5.9.1: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + entities@4.5.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + escape-html@1.0.3: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-jsdoc@62.9.0(eslint@10.9.0): + dependencies: + '@es-joy/jsdoccomment': 0.86.0 + '@es-joy/resolve.exports': 1.2.0 + are-docs-informative: 0.0.2 + comment-parser: 1.4.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint: 10.9.0 + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.1 + parse-imports-exports: 0.2.4 + semver: 7.8.5 + spdx-expression-parse: 4.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.9.0: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + esm-env@1.2.2: {} + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrap@2.3.6(@typescript-eslint/types@8.67.0): + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + optionalDependencies: + '@typescript-eslint/types': 8.67.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.5: {} + + fast-uri@4.1.2: {} + + fastify-plugin@6.0.0: {} + + fastify@5.12.1: + dependencies: + '@fastify/ajv-compiler': 4.0.6 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.9.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.1.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.4 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-my-way@9.9.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + follow-redirects@1.16.0(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globals@17.11.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + html-entities@2.6.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ignore@5.3.2: {} + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ipaddr.js@2.5.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + isexe@2.0.0: {} + + js2xmlparser@4.0.2: + dependencies: + xmlcreate: 2.0.4 + + jsdoc-type-pratt-parser@7.2.0: {} + + jsdoc@4.0.5: + dependencies: + '@babel/parser': 7.29.8 + '@jsdoc/salty': 0.2.12 + '@types/markdown-it': 14.2.0 + bluebird: 3.7.2 + catharsis: 0.9.0 + escape-string-regexp: 2.0.0 + js2xmlparser: 4.0.2 + klaw: 3.0.0 + markdown-it: 14.3.0 + markdown-it-anchor: 8.6.7(@types/markdown-it@14.2.0)(markdown-it@14.3.0) + marked: 4.3.0 + mkdirp: 1.0.4 + requizzle: 0.2.4 + strip-json-comments: 3.1.1 + underscore: 1.13.8 + + json-buffer@3.0.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + klaw@3.0.0: + dependencies: + graceful-fs: 4.2.11 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + + locate-character@3.0.0: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash@4.18.1: {} + + lru-cache@11.5.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@1.2.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-it-anchor@8.6.7(@types/markdown-it@14.2.0)(markdown-it@14.3.0): + dependencies: + '@types/markdown-it': 14.2.0 + markdown-it: 14.3.0 + + markdown-it@14.3.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + marked@4.3.0: {} + + math-intrinsics@1.1.0: {} + + mdurl@2.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@3.0.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minipass@7.1.3: {} + + mkdirp@1.0.4: {} + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + natural-compare@1.4.0: {} + + node-addon-api@8.9.2: {} + + node-cron@4.6.0: {} + + node-html-parser@7.1.0: + dependencies: + css-select: 5.2.2 + he: 1.2.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-deep-merge@2.0.1: {} + + obug@2.1.4: {} + + on-exit-leak-free@2.1.2: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + + parse-statements@1.0.11: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + process-warning@4.0.1: {} + + process-warning@5.1.0: {} + + proxy-from-env@2.1.0: {} + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + require-from-string@2.0.2: {} + + requizzle@0.2.4: + dependencies: + lodash: 4.18.1 + + reserved-identifiers@1.2.0: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rolldown@1.2.5: + dependencies: + '@oxc-project/types': 0.146.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.5 + '@rolldown/binding-android-arm64': 1.2.5 + '@rolldown/binding-darwin-arm64': 1.2.5 + '@rolldown/binding-darwin-x64': 1.2.5 + '@rolldown/binding-freebsd-x64': 1.2.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.5 + '@rolldown/binding-linux-arm64-gnu': 1.2.5 + '@rolldown/binding-linux-arm64-musl': 1.2.5 + '@rolldown/binding-linux-ppc64-gnu': 1.2.5 + '@rolldown/binding-linux-s390x-gnu': 1.2.5 + '@rolldown/binding-linux-x64-gnu': 1.2.5 + '@rolldown/binding-linux-x64-musl': 1.2.5 + '@rolldown/binding-openharmony-arm64': 1.2.5 + '@rolldown/binding-win32-arm64-msvc': 1.2.5 + '@rolldown/binding-win32-x64-msvc': 1.2.5 + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@4.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + split2@4.2.0: {} + + statuses@2.0.2: {} + + strip-json-comments@3.1.1: {} + + svelte@5.56.10(@typescript-eslint/types@8.67.0): + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.18.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.18.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.9.1 + esm-env: 1.2.2 + esrap: 2.3.6(@typescript-eslint/types@8.67.0) + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + + toad-cache@3.7.4: {} + + toidentifier@1.0.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + uc.micro@2.1.0: {} + + underscore@1.13.8: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.2.2: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.5 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitefu@1.1.3(vite@8.2.2): + optionalDependencies: + vite: 8.2.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + xmlcreate@2.0.4: {} + + yocto-queue@0.1.0: {} + + zimmerframe@1.1.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..65783dc --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + better-sqlite3: true diff --git a/scripts/fetch-page.js b/scripts/fetch-page.js new file mode 100644 index 0000000..75a9c00 --- /dev/null +++ b/scripts/fetch-page.js @@ -0,0 +1,397 @@ +const dns = require("dns"); +const fs = require("fs"); +const https = require("https"); +const net = require("net"); +const path = require("path"); +const axios = require("axios"); + +const USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + +/** + * @typedef {object} ParsedCli + * @property {boolean} help - Print usage and exit when true. + * @property {string|undefined} url - Absolute page URL. + * @property {string|undefined} outPath - Optional file to write the body to. + * @property {number} timeoutMs - Axios timeout in milliseconds. + * @property {string|null} domain - Optional Host header / logical hostname. + */ + +/** + * Parse CLI args: `node scripts/fetch-page.js [--domain ] [--out ] [--timeout ]`. + * @param {string[]} argv - `process.argv.slice(2)`. + * @returns {ParsedCli} Parsed flags and positional URL. + */ +function parseCli(argv) { + /** @type {ParsedCli} */ + const result = { + help: false, + url: undefined, + outPath: undefined, + timeoutMs: 15_000, + domain: null, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "-h" || arg === "--help") { + result.help = true; + continue; + } + + if (arg === "--domain" || arg === "-d") { + result.domain = argv[i + 1]; + i += 1; + continue; + } + + if (arg === "--out" || arg === "-o") { + result.outPath = argv[i + 1]; + i += 1; + continue; + } + if (arg.startsWith("--out=")) { + result.outPath = arg.slice("--out=".length); + continue; + } + if (arg === "--timeout") { + result.timeoutMs = Number(argv[i + 1]); + i += 1; + continue; + } + if (arg.startsWith("--timeout=")) { + result.timeoutMs = Number(arg.slice("--timeout=".length)); + continue; + } + if (!arg.startsWith("-") && result.url === undefined) { + result.url = arg; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + return result; +} + +/** + * Write a diagnostic line to stderr so stdout stays the page body. + * @param {string} line - Message. + * @returns {void} + */ +function log(line) { + console.error(line); +} + +/** + * Flatten Axios or Node headers into a plain object. + * @param {unknown} headers - AxiosHeaders, IncomingHttpHeaders, or plain object. + * @returns {Record} Header names mapped to string values. + */ +function plainHeaders(headers) { + if (!headers || typeof headers !== "object") { + return {}; + } + const raw = + typeof /** @type {{ toJSON?: () => unknown }} */ (headers).toJSON === + "function" + ? /** @type {{ toJSON: () => unknown }} */ (headers).toJSON() + : headers; + /** @type {Record} */ + const out = {}; + for (const [key, value] of Object.entries(raw || {})) { + if (value == null) { + continue; + } + out[key] = Array.isArray(value) ? value.join(", ") : String(value); + } + return out; +} + +/** + * Log HTTP headers, one per line. + * @param {unknown} headers - Response or request headers. + * @returns {void} + */ +function logHeaders(headers) { + const entries = Object.entries(plainHeaders(headers)); + if (entries.length === 0) { + log(" (no headers)"); + return; + } + for (const [key, value] of entries) { + log(` ${key}: ${value}`); + } +} + +/** + * Resolve A/AAAA records and OS lookup for a hostname; log the query. + * @param {string} hostname - Name or IP to resolve. + * @param {string} [label] - Extra context, e.g. "connect" or "Host header". + * @returns {Promise} + */ +async function logDnsQuery(hostname, label) { + const tag = label ? ` (${label})` : ""; + if (net.isIP(hostname)) { + log(`dns query${tag}: ${hostname}`); + log(` skipped — host is already an IP (IPv${String(net.isIP(hostname))})`); + return; + } + + const servers = dns.getServers(); + log(`dns query${tag}: ${hostname}`); + log(` servers: ${servers.join(", ")}`); + + const started = Date.now(); + const [a, aaaa, lookup] = await Promise.all([ + dns.promises.resolve4(hostname, { ttl: true }).catch((err) => err), + dns.promises.resolve6(hostname, { ttl: true }).catch((err) => err), + dns.promises.lookup(hostname, { all: true }).catch((err) => err), + ]); + const elapsed = Date.now() - started; + + if (a instanceof Error) { + log(` A: ${a.message}`); + } else { + for (const rec of a) { + log(` A: ${rec.address} ttl=${String(rec.ttl)}`); + } + if (a.length === 0) { + log(" A: (empty)"); + } + } + + if (aaaa instanceof Error) { + log(` AAAA: ${aaaa.message}`); + } else { + for (const rec of aaaa) { + log(` AAAA: ${rec.address} ttl=${String(rec.ttl)}`); + } + if (aaaa.length === 0) { + log(" AAAA: (empty)"); + } + } + + if (lookup instanceof Error) { + log(` lookup (getaddrinfo): ${lookup.message}`); + } else { + for (const rec of lookup) { + log(` lookup (getaddrinfo): ${rec.address} IPv${String(rec.family)}`); + } + if (lookup.length === 0) { + log(" lookup (getaddrinfo): (empty)"); + } + } + + log(` elapsed: ${String(elapsed)}ms`); +} + +/** + * Agent `lookup` that logs the hostname Node actually resolves for the socket. + * @param {string} hostname - Name passed by http/https. + * @param {import("dns").LookupOptions} options - dns.lookup options. + * @param {(...args: unknown[]) => void} callback - Node lookup callback. + * @returns {void} + */ +function loggedLookup(hostname, options, callback) { + const started = Date.now(); + dns.lookup(hostname, options, (err, address, family) => { + const elapsed = Date.now() - started; + if (err) { + log( + `dns lookup (socket): ${hostname} failed in ${String(elapsed)}ms: ${err.message}`, + ); + } else { + const addrs = Array.isArray(address) + ? address + .map((row) => `${row.address} IPv${String(row.family)}`) + .join(", ") + : `${String(address)} IPv${String(family)}`; + log( + `dns lookup (socket): ${hostname} -> ${addrs} (${String(elapsed)}ms)`, + ); + } + callback(err, address, family); + }); +} + +/** + * Log axios failure details (TLS handshake, no response, or HTTP error). + * @param {unknown} err - Caught error. + * @returns {void} + */ +function logAxiosError(err) { + const ax = /** @type {import("axios").AxiosError} */ (err); + log("response: (failed)"); + if (ax.code) { + log(` code: ${ax.code}`); + } + log(` message: ${ax instanceof Error ? ax.message : String(ax)}`); + + const syscall = + ax && typeof ax === "object" && "syscall" in ax + ? /** @type {{ syscall?: string }} */ (ax).syscall + : undefined; + const address = + ax && typeof ax === "object" && "address" in ax + ? /** @type {{ address?: string }} */ (ax).address + : undefined; + const port = + ax && typeof ax === "object" && "port" in ax + ? /** @type {{ port?: number }} */ (ax).port + : undefined; + if (syscall) { + log(` syscall: ${syscall}`); + } + if (address != null) { + log(` peer: ${address}${port != null ? `:${String(port)}` : ""}`); + } + + if (ax.response) { + log( + ` http status: ${String(ax.response.status)} ${ax.response.statusText || ""}`.trim(), + ); + log(" http headers:"); + logHeaders(ax.response.headers); + const body = ax.response.data; + if (typeof body === "string" && body.length > 0) { + const preview = body.length > 500 ? `${body.slice(0, 500)}…` : body; + log(` http body preview: ${preview}`); + } + return; + } + + log(" (no HTTP response — connection or TLS failed before headers)"); + if (ax.code === "EPROTO" || /ssl|tls|handshake/iu.test(String(ax.message))) { + log( + " hint: HTTPS to an IP uses that IP as TLS SNI unless servername is set; CDNs often send alert 40", + ); + } +} + +/** + * GET a page URL and return the response body as text. + * @param {string} url - Absolute page URL. + * @param {number} timeoutMs - Axios timeout. + * @param {string|null} domain - Optional Host header / logical hostname. + * @returns {Promise<{ status: number, contentType: string, body: string }>} HTTP status, content-type, and body text. + */ +async function fetchPage(url, timeoutMs, domain) { + const parsedUrl = new URL(url); + const connectHost = parsedUrl.hostname; + const hostHeader = domain || parsedUrl.host; + const sni = domain && net.isIP(connectHost) ? domain : connectHost; + + await logDnsQuery(connectHost, "connect"); + if (domain && domain !== connectHost) { + await logDnsQuery(domain, "Host / --domain"); + } + + log("request:"); + log(` GET ${url}`); + log(` connect host: ${connectHost}`); + log(` Host header: ${hostHeader}`); + log(` tls servername (SNI): ${sni}`); + + const httpsAgent = new https.Agent({ + lookup: loggedLookup, + servername: sni, + }); + + let data; + let status; + let headers; + try { + ({ data, status, headers } = await axios.get(url, { + responseType: "text", + timeout: timeoutMs, + httpsAgent, + validateStatus: (s) => s >= 200 && s < 300, + headers: { + Accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "User-Agent": USER_AGENT, + Host: hostHeader, + }, + })); + } catch (err) { + logAxiosError(err); + throw err; + } + + log(`response: ${String(status)}`); + logHeaders(headers); + + return { + status, + contentType: String(headers["content-type"] || ""), + body: typeof data === "string" ? data : String(data ?? ""), + }; +} + +/** + * Fetch a website page with axios and print the body (or write it to a file). + * + * Usage: + * node scripts/fetch-page.js [--domain ] [--out ] [--timeout ] + * @returns {Promise} + */ +async function main() { + let parsed; + try { + parsed = parseCli(process.argv.slice(2)); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + if (parsed.help || !parsed.url) { + console.error( + "Usage: node scripts/fetch-page.js [--domain ] [--out ] [--timeout ]", + ); + if (!parsed.help) { + process.exitCode = 1; + } + return; + } + + if (!Number.isFinite(parsed.timeoutMs) || parsed.timeoutMs <= 0) { + console.error("--timeout must be a positive number of milliseconds"); + process.exitCode = 1; + return; + } + + let page; + try { + page = await fetchPage(parsed.url, parsed.timeoutMs, parsed.domain); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + log(`bytes: ${String(Buffer.byteLength(page.body, "utf8"))}`); + + if (parsed.outPath) { + const abs = path.resolve(parsed.outPath); + fs.writeFileSync(abs, page.body, "utf8"); + log(`wrote: ${abs}`); + return; + } + + process.stdout.write(page.body); + if (!page.body.endsWith("\n")) { + process.stdout.write("\n"); + } +} + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} + +module.exports = { fetchPage, main, parseCli }; diff --git a/scripts/test.js b/scripts/test.js new file mode 100644 index 0000000..8624b2c --- /dev/null +++ b/scripts/test.js @@ -0,0 +1,25 @@ +const axios = require("axios"); +axios.get("https://104.26.5.47/", { + "headers": { + "Host": "104.26.5.47", + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "accept-language": "en-US,en;q=0.9", + "cache-control": "no-cache", + "pragma": "no-cache", + "priority": "u=0, i", + "sec-ch-ua": "\"Not;A=Brand\";v=\"8\", \"Chromium\";v=\"150\", \"Google Chrome\";v=\"150\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"macOS\"", + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "none", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + }, + }) + .then(response => { + console.log(response.data); + }) + .catch(error => { + console.error(error); + }); \ No newline at end of file diff --git a/src/cli/chapter-cbz.js b/src/cli/chapter-cbz.js new file mode 100644 index 0000000..3385d91 --- /dev/null +++ b/src/cli/chapter-cbz.js @@ -0,0 +1,142 @@ +const path = require("path"); + +/** + * Resolve an explicit CLI output path (`.cbz` file or directory). + * @param {string|undefined} cliOut - Second positional arg. + * @param {string} defaultCbzPath - Path from resolveCbzPath / default layout. + * @returns {string} Absolute `.cbz` path. + */ +function resolveExplicitOutput(cliOut, defaultCbzPath) { + if (cliOut === undefined || cliOut.trim() === "") { + return defaultCbzPath; + } + const abs = path.resolve(cliOut.trim()); + if (abs.toLowerCase().endsWith(".cbz")) { + return abs; + } + return path.join(abs, path.basename(defaultCbzPath)); +} + +/** + * Unified single-chapter CBZ CLI: detect source from chapter URL. + * + * Usage: + * node src/cli/chapter-cbz.js [output.cbz|outputDirectory] [--no-db] + * @returns {Promise} + */ +async function main() { + const argv = process.argv.slice(2); + const noDb = argv.includes("--no-db"); + const positional = argv.filter((a) => !a.startsWith("-")); + const chapterUrl = positional[0]; + if (!chapterUrl) { + console.error( + "Usage: node src/cli/chapter-cbz.js [output.cbz|outputDirectory] [--no-db]", + ); + process.exitCode = 1; + return; + } + + const outArg = positional[1]; + const { + detectSource, + downloadChapter, + fetchSeriesChapters, + resolveCbzPath, + siteLabel, + } = require("../server/sources.js"); + + let source; + try { + source = detectSource(chapterUrl); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + console.log(`source: ${source} (${siteLabel(source)})`); + + /** @type {import("better-sqlite3").Database|null} */ + let db = null; + /** @type {string|undefined} */ + let dataDir; + /** @type {object|undefined} */ + let chapterRow; + /** @type {string} */ + let target; + + try { + if (!noDb) { + const cliStore = require("../server/cli-store.js"); + ({ db, dataDir } = cliStore.openCliDatabase()); + const result = await cliStore.ensureTitleFromUrl(db, chapterUrl, dataDir); + console.log( + `SQLite: title id=${String(result.title.id)} "${result.title.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `${String(result.total)} chapter(s)`, + ); + chapterRow = cliStore.findChapterByUrl(db, result.title.id, chapterUrl); + const defaultPath = resolveCbzPath( + source, + result.title.title, + chapterUrl, + dataDir, + ); + target = resolveExplicitOutput(outArg, defaultPath); + if (chapterRow) { + cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" }); + } + } else { + let comicTitle = "comic"; + try { + const series = await fetchSeriesChapters(chapterUrl, source); + comicTitle = series.comicTitle; + } catch { + /* keep fallback for path resolution */ + } + const dataRoot = path.resolve( + process.env.DATA_DIR || path.join(process.cwd(), "data"), + ); + const defaultPath = resolveCbzPath(source, comicTitle, chapterUrl, dataRoot); + target = resolveExplicitOutput(outArg, defaultPath); + } + + const { imageCount, outputPath } = await downloadChapter(source, chapterUrl, target); + console.log(`Wrote ${String(imageCount)} page(s) → ${outputPath}`); + + if (db && chapterRow) { + const cliStore = require("../server/cli-store.js"); + cliStore.markDownload(db, chapterRow, { + ok: true, + cbzPath: outputPath, + phase: "finish", + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(msg); + if (db && chapterRow) { + const cliStore = require("../server/cli-store.js"); + cliStore.markDownload(db, chapterRow, { + ok: false, + error: msg, + phase: "finish", + }); + } + process.exitCode = 1; + } finally { + if (db) { + db.close(); + } + } +} + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} + +module.exports = { main, resolveExplicitOutput }; diff --git a/src/cli/chapters-cbz.js b/src/cli/chapters-cbz.js new file mode 100644 index 0000000..d374c25 --- /dev/null +++ b/src/cli/chapters-cbz.js @@ -0,0 +1,750 @@ +const path = require("path"); + +/** + * @typedef {object} ParsedCli + * @property {boolean} [help] + * @property {string|undefined} listingUrl + * @property {boolean} listOnly + * @property {boolean} allChapters + * @property {boolean} missingOnly + * @property {string|undefined} selectSpec + * @property {string|undefined} numbersSpec + * @property {string|undefined} outDir + * @property {boolean} noDb + * @property {number|undefined} titleId + * @property {boolean} outDirExplicit + * @property {boolean} verbose + * @property {number} delaySeconds + */ + +/** + * @typedef {object} ChapterRow + * @property {string} url + * @property {string} title + * @property {string} chapter_key + * @property {string|null} chapter_number + */ + +/** + * @param {string[]} argv - `process.argv`. + * @returns {ParsedCli} + */ +function parseCli(argv) { + /** @type {ParsedCli} */ + const result = { + help: false, + listingUrl: undefined, + listOnly: false, + allChapters: false, + missingOnly: false, + selectSpec: undefined, + numbersSpec: undefined, + outDir: undefined, + noDb: false, + titleId: undefined, + outDirExplicit: false, + verbose: false, + delaySeconds: 0, + }; + + for (let i = 2; i < argv.length; i += 1) { + const a = argv[i]; + if (a === "--help" || a === "-h") { + result.help = true; + } else if (a === "--list-only" || a === "-l") { + result.listOnly = true; + } else if (a === "--all") { + result.allChapters = true; + } else if (a === "--missing") { + result.missingOnly = true; + } else if (a === "--verbose" || a === "-v") { + result.verbose = true; + } else if (a === "--no-db") { + result.noDb = true; + } else if (a === "--delay") { + const next = argv[i + 1]; + if (next === undefined || (next.startsWith("-") && !/^-?\d+$/u.test(next))) { + throw new Error("Missing value for --delay"); + } + const seconds = Number.parseInt(next, 10); + if (!Number.isFinite(seconds) || seconds < 0) { + throw new Error(`Invalid --delay: ${next} (expected non-negative integer seconds)`); + } + result.delaySeconds = seconds; + i += 1; + } else if (a === "--title-id") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --title-id"); + } + const id = Number.parseInt(next, 10); + if (!Number.isFinite(id) || id < 1) { + throw new Error(`Invalid --title-id: ${next}`); + } + result.titleId = id; + i += 1; + } else if (a === "--select" || a === "-s") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --select"); + } + result.selectSpec = next; + i += 1; + } else if (a === "--numbers" || a === "-n") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --numbers"); + } + result.numbersSpec = next; + i += 1; + } else if (a === "--out" || a === "-o") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --out"); + } + result.outDir = path.resolve(next); + result.outDirExplicit = true; + i += 1; + } else if (!a.startsWith("-")) { + if (result.listingUrl) { + throw new Error(`Unexpected extra argument: ${a}`); + } + result.listingUrl = a; + } else { + throw new Error(`Unknown option: ${a}`); + } + } + + return result; +} + +/** + * @param {boolean} verbose + * @returns {(msg: string) => void} + */ +function makeVlog(verbose) { + return (msg) => { + if (verbose) { + console.log(`info: ${msg}`); + } + }; +} + +/** + * @param {number} seconds + * @returns {Promise} + */ +function sleepSeconds(seconds) { + return new Promise((resolve) => { + setTimeout(resolve, seconds * 1000); + }); +} + +/** + * @param {ChapterRow[]} chapters + * @param {object[]} needingRows + * @returns {Array<{ url: string, title: string, pickLabel: string }>} + */ +function queueItemsMissing(chapters, needingRows) { + /** @type {Map} */ + const byUrl = new Map(); + for (const row of needingRows) { + if (typeof row.url === "string" && row.url) { + byUrl.set(row.url, row); + } + } + + /** @type {Array<{ url: string, title: string, pickLabel: string }>} */ + const items = []; + /** @type {Set} */ + const used = new Set(); + + for (const ch of chapters) { + const row = byUrl.get(ch.url); + if (!row) { + continue; + } + used.add(ch.url); + const reason = + row.status === "downloaded" ? "file-missing" : String(row.status); + items.push({ + url: ch.url, + title: ch.title, + pickLabel: `missing:${reason}`, + }); + } + + for (const row of needingRows) { + if (typeof row.url !== "string" || !row.url || used.has(row.url)) { + continue; + } + const reason = + row.status === "downloaded" ? "file-missing" : String(row.status); + items.push({ + url: row.url, + title: row.label || row.chapter_key || row.url, + pickLabel: `missing:${reason}`, + }); + } + + return items; +} + +/** + * @param {import("better-sqlite3").Database} db + * @param {number} titleId + * @param {(p: string|null|undefined) => boolean} cbzExistsFn + * @returns {object[]} + */ +function listChaptersMissing(db, titleId, cbzExistsFn) { + const dbApi = require("../server/db.js"); + const rows = dbApi.listChapters(db, titleId); + return rows.filter((ch) => { + if (ch.status === "pending" || ch.status === "failed") { + return true; + } + if (ch.status === "downloaded" && !cbzExistsFn(ch.cbz_path)) { + return true; + } + return false; + }); +} + +/** + * @param {string} spec + * @param {number} count + * @returns {number[]} + */ +function parseChapterSelection(spec, count) { + if (count <= 0) { + throw new Error("Chapter list is empty; cannot select indices."); + } + + const trimmed = spec.trim(); + if (trimmed.length === 0) { + throw new Error("--select value is empty."); + } + + /** @type {Set} */ + const seen = new Set(); + + for (const rawPart of trimmed.split(",")) { + const part = rawPart.trim(); + if (part.length === 0) { + continue; + } + + if (/^\d+$/u.test(part)) { + const n = Number.parseInt(part, 10); + if (n < 1 || n > count) { + throw new Error( + `Index ${String(n)} is out of range (valid: 1–${String(count)}).`, + ); + } + seen.add(n); + continue; + } + + const rangeMatch = /^(\d+)\s*-\s*(\d+)$/u.exec(part); + if (rangeMatch) { + const lo = Number.parseInt(rangeMatch[1], 10); + const hi = Number.parseInt(rangeMatch[2], 10); + const a = Math.min(lo, hi); + const b = Math.max(lo, hi); + for (let j = a; j <= b; j += 1) { + if (j < 1 || j > count) { + throw new Error( + `Range ${part} includes invalid index ${String(j)} (valid: 1–${String(count)}).`, + ); + } + seen.add(j); + } + continue; + } + + throw new Error(`Invalid --select segment: "${part}"`); + } + + if (seen.size === 0) { + throw new Error("No indices parsed from --select."); + } + + return [...seen].sort((x, y) => x - y); +} + +/** + * @param {string} title + * @returns {string|null} + */ +function chapterNumberFromListingTitle(title) { + const m = /^Chapter\s+([\d.]+)\s*$/iu.exec(title.trim().replace(/\s+/gu, " ")); + return m ? m[1] : null; +} + +/** + * @param {string} spec + * @returns {string[]} + */ +function parseChapterNumbersSpec(spec) { + const trimmed = spec.trim(); + if (trimmed.length === 0) { + throw new Error("--numbers value is empty."); + } + + /** @type {string[]} */ + const ordered = []; + /** @type {Set} */ + const seen = new Set(); + + for (const rawPart of trimmed.split(",")) { + const part = rawPart.trim(); + if (part.length === 0) { + continue; + } + + const rangeMatch = /^(\d+)\s*-\s*(\d+)$/u.exec(part); + if (rangeMatch) { + const lo = Number.parseInt(rangeMatch[1], 10); + const hi = Number.parseInt(rangeMatch[2], 10); + const a = Math.min(lo, hi); + const b = Math.max(lo, hi); + for (let n = a; n <= b; n += 1) { + const k = String(n); + if (!seen.has(k)) { + seen.add(k); + ordered.push(k); + } + } + continue; + } + + if (/^\d+(\.\d+)?$/u.test(part)) { + if (!seen.has(part)) { + seen.add(part); + ordered.push(part); + } + continue; + } + + throw new Error(`Invalid --numbers segment: "${part}"`); + } + + if (ordered.length === 0) { + throw new Error("No chapter numbers parsed from --numbers."); + } + + return ordered; +} + +/** + * @param {string[]} orderedNums + * @param {ChapterRow[]} chapters + * @returns {Array<{ url: string, title: string, pickLabel: string }>} + */ +function queueItemsByChapterNumbers(orderedNums, chapters) { + /** @type {Map} */ + const first = new Map(); + for (const ch of chapters) { + const fromField = + typeof ch.chapter_number === "string" && ch.chapter_number.trim() + ? ch.chapter_number.trim() + : null; + const fromTitle = chapterNumberFromListingTitle(ch.title); + const num = fromField || fromTitle; + if (num !== null && !first.has(num)) { + first.set(num, ch); + } + } + + return orderedNums.map((num) => { + const ch = first.get(num); + if (!ch) { + throw new Error( + `No chapter numbered "${num}" in the listing. ` + + "Use --list-only and check titles / chapter numbers.", + ); + } + return { + url: ch.url, + title: ch.title, + pickLabel: `Ch.${num}`, + }; + }); +} + +/** + * @param {ChapterRow[]} chapters + * @returns {void} + */ +function printChapterMenu(chapters) { + const digits = String(chapters.length).length; + for (let i = 0; i < chapters.length; i += 1) { + const n = i + 1; + const pad = String(n).padStart(digits, " "); + const title = chapters[i].title.replace(/\s+/gu, " ").trim(); + console.log(`${pad}. ${title}`); + console.log(` ${chapters[i].url}`); + } +} + +/** + * @param {number[]} indices1 + * @param {ChapterRow[]} chapters + * @returns {Array<{ url: string, title: string, pickLabel: string }>} + */ +function chaptersByIndices(indices1, chapters) { + return indices1.map((idx) => ({ + url: chapters[idx - 1].url, + title: chapters[idx - 1].title, + pickLabel: `#${String(idx)}`, + })); +} + +/** + * Unified chapter queue: detect source from URL (or SQLite title.source). + * @returns {Promise} + */ +async function main() { + let parsed; + try { + parsed = parseCli(process.argv); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + if (parsed.help) { + console.log(`Unified chapter queue (auto-detects mangaread / manhwasusu / comicland): + +Usage: + node src/cli/chapters-cbz.js [--list-only|-l] + node src/cli/chapters-cbz.js --numbers|-n [--out|-o ] + node src/cli/chapters-cbz.js --select|-s [--out|-o ] + node src/cli/chapters-cbz.js --all [--out|-o ] + node src/cli/chapters-cbz.js --missing [--out|-o ] + node src/cli/chapters-cbz.js --title-id --missing … + node src/cli/chapters-cbz.js --title-id --numbers|-n … + +Listing URL: series page (chapter URL is also accepted; source is auto-detected). + +--title-id: load listing URL + source from SQLite (DATA_DIR/app.db) +--missing: download chapters that are pending, failed, or downloaded but CBZ missing +--delay N: wait N seconds between chapter downloads +--no-db: write CBZ files only; do not update SQLite +--verbose|-v: print informational progress logs +DATA_DIR: data root (default: ./data) + +Default CBZ layout (no --out): $DATA_DIR//comics//… + (mangaread uses $DATA_DIR/comics/<title>/) + +--numbers: chapter numbers, e.g. 22,23,24 or 22-24 +--select: 1-based rows on printed list (line 1 = first in reading order) +`); + return; + } + + const vlog = makeVlog(parsed.verbose); + const cliStore = require("../server/cli-store.js"); + const { + detectSource, + fetchSeriesChapters, + downloadChapter, + resolveCbzPath, + cbzExists, + comicsRoot, + sanitizeComicDirNameForSource, + siteLabel, + } = require("../server/sources.js"); + const dbApi = require("../server/db.js"); + + /** @type {import("better-sqlite3").Database|null} */ + let db = null; + /** @type {string|undefined} */ + let dataDir; + /** @type {object|undefined} */ + let dbTitle; + /** @type {string|undefined} */ + let source; + + try { + if (parsed.missingOnly && parsed.noDb) { + console.error("Cannot combine --missing with --no-db (SQLite is required)."); + process.exitCode = 1; + return; + } + + if (!parsed.noDb || parsed.titleId !== undefined || parsed.missingOnly) { + if (parsed.noDb && parsed.titleId !== undefined) { + console.error("Cannot combine --no-db with --title-id."); + process.exitCode = 1; + return; + } + vlog(`opening SQLite under DATA_DIR=${cliStore.resolveDataDir()}`); + ({ db, dataDir } = cliStore.openCliDatabase()); + vlog(`database ready: ${path.join(dataDir, "app.db")}`); + } + + if (parsed.titleId !== undefined) { + vlog(`looking up --title-id ${String(parsed.titleId)}`); + dbTitle = dbApi.getTitle(/** @type {import("better-sqlite3").Database} */ (db), parsed.titleId); + if (!dbTitle) { + console.error(`Title id ${String(parsed.titleId)} not found in SQLite.`); + process.exitCode = 1; + return; + } + parsed.listingUrl = dbTitle.url; + source = dbTitle.source; + vlog(`resolved title "${dbTitle.title}" (${source}) → ${dbTitle.url}`); + } + + if (!parsed.listingUrl) { + console.error( + "Usage: node src/cli/chapters-cbz.js <listing-url> [options]\n" + + "Or: --title-id <id> [options]", + ); + process.exitCode = 1; + return; + } + + if (!source) { + try { + source = detectSource(parsed.listingUrl); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + } + + console.log(`source: ${source} (${siteLabel(source)})`); + vlog(`fetching chapter listing: ${parsed.listingUrl}`); + + /** @type {ChapterRow[]} */ + let chapters; + let comicTitle; + try { + const series = await fetchSeriesChapters(parsed.listingUrl, source); + source = series.source; + chapters = series.chapters; + comicTitle = series.comicTitle; + parsed.listingUrl = series.listingUrl; + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + console.log(`Found ${String(chapters.length)} chapter link(s).`); + printChapterMenu(chapters); + vlog(`comic title from listing: ${comicTitle}`); + + if (parsed.listOnly) { + if (db && dataDir) { + vlog("upserting title/chapters into SQLite (--list-only)"); + const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir); + console.log( + `\nSQLite: title id=${String(result.title.id)} "${result.title.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `+${String(result.added)} chapter(s), total ${String(result.total)}`, + ); + } + return; + } + + const wantsAll = parsed.allChapters; + const wantsMissing = parsed.missingOnly; + const wantsSelect = Boolean(parsed.selectSpec); + const wantsNumbers = Boolean(parsed.numbersSpec); + const pickModes = [wantsAll, wantsMissing, wantsSelect, wantsNumbers].filter( + Boolean, + ).length; + if (pickModes === 0) { + console.error("Choose --numbers, --select, --all, or --missing (or --list-only)."); + process.exitCode = 1; + return; + } + if (pickModes > 1) { + console.error("Use only one of --all, --missing, --select, or --numbers."); + process.exitCode = 1; + return; + } + + if (db && dataDir) { + vlog("upserting title/chapters into SQLite"); + const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir); + dbTitle = result.title; + console.log( + `SQLite: title id=${String(dbTitle.id)} "${dbTitle.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `+${String(result.added)} chapter(s), total ${String(result.total)}`, + ); + } + + /** @type {Array<{ url: string, title: string, pickLabel: string }>} */ + let queue; + try { + if (wantsMissing) { + if (!db || !dbTitle) { + console.error("--missing requires SQLite (omit --no-db)."); + process.exitCode = 1; + return; + } + vlog("selecting chapters that need download (pending/failed/missing CBZ)"); + const needing = listChaptersMissing(db, dbTitle.id, cbzExists); + queue = queueItemsMissing(chapters, needing); + console.log( + `Missing: ${String(queue.length)} chapter(s) need download ` + + `(of ${String(chapters.length)} on listing).`, + ); + if (queue.length === 0) { + console.log("Nothing to download."); + return; + } + } else if (wantsAll) { + vlog(`queue mode: --all (${String(chapters.length)} chapter(s))`); + const indices = Array.from({ length: chapters.length }, (_, i) => i + 1); + queue = chaptersByIndices(indices, chapters); + } else if (wantsSelect) { + vlog(`queue mode: --select ${parsed.selectSpec}`); + const indices = parseChapterSelection( + /** @type {string} */ (parsed.selectSpec), + chapters.length, + ); + queue = chaptersByIndices(indices, chapters); + } else { + vlog(`queue mode: --numbers ${parsed.numbersSpec}`); + const orderedNums = parseChapterNumbersSpec( + /** @type {string} */ (parsed.numbersSpec), + ); + queue = queueItemsByChapterNumbers(orderedNums, chapters); + } + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + vlog(`download queue size: ${String(queue.length)}`); + + const titleForPaths = (dbTitle && dbTitle.title) || comicTitle; + const useDbPaths = Boolean(db && dataDir && dbTitle && !parsed.outDirExplicit); + const dataRoot = + dataDir || + path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data")); + const fallbackOutDir = parsed.outDirExplicit + ? path.join( + /** @type {string} */ (parsed.outDir), + sanitizeComicDirNameForSource(source, comicTitle), + ) + : path.join( + comicsRoot(source, dataRoot), + sanitizeComicDirNameForSource(source, comicTitle), + ); + const outDir = + useDbPaths && queue.length > 0 + ? path.dirname( + resolveCbzPath( + source, + titleForPaths, + queue[0].url, + /** @type {string} */ (dataDir), + ), + ) + : fallbackOutDir; + console.log(`Comic: ${comicTitle}`); + console.log(`Output directory: ${outDir}`); + if (parsed.delaySeconds > 0) { + console.log(`Delay between chapters: ${String(parsed.delaySeconds)}s`); + } + + let failedCount = 0; + let done = 0; + for (const item of queue) { + done += 1; + let targetPath = resolveCbzPath(source, titleForPaths, item.url, dataRoot); + if (parsed.outDirExplicit) { + targetPath = path.join(fallbackOutDir, path.basename(targetPath)); + } else if (useDbPaths && dataDir) { + targetPath = resolveCbzPath(source, titleForPaths, item.url, dataDir); + } + + /** @type {object|undefined} */ + let chapterRow; + if (db && dbTitle) { + chapterRow = cliStore.findChapterByUrl(db, dbTitle.id, item.url); + } + + console.log(`\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`); + console.log(` → ${item.url}`); + vlog(`target CBZ: ${targetPath}`); + + try { + if (db && chapterRow) { + cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" }); + } + const { imageCount, outputPath } = await downloadChapter( + source, + item.url, + targetPath, + ); + console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`); + if (db && chapterRow) { + cliStore.markDownload(db, chapterRow, { + ok: true, + cbzPath: outputPath, + phase: "finish", + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(` ✗ ${msg}`); + if (db && chapterRow) { + cliStore.markDownload(db, chapterRow, { + ok: false, + error: msg, + phase: "finish", + }); + } + failedCount += 1; + } + + if (parsed.delaySeconds > 0 && done < queue.length) { + console.log( + ` … waiting ${String(parsed.delaySeconds)}s before next chapter`, + ); + await sleepSeconds(parsed.delaySeconds); + } + } + + if (failedCount > 0) { + console.error( + `\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`, + ); + process.exitCode = 1; + } + } finally { + if (db) { + db.close(); + } + } +} + +module.exports = { + chapterNumberFromListingTitle, + chaptersByIndices, + listChaptersMissing, + parseChapterNumbersSpec, + parseChapterSelection, + parseCli, + printChapterMenu, + queueItemsByChapterNumbers, + queueItemsMissing, +}; + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} diff --git a/src/cli/fetch.js b/src/cli/fetch.js new file mode 100644 index 0000000..5625190 --- /dev/null +++ b/src/cli/fetch.js @@ -0,0 +1,92 @@ +/** + * Unified fetch CLI: detect source from URL, list chapters, optionally upsert SQLite. + * + * Usage: + * node src/cli/fetch.js <listing-or-chapter-url> [--no-db] + * @returns {Promise<void>} + */ +async function main() { + const argv = process.argv.slice(2); + const noDb = argv.includes("--no-db"); + const pageUrl = argv.find((a) => !a.startsWith("-")); + + if (!pageUrl) { + console.error( + "Usage: node src/cli/fetch.js <listing-or-chapter-url> [--no-db]", + ); + process.exitCode = 1; + return; + } + + const { + detectSource, + fetchSeriesChapters, + siteLabel, + } = require("../server/sources.js"); + + let source; + try { + source = detectSource(pageUrl); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + console.log(`source: ${source} (${siteLabel(source)})`); + + let series; + try { + series = await fetchSeriesChapters(pageUrl, source); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + console.log(`listing: ${series.listingUrl}`); + console.log(`title: ${series.comicTitle}`); + console.log(`slug: ${series.slug}`); + console.log(`Found ${String(series.chapters.length)} chapter(s):`); + console.log( + JSON.stringify( + series.chapters.map((ch) => ({ + url: ch.url, + title: ch.title, + chapter_key: ch.chapter_key, + chapter_number: ch.chapter_number, + })), + null, + 2, + ), + ); + + if (noDb) { + return; + } + + const { openCliDatabase, ensureTitleFromUrl } = require("../server/cli-store.js"); + const path = require("path"); + const { db, dataDir } = openCliDatabase(); + try { + const result = await ensureTitleFromUrl(db, pageUrl, dataDir); + console.log( + `\nSQLite (${path.join(dataDir, "app.db")}): ` + + `title id=${String(result.title.id)} ` + + `"${result.title.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `+${String(result.added)} chapter(s), total ${String(result.total)}`, + ); + } finally { + db.close(); + } +} + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} + +module.exports = { main }; diff --git a/src/comicland/chapters.js b/src/comicland/chapters.js new file mode 100644 index 0000000..410f861 --- /dev/null +++ b/src/comicland/chapters.js @@ -0,0 +1,177 @@ +const { + CHAPTER_PATHNAME_PATTERN, + COMICLAND_API_ORIGIN, + COMICLAND_HOSTS, + LISTING_PATHNAME_PATTERN, +} = require("./constants.js"); +const { fetchComicLandJson, unwrapApiData } = require("./io.js"); + +/** + * @typedef {object} ChapterLink + * @property {string} url - Absolute chapter reader URL. + * @property {string} title - Chapter label (trimmed). + */ + +/** + * @param {string} pathname - URL pathname. + * @returns {string} Path without trailing slashes. + */ +function stripTrailingSlash(pathname) { + return pathname.replace(/\/+$/u, "") || "/"; +} + +/** + * @param {string} pathname - URL pathname. + * @returns {boolean} Whether this path is a ComicLand chapter reader URL. + */ +function isChapterPathname(pathname) { + return CHAPTER_PATHNAME_PATTERN.test(stripTrailingSlash(pathname)); +} + +/** + * @param {string} pathname - URL pathname. + * @returns {boolean} Whether this path is a ComicLand series listing URL. + */ +function isListingPathname(pathname) { + return LISTING_PATHNAME_PATTERN.test(stripTrailingSlash(pathname)); +} + +/** + * @param {string} url - Any http(s) URL. + * @returns {boolean} True when hostname is ComicLand (hint only). + */ +function isLikelyComicLandSite(url) { + try { + const { hostname } = new URL(url); + const host = hostname.toLowerCase(); + return COMICLAND_HOSTS.some((h) => h === host) || host.includes("comicland"); + } catch { + return false; + } +} + +/** + * Series slug from `/comic/{slug}` or `/comic/{slug}/chapter/{index}`. + * @param {string} pageUrl - Series or chapter URL. + * @returns {string} Slug segment, or `"comic"`. + */ +function seriesSlugFromListingUrl(pageUrl) { + try { + const { pathname } = new URL(pageUrl); + const parts = pathname.split("/").filter(Boolean); + const comicIdx = parts.indexOf("comic"); + if (comicIdx !== -1 && parts[comicIdx + 1] !== undefined) { + return parts[comicIdx + 1]; + } + } catch { + /* invalid URL */ + } + return "comic"; +} + +/** + * @param {string} pageUrl - Series or chapter URL. + * @returns {{ origin: string, slug: string, chapterIndex: string|null }} Parsed parts. + */ +function parseComicLandUrl(pageUrl) { + const u = new URL(pageUrl); + const parts = u.pathname.split("/").filter(Boolean); + const comicIdx = parts.indexOf("comic"); + const slug = + comicIdx !== -1 && parts[comicIdx + 1] !== undefined + ? parts[comicIdx + 1] + : "comic"; + let chapterIndex = null; + const chapterIdx = parts.indexOf("chapter"); + if (chapterIdx !== -1 && parts[chapterIdx + 1] !== undefined) { + chapterIndex = parts[chapterIdx + 1]; + } + return { origin: u.origin, slug, chapterIndex }; +} + +/** + * @param {string} siteOrigin - e.g. `https://comicland.org`. + * @param {string} slug - Series slug. + * @param {string|number} chapterIndex - API `chapter_index`. + * @returns {string} Canonical reader URL (no trailing slash). + */ +function chapterUrlFromParts(siteOrigin, slug, chapterIndex) { + const origin = siteOrigin.replace(/\/+$/u, ""); + return `${origin}/comic/${slug}/chapter/${String(chapterIndex)}`; +} + +/** + * @param {unknown} rawIndex - API `chapter_index`. + * @returns {string} Index as used in the reader URL. + */ +function chapterIndexString(rawIndex) { + if (typeof rawIndex === "number" && Number.isFinite(rawIndex)) { + return Number.isInteger(rawIndex) ? String(rawIndex) : String(rawIndex); + } + if (typeof rawIndex === "string" && rawIndex.trim()) { + return rawIndex.trim(); + } + throw new Error("ComicLand chapter is missing chapter_index"); +} + +/** + * GET `/api/comic/detail?slug=` and return mapped chapter links (oldest first). + * @param {string} listingPageUrl - e.g. `https://comicland.org/comic/{slug}` + * @returns {Promise<{ chapters: ChapterLink[], comicTitle: string }>} Chapter rows and series title. + */ +async function fetchChapterLinks(listingPageUrl) { + const { origin, slug } = parseComicLandUrl(listingPageUrl); + const apiUrl = `${COMICLAND_API_ORIGIN}/api/comic/detail?slug=${encodeURIComponent(slug)}`; + const payload = await fetchComicLandJson(apiUrl); + const data = unwrapApiData(payload, "comic/detail"); + + const comicTitle = + typeof data.title === "string" && data.title.trim() + ? data.title.trim() + : slug; + + const rawChapters = Array.isArray(data.chapters) ? data.chapters : []; + /** @type {ChapterLink[]} */ + const chapters = []; + + for (const row of rawChapters) { + if (!row || typeof row !== "object") { + continue; + } + let index; + try { + index = chapterIndexString(row.chapter_index); + } catch { + continue; + } + const title = + typeof row.title === "string" && row.title.trim() + ? row.title.trim().replace(/\s+/gu, " ") + : `Chapter ${index}`; + chapters.push({ + url: chapterUrlFromParts(origin, slug, index), + title, + }); + } + + chapters.sort((a, b) => { + const na = Number.parseFloat(String(new URL(a.url).pathname.split("/").pop())); + const nb = Number.parseFloat(String(new URL(b.url).pathname.split("/").pop())); + if (Number.isFinite(na) && Number.isFinite(nb) && na !== nb) { + return na - nb; + } + return a.url.localeCompare(b.url); + }); + + return { chapters, comicTitle }; +} + +module.exports = { + chapterUrlFromParts, + fetchChapterLinks, + isChapterPathname, + isLikelyComicLandSite, + isListingPathname, + parseComicLandUrl, + seriesSlugFromListingUrl, +}; diff --git a/src/comicland/constants.js b/src/comicland/constants.js new file mode 100644 index 0000000..1455f1b --- /dev/null +++ b/src/comicland/constants.js @@ -0,0 +1,67 @@ +/** + * ComicLand API and CDN constants. The public site is a React SPA; listings and + * pages come from `api.comicland.org`, not from HTML. + * @see https://comicland.org/comic/{slug} + */ + +/** Hostnames this adapter is intended for (friendly checks + detectSource). */ +const COMICLAND_HOSTS = /** @type {const} */ ([ + "comicland.org", + "www.comicland.org", +]); + +/** Canonical site origin used as Origin/Referer for API and CDN requests. */ +const COMICLAND_ORIGIN = "https://comicland.org/"; + +/** JSON API origin used by the SPA (`Nt("/comic/detail", …)`). */ +const COMICLAND_API_ORIGIN = "https://api.comicland.org"; + +/** Default sample listing used by the fetch CLI when no URL is given. */ +const DEFAULT_LISTING_URL = "https://comicland.org/comic/the-delinquent-girl"; + +/** Series page: `/comic/{slug}` (trailing slash optional). */ +const LISTING_PATHNAME_PATTERN = /^\/comic\/[^/]+$/iu; + +/** Reader page: `/comic/{slug}/chapter/{index}` (index may include a decimal). */ +const CHAPTER_PATHNAME_PATTERN = /^\/comic\/[^/]+\/chapter\/[^/]+$/iu; + +/** Browser-like User-Agent for API + CDN (matches a desktop Chrome client). */ +const USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + +/** + * Headers for JSON API GETs (SPA sends Origin/Referer as comicland.org). + * @type {Readonly<Record<string, string>>} + */ +const API_JSON_HEADERS = { + Accept: "application/json,text/plain,*/*", + "Accept-Language": "en-US,en;q=0.9", + Origin: "https://comicland.org", + Referer: COMICLAND_ORIGIN, + "User-Agent": USER_AGENT, +}; + +/** + * Headers for CDN page image GETs. + * @type {Readonly<Record<string, string>>} + */ +const CDN_IMAGE_HEADERS = { + Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + Referer: COMICLAND_ORIGIN, + Origin: "https://comicland.org", + "User-Agent": USER_AGENT, +}; + +module.exports = { + API_JSON_HEADERS, + CDN_IMAGE_HEADERS, + CHAPTER_PATHNAME_PATTERN, + COMICLAND_API_ORIGIN, + COMICLAND_HOSTS, + COMICLAND_ORIGIN, + DEFAULT_LISTING_URL, + LISTING_PATHNAME_PATTERN, + USER_AGENT, +}; diff --git a/src/comicland/downloadChapterCbz.js b/src/comicland/downloadChapterCbz.js new file mode 100644 index 0000000..33573d0 --- /dev/null +++ b/src/comicland/downloadChapterCbz.js @@ -0,0 +1,240 @@ +const fs = require("fs/promises"); +const path = require("path"); +const os = require("os"); +const AdmZip = require("adm-zip"); + +const { downloadChapterImages } = require("./images.js"); +const { fetchChapterLinks, parseComicLandUrl, seriesSlugFromListingUrl } = + require("./chapters.js"); + +/** + * `/comic/my-series/chapter/12` → `my-series-chapter-12`. + * @param {string} chapterPageUrl - Full chapter URL. + * @returns {string} Suggested `.cbz` stem. + */ +function stemForComicLandChapterCbz(chapterPageUrl) { + try { + const { pathname } = new URL(chapterPageUrl); + const segs = pathname.replace(/\/+$/u, "").split("/").filter(Boolean); + const comicIdx = segs.indexOf("comic"); + if (comicIdx !== -1 && segs.length >= comicIdx + 4 && segs[comicIdx + 2] === "chapter") { + return `${segs[comicIdx + 1]}-chapter-${segs[comicIdx + 3]}`; + } + if (segs.length >= 2) { + return segs.slice(-2).join("-"); + } + return segs.at(-1) || "chapter"; + } catch { + return "chapter"; + } +} + +/** + * @param {string} stem - File base segment. + * @returns {string} Sanitized stem. + */ +function sanitizeFsStem(stem) { + const cleaned = stem.replace(/[^a-z0-9._-]+/giu, "_").replace(/^_+|_+$/gu, ""); + return cleaned.length > 0 ? cleaned.slice(0, 180) : "chapter"; +} + +/** + * @param {string} name - Human-readable comic title. + * @returns {string} Filesystem-safe directory name. + */ +function sanitizeComicDirName(name) { + const collapsed = name.trim().replace(/\s+/gu, " "); + const cleaned = [...collapsed] + .filter((ch) => { + const code = ch.charCodeAt(0); + if (code < 32 || code === 127) { + return false; + } + return !/[<>:"/\\|?*]/.test(ch); + }) + .join("") + .replace(/\.+$/g, "") + .trim(); + return cleaned.length > 0 ? cleaned.slice(0, 180) : "comic"; +} + +/** + * @param {string|undefined} cliOut - Positional output path/dir. + * @param {string} defaultStem - When `cliOut` is omitted or directory-like. + * @param {string} [comicTitle] - When set, default output goes under this subdirectory. + * @returns {string} Absolute `.cbz` path. + */ +function resolveCliOutputPath(cliOut, defaultStem, comicTitle) { + const stem = sanitizeFsStem(defaultStem); + const baseDir = + typeof comicTitle === "string" && comicTitle.trim().length > 0 + ? path.join( + process.cwd(), + "data", + "comicland", + "comics", + sanitizeComicDirName(comicTitle), + ) + : path.join(process.cwd(), "data", "comicland", "comics"); + if (cliOut === undefined || cliOut.trim() === "") { + return path.join(baseDir, `${stem}.cbz`); + } + const trimmed = cliOut.trim(); + const abs = path.resolve(trimmed); + if (abs.toLowerCase().endsWith(".cbz")) { + return abs; + } + return path.join(abs, `${stem}.cbz`); +} + +/** + * Downloads ComicLand chapter pages to temp storage, packs `.cbz`, removes temp folder. + * @param {string} chapterUrl - Chapter reader URL. + * @param {string} outputCbzPath - Destination `.cbz`. + * @returns {Promise<{ imageCount: number, outputPath: string }>} Page count and written archive path. + */ +async function chapterUrlToCbz(chapterUrl, outputCbzPath) { + const absOut = outputCbzPath.toLowerCase().endsWith(".cbz") + ? path.resolve(outputCbzPath) + : `${path.resolve(outputCbzPath)}.cbz`; + + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cxyz-comicland-chapter-")); + + try { + const saved = await downloadChapterImages(chapterUrl, tmpRoot); + + if (saved.length === 0) { + throw new Error("No chapter images were found for this ComicLand URL."); + } + + await fs.mkdir(path.dirname(absOut), { recursive: true }); + + const zip = new AdmZip(); + for (const { filePath } of saved) { + zip.addLocalFile(filePath, "", path.basename(filePath)); + } + zip.writeZip(absOut); + + return { imageCount: saved.length, outputPath: absOut }; + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {}); + } +} + +/** + * Listing URL for the series that owns this chapter. + * @param {string} chapterUrl - Chapter or listing URL. + * @returns {string} `/comic/{slug}/` listing URL. + */ +function listingUrlFromChapterUrl(chapterUrl) { + const { origin, slug } = parseComicLandUrl(chapterUrl); + return `${origin.replace(/\/+$/u, "")}/comic/${slug}/`; +} + +/** + * CLI: `node src/comicland/downloadChapterCbz.js <chapterUrl> [.cbz|dir] [--no-db]` + * Upserts the series into SQLite and marks this chapter downloaded unless `--no-db`. + * @returns {Promise<void>} + */ +async function main() { + const argv = process.argv.slice(2); + const noDb = argv.includes("--no-db"); + const positional = argv.filter((a) => !a.startsWith("-")); + const chapterUrl = positional[0]; + if (!chapterUrl) { + console.error( + "Usage: node src/comicland/downloadChapterCbz.js <chapterUrl> [output.cbz|outputDirectory] [--no-db]", + ); + process.exitCode = 1; + return; + } + + const outArg = positional[1]; + const stem = stemForComicLandChapterCbz(chapterUrl); + let comicTitle = seriesSlugFromListingUrl(chapterUrl); + try { + const listingUrl = listingUrlFromChapterUrl(chapterUrl); + const { comicTitle: fetchedTitle } = await fetchChapterLinks(listingUrl); + comicTitle = fetchedTitle || comicTitle; + } catch { + /* keep slug fallback */ + } + + /** @type {import("better-sqlite3").Database|null} */ + let db = null; + /** @type {string|undefined} */ + let dataDir; + /** @type {object|undefined} */ + let chapterRow; + let target; + + try { + if (!noDb) { + const cliStore = require("../server/cli-store.js"); + const { resolveCbzPath } = require("../server/sources.js"); + ({ db, dataDir } = cliStore.openCliDatabase()); + const result = await cliStore.ensureTitleFromUrl(db, chapterUrl, dataDir); + console.log( + `SQLite: title id=${String(result.title.id)} "${result.title.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `${String(result.total)} chapter(s)`, + ); + chapterRow = cliStore.findChapterByUrl(db, result.title.id, chapterUrl); + if (outArg === undefined || outArg.trim() === "") { + target = resolveCbzPath( + "comicland", + result.title.title || comicTitle, + chapterUrl, + dataDir, + ); + } else { + target = resolveCliOutputPath(outArg, stem, comicTitle); + } + if (chapterRow) { + cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" }); + } + } else { + target = resolveCliOutputPath(outArg, stem, comicTitle); + } + + const { imageCount, outputPath } = await chapterUrlToCbz(chapterUrl, target); + console.log(`Wrote ${String(imageCount)} page(s) → ${outputPath}`); + + if (db && chapterRow) { + const cliStore = require("../server/cli-store.js"); + cliStore.markDownload(db, chapterRow, { + ok: true, + cbzPath: outputPath, + phase: "finish", + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(msg); + if (db && chapterRow) { + const cliStore = require("../server/cli-store.js"); + cliStore.markDownload(db, chapterRow, { + ok: false, + error: msg, + phase: "finish", + }); + } + process.exitCode = 1; + } finally { + if (db) { + db.close(); + } + } +} + +module.exports = { + chapterUrlToCbz, + resolveCliOutputPath, + sanitizeComicDirName, + sanitizeFsStem, + stemForComicLandChapterCbz, +}; + +if (require.main === module) { + main(); +} diff --git a/src/comicland/downloadChaptersQueue.js b/src/comicland/downloadChaptersQueue.js new file mode 100644 index 0000000..4782b17 --- /dev/null +++ b/src/comicland/downloadChaptersQueue.js @@ -0,0 +1,732 @@ +const path = require("path"); + +const { fetchChapterLinks, isLikelyComicLandSite } = require("./chapters.js"); +const { stemForComicLandChapterCbz, chapterUrlToCbz, sanitizeComicDirName } = + require("./downloadChapterCbz.js"); + +/** + * @typedef {object} ParsedCli + * @property {boolean} [help] - Print usage and exit when true. + * @property {string|undefined} listingUrl - Series listing URL (required unless help). + * @property {boolean} listOnly - Only print the chapter menu. + * @property {boolean} allChapters - Queue every chapter (API already oldest-first). + * @property {boolean} missingOnly - Queue chapters that need download (SQLite + CBZ on disk). + * @property {string|undefined} selectSpec - `--select` string (1-based menu indices). + * @property {string|undefined} numbersSpec - `--numbers` string (chapter numbers from titles). + * @property {string} outDir - Output directory for `.cbz` files. + * @property {boolean} noDb - Skip SQLite updates. + * @property {number|undefined} titleId - Load listing URL from SQLite title id. + * @property {boolean} outDirExplicit - True when `--out` was passed. + * @property {boolean} verbose - Print informational progress logs. + * @property {number} delaySeconds - Pause between chapter downloads (0 = no delay). + */ + +/** + * @param {string[]} argv - `process.argv`. + * @returns {ParsedCli} Parsed flags and positional URL. + */ +function parseCli(argv) { + /** @type {ParsedCli} */ + const result = { + help: false, + listingUrl: undefined, + listOnly: false, + allChapters: false, + missingOnly: false, + selectSpec: undefined, + numbersSpec: undefined, + outDir: path.join(process.cwd(), "data", "comicland", "comics"), + noDb: false, + titleId: undefined, + outDirExplicit: false, + verbose: false, + delaySeconds: 0, + }; + + for (let i = 2; i < argv.length; i += 1) { + const a = argv[i]; + if (a === "--help" || a === "-h") { + result.help = true; + } else if (a === "--list-only" || a === "-l") { + result.listOnly = true; + } else if (a === "--all") { + result.allChapters = true; + } else if (a === "--missing") { + result.missingOnly = true; + } else if (a === "--verbose" || a === "-v") { + result.verbose = true; + } else if (a === "--no-db") { + result.noDb = true; + } else if (a === "--delay") { + const next = argv[i + 1]; + if (next === undefined || (next.startsWith("-") && !/^-?\d+$/u.test(next))) { + throw new Error("Missing value for --delay"); + } + const seconds = Number.parseInt(next, 10); + if (!Number.isFinite(seconds) || seconds < 0) { + throw new Error(`Invalid --delay: ${next} (expected non-negative integer seconds)`); + } + result.delaySeconds = seconds; + i += 1; + } else if (a === "--title-id") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --title-id"); + } + const id = Number.parseInt(next, 10); + if (!Number.isFinite(id) || id < 1) { + throw new Error(`Invalid --title-id: ${next}`); + } + result.titleId = id; + i += 1; + } else if (a === "--select" || a === "-s") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --select"); + } + result.selectSpec = next; + i += 1; + } else if (a === "--numbers" || a === "-n") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --numbers"); + } + result.numbersSpec = next; + i += 1; + } else if (a === "--out" || a === "-o") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --out"); + } + result.outDir = path.resolve(next); + result.outDirExplicit = true; + i += 1; + } else if (!a.startsWith("-")) { + if (result.listingUrl) { + throw new Error(`Unexpected extra argument: ${a}`); + } + result.listingUrl = a; + } else { + throw new Error(`Unknown option: ${a}`); + } + } + + return result; +} + +/** + * @param {boolean} verbose - When false, no-op. + * @returns {(msg: string) => void} Logger. + */ +function makeVlog(verbose) { + return (msg) => { + if (verbose) { + console.log(`info: ${msg}`); + } + }; +} + +/** + * @param {number} seconds - Seconds to wait. + * @returns {Promise<void>} + */ +function sleepSeconds(seconds) { + return new Promise((resolve) => { + setTimeout(resolve, seconds * 1000); + }); +} + +/** + * Build a download queue from SQLite chapters that still need a CBZ. + * @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first listing. + * @param {object[]} needingRows - Chapter rows from SQLite. + * @returns {Array<{ url: string, title: string, pickLabel: string }>} Queue items. + */ +function queueItemsMissing(chapters, needingRows) { + /** @type {Map<string, object>} */ + const byUrl = new Map(); + for (const row of needingRows) { + if (typeof row.url === "string" && row.url) { + byUrl.set(row.url, row); + } + } + + /** @type {Array<{ url: string, title: string, pickLabel: string }>} */ + const items = []; + /** @type {Set<string>} */ + const used = new Set(); + + for (const ch of chapters) { + const row = byUrl.get(ch.url); + if (!row) { + continue; + } + used.add(ch.url); + const reason = + row.status === "downloaded" ? "file-missing" : String(row.status); + items.push({ + url: ch.url, + title: ch.title, + pickLabel: `missing:${reason}`, + }); + } + + for (const row of needingRows) { + if (typeof row.url !== "string" || !row.url || used.has(row.url)) { + continue; + } + const reason = + row.status === "downloaded" ? "file-missing" : String(row.status); + items.push({ + url: row.url, + title: row.label || row.chapter_key || row.url, + pickLabel: `missing:${reason}`, + }); + } + + return items; +} + +/** + * Chapters that need a download (status pending/failed, or downloaded CBZ gone). + * @param {import("better-sqlite3").Database} db - Database handle. + * @param {number} titleId - Title id. + * @param {(path: string|null|undefined) => boolean} cbzExistsFn - File check. + * @returns {object[]} Chapter rows. + */ +function listChaptersMissing(db, titleId, cbzExistsFn) { + const dbApi = require("../server/db.js"); + const rows = dbApi.listChapters(db, titleId); + return rows.filter((ch) => { + if (ch.status === "pending" || ch.status === "failed") { + return true; + } + if (ch.status === "downloaded" && !cbzExistsFn(ch.cbz_path)) { + return true; + } + return false; + }); +} + +/** + * ComicLand API already returns oldest-first; copy so callers can mutate safely. + * @param {import("./chapters.js").ChapterLink[]} chapters - Site/API order. + * @returns {import("./chapters.js").ChapterLink[]} New array, oldest chapter first. + */ +function chaptersInReadingOrder(chapters) { + return [...chapters]; +} + +/** + * @param {string} spec - `--select` value. + * @param {number} count - Chapter count. + * @returns {number[]} Sorted unique 1-based row indices. + */ +function parseChapterSelection(spec, count) { + if (count <= 0) { + throw new Error("Chapter list is empty; cannot select indices."); + } + + const trimmed = spec.trim(); + if (trimmed.length === 0) { + throw new Error("--select value is empty."); + } + + /** @type {Set<number>} */ + const seen = new Set(); + + for (const rawPart of trimmed.split(",")) { + const part = rawPart.trim(); + if (part.length === 0) { + continue; + } + + if (/^\d+$/u.test(part)) { + const n = Number.parseInt(part, 10); + if (n < 1 || n > count) { + throw new Error( + `Index ${String(n)} is out of range (valid: 1–${String(count)}).`, + ); + } + seen.add(n); + continue; + } + + const rangeMatch = /^(\d+)\s*-\s*(\d+)$/u.exec(part); + if (rangeMatch) { + const lo = Number.parseInt(rangeMatch[1], 10); + const hi = Number.parseInt(rangeMatch[2], 10); + const a = Math.min(lo, hi); + const b = Math.max(lo, hi); + for (let j = a; j <= b; j += 1) { + if (j < 1 || j > count) { + throw new Error( + `Range ${part} includes invalid index ${String(j)} (valid: 1–${String(count)}).`, + ); + } + seen.add(j); + } + continue; + } + + throw new Error(`Invalid --select segment: "${part}"`); + } + + if (seen.size === 0) { + throw new Error("No indices parsed from --select."); + } + + return [...seen].sort((x, y) => x - y); +} + +/** + * @param {string} title - e.g. `Chapter 22`. + * @returns {string|null} Chapter number string from title, or null. + */ +function chapterNumberFromListingTitle(title) { + const m = /^Chapter\s+([\d.]+)\s*$/iu.exec(title.trim().replace(/\s+/gu, " ")); + return m ? m[1] : null; +} + +/** + * @param {string} spec - `--numbers` argument. + * @returns {string[]} Ordered unique chapter number strings as entered. + */ +function parseChapterNumbersSpec(spec) { + const trimmed = spec.trim(); + if (trimmed.length === 0) { + throw new Error("--numbers value is empty."); + } + + /** @type {string[]} */ + const ordered = []; + /** @type {Set<string>} */ + const seen = new Set(); + + for (const rawPart of trimmed.split(",")) { + const part = rawPart.trim(); + if (part.length === 0) { + continue; + } + + const rangeMatch = /^(\d+)\s*-\s*(\d+)$/u.exec(part); + if (rangeMatch) { + const lo = Number.parseInt(rangeMatch[1], 10); + const hi = Number.parseInt(rangeMatch[2], 10); + const a = Math.min(lo, hi); + const b = Math.max(lo, hi); + for (let n = a; n <= b; n += 1) { + const k = String(n); + if (!seen.has(k)) { + seen.add(k); + ordered.push(k); + } + } + continue; + } + + if (/^\d+(\.\d+)?$/u.test(part)) { + if (!seen.has(part)) { + seen.add(part); + ordered.push(part); + } + continue; + } + + throw new Error(`Invalid --numbers segment: "${part}"`); + } + + if (ordered.length === 0) { + throw new Error("No chapter numbers parsed from --numbers."); + } + + return ordered; +} + +/** + * @param {string[]} orderedNums - Chapter numbers requested. + * @param {import("./chapters.js").ChapterLink[]} chapters - Oldest→newest. + * @returns {Array<{ url: string, title: string, pickLabel: string }>} Rows to download. + */ +function queueItemsByChapterNumbers(orderedNums, chapters) { + /** @type {Map<string, import("./chapters.js").ChapterLink>} */ + const first = new Map(); + for (const ch of chapters) { + const num = chapterNumberFromListingTitle(ch.title); + if (num !== null && !first.has(num)) { + first.set(num, ch); + } + } + + return orderedNums.map((num) => { + const ch = first.get(num); + if (!ch) { + throw new Error( + `No chapter titled "Chapter ${num}" in the ComicLand listing. ` + + "Use --list-only and check titles.", + ); + } + return { + url: ch.url, + title: ch.title, + pickLabel: `Ch.${num}`, + }; + }); +} + +/** + * @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first list. + * @returns {void} + */ +function printChapterMenu(chapters) { + const digits = String(chapters.length).length; + for (let i = 0; i < chapters.length; i += 1) { + const n = i + 1; + const pad = String(n).padStart(digits, " "); + const title = chapters[i].title.replace(/\s+/gu, " ").trim(); + console.log(`${pad}. ${title}`); + console.log(` ${chapters[i].url}`); + } +} + +/** + * @param {number[]} indices1 - Sorted 1-based indices. + * @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first chapter list. + * @returns {Array<{ url: string, title: string, pickLabel: string }>} Queue rows. + */ +function chaptersByIndices(indices1, chapters) { + return indices1.map((idx) => ({ + url: chapters[idx - 1].url, + title: chapters[idx - 1].title, + pickLabel: `#${String(idx)}`, + })); +} + +/** + * CLI entry: fetch listing, optionally build `.cbz` archives. + * Upserts titles/chapters into SQLite unless `--no-db`. + * @returns {Promise<void>} + */ +async function main() { + let parsed; + try { + parsed = parseCli(process.argv); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + if (parsed.help) { + console.log(`ComicLand chapter queue (JSON API; URLs like /comic/{slug}): + +Usage: + node src/comicland/downloadChaptersQueue.js <listing-url> [--list-only|-l] + node src/comicland/downloadChaptersQueue.js <listing-url> --numbers|-n <spec> [--out|-o <dir>] + node src/comicland/downloadChaptersQueue.js <listing-url> --select|-s <spec> [--out|-o <dir>] + node src/comicland/downloadChaptersQueue.js <listing-url> --all [--out|-o <dir>] + node src/comicland/downloadChaptersQueue.js <listing-url> --missing [--out|-o <dir>] + node src/comicland/downloadChaptersQueue.js --title-id <id> --missing … + node src/comicland/downloadChaptersQueue.js --title-id <id> --numbers|-n <spec> … + +Listing URL: series page https://comicland.org/comic/{slug} (chapter URL is also accepted). + +--title-id: load listing URL from SQLite (DATA_DIR/app.db) instead of a URL argument +--missing: download chapters that are pending, failed, or downloaded but CBZ missing (requires SQLite) +--delay N: wait N seconds between chapter downloads (e.g. --delay 300) +--no-db: write CBZ files only; do not update SQLite (incompatible with --missing / --title-id) +--verbose|-v: print informational progress logs +DATA_DIR: data root (default: ./data) + +Default --out: data/comicland/comics/<comic title>/ + (with DB and no --out: $DATA_DIR/comicland/comics/<comic title>/) + +--numbers: titles like "Chapter 22", e.g. 22,23,24 or 22-24 +--select: 1-based rows on printed list (line 1 = Chapter 1) +`); + return; + } + + const vlog = makeVlog(parsed.verbose); + const cliStore = require("../server/cli-store.js"); + const { resolveCbzPath, cbzExists } = require("../server/sources.js"); + const dbApi = require("../server/db.js"); + + /** @type {import("better-sqlite3").Database|null} */ + let db = null; + /** @type {string|undefined} */ + let dataDir; + /** @type {object|undefined} */ + let dbTitle; + + try { + if (parsed.missingOnly && parsed.noDb) { + console.error("Cannot combine --missing with --no-db (SQLite is required)."); + process.exitCode = 1; + return; + } + + if (!parsed.noDb || parsed.titleId !== undefined || parsed.missingOnly) { + if (parsed.noDb && parsed.titleId !== undefined) { + console.error("Cannot combine --no-db with --title-id."); + process.exitCode = 1; + return; + } + vlog(`opening SQLite under DATA_DIR=${cliStore.resolveDataDir()}`); + ({ db, dataDir } = cliStore.openCliDatabase()); + vlog(`database ready: ${path.join(dataDir, "app.db")}`); + } + + if (parsed.titleId !== undefined) { + vlog(`looking up --title-id ${String(parsed.titleId)}`); + dbTitle = dbApi.getTitle(/** @type {import("better-sqlite3").Database} */ (db), parsed.titleId); + if (!dbTitle) { + console.error(`Title id ${String(parsed.titleId)} not found in SQLite.`); + process.exitCode = 1; + return; + } + if (dbTitle.source !== "comicland") { + console.error( + `Title id ${String(parsed.titleId)} is source "${dbTitle.source}"; use the matching CLI.`, + ); + process.exitCode = 1; + return; + } + parsed.listingUrl = dbTitle.url; + vlog(`resolved title "${dbTitle.title}" → ${dbTitle.url}`); + } + + if (!parsed.listingUrl) { + console.error( + "Usage: node src/comicland/downloadChaptersQueue.js <listing-url> [options]\n" + + "Or: --title-id <id> [options]", + ); + process.exitCode = 1; + return; + } + + if (!isLikelyComicLandSite(parsed.listingUrl)) { + console.warn( + "Warning: URL hostname does not look like ComicLand; the API client may fail.", + ); + } + + vlog(`fetching chapter listing: ${parsed.listingUrl}`); + let chapters; + let comicTitle; + try { + ({ chapters, comicTitle } = await fetchChapterLinks(parsed.listingUrl)); + chapters = chaptersInReadingOrder(chapters); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + console.log(`Found ${String(chapters.length)} ComicLand chapter link(s).`); + printChapterMenu(chapters); + vlog(`comic title from listing: ${comicTitle}`); + + if (parsed.listOnly) { + if (db && dataDir) { + vlog("upserting title/chapters into SQLite (--list-only)"); + const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir); + console.log( + `\nSQLite: title id=${String(result.title.id)} "${result.title.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `+${String(result.added)} chapter(s), total ${String(result.total)}`, + ); + } + return; + } + + const wantsAll = parsed.allChapters; + const wantsMissing = parsed.missingOnly; + const wantsSelect = Boolean(parsed.selectSpec); + const wantsNumbers = Boolean(parsed.numbersSpec); + const pickModes = [wantsAll, wantsMissing, wantsSelect, wantsNumbers].filter( + Boolean, + ).length; + if (pickModes === 0) { + console.error("Choose --numbers, --select, --all, or --missing (or --list-only)."); + process.exitCode = 1; + return; + } + if (pickModes > 1) { + console.error("Use only one of --all, --missing, --select, or --numbers."); + process.exitCode = 1; + return; + } + + if (db && dataDir) { + vlog("upserting title/chapters into SQLite"); + const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir); + dbTitle = result.title; + console.log( + `SQLite: title id=${String(dbTitle.id)} "${dbTitle.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `+${String(result.added)} chapter(s), total ${String(result.total)}`, + ); + } + + /** @type {Array<{ url: string, title: string, pickLabel: string }>} */ + let queue; + try { + if (wantsMissing) { + if (!db || !dbTitle) { + console.error("--missing requires SQLite (omit --no-db)."); + process.exitCode = 1; + return; + } + vlog("selecting chapters that need download (pending/failed/missing CBZ)"); + const needing = listChaptersMissing(db, dbTitle.id, cbzExists); + queue = queueItemsMissing(chapters, needing); + console.log( + `Missing: ${String(queue.length)} chapter(s) need download ` + + `(of ${String(chapters.length)} on listing).`, + ); + if (queue.length === 0) { + console.log("Nothing to download."); + return; + } + } else if (wantsAll) { + vlog(`queue mode: --all (${String(chapters.length)} chapter(s))`); + const indices = Array.from({ length: chapters.length }, (_, i) => i + 1); + queue = chaptersByIndices(indices, chapters); + } else if (wantsSelect) { + vlog(`queue mode: --select ${parsed.selectSpec}`); + const indices = parseChapterSelection( + /** @type {string} */ (parsed.selectSpec), + chapters.length, + ); + queue = chaptersByIndices(indices, chapters); + } else { + vlog(`queue mode: --numbers ${parsed.numbersSpec}`); + const orderedNums = parseChapterNumbersSpec( + /** @type {string} */ (parsed.numbersSpec), + ); + queue = queueItemsByChapterNumbers(orderedNums, chapters); + } + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + vlog(`download queue size: ${String(queue.length)}`); + + const titleForPaths = (dbTitle && dbTitle.title) || comicTitle; + const useDbPaths = Boolean(db && dataDir && dbTitle && !parsed.outDirExplicit); + const fallbackOutDir = path.join(parsed.outDir, sanitizeComicDirName(comicTitle)); + const outDir = + useDbPaths && queue.length > 0 + ? path.dirname( + resolveCbzPath( + "comicland", + titleForPaths, + queue[0].url, + /** @type {string} */ (dataDir), + ), + ) + : fallbackOutDir; + console.log(`Comic: ${comicTitle}`); + console.log(`Output directory: ${outDir}`); + if (parsed.delaySeconds > 0) { + console.log(`Delay between chapters: ${String(parsed.delaySeconds)}s`); + } + + let failedCount = 0; + let done = 0; + for (const item of queue) { + done += 1; + const stem = stemForComicLandChapterCbz(item.url); + let targetPath = path.join(fallbackOutDir, `${stem}.cbz`); + /** @type {object|undefined} */ + let chapterRow; + + if (useDbPaths) { + targetPath = resolveCbzPath( + "comicland", + titleForPaths, + item.url, + /** @type {string} */ (dataDir), + ); + chapterRow = cliStore.findChapterByUrl( + /** @type {import("better-sqlite3").Database} */ (db), + dbTitle.id, + item.url, + ); + } else if (db && dbTitle) { + chapterRow = cliStore.findChapterByUrl(db, dbTitle.id, item.url); + } + + console.log(`\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`); + console.log(` → ${item.url}`); + vlog(`target CBZ: ${targetPath}`); + + try { + if (db && chapterRow) { + cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" }); + } + const { imageCount, outputPath } = await chapterUrlToCbz(item.url, targetPath); + console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`); + if (db && chapterRow) { + cliStore.markDownload(db, chapterRow, { + ok: true, + cbzPath: outputPath, + phase: "finish", + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(` ✗ ${msg}`); + if (db && chapterRow) { + cliStore.markDownload(db, chapterRow, { + ok: false, + error: msg, + phase: "finish", + }); + } + failedCount += 1; + } + + if (parsed.delaySeconds > 0 && done < queue.length) { + console.log( + ` … waiting ${String(parsed.delaySeconds)}s before next chapter`, + ); + await sleepSeconds(parsed.delaySeconds); + } + } + + if (failedCount > 0) { + console.error( + `\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`, + ); + process.exitCode = 1; + } + } finally { + if (db) { + db.close(); + } + } +} + +module.exports = { + chapterNumberFromListingTitle, + chaptersByIndices, + chaptersInReadingOrder, + listChaptersMissing, + parseChapterNumbersSpec, + parseChapterSelection, + parseCli, + printChapterMenu, + queueItemsByChapterNumbers, + queueItemsMissing, +}; + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} diff --git a/src/comicland/fetchChapters.cli.js b/src/comicland/fetchChapters.cli.js new file mode 100644 index 0000000..b74e7eb --- /dev/null +++ b/src/comicland/fetchChapters.cli.js @@ -0,0 +1,74 @@ +const fs = require("fs/promises"); +const path = require("path"); + +const { DEFAULT_LISTING_URL } = require("./constants.js"); +const { fetchChapterLinks } = require("./chapters.js"); + +const OUTPUT_JSON = path.join( + __dirname, + "..", + "..", + "data", + "comicland", + "debug-listing.json", +); + +/** + * CLI: fetch ComicLand `/api/comic/detail` and print chapter JSON. + * Optionally upserts into SQLite (`DATA_DIR/app.db`) unless `--no-db`. + * + * Usage: + * node src/comicland/fetchChapters.cli.js [listing-url] [--no-db] + * @returns {Promise<void>} + */ +async function main() { + const argv = process.argv.slice(2); + const noDb = argv.includes("--no-db"); + const pageUrl = + argv.find((a) => !a.startsWith("-")) || + process.env.COMICLAND_LISTING_URL || + DEFAULT_LISTING_URL; + + const { chapters, comicTitle } = await fetchChapterLinks(pageUrl); + + await fs.mkdir(path.dirname(OUTPUT_JSON), { recursive: true }); + await fs.writeFile( + OUTPUT_JSON, + JSON.stringify({ comicTitle, listingUrl: pageUrl, chapters }, null, 2), + "utf8", + ); + + console.log(`Source: ${pageUrl}`); + console.log(`Title: ${comicTitle}`); + console.log(`Saved JSON: ${OUTPUT_JSON}`); + console.log(`Found ${String(chapters.length)} chapter link(s) (oldest-first):`); + console.log(JSON.stringify(chapters, null, 2)); + + if (noDb) { + return; + } + + const { openCliDatabase, ensureTitleFromUrl } = require("../server/cli-store.js"); + const { db, dataDir } = openCliDatabase(); + try { + const result = await ensureTitleFromUrl(db, pageUrl, dataDir); + console.log( + `\nSQLite (${path.join(dataDir, "app.db")}): ` + + `title id=${String(result.title.id)} ` + + `"${result.title.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `+${String(result.added)} chapter(s), total ${String(result.total)}`, + ); + } finally { + db.close(); + } +} + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} + +module.exports = { main }; diff --git a/src/comicland/images.js b/src/comicland/images.js new file mode 100644 index 0000000..8b83f9a --- /dev/null +++ b/src/comicland/images.js @@ -0,0 +1,104 @@ +const fs = require("fs/promises"); +const path = require("path"); +const axios = require("axios"); + +const { COMICLAND_API_ORIGIN } = require("./constants.js"); +const { parseComicLandUrl } = require("./chapters.js"); +const { + extensionFromContentType, + extensionFromImageUrl, + fetchComicLandJson, + imageGetConfig, + unwrapApiData, +} = require("./io.js"); + +/** + * Collect page image URLs from `/api/chapter/pages_by_index`. + * @param {string} chapterPageUrl - Reader URL `/comic/{slug}/chapter/{index}`. + * @returns {Promise<string[]>} Absolute image URLs in reading order. + */ +async function collectChapterImageUrls(chapterPageUrl) { + const { slug, chapterIndex } = parseComicLandUrl(chapterPageUrl); + if (!chapterIndex) { + throw new Error( + `ComicLand chapter URL is missing an index: ${chapterPageUrl}`, + ); + } + const apiUrl = + `${COMICLAND_API_ORIGIN}/api/chapter/pages_by_index` + + `?slug=${encodeURIComponent(slug)}&index=${encodeURIComponent(chapterIndex)}`; + const payload = await fetchComicLandJson(apiUrl); + const data = unwrapApiData(payload, "chapter/pages_by_index"); + const pages = Array.isArray(data.pages) ? data.pages : []; + /** @type {string[]} */ + const urls = []; + for (const raw of pages) { + if (typeof raw !== "string" || !raw.trim()) { + continue; + } + try { + urls.push(new URL(raw.trim()).href); + } catch { + /* skip bad URL */ + } + } + return urls; +} + +/** + * @typedef {object} SavedChapterImage + * @property {string} imageUrl - Remote URL that was downloaded. + * @property {string} filePath - Path of the saved file. + */ + +/** + * Downloads chapter pages into `data/comicland/chapters/{slug}-{index}/` by default. + * @param {string} chapterPageUrl - Full `/comic/{slug}/chapter/{index}` URL. + * @param {string} [outDir] - Override output directory. + * @returns {Promise<SavedChapterImage[]>} One record per saved page file. + */ +async function downloadChapterImages(chapterPageUrl, outDir) { + const urls = await collectChapterImageUrls(chapterPageUrl); + const { slug, chapterIndex } = parseComicLandUrl(chapterPageUrl); + const dirSlug = `${slug}-chapter-${chapterIndex || "unknown"}`; + + const targetDir = + typeof outDir === "string" && outDir.trim().length > 0 + ? path.resolve(outDir.trim()) + : path.join( + path.join(__dirname, "..", ".."), + "data", + "comicland", + "chapters", + dirSlug, + ); + + await fs.mkdir(targetDir, { recursive: true }); + + const cfg = imageGetConfig(); + + /** @type {SavedChapterImage[]} */ + const saved = []; + let index = 0; + + for (const imageUrl of urls) { + index += 1; + const { data, headers } = await axios.get(imageUrl, cfg); + const ct = headers["content-type"]; + let ext = extensionFromImageUrl(imageUrl); + if (ext.length === 0) { + ext = extensionFromContentType(typeof ct === "string" ? ct : "") || ".bin"; + } + const filename = `page-${String(index).padStart(4, "0")}${ext}`; + const filePath = path.join(targetDir, filename); + await fs.writeFile(filePath, Buffer.from(data)); + saved.push({ imageUrl, filePath }); + } + + return saved; +} + +module.exports = { + collectChapterImageUrls, + downloadChapterImages, +}; diff --git a/src/comicland/index.js b/src/comicland/index.js new file mode 100644 index 0000000..1bb9210 --- /dev/null +++ b/src/comicland/index.js @@ -0,0 +1,61 @@ +/** + * ComicLand adapter: JSON API listing + page URLs, image download, and CBZ CLIs. + * Tweak {@link ./constants.js} if the API origin or routes change. + */ + +const { + API_JSON_HEADERS, + CDN_IMAGE_HEADERS, + CHAPTER_PATHNAME_PATTERN, + COMICLAND_API_ORIGIN, + COMICLAND_HOSTS, + COMICLAND_ORIGIN, + DEFAULT_LISTING_URL, + LISTING_PATHNAME_PATTERN, +} = require("./constants.js"); + +const { + fetchChapterLinks, + isChapterPathname, + isLikelyComicLandSite, + isListingPathname, + parseComicLandUrl, + seriesSlugFromListingUrl, +} = require("./chapters.js"); + +const { + collectChapterImageUrls, + downloadChapterImages, +} = require("./images.js"); + +const { + chapterUrlToCbz, + resolveCliOutputPath, + sanitizeComicDirName, + sanitizeFsStem, + stemForComicLandChapterCbz, +} = require("./downloadChapterCbz.js"); + +module.exports = { + API_JSON_HEADERS, + CDN_IMAGE_HEADERS, + CHAPTER_PATHNAME_PATTERN, + COMICLAND_API_ORIGIN, + COMICLAND_HOSTS, + COMICLAND_ORIGIN, + DEFAULT_LISTING_URL, + LISTING_PATHNAME_PATTERN, + chapterUrlToCbz, + collectChapterImageUrls, + downloadChapterImages, + fetchChapterLinks, + isChapterPathname, + isLikelyComicLandSite, + isListingPathname, + parseComicLandUrl, + resolveCliOutputPath, + sanitizeComicDirName, + sanitizeFsStem, + seriesSlugFromListingUrl, + stemForComicLandChapterCbz, +}; diff --git a/src/comicland/io.js b/src/comicland/io.js new file mode 100644 index 0000000..9faf932 --- /dev/null +++ b/src/comicland/io.js @@ -0,0 +1,116 @@ +const path = require("path"); +const axios = require("axios"); + +const { API_JSON_HEADERS, CDN_IMAGE_HEADERS } = require("./constants.js"); + +/** @type {import("axios").AxiosRequestConfig} */ +const DEFAULT_GET_JSON = { + responseType: "json", + timeout: 30_000, + headers: { ...API_JSON_HEADERS }, + validateStatus: (s) => s >= 200 && s < 300, +}; + +/** + * GET a ComicLand API URL and return the parsed JSON body. + * @param {string} url - Absolute `api.comicland.org` URL. + * @returns {Promise<object>} JSON object. + */ +async function fetchComicLandJson(url) { + const { data } = await axios.get(url, DEFAULT_GET_JSON); + if (data && typeof data === "object") { + return data; + } + if (typeof data === "string") { + return JSON.parse(data); + } + throw new Error(`ComicLand API returned unexpected body from ${url}`); +} + +/** + * @param {object} payload - API envelope. + * @param {string} label - Endpoint name for errors. + * @returns {object} `payload.data` when `code === 0`. + */ +function unwrapApiData(payload, label) { + if (!payload || typeof payload !== "object") { + throw new Error(`ComicLand ${label}: empty response`); + } + const code = /** @type {{ code?: unknown }} */ (payload).code; + if (code !== 0) { + const msg = + typeof payload.message === "string" && payload.message.trim() + ? payload.message.trim() + : `code ${String(code)}`; + throw new Error(`ComicLand ${label} failed: ${msg}`); + } + const { data } = /** @type {{ data?: object }} */ (payload); + if (!data || typeof data !== "object") { + throw new Error(`ComicLand ${label}: missing data`); + } + return data; +} + +/** + * @param {string} contentType - `Content-Type` header value. + * @returns {string} File extension including dot, or empty string. + */ +function extensionFromContentType(contentType) { + if (!contentType || typeof contentType !== "string") { + return ""; + } + const ct = contentType.split(";")[0].trim().toLowerCase(); + /** @type {Record<string, string>} */ + const map = { + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/pjpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", + "image/avif": ".avif", + "image/svg+xml": ".svg", + }; + return map[ct] || ""; +} + +/** + * @param {string} imageUrl - Absolute image URL. + * @returns {string} Lowercase extension with dot, or empty string. + */ +function extensionFromImageUrl(imageUrl) { + try { + const p = new URL(imageUrl).pathname; + const ext = path.extname(p).toLowerCase(); + if (ext.length > 0 && ext.length <= 8 && /^\.[a-z0-9]+$/u.test(ext)) { + return ext; + } + } catch { + /* invalid URL */ + } + return ""; +} + +/** + * Axios config for CDN page bytes. + * @returns {import("axios").AxiosRequestConfig} GET config for binary image responses. + */ +function imageGetConfig() { + return { + responseType: "arraybuffer", + timeout: 120_000, + maxContentLength: 50 * 1024 * 1024, + maxBodyLength: 50 * 1024 * 1024, + headers: { ...CDN_IMAGE_HEADERS }, + validateStatus: (s) => s >= 200 && s < 300, + }; +} + +module.exports = { + DEFAULT_GET_JSON, + extensionFromContentType, + extensionFromImageUrl, + fetchComicLandJson, + imageGetConfig, + unwrapApiData, +}; diff --git a/src/downloadChapterCbz.js b/src/downloadChapterCbz.js index 87ceb9d..30679a0 100644 --- a/src/downloadChapterCbz.js +++ b/src/downloadChapterCbz.js @@ -114,20 +114,24 @@ async function chapterUrlToCbz(chapterUrl, outputCbzPath) { } /** - * CLI: `node src/downloadChapterCbz.js <chapterUrl> [path/to/file.cbz|path/to/dir/]` + * CLI: `node src/downloadChapterCbz.js <chapterUrl> [path/to/file.cbz|path/to/dir/] [--no-db]` + * Upserts the series into SQLite and marks this chapter downloaded unless `--no-db`. * @returns {Promise<void>} */ async function main() { - const chapterUrl = process.argv[2]; - if (!chapterUrl || chapterUrl.startsWith("-")) { + const argv = process.argv.slice(2); + const noDb = argv.includes("--no-db"); + const positional = argv.filter((a) => !a.startsWith("-")); + const chapterUrl = positional[0]; + if (!chapterUrl) { console.error( - "Usage: node src/downloadChapterCbz.js <chapterUrl> [output.cbz|outputDirectory]", + "Usage: node src/downloadChapterCbz.js <chapterUrl> [output.cbz|outputDirectory] [--no-db]", ); process.exitCode = 1; return; } - const outArg = process.argv[3]; + const outArg = positional[1]; const stem = stemForCbzFilename(chapterUrl); let comicTitle = mangaSlugFromUrl(chapterUrl); try { @@ -136,14 +140,71 @@ async function main() { } catch { /* keep slug fallback */ } - const target = resolveCliOutputPath(outArg, stem, comicTitle); + + /** @type {import("better-sqlite3").Database|null} */ + let db = null; + /** @type {string|undefined} */ + let dataDir; + /** @type {object|undefined} */ + let chapterRow; + let target; try { + if (!noDb) { + const cliStore = require("./server/cli-store.js"); + const { resolveCbzPath } = require("./server/sources.js"); + ({ db, dataDir } = cliStore.openCliDatabase()); + const result = await cliStore.ensureTitleFromUrl(db, chapterUrl, dataDir); + console.log( + `SQLite: title id=${String(result.title.id)} "${result.title.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `${String(result.total)} chapter(s)`, + ); + chapterRow = cliStore.findChapterByUrl(db, result.title.id, chapterUrl); + if (outArg === undefined || outArg.trim() === "") { + target = resolveCbzPath( + "mangaread", + result.title.title || comicTitle, + chapterUrl, + dataDir, + ); + } else { + target = resolveCliOutputPath(outArg, stem, comicTitle); + } + if (chapterRow) { + cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" }); + } + } else { + target = resolveCliOutputPath(outArg, stem, comicTitle); + } + const { imageCount, outputPath } = await chapterUrlToCbz(chapterUrl, target); console.log(`Wrote ${String(imageCount)} page(s) → ${outputPath}`); + + if (db && chapterRow) { + const cliStore = require("./server/cli-store.js"); + cliStore.markDownload(db, chapterRow, { + ok: true, + cbzPath: outputPath, + phase: "finish", + }); + } } catch (err) { - console.error(err instanceof Error ? err.message : err); + const msg = err instanceof Error ? err.message : String(err); + console.error(msg); + if (db && chapterRow) { + const cliStore = require("./server/cli-store.js"); + cliStore.markDownload(db, chapterRow, { + ok: false, + error: msg, + phase: "finish", + }); + } process.exitCode = 1; + } finally { + if (db) { + db.close(); + } } } diff --git a/src/downloadChaptersQueue.js b/src/downloadChaptersQueue.js index e29e400..ba5875f 100644 --- a/src/downloadChaptersQueue.js +++ b/src/downloadChaptersQueue.js @@ -9,9 +9,15 @@ const { chapterUrlToCbz, sanitizeComicDirName, stemForCbzFilename } = require(". * @property {string|undefined} listingUrl - Page containing `li.wp-manga-chapter` links. * @property {boolean} listOnly - Print numbered chapters and exit. * @property {boolean} allChapters - Queue every chapter in list order. + * @property {boolean} missingOnly - Queue chapters that need download (SQLite + CBZ on disk). * @property {string|undefined} selectSpec - Comma-separated **list indices** on the printed menu (oldest first: row 1 ≈ Chapter 1), e.g. `1,3,5-7`. * @property {string|undefined} numbersSpec - Comma-separated **chapter numbers** from titles, e.g. `22,23,24` or `22-24`. * @property {string} outDir - Directory for `.cbz` files. + * @property {boolean} noDb - Skip SQLite updates. + * @property {number|undefined} titleId - Load listing URL from SQLite title id. + * @property {boolean} outDirExplicit - True when `--out` was passed (custom CBZ root). + * @property {boolean} verbose - Print informational progress logs. + * @property {number} delaySeconds - Pause between chapter downloads (0 = no delay). */ /** @@ -26,9 +32,15 @@ function parseCli(argv) { listingUrl: undefined, listOnly: false, allChapters: false, + missingOnly: false, selectSpec: undefined, numbersSpec: undefined, outDir: path.join(process.cwd(), "data", "comics"), + noDb: false, + titleId: undefined, + outDirExplicit: false, + verbose: false, + delaySeconds: 0, }; for (let i = 2; i < argv.length; i += 1) { @@ -39,6 +51,34 @@ function parseCli(argv) { result.listOnly = true; } else if (a === "--all") { result.allChapters = true; + } else if (a === "--missing") { + result.missingOnly = true; + } else if (a === "--verbose" || a === "-v") { + result.verbose = true; + } else if (a === "--no-db") { + result.noDb = true; + } else if (a === "--delay") { + const next = argv[i + 1]; + if (next === undefined || (next.startsWith("-") && !/^-?\d+$/u.test(next))) { + throw new Error("Missing value for --delay"); + } + const seconds = Number.parseInt(next, 10); + if (!Number.isFinite(seconds) || seconds < 0) { + throw new Error(`Invalid --delay: ${next} (expected non-negative integer seconds)`); + } + result.delaySeconds = seconds; + i += 1; + } else if (a === "--title-id") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --title-id"); + } + const id = Number.parseInt(next, 10); + if (!Number.isFinite(id) || id < 1) { + throw new Error(`Invalid --title-id: ${next}`); + } + result.titleId = id; + i += 1; } else if (a === "--select" || a === "-s") { const next = argv[i + 1]; if (next === undefined || next.startsWith("-")) { @@ -59,6 +99,7 @@ function parseCli(argv) { throw new Error("Missing value for --out"); } result.outDir = path.resolve(next); + result.outDirExplicit = true; i += 1; } else if (!a.startsWith("-")) { if (result.listingUrl) { @@ -73,6 +114,102 @@ function parseCli(argv) { return result; } +/** + * @param {boolean} verbose - When false, no-op. + * @returns {(msg: string) => void} Logger. + */ +function makeVlog(verbose) { + return (msg) => { + if (verbose) { + console.log(`info: ${msg}`); + } + }; +} + +/** + * @param {number} seconds - Seconds to wait. + * @returns {Promise<void>} + */ +function sleepSeconds(seconds) { + return new Promise((resolve) => { + setTimeout(resolve, seconds * 1000); + }); +} + +/** + * Build a download queue from SQLite chapters that still need a CBZ + * (pending, failed, or marked downloaded but file missing), in listing order. + * @param {import("./mangareadFetchChapters.js").ChapterLink[]} chapters - Oldest-first listing. + * @param {object[]} needingRows - Chapter rows from SQLite. + * @returns {Array<{ url: string, title: string, pickLabel: string }>} Queue items. + */ +function queueItemsMissing(chapters, needingRows) { + /** @type {Map<string, object>} */ + const byUrl = new Map(); + for (const row of needingRows) { + if (typeof row.url === "string" && row.url) { + byUrl.set(row.url, row); + } + } + + /** @type {Array<{ url: string, title: string, pickLabel: string }>} */ + const items = []; + /** @type {Set<string>} */ + const used = new Set(); + + for (let i = 0; i < chapters.length; i += 1) { + const ch = chapters[i]; + const row = byUrl.get(ch.url); + if (!row) { + continue; + } + used.add(ch.url); + const reason = + row.status === "downloaded" ? "file-missing" : String(row.status); + items.push({ + url: ch.url, + title: ch.title, + pickLabel: `missing:${reason}`, + }); + } + + for (const row of needingRows) { + if (typeof row.url !== "string" || !row.url || used.has(row.url)) { + continue; + } + const reason = + row.status === "downloaded" ? "file-missing" : String(row.status); + items.push({ + url: row.url, + title: row.label || row.chapter_key || row.url, + pickLabel: `missing:${reason}`, + }); + } + + return items; +} + +/** + * Chapters that need a download (status pending/failed, or downloaded CBZ gone). + * @param {import("better-sqlite3").Database} db - Database handle. + * @param {number} titleId - Title id. + * @param {(path: string|null|undefined) => boolean} cbzExistsFn - File check. + * @returns {object[]} Chapter rows. + */ +function listChaptersMissing(db, titleId, cbzExistsFn) { + const dbApi = require("./server/db.js"); + const rows = dbApi.listChapters(db, titleId); + return rows.filter((ch) => { + if (ch.status === "pending" || ch.status === "failed") { + return true; + } + if (ch.status === "downloaded" && !cbzExistsFn(ch.cbz_path)) { + return true; + } + return false; + }); +} + /** * WP‑Manga lists chapters **newest first** in the HTML. We reverse so the numbered * menu and `--select` indices match reading order (line 1 ≈ Chapter 1). @@ -278,6 +415,7 @@ function chaptersByIndices(indices1, chapters) { /** * Fetches chapter list, queues selected chapters, downloads one `.cbz` at a time. + * Upserts titles/chapters into SQLite unless `--no-db`. * @returns {Promise<void>} */ async function main() { @@ -296,114 +434,316 @@ async function main() { node src/downloadChaptersQueue.js <listing-page-url> --numbers|-n <spec> [--out|-o <dir>] node src/downloadChaptersQueue.js <listing-page-url> --select|-s <spec> [--out|-o <dir>] node src/downloadChaptersQueue.js <listing-page-url> --all [--out|-o <dir>] + node src/downloadChaptersQueue.js <listing-page-url> --missing [--out|-o <dir>] + node src/downloadChaptersQueue.js --title-id <id> --missing … + node src/downloadChaptersQueue.js --title-id <id> --numbers|-n <spec> … Any chapter URL for that manga usually works as <listing-page-url> (same HTML includes the chapter list). +--title-id: load listing URL from SQLite (DATA_DIR/app.db) instead of a URL argument +--missing: download chapters that are pending, failed, or downloaded but CBZ missing (requires SQLite) +--delay N: wait N seconds between chapter downloads (e.g. --delay 300) +--no-db: write CBZ files only; do not update SQLite (incompatible with --missing / --title-id) +--verbose|-v: print informational progress logs +DATA_DIR: data root (default: ./data); CBZ paths match the web app when --out is omitted + --numbers spec: chapter numbers from link titles ("Chapter 22"), e.g. 22,23,24 or 22-24 Decimals (e.g. 160.5) must be listed explicitly; integer ranges expand to whole numbers only. --select spec: 1-based row numbers on the **printed list** (oldest first; row 1 is Chapter 1), e.g. 1,3,5-10. This is the menu index, not a substring of the title (use --numbers for that). -Chapters are processed in list order for --select/--all, or in the order given for --numbers. -Default --out: data/comics/<comic title>/ (under current working directory).`); +Chapters are processed in list order for --select/--all/--missing, or in the order given for --numbers. +Default --out: data/comics/<comic title>/ (under current working directory; with DB: + $DATA_DIR/comics/<comic title>/).`); return; } - if (!parsed.listingUrl) { - console.error( - "Usage: node src/downloadChaptersQueue.js <listing-page-url> [options]\nTry --help for details.", - ); - process.exitCode = 1; - return; - } + const vlog = makeVlog(parsed.verbose); + const cliStore = require("./server/cli-store.js"); + const { resolveCbzPath, cbzExists } = require("./server/sources.js"); + const dbApi = require("./server/db.js"); + + /** @type {import("better-sqlite3").Database|null} */ + let db = null; + /** @type {string|undefined} */ + let dataDir; + /** @type {object|undefined} */ + let dbTitle; - let chapters; - let comicTitle; try { - ({ chapters, comicTitle } = await fetchWpMangaChapterLinks(parsed.listingUrl)); - chapters = chaptersInReadingOrder(chapters); - } catch (err) { - console.error(err instanceof Error ? err.message : err); - process.exitCode = 1; - return; - } - - console.log(`Found ${String(chapters.length)} chapter link(s) on listing page.`); - printChapterMenu(chapters); - - if (parsed.listOnly) { - return; - } - - const wantsAll = parsed.allChapters; - const wantsSelect = Boolean(parsed.selectSpec); - const wantsNumbers = Boolean(parsed.numbersSpec); - const pickModes = [wantsAll, wantsSelect, wantsNumbers].filter(Boolean).length; - if (pickModes === 0) { - console.error( - "Choose: --numbers <spec>, --select <spec>, or --all (or use --list-only to exit after listing).", - ); - process.exitCode = 1; - return; - } - if (pickModes > 1) { - console.error("Use only one of --all, --select, or --numbers."); - process.exitCode = 1; - return; - } - - /** @type {Array<{ url: string, title: string, pickLabel: string }>} */ - let queue; - try { - if (wantsAll) { - const indices = Array.from({ length: chapters.length }, (_, i) => i + 1); - queue = chaptersByIndices(indices, chapters); - } else if (wantsSelect) { - const indices = parseChapterSelection( - /** @type {string} */ (parsed.selectSpec), - chapters.length, - ); - queue = chaptersByIndices(indices, chapters); - } else { - const orderedNums = parseMangaChapterNumbersSpec( - /** @type {string} */ (parsed.numbersSpec), - ); - queue = queueItemsByChapterNumbers(orderedNums, chapters); + if (parsed.missingOnly && parsed.noDb) { + console.error("Cannot combine --missing with --no-db (SQLite is required)."); + process.exitCode = 1; + return; } - } catch (err) { - console.error(err instanceof Error ? err.message : err); - process.exitCode = 1; - return; - } - const outDir = path.join(parsed.outDir, sanitizeComicDirName(comicTitle)); - console.log(`Comic: ${comicTitle}`); - console.log(`Output directory: ${outDir}`); - let failedCount = 0; - let done = 0; - for (const item of queue) { - done += 1; - const stem = stemForCbzFilename(item.url); - const targetPath = path.join(outDir, `${stem}.cbz`); - console.log( - `\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`, - ); - console.log(` → ${item.url}`); + if (!parsed.noDb || parsed.titleId !== undefined || parsed.missingOnly) { + if (parsed.noDb && parsed.titleId !== undefined) { + console.error("Cannot combine --no-db with --title-id."); + process.exitCode = 1; + return; + } + vlog(`opening SQLite under DATA_DIR=${cliStore.resolveDataDir()}`); + ({ db, dataDir } = cliStore.openCliDatabase()); + vlog(`database ready: ${path.join(dataDir, "app.db")}`); + } + + if (parsed.titleId !== undefined) { + vlog(`looking up --title-id ${String(parsed.titleId)}`); + dbTitle = dbApi.getTitle(/** @type {import("better-sqlite3").Database} */ (db), parsed.titleId); + if (!dbTitle) { + console.error(`Title id ${String(parsed.titleId)} not found in SQLite.`); + process.exitCode = 1; + return; + } + if (dbTitle.source !== "mangaread") { + console.error( + `Title id ${String(parsed.titleId)} is source "${dbTitle.source}"; use the matching CLI.`, + ); + process.exitCode = 1; + return; + } + parsed.listingUrl = dbTitle.url; + vlog(`resolved title "${dbTitle.title}" → ${dbTitle.url}`); + } + + if (!parsed.listingUrl) { + console.error( + "Usage: node src/downloadChaptersQueue.js <listing-page-url> [options]\n" + + "Or: node src/downloadChaptersQueue.js --title-id <id> [options]\n" + + "Try --help for details.", + ); + process.exitCode = 1; + return; + } + + vlog(`fetching chapter listing: ${parsed.listingUrl}`); + let chapters; + let comicTitle; try { - const { imageCount, outputPath } = await chapterUrlToCbz(item.url, targetPath); - console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`); + ({ chapters, comicTitle } = await fetchWpMangaChapterLinks(parsed.listingUrl)); + chapters = chaptersInReadingOrder(chapters); } catch (err) { - console.error(` ✗ ${err instanceof Error ? err.message : err}`); - failedCount += 1; + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; } - } - if (failedCount > 0) { - console.error( - `\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`, + console.log(`Found ${String(chapters.length)} chapter link(s) on listing page.`); + printChapterMenu(chapters); + vlog(`comic title from listing: ${comicTitle}`); + + if (parsed.listOnly) { + if (db && dataDir) { + vlog("upserting title/chapters into SQLite (--list-only)"); + const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir); + console.log( + `\nSQLite: title id=${String(result.title.id)} "${result.title.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `+${String(result.added)} chapter(s), total ${String(result.total)}`, + ); + } + return; + } + + const wantsAll = parsed.allChapters; + const wantsMissing = parsed.missingOnly; + const wantsSelect = Boolean(parsed.selectSpec); + const wantsNumbers = Boolean(parsed.numbersSpec); + const pickModes = [wantsAll, wantsMissing, wantsSelect, wantsNumbers].filter( + Boolean, + ).length; + if (pickModes === 0) { + console.error( + "Choose: --numbers <spec>, --select <spec>, --all, or --missing (or use --list-only).", + ); + process.exitCode = 1; + return; + } + if (pickModes > 1) { + console.error("Use only one of --all, --missing, --select, or --numbers."); + process.exitCode = 1; + return; + } + + if (db && dataDir) { + vlog("upserting title/chapters into SQLite"); + const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir); + dbTitle = result.title; + console.log( + `SQLite: title id=${String(dbTitle.id)} "${dbTitle.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `+${String(result.added)} chapter(s), total ${String(result.total)}`, + ); + } + + /** @type {Array<{ url: string, title: string, pickLabel: string }>} */ + let queue; + try { + if (wantsMissing) { + if (!db || !dbTitle) { + console.error("--missing requires SQLite (omit --no-db)."); + process.exitCode = 1; + return; + } + vlog("selecting chapters that need download (pending/failed/missing CBZ)"); + const needing = listChaptersMissing(db, dbTitle.id, cbzExists); + queue = queueItemsMissing(chapters, needing); + console.log( + `Missing: ${String(queue.length)} chapter(s) need download ` + + `(of ${String(chapters.length)} on listing).`, + ); + if (parsed.verbose) { + for (const item of queue) { + vlog(` queue ${item.pickLabel} ${item.title} → ${item.url}`); + } + } + if (queue.length === 0) { + console.log("Nothing to download."); + return; + } + } else if (wantsAll) { + vlog(`queue mode: --all (${String(chapters.length)} chapter(s))`); + const indices = Array.from({ length: chapters.length }, (_, i) => i + 1); + queue = chaptersByIndices(indices, chapters); + } else if (wantsSelect) { + vlog(`queue mode: --select ${parsed.selectSpec}`); + const indices = parseChapterSelection( + /** @type {string} */ (parsed.selectSpec), + chapters.length, + ); + queue = chaptersByIndices(indices, chapters); + } else { + vlog(`queue mode: --numbers ${parsed.numbersSpec}`); + const orderedNums = parseMangaChapterNumbersSpec( + /** @type {string} */ (parsed.numbersSpec), + ); + queue = queueItemsByChapterNumbers(orderedNums, chapters); + } + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + vlog(`download queue size: ${String(queue.length)}`); + + const titleForPaths = (dbTitle && dbTitle.title) || comicTitle; + const useDbPaths = Boolean(db && dataDir && dbTitle && !parsed.outDirExplicit); + const fallbackOutDir = path.join(parsed.outDir, sanitizeComicDirName(comicTitle)); + const outDir = + useDbPaths && queue.length > 0 + ? path.dirname( + resolveCbzPath( + "mangaread", + titleForPaths, + queue[0].url, + /** @type {string} */ (dataDir), + ), + ) + : fallbackOutDir; + console.log(`Comic: ${comicTitle}`); + console.log(`Output directory: ${outDir}`); + if (parsed.delaySeconds > 0) { + console.log(`Delay between chapters: ${String(parsed.delaySeconds)}s`); + } + vlog( + useDbPaths + ? "CBZ paths from resolveCbzPath (DATA_DIR layout)" + : `CBZ paths under ${fallbackOutDir}`, ); - process.exitCode = 1; + + let failedCount = 0; + let done = 0; + for (const item of queue) { + done += 1; + const stem = stemForCbzFilename(item.url); + let targetPath = path.join(fallbackOutDir, `${stem}.cbz`); + /** @type {object|undefined} */ + let chapterRow; + + if (useDbPaths) { + targetPath = resolveCbzPath( + "mangaread", + titleForPaths, + item.url, + /** @type {string} */ (dataDir), + ); + chapterRow = cliStore.findChapterByUrl( + /** @type {import("better-sqlite3").Database} */ (db), + dbTitle.id, + item.url, + ); + } else if (db && dbTitle) { + chapterRow = cliStore.findChapterByUrl(db, dbTitle.id, item.url); + } + + console.log( + `\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`, + ); + console.log(` → ${item.url}`); + vlog(`target CBZ: ${targetPath}`); + if (chapterRow) { + vlog( + `sqlite chapter id=${String(chapterRow.id)} status=${String(chapterRow.status)}`, + ); + } else if (db) { + vlog("no matching chapter row in SQLite for this URL"); + } + + try { + if (db && chapterRow) { + vlog("marking chapter downloading"); + cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" }); + } + vlog("starting chapterUrlToCbz"); + const { imageCount, outputPath } = await chapterUrlToCbz(item.url, targetPath); + console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`); + if (db && chapterRow) { + vlog("marking chapter downloaded"); + cliStore.markDownload(db, chapterRow, { + ok: true, + cbzPath: outputPath, + phase: "finish", + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(` ✗ ${msg}`); + if (db && chapterRow) { + vlog("marking chapter failed"); + cliStore.markDownload(db, chapterRow, { + ok: false, + error: msg, + phase: "finish", + }); + } + failedCount += 1; + } + + if (parsed.delaySeconds > 0 && done < queue.length) { + console.log( + ` … waiting ${String(parsed.delaySeconds)}s before next chapter`, + ); + vlog(`delay ${String(parsed.delaySeconds)}s`); + await sleepSeconds(parsed.delaySeconds); + } + } + + if (failedCount > 0) { + console.error( + `\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`, + ); + process.exitCode = 1; + } else { + vlog(`finished: ${String(queue.length)} chapter(s) succeeded`); + } + } finally { + if (db) { + vlog("closing database"); + db.close(); + } } } @@ -411,11 +751,13 @@ module.exports = { chapterNumberFromWpTitle, chaptersByIndices, chaptersInReadingOrder, + listChaptersMissing, parseChapterSelection, parseCli, parseMangaChapterNumbersSpec, printChapterMenu, queueItemsByChapterNumbers, + queueItemsMissing, }; if (require.main === module) { diff --git a/src/mangareadFetchChapters.js b/src/mangareadFetchChapters.js index d63554a..8ca7fcd 100644 --- a/src/mangareadFetchChapters.js +++ b/src/mangareadFetchChapters.js @@ -391,19 +391,51 @@ async function downloadWpMangaChapterImages(chapterPageUrl, outDir) { /** * Fetches the page, writes HTML to disk, parses chapter rows. + * Optionally upserts into SQLite (`DATA_DIR/app.db`) unless `--no-db`. + * + * Usage: + * node src/mangareadFetchChapters.js [listing-url] [--no-db] * @returns {Promise<void>} */ async function main() { - const html = await fetchPageHtml(PAGE_URL); + const argv = process.argv.slice(2); + const noDb = argv.includes("--no-db"); + const pageUrl = + argv.find((a) => !a.startsWith("-")) || PAGE_URL; + + const html = await fetchPageHtml(pageUrl); await fs.mkdir(path.dirname(OUTPUT_HTML), { recursive: true }); await fs.writeFile(OUTPUT_HTML, html, "utf8"); const chapters = extractWpMangaChapters(html); + console.log(`Source: ${pageUrl}`); console.log(`Saved HTML: ${OUTPUT_HTML}`); console.log(`Found ${String(chapters.length)} li.wp-manga-chapter link(s):`); console.log(JSON.stringify(chapters, null, 2)); + + if (noDb) { + return; + } + + const { + openCliDatabase, + ensureTitleFromUrl, + } = require("./server/cli-store.js"); + const { db, dataDir } = openCliDatabase(); + try { + const result = await ensureTitleFromUrl(db, pageUrl, dataDir); + console.log( + `\nSQLite (${path.join(dataDir, "app.db")}): ` + + `title id=${String(result.title.id)} ` + + `"${result.title.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `+${String(result.added)} chapter(s), total ${String(result.total)}`, + ); + } finally { + db.close(); + } } module.exports = { diff --git a/src/manhwasusu/downloadChapterCbz.js b/src/manhwasusu/downloadChapterCbz.js index 8721db5..21bcb6b 100644 --- a/src/manhwasusu/downloadChapterCbz.js +++ b/src/manhwasusu/downloadChapterCbz.js @@ -124,19 +124,24 @@ async function chapterUrlToCbz(chapterUrl, outputCbzPath) { } /** - * CLI: `node src/manhwasusu/downloadChapterCbz.js <chapterUrl> [.cbz|dir]` + * CLI: `node src/manhwasusu/downloadChapterCbz.js <chapterUrl> [.cbz|dir] [--no-db]` + * Upserts the series into SQLite and marks this chapter downloaded unless `--no-db`. * @returns {Promise<void>} */ async function main() { - const chapterUrl = process.argv[2]; - if (!chapterUrl || chapterUrl.startsWith("-")) { + const argv = process.argv.slice(2); + const noDb = argv.includes("--no-db"); + const positional = argv.filter((a) => !a.startsWith("-")); + const chapterUrl = positional[0]; + if (!chapterUrl) { console.error( - "Usage: node src/manhwasusu/downloadChapterCbz.js <chapterUrl> [output.cbz|outputDirectory]", + "Usage: node src/manhwasusu/downloadChapterCbz.js <chapterUrl> [output.cbz|outputDirectory] [--no-db]", ); process.exitCode = 1; return; } + const outArg = positional[1]; const stem = stemForManhwaSusuChapterCbz(chapterUrl); let comicTitle = seriesSlugFromListingUrl(chapterUrl); try { @@ -145,14 +150,71 @@ async function main() { } catch { /* keep slug fallback */ } - const target = resolveCliOutputPath(process.argv[3], stem, comicTitle); + + /** @type {import("better-sqlite3").Database|null} */ + let db = null; + /** @type {string|undefined} */ + let dataDir; + /** @type {object|undefined} */ + let chapterRow; + let target; try { + if (!noDb) { + const cliStore = require("../server/cli-store.js"); + const { resolveCbzPath } = require("../server/sources.js"); + ({ db, dataDir } = cliStore.openCliDatabase()); + const result = await cliStore.ensureTitleFromUrl(db, chapterUrl, dataDir); + console.log( + `SQLite: title id=${String(result.title.id)} "${result.title.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `${String(result.total)} chapter(s)`, + ); + chapterRow = cliStore.findChapterByUrl(db, result.title.id, chapterUrl); + if (outArg === undefined || outArg.trim() === "") { + target = resolveCbzPath( + "manhwasusu", + result.title.title || comicTitle, + chapterUrl, + dataDir, + ); + } else { + target = resolveCliOutputPath(outArg, stem, comicTitle); + } + if (chapterRow) { + cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" }); + } + } else { + target = resolveCliOutputPath(outArg, stem, comicTitle); + } + const { imageCount, outputPath } = await chapterUrlToCbz(chapterUrl, target); console.log(`Wrote ${String(imageCount)} page(s) → ${outputPath}`); + + if (db && chapterRow) { + const cliStore = require("../server/cli-store.js"); + cliStore.markDownload(db, chapterRow, { + ok: true, + cbzPath: outputPath, + phase: "finish", + }); + } } catch (err) { - console.error(err instanceof Error ? err.message : err); + const msg = err instanceof Error ? err.message : String(err); + console.error(msg); + if (db && chapterRow) { + const cliStore = require("../server/cli-store.js"); + cliStore.markDownload(db, chapterRow, { + ok: false, + error: msg, + phase: "finish", + }); + } process.exitCode = 1; + } finally { + if (db) { + db.close(); + } } } diff --git a/src/manhwasusu/downloadChaptersQueue.js b/src/manhwasusu/downloadChaptersQueue.js index 1fdd26b..b505d6d 100644 --- a/src/manhwasusu/downloadChaptersQueue.js +++ b/src/manhwasusu/downloadChaptersQueue.js @@ -10,9 +10,15 @@ const { stemForManhwaSusuChapterCbz, chapterUrlToCbz, sanitizeComicDirName } = * @property {string|undefined} listingUrl - Series listing URL (required unless help). * @property {boolean} listOnly - Only print the chapter menu. * @property {boolean} allChapters - Queue every chapter after reversal to reading order. + * @property {boolean} missingOnly - Queue chapters that need download (SQLite + CBZ on disk). * @property {string|undefined} selectSpec - `--select` string (1-based menu indices). * @property {string|undefined} numbersSpec - `--numbers` string (chapter numbers from titles). * @property {string} outDir - Output directory for `.cbz` files. + * @property {boolean} noDb - Skip SQLite updates. + * @property {number|undefined} titleId - Load listing URL from SQLite title id. + * @property {boolean} outDirExplicit - True when `--out` was passed. + * @property {boolean} verbose - Print informational progress logs. + * @property {number} delaySeconds - Pause between chapter downloads (0 = no delay). */ /** @@ -26,9 +32,15 @@ function parseCli(argv) { listingUrl: undefined, listOnly: false, allChapters: false, + missingOnly: false, selectSpec: undefined, numbersSpec: undefined, outDir: path.join(process.cwd(), "data", "manhwasusu", "comics"), + noDb: false, + titleId: undefined, + outDirExplicit: false, + verbose: false, + delaySeconds: 0, }; for (let i = 2; i < argv.length; i += 1) { @@ -39,6 +51,34 @@ function parseCli(argv) { result.listOnly = true; } else if (a === "--all") { result.allChapters = true; + } else if (a === "--missing") { + result.missingOnly = true; + } else if (a === "--verbose" || a === "-v") { + result.verbose = true; + } else if (a === "--no-db") { + result.noDb = true; + } else if (a === "--delay") { + const next = argv[i + 1]; + if (next === undefined || (next.startsWith("-") && !/^-?\d+$/u.test(next))) { + throw new Error("Missing value for --delay"); + } + const seconds = Number.parseInt(next, 10); + if (!Number.isFinite(seconds) || seconds < 0) { + throw new Error(`Invalid --delay: ${next} (expected non-negative integer seconds)`); + } + result.delaySeconds = seconds; + i += 1; + } else if (a === "--title-id") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Missing value for --title-id"); + } + const id = Number.parseInt(next, 10); + if (!Number.isFinite(id) || id < 1) { + throw new Error(`Invalid --title-id: ${next}`); + } + result.titleId = id; + i += 1; } else if (a === "--select" || a === "-s") { const next = argv[i + 1]; if (next === undefined || next.startsWith("-")) { @@ -59,6 +99,7 @@ function parseCli(argv) { throw new Error("Missing value for --out"); } result.outDir = path.resolve(next); + result.outDirExplicit = true; i += 1; } else if (!a.startsWith("-")) { if (result.listingUrl) { @@ -73,6 +114,101 @@ function parseCli(argv) { return result; } +/** + * @param {boolean} verbose - When false, no-op. + * @returns {(msg: string) => void} Logger. + */ +function makeVlog(verbose) { + return (msg) => { + if (verbose) { + console.log(`info: ${msg}`); + } + }; +} + +/** + * @param {number} seconds - Seconds to wait. + * @returns {Promise<void>} + */ +function sleepSeconds(seconds) { + return new Promise((resolve) => { + setTimeout(resolve, seconds * 1000); + }); +} + +/** + * Build a download queue from SQLite chapters that still need a CBZ. + * @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first listing. + * @param {object[]} needingRows - Chapter rows from SQLite. + * @returns {Array<{ url: string, title: string, pickLabel: string }>} Queue items. + */ +function queueItemsMissing(chapters, needingRows) { + /** @type {Map<string, object>} */ + const byUrl = new Map(); + for (const row of needingRows) { + if (typeof row.url === "string" && row.url) { + byUrl.set(row.url, row); + } + } + + /** @type {Array<{ url: string, title: string, pickLabel: string }>} */ + const items = []; + /** @type {Set<string>} */ + const used = new Set(); + + for (let i = 0; i < chapters.length; i += 1) { + const ch = chapters[i]; + const row = byUrl.get(ch.url); + if (!row) { + continue; + } + used.add(ch.url); + const reason = + row.status === "downloaded" ? "file-missing" : String(row.status); + items.push({ + url: ch.url, + title: ch.title, + pickLabel: `missing:${reason}`, + }); + } + + for (const row of needingRows) { + if (typeof row.url !== "string" || !row.url || used.has(row.url)) { + continue; + } + const reason = + row.status === "downloaded" ? "file-missing" : String(row.status); + items.push({ + url: row.url, + title: row.label || row.chapter_key || row.url, + pickLabel: `missing:${reason}`, + }); + } + + return items; +} + +/** + * Chapters that need a download (status pending/failed, or downloaded CBZ gone). + * @param {import("better-sqlite3").Database} db - Database handle. + * @param {number} titleId - Title id. + * @param {(path: string|null|undefined) => boolean} cbzExistsFn - File check. + * @returns {object[]} Chapter rows. + */ +function listChaptersMissing(db, titleId, cbzExistsFn) { + const dbApi = require("../server/db.js"); + const rows = dbApi.listChapters(db, titleId); + return rows.filter((ch) => { + if (ch.status === "pending" || ch.status === "failed") { + return true; + } + if (ch.status === "downloaded" && !cbzExistsFn(ch.cbz_path)) { + return true; + } + return false; + }); +} + /** * Listing HTML is newest-first; reverse so `--select` line 1 is Chapter 1. * @param {import("./chapters.js").ChapterLink[]} chapters - Site order. @@ -276,6 +412,7 @@ function chaptersByIndices(indices1, chapters) { /** * CLI entry: fetch listing, optionally build `.cbz` archives. + * Upserts titles/chapters into SQLite unless `--no-db`. * @returns {Promise<void>} */ async function main() { @@ -296,10 +433,21 @@ Usage: node src/manhwasusu/downloadChaptersQueue.js <listing-url> --numbers|-n <spec> [--out|-o <dir>] node src/manhwasusu/downloadChaptersQueue.js <listing-url> --select|-s <spec> [--out|-o <dir>] node src/manhwasusu/downloadChaptersQueue.js <listing-url> --all [--out|-o <dir>] + node src/manhwasusu/downloadChaptersQueue.js <listing-url> --missing [--out|-o <dir>] + node src/manhwasusu/downloadChaptersQueue.js --title-id <id> --missing … + node src/manhwasusu/downloadChaptersQueue.js --title-id <id> --numbers|-n <spec> … Listing URL: series page ending in /read/{slug}/ (or chapter URL — site embeds chapter list markup). +--title-id: load listing URL from SQLite (DATA_DIR/app.db) instead of a URL argument +--missing: download chapters that are pending, failed, or downloaded but CBZ missing (requires SQLite) +--delay N: wait N seconds between chapter downloads (e.g. --delay 300) +--no-db: write CBZ files only; do not update SQLite (incompatible with --missing / --title-id) +--verbose|-v: print informational progress logs +DATA_DIR: data root (default: ./data) + Default --out: data/manhwasusu/comics/<comic title>/ + (with DB and no --out: $DATA_DIR/manhwasusu/comics/<comic title>/) --numbers: titles like "Chapter 22", e.g. 22,23,24 or 22-24 --select: 1-based rows on printed list after reversal (line 1 = Chapter 1) @@ -307,103 +455,292 @@ Default --out: data/manhwasusu/comics/<comic title>/ return; } - if (!parsed.listingUrl) { - console.error( - "Usage: node src/manhwasusu/downloadChaptersQueue.js <listing-url> [options]", - ); - process.exitCode = 1; - return; - } + const vlog = makeVlog(parsed.verbose); + const cliStore = require("../server/cli-store.js"); + const { resolveCbzPath, cbzExists } = require("../server/sources.js"); + const dbApi = require("../server/db.js"); - if (!isLikelyManhwaSusuSite(parsed.listingUrl)) { - console.warn( - "Warning: URL hostname does not look like ManhwaSusu; parsers may fail if structure differs.", - ); - } + /** @type {import("better-sqlite3").Database|null} */ + let db = null; + /** @type {string|undefined} */ + let dataDir; + /** @type {object|undefined} */ + let dbTitle; - let chapters; - let comicTitle; try { - ({ chapters, comicTitle } = await fetchChapterLinks(parsed.listingUrl)); - chapters = chaptersInReadingOrder(chapters); - } catch (err) { - console.error(err instanceof Error ? err.message : err); - process.exitCode = 1; - return; - } - - console.log(`Found ${String(chapters.length)} ManhwaSusu chapter link(s).`); - printChapterMenu(chapters); - - if (parsed.listOnly) { - return; - } - - const wantsAll = parsed.allChapters; - const wantsSelect = Boolean(parsed.selectSpec); - const wantsNumbers = Boolean(parsed.numbersSpec); - const pickModes = [wantsAll, wantsSelect, wantsNumbers].filter(Boolean).length; - if (pickModes === 0) { - console.error("Choose --numbers, --select, or --all (or --list-only)."); - process.exitCode = 1; - return; - } - if (pickModes > 1) { - console.error("Use only one of --all, --select, or --numbers."); - process.exitCode = 1; - return; - } - - /** @type {Array<{ url: string, title: string, pickLabel: string }>} */ - let queue; - try { - if (wantsAll) { - const indices = Array.from({ length: chapters.length }, (_, i) => i + 1); - queue = chaptersByIndices(indices, chapters); - } else if (wantsSelect) { - const indices = parseChapterSelection( - /** @type {string} */ (parsed.selectSpec), - chapters.length, - ); - queue = chaptersByIndices(indices, chapters); - } else { - const orderedNums = parseChapterNumbersSpec( - /** @type {string} */ (parsed.numbersSpec), - ); - queue = queueItemsByChapterNumbers(orderedNums, chapters); + if (parsed.missingOnly && parsed.noDb) { + console.error("Cannot combine --missing with --no-db (SQLite is required)."); + process.exitCode = 1; + return; } - } catch (err) { - console.error(err instanceof Error ? err.message : err); - process.exitCode = 1; - return; - } - const outDir = path.join(parsed.outDir, sanitizeComicDirName(comicTitle)); - console.log(`Comic: ${comicTitle}`); - console.log(`Output directory: ${outDir}`); + if (!parsed.noDb || parsed.titleId !== undefined || parsed.missingOnly) { + if (parsed.noDb && parsed.titleId !== undefined) { + console.error("Cannot combine --no-db with --title-id."); + process.exitCode = 1; + return; + } + vlog(`opening SQLite under DATA_DIR=${cliStore.resolveDataDir()}`); + ({ db, dataDir } = cliStore.openCliDatabase()); + vlog(`database ready: ${path.join(dataDir, "app.db")}`); + } - let failedCount = 0; - let done = 0; - for (const item of queue) { - done += 1; - const stem = stemForManhwaSusuChapterCbz(item.url); - const targetPath = path.join(outDir, `${stem}.cbz`); - console.log(`\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`); - console.log(` → ${item.url}`); + if (parsed.titleId !== undefined) { + vlog(`looking up --title-id ${String(parsed.titleId)}`); + dbTitle = dbApi.getTitle(/** @type {import("better-sqlite3").Database} */ (db), parsed.titleId); + if (!dbTitle) { + console.error(`Title id ${String(parsed.titleId)} not found in SQLite.`); + process.exitCode = 1; + return; + } + if (dbTitle.source !== "manhwasusu") { + console.error( + `Title id ${String(parsed.titleId)} is source "${dbTitle.source}"; use the matching CLI.`, + ); + process.exitCode = 1; + return; + } + parsed.listingUrl = dbTitle.url; + vlog(`resolved title "${dbTitle.title}" → ${dbTitle.url}`); + } + + if (!parsed.listingUrl) { + console.error( + "Usage: node src/manhwasusu/downloadChaptersQueue.js <listing-url> [options]\n" + + "Or: --title-id <id> [options]", + ); + process.exitCode = 1; + return; + } + + if (!isLikelyManhwaSusuSite(parsed.listingUrl)) { + console.warn( + "Warning: URL hostname does not look like ManhwaSusu; parsers may fail if structure differs.", + ); + } + + vlog(`fetching chapter listing: ${parsed.listingUrl}`); + let chapters; + let comicTitle; try { - const { imageCount, outputPath } = await chapterUrlToCbz(item.url, targetPath); - console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`); + ({ chapters, comicTitle } = await fetchChapterLinks(parsed.listingUrl)); + chapters = chaptersInReadingOrder(chapters); } catch (err) { - console.error(` ✗ ${err instanceof Error ? err.message : err}`); - failedCount += 1; + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; } - } - if (failedCount > 0) { - console.error( - `\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`, + console.log(`Found ${String(chapters.length)} ManhwaSusu chapter link(s).`); + printChapterMenu(chapters); + vlog(`comic title from listing: ${comicTitle}`); + + if (parsed.listOnly) { + if (db && dataDir) { + vlog("upserting title/chapters into SQLite (--list-only)"); + const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir); + console.log( + `\nSQLite: title id=${String(result.title.id)} "${result.title.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `+${String(result.added)} chapter(s), total ${String(result.total)}`, + ); + } + return; + } + + const wantsAll = parsed.allChapters; + const wantsMissing = parsed.missingOnly; + const wantsSelect = Boolean(parsed.selectSpec); + const wantsNumbers = Boolean(parsed.numbersSpec); + const pickModes = [wantsAll, wantsMissing, wantsSelect, wantsNumbers].filter( + Boolean, + ).length; + if (pickModes === 0) { + console.error("Choose --numbers, --select, --all, or --missing (or --list-only)."); + process.exitCode = 1; + return; + } + if (pickModes > 1) { + console.error("Use only one of --all, --missing, --select, or --numbers."); + process.exitCode = 1; + return; + } + + if (db && dataDir) { + vlog("upserting title/chapters into SQLite"); + const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir); + dbTitle = result.title; + console.log( + `SQLite: title id=${String(dbTitle.id)} "${dbTitle.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `+${String(result.added)} chapter(s), total ${String(result.total)}`, + ); + } + + /** @type {Array<{ url: string, title: string, pickLabel: string }>} */ + let queue; + try { + if (wantsMissing) { + if (!db || !dbTitle) { + console.error("--missing requires SQLite (omit --no-db)."); + process.exitCode = 1; + return; + } + vlog("selecting chapters that need download (pending/failed/missing CBZ)"); + const needing = listChaptersMissing(db, dbTitle.id, cbzExists); + queue = queueItemsMissing(chapters, needing); + console.log( + `Missing: ${String(queue.length)} chapter(s) need download ` + + `(of ${String(chapters.length)} on listing).`, + ); + if (parsed.verbose) { + for (const item of queue) { + vlog(` queue ${item.pickLabel} ${item.title} → ${item.url}`); + } + } + if (queue.length === 0) { + console.log("Nothing to download."); + return; + } + } else if (wantsAll) { + vlog(`queue mode: --all (${String(chapters.length)} chapter(s))`); + const indices = Array.from({ length: chapters.length }, (_, i) => i + 1); + queue = chaptersByIndices(indices, chapters); + } else if (wantsSelect) { + vlog(`queue mode: --select ${parsed.selectSpec}`); + const indices = parseChapterSelection( + /** @type {string} */ (parsed.selectSpec), + chapters.length, + ); + queue = chaptersByIndices(indices, chapters); + } else { + vlog(`queue mode: --numbers ${parsed.numbersSpec}`); + const orderedNums = parseChapterNumbersSpec( + /** @type {string} */ (parsed.numbersSpec), + ); + queue = queueItemsByChapterNumbers(orderedNums, chapters); + } + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + return; + } + + vlog(`download queue size: ${String(queue.length)}`); + + const titleForPaths = (dbTitle && dbTitle.title) || comicTitle; + const useDbPaths = Boolean(db && dataDir && dbTitle && !parsed.outDirExplicit); + const fallbackOutDir = path.join(parsed.outDir, sanitizeComicDirName(comicTitle)); + const outDir = + useDbPaths && queue.length > 0 + ? path.dirname( + resolveCbzPath( + "manhwasusu", + titleForPaths, + queue[0].url, + /** @type {string} */ (dataDir), + ), + ) + : fallbackOutDir; + console.log(`Comic: ${comicTitle}`); + console.log(`Output directory: ${outDir}`); + if (parsed.delaySeconds > 0) { + console.log(`Delay between chapters: ${String(parsed.delaySeconds)}s`); + } + vlog( + useDbPaths + ? "CBZ paths from resolveCbzPath (DATA_DIR layout)" + : `CBZ paths under ${fallbackOutDir}`, ); - process.exitCode = 1; + + let failedCount = 0; + let done = 0; + for (const item of queue) { + done += 1; + const stem = stemForManhwaSusuChapterCbz(item.url); + let targetPath = path.join(fallbackOutDir, `${stem}.cbz`); + /** @type {object|undefined} */ + let chapterRow; + + if (useDbPaths) { + targetPath = resolveCbzPath( + "manhwasusu", + titleForPaths, + item.url, + /** @type {string} */ (dataDir), + ); + chapterRow = cliStore.findChapterByUrl( + /** @type {import("better-sqlite3").Database} */ (db), + dbTitle.id, + item.url, + ); + } else if (db && dbTitle) { + chapterRow = cliStore.findChapterByUrl(db, dbTitle.id, item.url); + } + + console.log(`\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`); + console.log(` → ${item.url}`); + vlog(`target CBZ: ${targetPath}`); + if (chapterRow) { + vlog( + `sqlite chapter id=${String(chapterRow.id)} status=${String(chapterRow.status)}`, + ); + } else if (db) { + vlog("no matching chapter row in SQLite for this URL"); + } + + try { + if (db && chapterRow) { + vlog("marking chapter downloading"); + cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" }); + } + vlog("starting chapterUrlToCbz"); + const { imageCount, outputPath } = await chapterUrlToCbz(item.url, targetPath); + console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`); + if (db && chapterRow) { + vlog("marking chapter downloaded"); + cliStore.markDownload(db, chapterRow, { + ok: true, + cbzPath: outputPath, + phase: "finish", + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(` ✗ ${msg}`); + if (db && chapterRow) { + vlog("marking chapter failed"); + cliStore.markDownload(db, chapterRow, { + ok: false, + error: msg, + phase: "finish", + }); + } + failedCount += 1; + } + + if (parsed.delaySeconds > 0 && done < queue.length) { + console.log( + ` … waiting ${String(parsed.delaySeconds)}s before next chapter`, + ); + vlog(`delay ${String(parsed.delaySeconds)}s`); + await sleepSeconds(parsed.delaySeconds); + } + } + + if (failedCount > 0) { + console.error( + `\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`, + ); + process.exitCode = 1; + } else { + vlog(`finished: ${String(queue.length)} chapter(s) succeeded`); + } + } finally { + if (db) { + vlog("closing database"); + db.close(); + } } } @@ -411,11 +748,13 @@ module.exports = { chapterNumberFromListingTitle, chaptersByIndices, chaptersInReadingOrder, + listChaptersMissing, parseChapterNumbersSpec, parseChapterSelection, parseCli, printChapterMenu, queueItemsByChapterNumbers, + queueItemsMissing, }; if (require.main === module) { diff --git a/src/manhwasusu/fetchChapters.cli.js b/src/manhwasusu/fetchChapters.cli.js index 00b8a06..1c3f24d 100644 --- a/src/manhwasusu/fetchChapters.cli.js +++ b/src/manhwasusu/fetchChapters.cli.js @@ -15,11 +15,23 @@ const OUTPUT_HTML = path.join( ); /** - * CLI: snapshot listing HTML + print chapter JSON. Optional `MANHWASUSU_LISTING_URL` env overrides default. + * CLI: snapshot listing HTML + print chapter JSON. + * Optionally upserts into SQLite (`DATA_DIR/app.db`) unless `--no-db`. + * + * URL resolution order: argv listing URL, then `MANHWASUSU_LISTING_URL`, then default. + * + * Usage: + * node src/manhwasusu/fetchChapters.cli.js [listing-url] [--no-db] * @returns {Promise<void>} */ async function main() { - const pageUrl = process.env.MANHWASUSU_LISTING_URL || DEFAULT_LISTING_URL; + const argv = process.argv.slice(2); + const noDb = argv.includes("--no-db"); + const pageUrl = + argv.find((a) => !a.startsWith("-")) || + process.env.MANHWASUSU_LISTING_URL || + DEFAULT_LISTING_URL; + const html = await fetchManhwaSusuHtml(pageUrl); await fs.mkdir(path.dirname(OUTPUT_HTML), { recursive: true }); @@ -33,6 +45,28 @@ async function main() { `Found ${String(chapters.length)} chapter link(s) (newest-first on page; reverse for reading order):`, ); console.log(JSON.stringify(chapters, null, 2)); + + if (noDb) { + return; + } + + const { + openCliDatabase, + ensureTitleFromUrl, + } = require("../server/cli-store.js"); + const { db, dataDir } = openCliDatabase(); + try { + const result = await ensureTitleFromUrl(db, pageUrl, dataDir); + console.log( + `\nSQLite (${path.join(dataDir, "app.db")}): ` + + `title id=${String(result.title.id)} ` + + `"${result.title.title}" ` + + `(${result.created ? "created" : "updated"}), ` + + `+${String(result.added)} chapter(s), total ${String(result.total)}`, + ); + } finally { + db.close(); + } } if (require.main === module) { diff --git a/src/server/cli-store.js b/src/server/cli-store.js new file mode 100644 index 0000000..fb4568e --- /dev/null +++ b/src/server/cli-store.js @@ -0,0 +1,244 @@ +const path = require("path"); + +const dbApi = require("./db.js"); +const { + detectSource, + normalizeListingUrl, + fetchSeriesChapters, + resolveCbzPath, + cbzExists, + chapterKeyFromUrl, +} = require("./sources.js"); + +/** + * Resolve data directory the same way the web server does. + * @returns {string} Absolute data root. + */ +function resolveDataDir() { + return path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data")); +} + +/** + * Open SQLite under the resolved data directory. + * @returns {{ db: import("better-sqlite3").Database, dataDir: string }} Handle and data root. + */ +function openCliDatabase() { + const dataDir = resolveDataDir(); + const db = dbApi.openDatabase(dataDir); + return { db, dataDir }; +} + +/** + * Apply a fetched series listing onto chapter rows (upsert + heal). + * @param {import("better-sqlite3").Database} db - Database handle. + * @param {object} title - Title row (id, source, title). + * @param {object} series - From fetchSeriesChapters. + * @param {string} dataDir - Absolute data root for CBZ paths. + * @returns {{ added: number, total: number }} Insert count and listing size. + */ +function applySeriesChapters(db, title, series, dataDir) { + let added = 0; + + for (const ch of series.chapters) { + const cbzPath = resolveCbzPath( + title.source, + series.comicTitle || title.title, + ch.url, + dataDir, + ); + const exists = cbzExists(cbzPath); + const { inserted } = dbApi.upsertChapter(db, { + title_id: title.id, + chapter_key: ch.chapter_key, + chapter_number: ch.chapter_number, + label: ch.title, + url: ch.url, + status: exists ? "downloaded" : "pending", + cbz_path: cbzPath, + }); + if (inserted) { + added += 1; + } + + // Heal statuses without interrupting an in-flight download + const row = db + .prepare( + "SELECT id, status, cbz_path FROM chapters WHERE title_id = ? AND chapter_key = ?", + ) + .get(title.id, ch.chapter_key); + if (row && row.status === "downloading") { + continue; + } + if (row && row.status === "downloaded" && !cbzExists(row.cbz_path)) { + dbApi.updateChapter(db, row.id, { + status: "pending", + error: "CBZ missing on disk", + }); + } + if (row && exists && row.status !== "downloaded") { + dbApi.updateChapter(db, row.id, { + status: "downloaded", + cbz_path: cbzPath, + downloaded_at: new Date().toISOString(), + error: null, + }); + } + } + + if (series.comicTitle && series.comicTitle !== title.title) { + dbApi.updateTitle(db, title.id, { title: series.comicTitle }); + } + + dbApi.touchTitleChecked(db, title.id); + + return { + added, + total: series.chapters.length, + }; +} + +/** + * Fetch listing, upsert chapter rows, heal statuses, refresh title name. + * Shared by the cron watcher and CLI scripts (no job / queue side effects). + * @param {import("better-sqlite3").Database} db - Database handle. + * @param {number} titleId - Title to refresh. + * @param {string} dataDir - Absolute data root for CBZ paths. + * @returns {Promise<{ added: number, total: number, series: object }>} Refresh summary. + */ +async function refreshTitleChapters(db, titleId, dataDir) { + const title = dbApi.getTitle(db, titleId); + if (!title) { + throw new Error(`Title ${String(titleId)} not found.`); + } + + const series = await fetchSeriesChapters(title.url, title.source); + const counts = applySeriesChapters(db, title, series, dataDir); + return { + ...counts, + series, + }; +} + +/** + * Find or insert a title from a listing/chapter URL, then refresh chapters. + * @param {import("better-sqlite3").Database} db - Database handle. + * @param {string} rawUrl - Series or chapter URL. + * @param {string} dataDir - Absolute data root. + * @returns {Promise<{ title: object, created: boolean, added: number, total: number }>} Upsert result. + */ +async function ensureTitleFromUrl(db, rawUrl, dataDir) { + const source = detectSource(rawUrl); + const listingUrl = normalizeListingUrl(rawUrl, source); + + const existing = dbApi.findTitleByUrl(db, listingUrl); + if (existing) { + const result = await refreshTitleChapters(db, existing.id, dataDir); + return { + title: dbApi.getTitle(db, existing.id), + created: false, + added: result.added, + total: result.total, + }; + } + + const series = await fetchSeriesChapters(listingUrl, source); + const inserted = dbApi.insertTitle(db, { + source: series.source, + url: series.listingUrl, + slug: series.slug, + title: series.comicTitle, + }); + const counts = applySeriesChapters(db, inserted, series, dataDir); + return { + title: dbApi.getTitle(db, inserted.id), + created: true, + added: counts.added, + total: counts.total, + }; +} + +/** + * Look up a chapter row by title id and chapter URL (via chapter_key). + * @param {import("better-sqlite3").Database} db - Database handle. + * @param {number} titleId - Parent title. + * @param {string} chapterUrl - Chapter reader URL. + * @returns {object|undefined} Chapter row when found. + */ +function findChapterByUrl(db, titleId, chapterUrl) { + const key = chapterKeyFromUrl(chapterUrl); + return db + .prepare("SELECT * FROM chapters WHERE title_id = ? AND chapter_key = ?") + .get(titleId, key); +} + +/** + * Record a CLI download attempt: chapter status + a jobs row for the UI strip. + * @param {import("better-sqlite3").Database} db - Database handle. + * @param {object} chapter - Chapter row (needs id, title_id, label). + * @param {{ ok: boolean, cbzPath?: string|null, error?: string|null, phase?: 'start'|'finish' }} opts - Start or finish payload. + * @returns {number|null} Job id when a finish row was written, else null. + */ +function markDownload(db, chapter, opts) { + if (opts.phase === "start") { + dbApi.updateChapter(db, chapter.id, { + status: "downloading", + error: null, + }); + return null; + } + + const jobId = dbApi.createJob(db, { + type: "download", + title_id: chapter.title_id, + chapter_id: chapter.id, + message: opts.ok + ? `CLI: ${chapter.label}` + : `CLI failed: ${opts.error || "unknown"}`, + }); + + if (opts.ok) { + dbApi.updateChapter(db, chapter.id, { + status: "downloaded", + cbz_path: opts.cbzPath ?? chapter.cbz_path, + downloaded_at: new Date().toISOString(), + error: null, + }); + dbApi.updateJob(db, jobId, { + status: "done", + message: `CLI: ${chapter.label}`, + finished_at: new Date().toISOString(), + }); + } else { + const msg = opts.error || "Download failed"; + dbApi.updateChapter(db, chapter.id, { + status: "failed", + error: msg, + }); + dbApi.updateJob(db, jobId, { + status: "failed", + message: msg, + finished_at: new Date().toISOString(), + }); + } + + return jobId; +} + +/** + * Whether argv requests file-only mode (skip SQLite). + * @param {string[]} argv - process.argv. + * @returns {boolean} True when `--no-db` is present. + */ +function wantsNoDb(argv) { + return argv.includes("--no-db"); +} + +module.exports = { + resolveDataDir, + openCliDatabase, + refreshTitleChapters, + ensureTitleFromUrl, + findChapterByUrl, + markDownload, + wantsNoDb, +}; diff --git a/src/server/db.js b/src/server/db.js index 256b843..1ac224c 100644 --- a/src/server/db.js +++ b/src/server/db.js @@ -23,7 +23,7 @@ function migrate(db) { db.exec(` CREATE TABLE IF NOT EXISTS titles ( id INTEGER PRIMARY KEY AUTOINCREMENT, - source TEXT NOT NULL CHECK (source IN ('mangaread', 'manhwasusu')), + source TEXT NOT NULL CHECK (source IN ('mangaread', 'manhwasusu', 'comicland')), url TEXT NOT NULL UNIQUE, slug TEXT NOT NULL, title TEXT NOT NULL, @@ -64,6 +64,8 @@ function migrate(db) { CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); `); + migrateTitlesSourceCheck(db); + // Drop active jobs left behind after a title was removed (ON DELETE SET NULL). db.prepare( ` @@ -74,6 +76,55 @@ function migrate(db) { ).run(); } +/** + * Existing DBs created with the two-source CHECK are not updated by CREATE TABLE IF NOT EXISTS. + * Rebuild `titles` so `comicland` is allowed. + * @param {import("better-sqlite3").Database} db - Database handle. + */ +function migrateTitlesSourceCheck(db) { + const row = db + .prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'titles'", + ) + .get(); + if (!row || typeof row.sql !== "string" || row.sql.includes("'comicland'")) { + return; + } + + const cols = db.prepare("PRAGMA table_info(titles)").all(); + const colList = cols.map((c) => c.name).join(", "); + + db.pragma("foreign_keys = OFF"); + try { + db.exec("BEGIN"); + db.exec(` + CREATE TABLE titles_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL CHECK (source IN ('mangaread', 'manhwasusu', 'comicland')), + url TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL, + title TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_checked_at TEXT + ); + `); + db.exec(`INSERT INTO titles_new (${colList}) SELECT ${colList} FROM titles`); + db.exec("DROP TABLE titles"); + db.exec("ALTER TABLE titles_new RENAME TO titles"); + db.exec("COMMIT"); + } catch (err) { + try { + db.exec("ROLLBACK"); + } catch { + /* no open transaction */ + } + throw err; + } finally { + db.pragma("foreign_keys = ON"); + } +} + /** * @param {import("better-sqlite3").Database} db * @returns {object[]} diff --git a/src/server/routes.js b/src/server/routes.js index e7c8d54..df3c564 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -8,6 +8,7 @@ const { resolveCbzPath, cbzExists, sanitizeComicDirNameForSource, + siteLabel, transferLinkBasename, } = require("./sources.js"); const { syncTitle } = require("./watcher.js"); @@ -51,7 +52,7 @@ async function routesPlugin(fastify, opts) { .send({ error: err instanceof Error ? err.message : String(err) }); } - const siteLabel = source === "manhwasusu" ? "ManhwaSusu" : "Mangaread"; + const siteName = siteLabel(source); const existing = dbApi.findTitleByUrl(db, listingUrl); if (existing) { @@ -66,7 +67,7 @@ async function routesPlugin(fastify, opts) { }; } catch (err) { return reply.code(502).send({ - error: formatUpstreamError(err, siteLabel), + error: formatUpstreamError(err, siteName), }); } } @@ -76,7 +77,7 @@ async function routesPlugin(fastify, opts) { series = await fetchSeriesChapters(listingUrl, source); } catch (err) { return reply.code(502).send({ - error: formatUpstreamError(err, siteLabel), + error: formatUpstreamError(err, siteName), }); } @@ -178,13 +179,13 @@ async function routesPlugin(fastify, opts) { if (!title) { return reply.code(404).send({ error: "Title not found" }); } - const siteLabel = title.source === "manhwasusu" ? "ManhwaSusu" : "Mangaread"; + const siteName = siteLabel(title.source); try { const result = await syncTitle(db, queue, id, dataDir, { enqueue: true }); return { title: dbApi.getTitle(db, id), ...result }; } catch (err) { return reply.code(502).send({ - error: formatUpstreamError(err, siteLabel), + error: formatUpstreamError(err, siteName), }); } }); diff --git a/src/server/sources.js b/src/server/sources.js index 423c621..ec44758 100644 --- a/src/server/sources.js +++ b/src/server/sources.js @@ -19,8 +19,13 @@ const { chaptersInReadingOrder: manhwaReadingOrder, } = require("../manhwasusu/downloadChaptersQueue.js"); +const comicland = require("../comicland/index.js"); +const { + chaptersInReadingOrder: comiclandReadingOrder, +} = require("../comicland/downloadChaptersQueue.js"); + /** - * @typedef {'mangaread'|'manhwasusu'} SourceId + * @typedef {'mangaread'|'manhwasusu'|'comicland'} SourceId */ /** @@ -39,6 +44,13 @@ function detectSource(rawUrl) { const host = parsed.hostname.toLowerCase(); const pathname = parsed.pathname.replace(/\/+$/u, ""); + if (host.includes("comicland") || comicland.isLikelyComicLandSite(rawUrl)) { + return "comicland"; + } + if (/^\/comic\/[^/]+/iu.test(pathname)) { + return "comicland"; + } + if ( host.includes("manhwasusu") || manhwa.isLikelyManhwaSusuSite(rawUrl) || @@ -65,7 +77,7 @@ function detectSource(rawUrl) { } throw new Error( - "Unsupported URL. Use a Mangaread `/manga/{slug}/` or ManhwaSusu `/read/{slug}/` series page.", + "Unsupported URL. Use a Mangaread `/manga/{slug}/`, ManhwaSusu `/read/{slug}/`, or ComicLand `/comic/{slug}` series page.", ); } @@ -99,6 +111,16 @@ function normalizeListingUrl(rawUrl, source) { } } + if (source === "comicland") { + const comicIdx = segs.indexOf("comic"); + if (comicIdx !== -1 && segs[comicIdx + 1]) { + u.pathname = `/comic/${segs[comicIdx + 1]}/`; + u.search = ""; + u.hash = ""; + return u.toString(); + } + } + if (!u.pathname.endsWith("/")) { u.pathname = `${u.pathname}/`; } @@ -140,6 +162,9 @@ function comicsRoot(source, dataDir) { if (source === "manhwasusu") { return path.join(dataDir, "manhwasusu", "comics"); } + if (source === "comicland") { + return path.join(dataDir, "comicland", "comics"); + } return path.join(dataDir, "comics"); } @@ -151,14 +176,18 @@ function comicsRoot(source, dataDir) { * @returns {string} Absolute CBZ path */ function resolveCbzPath(source, comicTitle, chapterUrl, dataDir) { - const dirName = - source === "manhwasusu" - ? manhwa.sanitizeComicDirName(comicTitle) - : mangareadSanitizeDir(comicTitle); - const stem = - source === "manhwasusu" - ? manhwa.sanitizeFsStem(manhwa.stemForManhwaSusuChapterCbz(chapterUrl)) - : mangareadSanitizeStem(mangareadStem(chapterUrl)); + let dirName; + let stem; + if (source === "manhwasusu") { + dirName = manhwa.sanitizeComicDirName(comicTitle); + stem = manhwa.sanitizeFsStem(manhwa.stemForManhwaSusuChapterCbz(chapterUrl)); + } else if (source === "comicland") { + dirName = comicland.sanitizeComicDirName(comicTitle); + stem = comicland.sanitizeFsStem(comicland.stemForComicLandChapterCbz(chapterUrl)); + } else { + dirName = mangareadSanitizeDir(comicTitle); + stem = mangareadSanitizeStem(mangareadStem(chapterUrl)); + } return path.join(comicsRoot(source, dataDir), dirName, `${stem}.cbz`); } @@ -190,6 +219,24 @@ async function fetchSeriesChapters(listingUrl, sourceHint) { }; } + if (source === "comicland") { + const { chapters, comicTitle } = await comicland.fetchChapterLinks(normalized); + const ordered = comiclandReadingOrder(chapters); + const slug = comicland.seriesSlugFromListingUrl(normalized); + return { + source, + listingUrl: normalized, + slug, + comicTitle, + chapters: ordered.map((ch) => ({ + url: ch.url, + title: ch.title, + chapter_key: chapterKeyFromUrl(ch.url), + chapter_number: chapterNumberFromTitle(ch.title, source), + })), + }; + } + const { chapters, comicTitle } = await fetchWpMangaChapterLinks(normalized); const ordered = mangareadReadingOrder(chapters); const slug = mangaSlugFromUrl(normalized); @@ -218,6 +265,9 @@ async function downloadChapter(source, chapterUrl, outputCbzPath) { if (source === "manhwasusu") { return manhwa.chapterUrlToCbz(chapterUrl, outputCbzPath); } + if (source === "comicland") { + return comicland.chapterUrlToCbz(chapterUrl, outputCbzPath); + } return mangareadChapterUrlToCbz(chapterUrl, outputCbzPath); } @@ -243,9 +293,27 @@ function cbzExists(cbzPath) { * @returns {string} */ function sanitizeComicDirNameForSource(source, comicTitle) { - return source === "manhwasusu" - ? manhwa.sanitizeComicDirName(comicTitle) - : mangareadSanitizeDir(comicTitle); + if (source === "manhwasusu") { + return manhwa.sanitizeComicDirName(comicTitle); + } + if (source === "comicland") { + return comicland.sanitizeComicDirName(comicTitle); + } + return mangareadSanitizeDir(comicTitle); +} + +/** + * @param {SourceId} source + * @returns {string} Human-readable site name for errors/UI. + */ +function siteLabel(source) { + if (source === "manhwasusu") { + return "ManhwaSusu"; + } + if (source === "comicland") { + return "ComicLand"; + } + return "Mangaread"; } /** @@ -291,6 +359,7 @@ module.exports = { chapterKeyFromUrl, resolveCbzPath, sanitizeComicDirNameForSource, + siteLabel, transferLinkBasename, fetchSeriesChapters, downloadChapter, diff --git a/src/server/watcher.js b/src/server/watcher.js index 0c33d09..6397518 100644 --- a/src/server/watcher.js +++ b/src/server/watcher.js @@ -1,10 +1,6 @@ const cron = require("node-cron"); const dbApi = require("./db.js"); -const { - fetchSeriesChapters, - resolveCbzPath, - cbzExists, -} = require("./sources.js"); +const { refreshTitleChapters } = require("./cli-store.js"); /** * Sync a title's chapter list from the source site and enqueue missing downloads. @@ -29,61 +25,7 @@ async function syncTitle(db, queue, titleId, dataDir, opts = {}) { dbApi.updateJob(db, syncJobId, { status: "running" }); try { - const series = await fetchSeriesChapters(title.url, title.source); - let added = 0; - - for (const ch of series.chapters) { - const cbzPath = resolveCbzPath( - title.source, - series.comicTitle || title.title, - ch.url, - dataDir, - ); - const exists = cbzExists(cbzPath); - const { inserted } = dbApi.upsertChapter(db, { - title_id: titleId, - chapter_key: ch.chapter_key, - chapter_number: ch.chapter_number, - label: ch.title, - url: ch.url, - status: exists ? "downloaded" : "pending", - cbz_path: cbzPath, - }); - if (inserted) { - added += 1; - } - - // Heal statuses without interrupting an in-flight download - const row = db - .prepare( - "SELECT id, status, cbz_path FROM chapters WHERE title_id = ? AND chapter_key = ?", - ) - .get(titleId, ch.chapter_key); - if (row && row.status === "downloading") { - continue; - } - if (row && row.status === "downloaded" && !cbzExists(row.cbz_path)) { - dbApi.updateChapter(db, row.id, { - status: "pending", - error: "CBZ missing on disk", - }); - } - if (row && exists && row.status !== "downloaded") { - dbApi.updateChapter(db, row.id, { - status: "downloaded", - cbz_path: cbzPath, - downloaded_at: new Date().toISOString(), - error: null, - }); - } - } - - // Refresh comic title if site provides a better one - if (series.comicTitle && series.comicTitle !== title.title) { - dbApi.updateTitle(db, titleId, { title: series.comicTitle }); - } - - dbApi.touchTitleChecked(db, titleId); + const { added, total } = await refreshTitleChapters(db, titleId, dataDir); let enqueued = 0; if (opts.enqueue !== false) { @@ -99,7 +41,7 @@ async function syncTitle(db, queue, titleId, dataDir, opts = {}) { return { added, enqueued, - total: series.chapters.length, + total, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err);