├── native ├── src │ ├── api │ │ ├── simple.rs │ │ └── mod.rs │ ├── copy_client │ │ ├── mod.rs │ │ ├── lib.rs │ │ ├── types.rs │ │ ├── tests.rs │ │ ├── dtos.rs │ │ └── client.rs │ ├── database │ │ ├── properties │ │ │ ├── mod.rs │ │ │ └── property.rs │ │ ├── active │ │ │ ├── mod.rs │ │ │ ├── local_collect.rs │ │ │ └── comic_view_log.rs │ │ ├── cache │ │ │ ├── mod.rs │ │ │ ├── image_cache.rs │ │ │ └── web_cache.rs │ │ ├── download │ │ │ ├── download_comic_group.rs │ │ │ ├── download_comic_chapter.rs │ │ │ ├── download_comic_page.rs │ │ │ ├── mod.rs │ │ │ └── download_comic.rs │ │ └── mod.rs │ ├── utils.rs │ ├── lib.rs │ ├── exports.rs │ └── downloading.rs ├── package │ ├── index.ets │ ├── CHANGELOG.md │ ├── src │ │ └── main │ │ │ └── module.json5 │ ├── oh-package.json5 │ ├── README.md │ └── LICENSE ├── build.rs ├── .gitignore ├── Makefile └── Cargo.toml ├── entry ├── src │ ├── mock │ │ └── mock-config.json5 │ ├── main │ │ ├── resources │ │ │ ├── base │ │ │ │ ├── profile │ │ │ │ │ ├── backup_config.json │ │ │ │ │ └── main_pages.json │ │ │ │ ├── media │ │ │ │ │ ├── background.png │ │ │ │ │ ├── foreground.png │ │ │ │ │ ├── startIcon.png │ │ │ │ │ └── layered_image.json │ │ │ │ └── element │ │ │ │ │ ├── float.json │ │ │ │ │ ├── color.json │ │ │ │ │ └── string.json │ │ │ ├── rawfile │ │ │ │ └── MaterialIcons-Regular.ttf │ │ │ └── dark │ │ │ │ └── element │ │ │ │ └── color.json │ │ ├── ets │ │ │ ├── pages │ │ │ │ ├── components │ │ │ │ │ ├── Context.ets │ │ │ │ │ ├── Error.ets │ │ │ │ │ ├── Nav.ets │ │ │ │ │ ├── Loading.ets │ │ │ │ │ ├── MaterialIcons.ets │ │ │ │ │ ├── ComicListData.ets │ │ │ │ │ ├── Uuid.ets │ │ │ │ │ ├── User.ets │ │ │ │ │ ├── LoginStorage.ets │ │ │ │ │ ├── ComicCard.ets │ │ │ │ │ ├── Rank.ets │ │ │ │ │ ├── ComicCardList.ets │ │ │ │ │ ├── CachedImage.ets │ │ │ │ │ ├── Discovery.ets │ │ │ │ │ ├── VersionStore.ets │ │ │ │ │ └── Settings.ets │ │ │ │ ├── Index.ets │ │ │ │ ├── Home.ets │ │ │ │ ├── ComicInfo.ets │ │ │ │ └── ComicReader.ets │ │ │ ├── entrybackupability │ │ │ │ └── EntryBackupAbility.ets │ │ │ └── entryability │ │ │ │ └── EntryAbility.ets │ │ └── module.json5 │ ├── test │ │ ├── List.test.ets │ │ └── LocalUnit.test.ets │ └── ohosTest │ │ ├── ets │ │ └── test │ │ │ ├── List.test.ets │ │ │ └── Ability.test.ets │ │ └── module.json5 ├── .gitignore ├── hvigorfile.ts ├── oh-package.json5 ├── build-profile.json5 ├── obfuscation-rules.txt └── oh-package-lock.json5 ├── images ├── 1.png ├── 2.png ├── 3.png └── 4.png ├── AppScope ├── resources │ └── base │ │ ├── media │ │ ├── background.png │ │ ├── foreground.png │ │ └── layered_image.json │ │ └── element │ │ └── string.json └── app.json5 ├── .gitignore ├── oh-package.json5 ├── hvigorfile.ts ├── README.md ├── Makefile ├── code-linter.json5 ├── oh-package-lock.json5 ├── hvigor └── hvigor-config.json5 └── .github └── workflows ├── Build.yaml └── Release.yaml /native/src/api/simple.rs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /entry/src/mock/mock-config.json5: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /native/package/index.ets: -------------------------------------------------------------------------------- 1 | export * from "libnative.so" -------------------------------------------------------------------------------- /native/package/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.0.1 2 | - init package 3 | -------------------------------------------------------------------------------- /native/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | napi_build_ohos::setup(); 3 | } 4 | -------------------------------------------------------------------------------- /images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niuhuan/copi-ohos/HEAD/images/1.png -------------------------------------------------------------------------------- /images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niuhuan/copi-ohos/HEAD/images/2.png -------------------------------------------------------------------------------- /images/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niuhuan/copi-ohos/HEAD/images/3.png -------------------------------------------------------------------------------- /images/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niuhuan/copi-ohos/HEAD/images/4.png -------------------------------------------------------------------------------- /entry/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /oh_modules 3 | /.preview 4 | /build 5 | /.cxx 6 | /.test -------------------------------------------------------------------------------- /entry/src/main/resources/base/profile/backup_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "allowToBackupRestore": true 3 | } -------------------------------------------------------------------------------- /native/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | target/ 3 | .DS_Store 4 | .idea/ 5 | package/libs 6 | 7 | *.har 8 | 9 | Cargo.lock 10 | -------------------------------------------------------------------------------- /AppScope/resources/base/media/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niuhuan/copi-ohos/HEAD/AppScope/resources/base/media/background.png -------------------------------------------------------------------------------- /AppScope/resources/base/media/foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niuhuan/copi-ohos/HEAD/AppScope/resources/base/media/foreground.png -------------------------------------------------------------------------------- /native/src/api/mod.rs: -------------------------------------------------------------------------------- 1 | // 2 | // Do not put code in `mod.rs`, but put in e.g. `simple.rs`. 3 | // 4 | 5 | pub mod api; 6 | pub mod simple; 7 | -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niuhuan/copi-ohos/HEAD/entry/src/main/resources/base/media/background.png -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niuhuan/copi-ohos/HEAD/entry/src/main/resources/base/media/foreground.png -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/startIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niuhuan/copi-ohos/HEAD/entry/src/main/resources/base/media/startIcon.png -------------------------------------------------------------------------------- /entry/src/test/List.test.ets: -------------------------------------------------------------------------------- 1 | import localUnitTest from './LocalUnit.test'; 2 | 3 | export default function testsuite() { 4 | localUnitTest(); 5 | } -------------------------------------------------------------------------------- /AppScope/resources/base/element/string.json: -------------------------------------------------------------------------------- 1 | { 2 | "string": [ 3 | { 4 | "name": "app_name", 5 | "value": "copi" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /entry/src/ohosTest/ets/test/List.test.ets: -------------------------------------------------------------------------------- 1 | import abilityTest from './Ability.test'; 2 | 3 | export default function testsuite() { 4 | abilityTest(); 5 | } -------------------------------------------------------------------------------- /entry/src/main/resources/base/profile/main_pages.json: -------------------------------------------------------------------------------- 1 | { 2 | "src": [ 3 | "pages/Index", 4 | "pages/Home", 5 | "pages/ComicInfo" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /entry/src/main/resources/base/element/float.json: -------------------------------------------------------------------------------- 1 | { 2 | "float": [ 3 | { 4 | "name": "page_text_font_size", 5 | "value": "50fp" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /entry/src/main/resources/rawfile/MaterialIcons-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/niuhuan/copi-ohos/HEAD/entry/src/main/resources/rawfile/MaterialIcons-Regular.ttf -------------------------------------------------------------------------------- /entry/src/main/resources/base/element/color.json: -------------------------------------------------------------------------------- 1 | { 2 | "color": [ 3 | { 4 | "name": "start_window_background", 5 | "value": "#FFFFFF" 6 | } 7 | ] 8 | } -------------------------------------------------------------------------------- /entry/src/main/resources/dark/element/color.json: -------------------------------------------------------------------------------- 1 | { 2 | "color": [ 3 | { 4 | "name": "start_window_background", 5 | "value": "#000000" 6 | } 7 | ] 8 | } -------------------------------------------------------------------------------- /native/package/src/main/module.json5: -------------------------------------------------------------------------------- 1 | { 2 | "module": { 3 | "name": "native", 4 | "type": "har", 5 | "deviceTypes": ["default", "tablet", "2in1"] 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /AppScope/resources/base/media/layered_image.json: -------------------------------------------------------------------------------- 1 | { 2 | "layered-image": 3 | { 4 | "background" : "$media:background", 5 | "foreground" : "$media:foreground" 6 | } 7 | } -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/layered_image.json: -------------------------------------------------------------------------------- 1 | { 2 | "layered-image": 3 | { 4 | "background" : "$media:background", 5 | "foreground" : "$media:foreground" 6 | } 7 | } -------------------------------------------------------------------------------- /native/Makefile: -------------------------------------------------------------------------------- 1 | Default: BuildRelease 2 | 3 | BuildRelease: 4 | ohrs build --release -a arm64 5 | ohrs artifact 6 | 7 | Clean: 8 | cargo clean 9 | rm -rf dist package/libs 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /oh_modules 3 | /local.properties 4 | /.idea 5 | **/build 6 | /.hvigor 7 | .cxx 8 | /.clangd 9 | /.clang-format 10 | /.clang-tidy 11 | **/.test 12 | /.appanalyzer 13 | 14 | patch.json 15 | -------------------------------------------------------------------------------- /native/src/copy_client/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod client; 2 | pub mod dtos; 3 | pub mod types; 4 | 5 | pub use client::*; 6 | pub use dtos::*; 7 | #[allow(unused_imports)] 8 | pub use types::*; 9 | 10 | #[cfg(test)] 11 | mod tests; 12 | -------------------------------------------------------------------------------- /native/package/oh-package.json5: -------------------------------------------------------------------------------- 1 | { 2 | license: "MIT", 3 | author: "niuhuan", 4 | name: "native", 5 | description: "", 6 | main: "index.ets", 7 | version: "0.0.1", 8 | types: "libs/index.d.ts", 9 | dependencies: {} 10 | } -------------------------------------------------------------------------------- /oh-package.json5: -------------------------------------------------------------------------------- 1 | { 2 | "modelVersion": "5.0.4", 3 | "description": "Please describe the basic information.", 4 | "dependencies": { 5 | }, 6 | "devDependencies": { 7 | "@ohos/hypium": "1.0.21", 8 | "@ohos/hamock": "1.0.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /hvigorfile.ts: -------------------------------------------------------------------------------- 1 | import { appTasks } from '@ohos/hvigor-ohos-plugin'; 2 | 3 | export default { 4 | system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ 5 | plugins:[] /* Custom plugin to extend the functionality of Hvigor. */ 6 | } 7 | -------------------------------------------------------------------------------- /entry/hvigorfile.ts: -------------------------------------------------------------------------------- 1 | import { hapTasks } from '@ohos/hvigor-ohos-plugin'; 2 | 3 | export default { 4 | system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ 5 | plugins:[] /* Custom plugin to extend the functionality of Hvigor. */ 6 | } 7 | -------------------------------------------------------------------------------- /native/package/README.md: -------------------------------------------------------------------------------- 1 | # `native` 2 | 3 | ## Install 4 | 5 | use`ohpm` to install package. 6 | 7 | ```shell 8 | ohpm install native 9 | ``` 10 | 11 | ## API 12 | 13 | ```ts 14 | // todo 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```ts 20 | // todo 21 | ``` 22 | -------------------------------------------------------------------------------- /AppScope/app.json5: -------------------------------------------------------------------------------- 1 | { 2 | "app": { 3 | "bundleName": "opensource.ohos.copi", 4 | "vendor": "example", 5 | "versionCode": 1000000, 6 | "versionName": "0.0.6", 7 | "icon": "$media:layered_image", 8 | "label": "$string:app_name" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/Context.ets: -------------------------------------------------------------------------------- 1 | import { UiLoginState, initLoginState } from 'native' 2 | 3 | interface Colors { 4 | authorColor: string 5 | notActive: string 6 | } 7 | 8 | export let colors: Colors = { 9 | authorColor: "#F48FB1", 10 | notActive: "#999999" 11 | } 12 | -------------------------------------------------------------------------------- /entry/src/ohosTest/module.json5: -------------------------------------------------------------------------------- 1 | { 2 | "module": { 3 | "name": "entry_test", 4 | "type": "feature", 5 | "deviceTypes": [ 6 | "phone", 7 | "tablet", 8 | "2in1" 9 | ], 10 | "deliveryWithInstall": true, 11 | "installationFree": false 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | COPI 2 | ==== 3 | 4 | 鸿蒙NEXT上的漫画客户端 5 | 6 | ## 预览 7 | 8 | 9 | 10 | 11 | 12 | ## 构建 13 | 14 | ### 1. 构建rust依赖 15 | ```shell 16 | make 17 | ``` 18 | 19 | ### 2. 构建鸿蒙HAP 20 | 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | Default: BuildNative OhpmInstall 2 | 3 | all: Default BuildHap 4 | 5 | Clean: CleanNative 6 | 7 | OhpmInstall: 8 | ohpm install 9 | 10 | BuildNative: 11 | $(MAKE) -C native 12 | 13 | CleanNative: 14 | $(MAKE) -C native Clean 15 | 16 | BuildHap: 17 | hvigorw assembleHap --mode module -p product=default -p buildMode=release --no-daemon 18 | -------------------------------------------------------------------------------- /entry/src/main/resources/base/element/string.json: -------------------------------------------------------------------------------- 1 | { 2 | "string": [ 3 | { 4 | "name": "module_desc", 5 | "value": "module description" 6 | }, 7 | { 8 | "name": "EntryAbility_desc", 9 | "value": "description" 10 | }, 11 | { 12 | "name": "EntryAbility_label", 13 | "value": "copi" 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /entry/oh-package.json5: -------------------------------------------------------------------------------- 1 | { 2 | "name": "entry", 3 | "version": "1.0.0", 4 | "description": "Please describe the basic information.", 5 | "main": "", 6 | "author": "", 7 | "license": "", 8 | "dependencies": { 9 | "native": "file:../native/package.har", 10 | "@candies/loading_more_list": "^1.0.3", 11 | "@hadss/state_store": "^1.0.0-rc.3" 12 | }, 13 | "devDependencies": {}, 14 | "dynamicDependencies": {} 15 | } -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/Error.ets: -------------------------------------------------------------------------------- 1 | @Entry 2 | @Component 3 | export struct Error { 4 | @Prop text: string 5 | 6 | build() { 7 | Column() { 8 | Text('Error') 9 | if (this.text != null && this.text.length > 0) { 10 | Blank() 11 | .height(20) 12 | Text(this.text) 13 | } 14 | } 15 | .justifyContent(FlexAlign.Center) 16 | .height('100%') 17 | .width('100%') 18 | } 19 | } -------------------------------------------------------------------------------- /native/src/copy_client/lib.rs: -------------------------------------------------------------------------------- 1 | pub use super::types::*; 2 | 3 | pub struct Client { 4 | 5 | } 6 | 7 | impl Client { 8 | 9 | async fn request serde::Deserialize<'de>>( 10 | &self, 11 | method: reqwest::Method, 12 | path: &str, 13 | params: serde_json::Value, 14 | ) -> Result { 15 | let mut obj = query.as_object()?; 16 | Ok(serde_json::from_str("")?) 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /native/src/database/properties/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::database::connect_db; 2 | use once_cell::sync::OnceCell; 3 | use sea_orm::DatabaseConnection; 4 | use tokio::sync::Mutex; 5 | 6 | pub(crate) mod property; 7 | 8 | pub(crate) static PROPERTIES_DATABASE: OnceCell> = OnceCell::new(); 9 | 10 | pub(crate) async fn init() { 11 | let db = connect_db("properties.db").await; 12 | PROPERTIES_DATABASE.set(Mutex::new(db)).unwrap(); 13 | // init tables 14 | property::init().await; 15 | } 16 | -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/Nav.ets: -------------------------------------------------------------------------------- 1 | export const navStack = new NavPathStack() 2 | 3 | 4 | export const navEvents: Map void> = new Map() 5 | 6 | export function navNamesJoin(): string { 7 | return navStack.getAllPathName().join(":") 8 | } 9 | 10 | navStack.setInterception({ 11 | willShow() { 12 | const namesJoin = navNamesJoin(); 13 | navEvents.forEach((v) => { 14 | try { 15 | v(namesJoin) 16 | } catch (e) { 17 | console.error(e) 18 | } 19 | }) 20 | } 21 | }) -------------------------------------------------------------------------------- /entry/build-profile.json5: -------------------------------------------------------------------------------- 1 | { 2 | "apiType": "stageMode", 3 | "buildOption": { 4 | }, 5 | "buildOptionSet": [ 6 | { 7 | "name": "release", 8 | "arkOptions": { 9 | "obfuscation": { 10 | "ruleOptions": { 11 | "enable": false, 12 | "files": [ 13 | "./obfuscation-rules.txt" 14 | ] 15 | } 16 | } 17 | } 18 | }, 19 | ], 20 | "targets": [ 21 | { 22 | "name": "default" 23 | }, 24 | { 25 | "name": "ohosTest", 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /native/src/database/active/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::database::connect_db; 2 | use once_cell::sync::OnceCell; 3 | use sea_orm::DatabaseConnection; 4 | use tokio::sync::Mutex; 5 | pub(crate) mod comic_view_log; 6 | pub(crate) mod local_collect; 7 | 8 | pub(crate) static ACTIVE_DATABASE: OnceCell> = OnceCell::new(); 9 | pub(crate) async fn init() { 10 | let db = connect_db("active.db").await; 11 | ACTIVE_DATABASE.set(Mutex::new(db)).unwrap(); 12 | // init tables 13 | comic_view_log::init().await; 14 | local_collect::init().await; 15 | } 16 | -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/Loading.ets: -------------------------------------------------------------------------------- 1 | @Entry 2 | @Component 3 | export struct Loading { 4 | @Prop text: string 5 | 6 | build() { 7 | Column() { 8 | Progress({ 9 | type: ProgressType.Ring, 10 | value: 50, 11 | }).style({ strokeWidth: 20, status: ProgressStatus.LOADING }) 12 | if (this.text != null && this.text.length > 0) { 13 | Blank() 14 | .height(20) 15 | Text(this.text) 16 | } 17 | } 18 | .justifyContent(FlexAlign.Center) 19 | .height('100%') 20 | .width('100%') 21 | } 22 | } -------------------------------------------------------------------------------- /entry/src/main/ets/entrybackupability/EntryBackupAbility.ets: -------------------------------------------------------------------------------- 1 | import { hilog } from '@kit.PerformanceAnalysisKit'; 2 | import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit'; 3 | 4 | const DOMAIN = 0x0000; 5 | 6 | export default class EntryBackupAbility extends BackupExtensionAbility { 7 | async onBackup() { 8 | hilog.info(DOMAIN, 'testTag', 'onBackup ok'); 9 | await Promise.resolve(); 10 | } 11 | 12 | async onRestore(bundleVersion: BundleVersion) { 13 | hilog.info(DOMAIN, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion)); 14 | await Promise.resolve(); 15 | } 16 | } -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/MaterialIcons.ets: -------------------------------------------------------------------------------- 1 | import { font } from '@kit.ArkUI' 2 | import { codePoints } from './MaterialIconsCodePoints' 3 | 4 | export const materialIconsFontFamily: string = 'Material Icons' 5 | 6 | font.registerFont({ 7 | familyName: materialIconsFontFamily, 8 | familySrc: $rawfile('MaterialIcons-Regular.ttf') 9 | }) 10 | 11 | const charMap = new Map() 12 | 13 | const lines = codePoints.split('\n'); 14 | lines.forEach(line => { 15 | if (line.length > 0) { 16 | const split = line.split(" "); 17 | if (split.length == 2 && split[1].match(/^[0-9a-zA-Z]{4}$/)) { 18 | charMap.set( 19 | split[0], 20 | String.fromCodePoint(parseInt(split[1], 16)), 21 | ) 22 | } 23 | } 24 | }); 25 | 26 | export function materialIconData(name: string): string { 27 | return charMap.get(name) ?? '?'; 28 | } -------------------------------------------------------------------------------- /entry/src/main/ets/pages/Index.ets: -------------------------------------------------------------------------------- 1 | import router from '@ohos.router'; 2 | import { LoginStore, LoginStoreActions } from './components/LoginStorage'; 3 | 4 | @Entry 5 | @Component 6 | struct Index { 7 | @State message: string = 'Loading'; 8 | 9 | aboutToAppear(): void { 10 | this.init() 11 | } 12 | 13 | async init() { 14 | LoginStore.dispatch(LoginStoreActions.preLogin); 15 | router.replaceUrl({ 16 | url: 'pages/Home', 17 | }) 18 | } 19 | 20 | build() { 21 | RelativeContainer() { 22 | Text(this.message) 23 | .id('Loading') 24 | .fontSize(50) 25 | .fontWeight(FontWeight.Bold) 26 | .alignRules({ 27 | center: { anchor: '__container__', align: VerticalAlign.Center }, 28 | middle: { anchor: '__container__', align: HorizontalAlign.Center } 29 | }) 30 | } 31 | .height('100%') 32 | .width('100%') 33 | } 34 | } -------------------------------------------------------------------------------- /native/src/database/cache/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::database::connect_db; 2 | use once_cell::sync::OnceCell; 3 | use sea_orm::{ConnectionTrait, DatabaseConnection, ExecResult, Statement}; 4 | use tokio::sync::Mutex; 5 | 6 | pub(crate) mod image_cache; 7 | pub(crate) mod web_cache; 8 | 9 | pub(crate) static CACHE_DATABASE: OnceCell> = OnceCell::new(); 10 | 11 | pub(crate) async fn init() { 12 | let db = connect_db("cache.db").await; 13 | CACHE_DATABASE.set(Mutex::new(db)).unwrap(); 14 | // init tables 15 | image_cache::init().await; 16 | web_cache::init().await; 17 | } 18 | 19 | pub(crate) async fn vacuum() -> anyhow::Result<()> { 20 | let db = CACHE_DATABASE.get().unwrap().lock().await; 21 | let backend = db.get_database_backend(); 22 | let _: ExecResult = db 23 | .execute(Statement::from_string(backend, "VACUUM".to_owned())) 24 | .await?; 25 | Ok(()) 26 | } 27 | -------------------------------------------------------------------------------- /code-linter.json5: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "**/*.ets" 4 | ], 5 | "ignore": [ 6 | "**/src/ohosTest/**/*", 7 | "**/src/test/**/*", 8 | "**/src/mock/**/*", 9 | "**/node_modules/**/*", 10 | "**/oh_modules/**/*", 11 | "**/build/**/*", 12 | "**/.preview/**/*" 13 | ], 14 | "ruleSet": [ 15 | "plugin:@performance/recommended", 16 | "plugin:@typescript-eslint/recommended" 17 | ], 18 | "rules": { 19 | "@security/no-unsafe-aes": "error", 20 | "@security/no-unsafe-hash": "error", 21 | "@security/no-unsafe-mac": "warn", 22 | "@security/no-unsafe-dh": "error", 23 | "@security/no-unsafe-dsa": "error", 24 | "@security/no-unsafe-ecdsa": "error", 25 | "@security/no-unsafe-rsa-encrypt": "error", 26 | "@security/no-unsafe-rsa-sign": "error", 27 | "@security/no-unsafe-rsa-key": "error", 28 | "@security/no-unsafe-dsa-key": "error", 29 | "@security/no-unsafe-dh-key": "error", 30 | "@security/no-unsafe-3des": "error" 31 | } 32 | } -------------------------------------------------------------------------------- /oh-package-lock.json5: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "stableOrder": true 4 | }, 5 | "lockfileVersion": 3, 6 | "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", 7 | "specifiers": { 8 | "@ohos/hamock@1.0.0": "@ohos/hamock@1.0.0", 9 | "@ohos/hypium@1.0.21": "@ohos/hypium@1.0.21" 10 | }, 11 | "packages": { 12 | "@ohos/hamock@1.0.0": { 13 | "name": "@ohos/hamock", 14 | "version": "1.0.0", 15 | "integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==", 16 | "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har", 17 | "registryType": "ohpm" 18 | }, 19 | "@ohos/hypium@1.0.21": { 20 | "name": "@ohos/hypium", 21 | "version": "1.0.21", 22 | "integrity": "sha512-iyKGMXxE+9PpCkqEwu0VykN/7hNpb+QOeIuHwkmZnxOpI+dFZt6yhPB7k89EgV1MiSK/ieV/hMjr5Z2mWwRfMQ==", 23 | "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.21.har", 24 | "registryType": "ohpm" 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /entry/obfuscation-rules.txt: -------------------------------------------------------------------------------- 1 | # Define project specific obfuscation rules here. 2 | # You can include the obfuscation configuration files in the current module's build-profile.json5. 3 | # 4 | # For more details, see 5 | # https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/source-obfuscation-V5 6 | 7 | # Obfuscation options: 8 | # -disable-obfuscation: disable all obfuscations 9 | # -enable-property-obfuscation: obfuscate the property names 10 | # -enable-toplevel-obfuscation: obfuscate the names in the global scope 11 | # -compact: remove unnecessary blank spaces and all line feeds 12 | # -remove-log: remove all console.* statements 13 | # -print-namecache: print the name cache that contains the mapping from the old names to new names 14 | # -apply-namecache: reuse the given cache file 15 | 16 | # Keep options: 17 | # -keep-property-name: specifies property names that you want to keep 18 | # -keep-global-name: specifies names that you want to keep in the global scope 19 | 20 | -enable-property-obfuscation 21 | -enable-toplevel-obfuscation 22 | -enable-filename-obfuscation 23 | -enable-export-obfuscation -------------------------------------------------------------------------------- /native/package/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-present niuhuan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/ComicListData.ets: -------------------------------------------------------------------------------- 1 | import { LoadingMoreBase } from '@candies/loading_more_list'; 2 | import { ComicCardData } from './ComicCard'; 3 | 4 | export interface DataExplore { 5 | offset: number, 6 | limit: number, 7 | list: Array, 8 | } 9 | 10 | export type DataExplorer = (offset: number, limit: number) => Promise 11 | 12 | export class ComicListData extends LoadingMoreBase { 13 | 14 | hasMore = true 15 | 16 | private offset = 0 17 | private limit = 20 18 | private fn: DataExplorer 19 | 20 | constructor(fn: DataExplorer) { 21 | super(); 22 | this.fn = fn 23 | } 24 | 25 | public async refresh(notifyStateChanged: boolean = false): Promise { 26 | this.offset = 0 27 | this.hasMore = true 28 | return super.refresh(notifyStateChanged); 29 | } 30 | 31 | async loadData(isLoadMoreAction: boolean): Promise { 32 | try { 33 | let data = await this.fn( 34 | this.offset, 35 | this.limit, 36 | ) 37 | this.offset += this.limit 38 | this.hasMore = data.list.length > 0 39 | this.addAll(data.list) 40 | return true 41 | } catch (e) { 42 | console.error(`FETCH ERROR : ${e}}`) 43 | return false 44 | } 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /hvigor/hvigor-config.json5: -------------------------------------------------------------------------------- 1 | { 2 | "modelVersion": "5.0.4", 3 | "dependencies": { 4 | }, 5 | "execution": { 6 | // "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | false ]. Default: "normal" */ 7 | // "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */ 8 | // "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */ 9 | // "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */ 10 | // "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */ 11 | }, 12 | "logging": { 13 | // "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */ 14 | }, 15 | "debugging": { 16 | // "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */ 17 | }, 18 | "nodeOptions": { 19 | // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ 20 | // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /entry/oh-package-lock.json5: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "stableOrder": true 4 | }, 5 | "lockfileVersion": 3, 6 | "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", 7 | "specifiers": { 8 | "@candies/loading_more_list@^1.0.3": "@candies/loading_more_list@1.0.3", 9 | "@hadss/state_store@^1.0.0-rc.3": "@hadss/state_store@1.0.0-rc.3", 10 | "native@../native/package.har": "native@../native/package.har" 11 | }, 12 | "packages": { 13 | "@candies/loading_more_list@1.0.3": { 14 | "name": "@candies/loading_more_list", 15 | "version": "1.0.3", 16 | "integrity": "sha512-mVAgoeiQ742Uu2/VL9JAkCzlffSD4R0KGbGCLWvdByVOdKUBOr8DcJ9J63shTNOoDQvxe5ZCwGGXxp+hL0i/kg==", 17 | "resolved": "https://ohpm.openharmony.cn/ohpm/@candies/loading_more_list/-/loading_more_list-1.0.3.har", 18 | "registryType": "ohpm" 19 | }, 20 | "@hadss/state_store@1.0.0-rc.3": { 21 | "name": "@hadss/state_store", 22 | "version": "1.0.0-rc.3", 23 | "integrity": "sha512-M6XuVPPo7VW01ox/Y8aVBT5cWTaCeJjg/MK+4YURsc6mTjFf47d6V48JH3+4qeKKhEg0aBaO980u2x+24dNqgA==", 24 | "resolved": "https://ohpm.openharmony.cn/ohpm/@hadss/state_store/-/state_store-1.0.0-rc.3.har", 25 | "registryType": "ohpm" 26 | }, 27 | "native@../native/package.har": { 28 | "name": "native", 29 | "version": "0.0.1", 30 | "resolved": "../native/package.har", 31 | "registryType": "local" 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/Uuid.ets: -------------------------------------------------------------------------------- 1 | const hexChars = "0123456789abcdef" 2 | 3 | export class Uuid { 4 | public static v4(): string { 5 | return Uuid.v4Instance().toString(); 6 | } 7 | 8 | public static v4Instance(): UuidV4 { 9 | return new UuidV4(); 10 | } 11 | } 12 | 13 | // DATA: 14 | // RANDOM 128 bytes 15 | // AND calculate 16 | // POSITION & 0x 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 | 0x 06 07 08 09 10 11 12 13 14 15 17 | // DATA & 0x FF FF FF FF FF FF 4F FF BF FF FF FF FF FF FF FF | 0x 40 00 80 00 00 00 00 00 00 00 18 | // STR-FORMAT: 8-4-4-4-12 19 | class UuidV4 extends Uuid { 20 | private data: Uint8Array 21 | 22 | constructor() { 23 | super(); 24 | this.data = this.generate() 25 | } 26 | 27 | generate(): Uint8Array { 28 | let buffer = new ArrayBuffer(16); 29 | const view = new DataView(buffer); 30 | for (let i = 0; i < 4; i++) { 31 | view.setUint32(4 * i, (Math.random() * 0xFFFFFFFFFF) ^ 0xFFFFFFFF) 32 | } 33 | view.setUint8(6, view.getUint8(6) & 0x4F | 0x40) 34 | view.setUint8(8, view.getUint8(8) & 0xBF | 0x80) 35 | return new Uint8Array(buffer); 36 | } 37 | 38 | toString(): string { 39 | let hex = ""; 40 | for (let i = 0; i < this.data.length; i++) { 41 | if (i == 4 || i == 6 || i == 8 || i == 10) { 42 | hex += '-' 43 | } 44 | const byte = this.data[i]; 45 | hex += hexChars.charAt((byte >> 4) & 0x0f) + hexChars.charAt(byte & 0x0f) 46 | } 47 | return hex; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /native/src/database/active/local_collect.rs: -------------------------------------------------------------------------------- 1 | use crate::database::active::ACTIVE_DATABASE; 2 | use crate::database::create_table_if_not_exists; 3 | use sea_orm::entity::prelude::*; 4 | use serde_derive::{Deserialize, Serialize}; 5 | use std::convert::TryInto; 6 | use std::ops::Deref; 7 | 8 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] 9 | #[sea_orm(table_name = "local_collect")] 10 | pub struct Model { 11 | #[sea_orm(primary_key, auto_increment = false)] 12 | pub path_word: String, 13 | pub alias: Option, 14 | pub author: String, 15 | pub b_404: bool, 16 | pub b_hidden: bool, 17 | pub ban: i64, 18 | pub brief: String, 19 | pub close_comment: bool, 20 | pub close_roast: bool, 21 | pub cover: String, 22 | pub datetime_updated: String, 23 | pub females: String, 24 | pub free_type: String, 25 | pub img_type: i64, 26 | pub males: String, 27 | pub name: String, 28 | pub popular: i64, 29 | pub reclass: String, 30 | pub region: String, 31 | pub restrict: String, 32 | pub seo_baidu: String, 33 | pub status: String, 34 | pub theme: String, 35 | pub uuid: String, 36 | // 37 | pub append_time: i64, 38 | } 39 | 40 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 41 | pub enum Relation {} 42 | 43 | impl ActiveModelBehavior for ActiveModel {} 44 | 45 | pub(crate) async fn init() { 46 | let db = ACTIVE_DATABASE.get().unwrap().lock().await; 47 | create_table_if_not_exists(db.deref(), Entity).await; 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/Build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | 8 | build_release_assets: 9 | name: build_release_assets 10 | runs-on: ubuntu-latest 11 | container: ghcr.io/sanchuanhehe/harmony-next-pipeline-docker/harmonyos-ci-image:latest 12 | steps: 13 | - name: install dependencies 14 | run: | 15 | apt update 16 | apt install -y jq curl xz-utils build-essential rsync 17 | - name: Install rust toolchain 18 | uses: actions-rs/toolchain@v1 19 | with: 20 | toolchain: nightly-2024-12-02 21 | - name: Checkout 22 | uses: actions/checkout@v2 23 | - name: Build 24 | run: | 25 | export PATH=/opt/harmonyos-tools/command-line-tools/ohpm/bin:$PATH 26 | export PATH=/opt/harmonyos-tools/command-line-tools/bin:$PATH 27 | export OHOS_NDK_HOME=/opt/harmonyos-tools/command-line-tools/sdk/default/openharmony 28 | 29 | export OHOS_BASE_SDK_HOME=$OHOS_NDK_HOME 30 | export OHOS_SDK_NATIVE=$OHOS_NDK_HOME 31 | 32 | export HOS_SDK_HOME=$OHOS_NDK_HOME 33 | export OHOS_SDK_HOME=$OHOS_NDK_HOME 34 | 35 | cargo install -f --git https://github.com/ohos-rs/ohos-rs.git 36 | rustup target add aarch64-unknown-linux-ohos 37 | make all 38 | - name: upload hap 39 | uses: actions/upload-artifact@v4 40 | with: 41 | path: entry/build/default/outputs/default/entry-default-unsigned.hap 42 | name: entry-default.hap 43 | 44 | -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/User.ets: -------------------------------------------------------------------------------- 1 | import { ComicListData, DataExplore } from "./ComicListData" 2 | import { ComicCardData } from "./ComicCard" 3 | import { ComicCardList } from "./ComicCardList" 4 | import { listComicViewLogs } from 'native' 5 | import { Settings } from "./Settings" 6 | 7 | @Component 8 | @Entry 9 | export struct User { 10 | @State listData: HistoryListData = new HistoryListData() 11 | 12 | onChange(idx: number) { 13 | switch (idx) { 14 | } 15 | } 16 | 17 | build() { 18 | Tabs({}) { 19 | TabContent() { 20 | ComicCardList({ listData: this.listData }) 21 | }.tabBar('历史记录') 22 | 23 | TabContent() { 24 | Settings() 25 | }.tabBar('设置') 26 | } 27 | .width('100%').height('100%') 28 | } 29 | } 30 | 31 | 32 | class HistoryListData extends ComicListData { 33 | constructor() { 34 | super((o, l) => { 35 | return this.listComicViewLogs(o, l) 36 | }); 37 | } 38 | 39 | listComicViewLogs(offset: number, limit: number): Promise { 40 | return listComicViewLogs(offset, limit).then(rustResult => { 41 | const a: DataExplore = { 42 | offset: rustResult.offset, 43 | limit: rustResult.limit, 44 | list: rustResult.list.map(r => { 45 | let b: ComicCardData = { 46 | name: r.comicName, 47 | pathWord: r.comicPathWord, 48 | author: JSON.parse(r.comicAuthors), 49 | cover: r.comicCover, 50 | popular: 0, 51 | } 52 | return b 53 | }), 54 | }; 55 | return a; 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /entry/src/main/module.json5: -------------------------------------------------------------------------------- 1 | { 2 | "module": { 3 | "name": "entry", 4 | "type": "entry", 5 | "description": "$string:module_desc", 6 | "mainElement": "EntryAbility", 7 | "deviceTypes": [ 8 | "phone", 9 | "tablet", 10 | "2in1" 11 | ], 12 | "deliveryWithInstall": true, 13 | "installationFree": false, 14 | "pages": "$profile:main_pages", 15 | "abilities": [ 16 | { 17 | "name": "EntryAbility", 18 | "srcEntry": "./ets/entryability/EntryAbility.ets", 19 | "description": "$string:EntryAbility_desc", 20 | "icon": "$media:layered_image", 21 | "label": "$string:EntryAbility_label", 22 | "startWindowIcon": "$media:startIcon", 23 | "startWindowBackground": "$color:start_window_background", 24 | "exported": true, 25 | "skills": [ 26 | { 27 | "entities": [ 28 | "entity.system.home" 29 | ], 30 | "actions": [ 31 | "action.system.home" 32 | ] 33 | } 34 | ] 35 | } 36 | ], 37 | "extensionAbilities": [ 38 | { 39 | "name": "EntryBackupAbility", 40 | "srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets", 41 | "type": "backup", 42 | "exported": false, 43 | "metadata": [ 44 | { 45 | "name": "ohos.extension.backup", 46 | "resource": "$profile:backup_config" 47 | } 48 | ], 49 | } 50 | ], 51 | "requestPermissions": [ 52 | { 53 | "name": "ohos.permission.INTERNET", 54 | }, 55 | ], 56 | } 57 | } -------------------------------------------------------------------------------- /native/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "native" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | napi-ohos = { version = "1.0.1", default-features = false, features = [ 13 | "napi8", 14 | "async", 15 | ] } 16 | napi-derive-ohos = { version = "1.0.1" } 17 | once_cell = "1.20.2" 18 | sea-orm = { version = "1.1.2" , features = ["sqlx-sqlite", "macros", "runtime-tokio-rustls"], default-features = false} 19 | tokio = { version = "1.42.0", features = ["full"] } 20 | lazy_static = "1.5.0" 21 | anyhow = "1.0.94" 22 | reqwest = { version = "0.12.9", default-features = false, features = ["http2", "json", "multipart", "rustls-tls-native-roots"] } 23 | serde = { version = "1.0.215", features = ["derive", "serde_derive"] } 24 | serde_derive = "1.0.215" 25 | serde_json = "1.0.133" 26 | chrono = { version = "0.4.38", features = ["serde"] } 27 | md5 = "0.7.0" 28 | hex = "0.4.3" 29 | futures-util = "0.3.31" 30 | image = { version = "0.25.5", features = ["jpeg", "gif", "webp", "bmp", "png"] } 31 | linked-hash-map = { version = "0.5.6", features = ["serde", "serde_impl"] } 32 | async_zip = { version = "0.0.16", features = ["full", "tokio-util", "tokio", "tokio-fs", "async-compression"] } 33 | async-trait = "0.1.83" 34 | bytes = { version = "1.9.0", features = ["serde"] } 35 | base64 = "0.22.1" 36 | itertools = "0.13.0" 37 | url = { version = "2.5.4", features = ["serde"] } 38 | num-iter = "0.1.45" 39 | rand = "0.9.1" 40 | 41 | [build-dependencies] 42 | napi-build-ohos = { version = "1.0.1" } 43 | 44 | [profile.release] 45 | lto = true 46 | -------------------------------------------------------------------------------- /entry/src/test/LocalUnit.test.ets: -------------------------------------------------------------------------------- 1 | import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'; 2 | 3 | export default function localUnitTest() { 4 | describe('localUnitTest', () => { 5 | // Defines a test suite. Two parameters are supported: test suite name and test suite function. 6 | beforeAll(() => { 7 | // Presets an action, which is performed only once before all test cases of the test suite start. 8 | // This API supports only one parameter: preset action function. 9 | }); 10 | beforeEach(() => { 11 | // Presets an action, which is performed before each unit test case starts. 12 | // The number of execution times is the same as the number of test cases defined by **it**. 13 | // This API supports only one parameter: preset action function. 14 | }); 15 | afterEach(() => { 16 | // Presets a clear action, which is performed after each unit test case ends. 17 | // The number of execution times is the same as the number of test cases defined by **it**. 18 | // This API supports only one parameter: clear action function. 19 | }); 20 | afterAll(() => { 21 | // Presets a clear action, which is performed after all test cases of the test suite end. 22 | // This API supports only one parameter: clear action function. 23 | }); 24 | it('assertContain', 0, () => { 25 | // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. 26 | let a = 'abc'; 27 | let b = 'b'; 28 | // Defines a variety of assertion methods, which are used to declare expected boolean conditions. 29 | expect(a).assertContain(b); 30 | expect(a).assertEqual(a); 31 | }); 32 | }); 33 | } -------------------------------------------------------------------------------- /native/src/utils.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use std::collections::hash_map::DefaultHasher; 3 | use std::hash::Hasher; 4 | use std::path::{Path, PathBuf}; 5 | use tokio::sync::{Mutex, MutexGuard}; 6 | 7 | #[allow(dead_code)] 8 | pub(crate) fn join_paths>(paths: Vec

) -> String { 9 | match paths.len() { 10 | 0 => String::default(), 11 | _ => { 12 | let mut path: PathBuf = PathBuf::new(); 13 | for x in paths { 14 | path = path.join(x); 15 | } 16 | return path.to_str().unwrap().to_string(); 17 | } 18 | } 19 | } 20 | 21 | pub(crate) fn create_dir_if_not_exists(path: &str) { 22 | if !Path::new(path).exists() { 23 | std::fs::create_dir_all(path).unwrap(); 24 | } 25 | } 26 | 27 | lazy_static! { 28 | static ref HASH_LOCK: Vec> = { 29 | let mut mutex_vec: Vec> = vec![]; 30 | for _ in 0..64 { 31 | mutex_vec.push(Mutex::<()>::new(())); 32 | } 33 | mutex_vec 34 | }; 35 | } 36 | 37 | pub(crate) async fn hash_lock(url: &String) -> MutexGuard<'static, ()> { 38 | let mut s = DefaultHasher::new(); 39 | s.write(url.as_bytes()); 40 | HASH_LOCK[s.finish() as usize % HASH_LOCK.len()] 41 | .lock() 42 | .await 43 | } 44 | 45 | pub(crate) fn allowed_file_name(title: &str) -> String { 46 | title 47 | .replace("#", "_") 48 | .replace("'", "_") 49 | .replace("/", "_") 50 | .replace("\\", "_") 51 | .replace(":", "_") 52 | .replace("*", "_") 53 | .replace("?", "_") 54 | .replace("\"", "_") 55 | .replace(">", "_") 56 | .replace("<", "_") 57 | .replace("|", "_") 58 | .replace("&", "_") 59 | } 60 | 61 | -------------------------------------------------------------------------------- /native/src/database/properties/property.rs: -------------------------------------------------------------------------------- 1 | use crate::database::properties::PROPERTIES_DATABASE; 2 | use crate::database::{create_index, create_table_if_not_exists, index_exists}; 3 | use sea_orm::entity::prelude::*; 4 | use sea_orm::IntoActiveModel; 5 | use sea_orm::Set; 6 | use std::ops::Deref; 7 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] 8 | #[sea_orm(table_name = "property")] 9 | pub struct Model { 10 | #[sea_orm(primary_key, auto_increment = false)] 11 | pub k: String, 12 | pub v: String, 13 | } 14 | 15 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 16 | pub enum Relation {} 17 | 18 | impl ActiveModelBehavior for ActiveModel {} 19 | 20 | pub(crate) async fn init() { 21 | let db = PROPERTIES_DATABASE.get().unwrap().lock().await; 22 | create_table_if_not_exists(db.deref(), Entity).await; 23 | if !index_exists(db.deref(), "property", "property_idx_k").await { 24 | create_index(db.deref(), "property", vec!["k"], "property_idx_k").await; 25 | } 26 | } 27 | 28 | pub async fn save_property(k: String, v: String) -> anyhow::Result<()> { 29 | let db = PROPERTIES_DATABASE.get().unwrap().lock().await; 30 | if let Some(in_db) = Entity::find_by_id(k.clone()).one(db.deref()).await? { 31 | let mut in_db = in_db.into_active_model(); 32 | in_db.v = Set(v); 33 | in_db.update(db.deref()).await?; 34 | } else { 35 | Model { k, v } 36 | .into_active_model() 37 | .insert(db.deref()) 38 | .await?; 39 | } 40 | Ok(()) 41 | } 42 | 43 | pub async fn load_property(k: String) -> anyhow::Result { 44 | let in_db = Entity::find_by_id(k) 45 | .one(PROPERTIES_DATABASE.get().unwrap().lock().await.deref()) 46 | .await?; 47 | Ok(if let Some(in_db) = in_db { 48 | in_db.v 49 | } else { 50 | "".to_owned() 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /entry/src/ohosTest/ets/test/Ability.test.ets: -------------------------------------------------------------------------------- 1 | import { hilog } from '@kit.PerformanceAnalysisKit'; 2 | import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'; 3 | 4 | export default function abilityTest() { 5 | describe('ActsAbilityTest', () => { 6 | // Defines a test suite. Two parameters are supported: test suite name and test suite function. 7 | beforeAll(() => { 8 | // Presets an action, which is performed only once before all test cases of the test suite start. 9 | // This API supports only one parameter: preset action function. 10 | }) 11 | beforeEach(() => { 12 | // Presets an action, which is performed before each unit test case starts. 13 | // The number of execution times is the same as the number of test cases defined by **it**. 14 | // This API supports only one parameter: preset action function. 15 | }) 16 | afterEach(() => { 17 | // Presets a clear action, which is performed after each unit test case ends. 18 | // The number of execution times is the same as the number of test cases defined by **it**. 19 | // This API supports only one parameter: clear action function. 20 | }) 21 | afterAll(() => { 22 | // Presets a clear action, which is performed after all test cases of the test suite end. 23 | // This API supports only one parameter: clear action function. 24 | }) 25 | it('assertContain', 0, () => { 26 | // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. 27 | hilog.info(0x0000, 'testTag', '%{public}s', 'it begin'); 28 | let a = 'abc'; 29 | let b = 'b'; 30 | // Defines a variety of assertion methods, which are used to declare expected boolean conditions. 31 | expect(a).assertContain(b); 32 | expect(a).assertEqual(a); 33 | }) 34 | }) 35 | } -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/LoginStorage.ets: -------------------------------------------------------------------------------- 1 | import { Action, Reducer, StateStore, Store } from "@hadss/state_store"; 2 | import { hilog } from "@kit.PerformanceAnalysisKit"; 3 | import { initLoginState } from "native"; 4 | import { UiLoginState } from "native"; 5 | import { loadProperty } from "native"; 6 | import { login } from "native"; 7 | 8 | 9 | @ObservedV2 10 | export class LoginStoreModel { 11 | @Trace public loginInfo: UiLoginState = { 12 | state: 0, 13 | message: "" 14 | }; 15 | } 16 | 17 | export class LoginStoreActions { 18 | static preLogin: Action = StateStore.createAction('preLogin'); 19 | static login: Action = StateStore.createAction('login'); 20 | } 21 | 22 | export const loginStoreReducer: Reducer = (state: LoginStoreModel, action: Action) => { 23 | hilog.info(0x0000, 'StateStore', 'actions: %{public}s', action.type); 24 | switch (action.type) { 25 | case LoginStoreActions.preLogin.type: 26 | return async () => { 27 | if (state.loginInfo.state == -1) { 28 | return 29 | } 30 | state.loginInfo = { 31 | state: -1, 32 | message: "" 33 | }; 34 | state.loginInfo = await initLoginState(); 35 | } 36 | case LoginStoreActions.login.type: 37 | return async () => { 38 | if (state.loginInfo.state == -1) { 39 | return 40 | } 41 | state.loginInfo = { 42 | state: -1, 43 | message: "" 44 | }; 45 | let username = await loadProperty("username"); 46 | let password = await loadProperty("password"); 47 | state.loginInfo = await login( 48 | username, 49 | password, 50 | ); 51 | }; 52 | } 53 | return null; 54 | } 55 | 56 | export const LoginStore: Store = 57 | StateStore.createStore('LoginStore', new LoginStoreModel(), loginStoreReducer, []); 58 | 59 | -------------------------------------------------------------------------------- /entry/src/main/ets/entryability/EntryAbility.ets: -------------------------------------------------------------------------------- 1 | import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; 2 | import { hilog } from '@kit.PerformanceAnalysisKit'; 3 | import { window } from '@kit.ArkUI'; 4 | import { init, cleanCache } from 'native' 5 | 6 | const DOMAIN = 0x0000; 7 | 8 | export default class EntryAbility extends UIAbility { 9 | async init(windowStage: window.WindowStage) { 10 | try { 11 | await init(this.context.filesDir); 12 | await cleanCache(3600 * 24 * 7); 13 | } catch (e) { 14 | hilog.error(DOMAIN, 'init', `${e}`); 15 | } 16 | windowStage.loadContent('pages/Index', (err) => { 17 | if (err.code) { 18 | hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? ''); 19 | return; 20 | } 21 | hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); 22 | }); 23 | } 24 | 25 | onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { 26 | this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); 27 | hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); 28 | } 29 | 30 | onDestroy(): void { 31 | hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy'); 32 | } 33 | 34 | onWindowStageCreate(windowStage: window.WindowStage): void { 35 | // Main window is created, set main page for this ability 36 | hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); 37 | this.init(windowStage); 38 | } 39 | 40 | onWindowStageDestroy(): void { 41 | // Main window is destroyed, release UI related resources 42 | hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); 43 | } 44 | 45 | onForeground(): void { 46 | // Ability has brought to foreground 47 | hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground'); 48 | } 49 | 50 | onBackground(): void { 51 | // Ability has back to background 52 | hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground'); 53 | } 54 | } -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/ComicCard.ets: -------------------------------------------------------------------------------- 1 | import { Author } from 'native' 2 | import { colors } from './Context' 3 | import { CachedImage } from './CachedImage' 4 | import { materialIconData, materialIconsFontFamily } from './MaterialIcons' 5 | 6 | export interface ComicCardData { 7 | name: string 8 | pathWord: string 9 | author: Array 10 | cover: string 11 | popular: number 12 | datetimeUpdated?: string 13 | } 14 | 15 | @Entry 16 | @Component 17 | export struct ComicCard { 18 | @Require @Prop comic: ComicCardData 19 | 20 | build() { 21 | Flex() { 22 | CachedImage({ 23 | source: this.comic.cover, 24 | useful: 'COMIC_COVER', 25 | extendsFieldFirst: this.comic.pathWord, 26 | borderOptions: { radius: 3.5 }, 27 | imageWidth: 328 / 4, 28 | imageHeight: 422 / 4, 29 | }) 30 | .width(328 / 4) 31 | .height(422 / 4) 32 | .flexShrink(0) 33 | .flexGrow(0) 34 | Blank(10) 35 | Column() { 36 | Blank(10) 37 | Text(`${this.comic.name}\n`) 38 | .maxLines(2) 39 | .fontWeight(FontWeight.Bold) 40 | Blank(10) 41 | Text(this.comic.author?.map(a => a.name)?.join("、") ?? "") 42 | .fontSize(14) 43 | .fontColor(colors.authorColor) 44 | Blank(10) 45 | Flex() { 46 | Text(this.comic.datetimeUpdated) 47 | .flexGrow(0) 48 | .flexShrink(0) 49 | Blank(1) 50 | .flexGrow(1) 51 | .flexShrink(1) 52 | Text(materialIconData('local_fire_department')) 53 | .fontFamily(materialIconsFontFamily) 54 | .fontColor(colors.authorColor) 55 | .fontSize(16) 56 | Text(` ${this.comic.popular}`) 57 | .flexGrow(0) 58 | .flexShrink(0) 59 | .fontSize(14) 60 | } 61 | } 62 | .flexGrow(1) 63 | .alignItems(HorizontalAlign.Start) 64 | } 65 | .padding({ 66 | top: 8, 67 | bottom: 8, 68 | left: 15, 69 | right: 15 70 | }) 71 | .border({ 72 | color: '#33666666', 73 | width: .4, 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /native/src/copy_client/types.rs: -------------------------------------------------------------------------------- 1 | use std::backtrace::Backtrace; 2 | use std::fmt::{Display, Formatter}; 3 | 4 | pub type Result = std::result::Result; 5 | 6 | #[derive(Debug)] 7 | pub struct Error { 8 | pub backtrace: Backtrace, 9 | pub info: ErrorInfo, 10 | } 11 | 12 | #[derive(Debug)] 13 | pub enum ErrorInfo { 14 | Network(reqwest::Error), 15 | Message(String), 16 | Convert(serde_json::Error), 17 | Other(Box), 18 | } 19 | 20 | impl std::error::Error for Error {} 21 | 22 | impl Display for Error { 23 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 24 | let mut builder = f.debug_struct("copy_client::Error"); 25 | match &self.info { 26 | ErrorInfo::Convert(err) => { 27 | builder.field("kind", &"Convert"); 28 | builder.field("source", err); 29 | } 30 | ErrorInfo::Network(err) => { 31 | builder.field("kind", &"Network"); 32 | builder.field("source", err); 33 | } 34 | ErrorInfo::Message(err) => { 35 | builder.field("kind", &"Message"); 36 | builder.field("source", err); 37 | } 38 | ErrorInfo::Other(err) => { 39 | builder.field("kind", &"Other"); 40 | builder.field("source", err); 41 | } 42 | } 43 | builder.finish() 44 | } 45 | } 46 | 47 | impl Error { 48 | pub(crate) fn message(content: impl Into) -> Self { 49 | Self { 50 | backtrace: Backtrace::capture(), 51 | info: ErrorInfo::Message(content.into()), 52 | } 53 | } 54 | } 55 | 56 | macro_rules! from_error { 57 | ($error_type:ty, $info_type:path) => { 58 | impl From<$error_type> for Error { 59 | fn from(value: $error_type) -> Self { 60 | Self { 61 | backtrace: Backtrace::capture(), 62 | info: $info_type(value), 63 | } 64 | } 65 | } 66 | }; 67 | } 68 | 69 | from_error!(::reqwest::Error, ErrorInfo::Network); 70 | from_error!(::serde_json::Error, ErrorInfo::Convert); 71 | -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/Rank.ets: -------------------------------------------------------------------------------- 1 | import { ComicListData, DataExplore } from "./ComicListData" 2 | import { recommends, rank as nativeRank } from 'native' 3 | import { ComicCardList } from "./ComicCardList" 4 | 5 | @Component 6 | @Entry 7 | export struct Rank { 8 | @State dataList: ComicListData = new RecommendsListData() 9 | 10 | onChange(idx: number) { 11 | switch (idx) { 12 | case 0: 13 | this.dataList = new RecommendsListData() 14 | break; 15 | case 1: 16 | this.dataList = new RankListData("day") 17 | break; 18 | case 2: 19 | this.dataList = new RankListData("week") 20 | break; 21 | case 3: 22 | this.dataList = new RankListData("month") 23 | break; 24 | case 4: 25 | this.dataList = new RankListData("total") 26 | break; 27 | } 28 | } 29 | 30 | build() { 31 | Column() { 32 | Tabs({}) { 33 | TabContent() { 34 | 35 | }.tabBar('荐') 36 | 37 | TabContent() { 38 | 39 | }.tabBar('天') 40 | 41 | TabContent() { 42 | 43 | }.tabBar('周') 44 | 45 | TabContent() { 46 | 47 | }.tabBar('月') 48 | 49 | TabContent() { 50 | 51 | }.tabBar('总') 52 | } 53 | .barHeight(65) 54 | .height(65) 55 | .onChange((a) => this.onChange(a)) 56 | 57 | ComicCardList({ listData: this.dataList }) 58 | } 59 | .width('100%').height('100%') 60 | } 61 | } 62 | 63 | 64 | class RecommendsListData extends ComicListData { 65 | constructor() { 66 | super((offset, limit) => recommends( 67 | offset, 68 | limit, 69 | )); 70 | } 71 | } 72 | 73 | class RankListData extends ComicListData { 74 | constructor(rank: string) { 75 | super((o, l) => { 76 | return this.rankLoad(o, l, rank) 77 | }); 78 | } 79 | 80 | rankLoad(offset: number, limit: number, rank: string): Promise { 81 | return nativeRank(rank, offset, limit).then(rankResult => { 82 | const a: DataExplore = { 83 | offset: rankResult.offset, 84 | limit: rankResult.limit, 85 | list: rankResult.list.map(r => { 86 | return r.comic 87 | }), 88 | }; 89 | return a; 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /native/src/copy_client/tests.rs: -------------------------------------------------------------------------------- 1 | use super::client::Client; 2 | use anyhow::Result; 3 | use base64::Engine; 4 | use reqwest::Method; 5 | use serde_json::json; 6 | 7 | const API_URL: &str = "aHR0cHM6Ly9hcGkuY29weW1hbmdhLm5ldA=="; 8 | 9 | fn api_url() -> String { 10 | String::from_utf8(base64::prelude::BASE64_STANDARD.decode(API_URL).unwrap()).unwrap() 11 | } 12 | 13 | fn client() -> Client { 14 | Client::new(reqwest::Client::builder().build().unwrap(), api_url()) 15 | } 16 | 17 | #[tokio::test] 18 | async fn test_request() -> Result<()> { 19 | let value = client() 20 | .request( 21 | Method::GET, 22 | "/api/v3/comics", 23 | json!({ 24 | "_update": true, 25 | "limit": 21, 26 | "offset": 42, 27 | "platform": 3, 28 | }), 29 | ) 30 | .await?; 31 | println!("{}", serde_json::to_string(&value).unwrap()); 32 | Ok(()) 33 | } 34 | 35 | #[tokio::test] 36 | async fn test_comic() -> Result<()> { 37 | let value = client().comic("dokunidakareteoboreteitai").await?; 38 | println!("{}", serde_json::to_string(&value).unwrap()); 39 | Ok(()) 40 | } 41 | 42 | #[tokio::test] 43 | async fn test_chapters() -> Result<()> { 44 | let value = client() 45 | .comic_chapter("fxzhanshijiuliumei", "default", 100, 0) 46 | .await?; 47 | println!("{}", serde_json::to_string(&value).unwrap()); 48 | Ok(()) 49 | } 50 | 51 | #[tokio::test] 52 | async fn test_recommends() -> Result<()> { 53 | let value = client().recommends(0, 21).await?; 54 | println!("{}", serde_json::to_string(&value).unwrap()); 55 | Ok(()) 56 | } 57 | 58 | #[tokio::test] 59 | async fn test_explore() -> Result<()> { 60 | let value = client() 61 | .explore(Some("-datetime_updated"), None, None, 0, 21) 62 | .await?; 63 | println!("{}", serde_json::to_string(&value).unwrap()); 64 | Ok(()) 65 | } 66 | 67 | #[tokio::test] 68 | async fn test_collect() -> Result<()> { 69 | let client = client(); 70 | client.set_token("token").await; 71 | let value = client 72 | .collect("9581bff2-3892-11ec-8e8b-024352452ce0", true) 73 | .await?; 74 | println!("{}", serde_json::to_string(&value).unwrap()); 75 | Ok(()) 76 | } 77 | -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/ComicCardList.ets: -------------------------------------------------------------------------------- 1 | import { IndicatorStatus, IndicatorWidget, LoadingMoreBase } from "@candies/loading_more_list" 2 | import { ComicCard, ComicCardData } from "./ComicCard" 3 | import { Error } from "./Error" 4 | import { Loading } from "./Loading" 5 | import { navStack } from "./Nav" 6 | 7 | 8 | @Component 9 | @Entry 10 | export struct ComicCardList { 11 | private scroller: Scroller = new ListScroller() 12 | @Require @Prop listData: LoadingMoreBase 13 | 14 | build() { 15 | if (this.listData) { 16 | this.buildList() 17 | } 18 | } 19 | 20 | @Builder 21 | buildList() { 22 | if (this.listData.indicatorStatus == IndicatorStatus.empty) { 23 | Text('空空如也') 24 | } else if ( 25 | this.listData.indicatorStatus == IndicatorStatus.fullScreenError 26 | || (this.listData.totalCount() == 1 && `${this.listData.getData(0)}` == 'LoadingMoreErrorItem') 27 | ) { 28 | Error() 29 | .width('100%') 30 | .height('100%') 31 | .onClick(() => { 32 | this.listData.refresh(true); 33 | }) 34 | } else if (this.listData.totalCount() == 1 && this.listData.isLoadingMoreItem(this.listData.getData(0))) { 35 | Loading() 36 | .width('100%') 37 | .height('100%') 38 | } else { 39 | this.foreachList() 40 | } 41 | } 42 | 43 | @Builder 44 | foreachList() { 45 | List({ scroller: this.scroller }) { 46 | ListItem() { 47 | Column() { 48 | }.height(10) 49 | } 50 | 51 | LazyForEach(this.listData, (item: ComicCardData, index) => { 52 | ListItem() { 53 | if (this.listData.isLoadingMoreItem(item)) { 54 | if (this.listData.getLoadingMoreItemStatus(item)) { 55 | if (IndicatorStatus.noMoreLoad == this.listData.getLoadingMoreItemStatus(item)) { 56 | 57 | } else { 58 | IndicatorWidget({ 59 | indicatorStatus: this.listData.getLoadingMoreItemStatus(item), 60 | sourceList: this.listData, 61 | }) 62 | } 63 | } 64 | } else { 65 | ComicCard({ comic: item }) 66 | .onClick(() => { 67 | navStack.pushPath(new NavPathInfo('pages/ComicInfo', item)) 68 | }) 69 | } 70 | }.width('100%') 71 | }, 72 | ) 73 | } 74 | .width('100%') 75 | .height('100%') 76 | .onReachEnd(() => { 77 | this.listData.loadMore(); 78 | }) 79 | } 80 | } -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/CachedImage.ets: -------------------------------------------------------------------------------- 1 | import { cacheImage } from 'native' 2 | import { url } from '@kit.ArkTS'; 3 | import { image } from '@kit.ImageKit'; 4 | import { materialIconData, materialIconsFontFamily } from './MaterialIcons'; 5 | 6 | @Entry 7 | @Component 8 | export struct CachedImage { 9 | @Require @Prop source: string 10 | @Prop useful: string 11 | @Prop extendsFieldFirst?: string 12 | @Prop extendsFieldSecond?: string 13 | @Prop extendsFieldThird?: string 14 | @Prop borderOptions?: BorderOptions 15 | @Prop imageWidth?: Length 16 | @Prop imageHeight?: Length 17 | @Prop ratio: number | null 18 | @Prop onSize: OnSize | null = null; 19 | @State state: number = 0 20 | @State pixelMap: image.PixelMap | null = null 21 | @State trueSize: image.Size | null = null 22 | @State absPath: string | null = null 23 | 24 | aboutToAppear(): void { 25 | this.init() 26 | } 27 | 28 | async init() { 29 | try { 30 | console.error(`load image : ${this.source}`) 31 | let ci = await cacheImage( 32 | this.cacheKey(this.source), 33 | this.source, 34 | this.useful ?? '', 35 | this.extendsFieldFirst ?? '', 36 | this.extendsFieldSecond ?? '', 37 | this.extendsFieldThird ?? '', 38 | ) 39 | this.absPath = `file://${ci.absPath}` 40 | console.error(this.absPath) 41 | if (this.onSize != null) { 42 | this.onSize!.onSize({ 43 | width: ci.imageWidth, 44 | height: ci.imageHeight, 45 | }) 46 | } 47 | this.state = 1 48 | } catch (e) { 49 | this.state = 2 50 | console.error(`image error : ${e} `) 51 | } 52 | } 53 | 54 | cacheKey(source: string): string { 55 | let u = url.URL.parseURL(source) 56 | return u.pathname 57 | } 58 | 59 | build() { 60 | if (this.state == 1) { 61 | Image(this.absPath) 62 | .border(this.borderOptions) 63 | .width(this.imageWidth ?? '') 64 | .height(this.imageHeight ?? '') 65 | .aspectRatio(this.ratio) 66 | .objectFit(ImageFit.Cover) 67 | .renderFit(RenderFit.CENTER) 68 | } else { 69 | Flex({ justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) { 70 | if (this.state == 0) { 71 | Text(materialIconData('download')) 72 | .fontFamily(materialIconsFontFamily) 73 | .fontSize(30) 74 | .fontColor('#666666') 75 | } else { 76 | Text(materialIconData('error')) 77 | .fontFamily(materialIconsFontFamily) 78 | .fontSize(30) 79 | .fontColor('#666666') 80 | } 81 | } 82 | .width(this.imageWidth) 83 | .height(this.imageHeight) 84 | .aspectRatio(this.ratio) 85 | } 86 | } 87 | } 88 | 89 | interface OnSize { 90 | onSize: ((size: image.Size) => void) 91 | } 92 | -------------------------------------------------------------------------------- /native/src/database/download/download_comic_group.rs: -------------------------------------------------------------------------------- 1 | use crate::database::download::DOWNLOAD_DATABASE; 2 | use crate::database::{create_index, create_table_if_not_exists, index_exists}; 3 | use sea_orm::entity::prelude::*; 4 | use sea_orm::sea_query::OnConflict; 5 | use sea_orm::{DeleteResult, Order, QueryOrder}; 6 | use sea_orm::{IntoActiveModel}; 7 | use serde_derive::{Deserialize, Serialize}; 8 | use std::ops::Deref; 9 | 10 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] 11 | #[sea_orm(table_name = "download_comic_group")] 12 | pub struct Model { 13 | #[sea_orm(primary_key, auto_increment = false)] 14 | pub comic_path_word: String, 15 | #[sea_orm(primary_key, auto_increment = false)] 16 | pub group_path_word: String, 17 | pub count: i64, 18 | pub name: String, 19 | // 20 | pub group_rank: i64, 21 | } 22 | 23 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 24 | pub enum Relation {} 25 | 26 | impl ActiveModelBehavior for ActiveModel {} 27 | 28 | pub(crate) async fn init() { 29 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 30 | create_table_if_not_exists(db.deref(), Entity).await; 31 | if !index_exists( 32 | db.deref(), 33 | "download_comic_group", 34 | "download_comic_group_idx_comic_path_word", 35 | ) 36 | .await 37 | { 38 | create_index( 39 | db.deref(), 40 | "download_comic_group", 41 | vec!["comic_path_word"], 42 | "download_comic_group_idx_comic_path_word", 43 | ) 44 | .await; 45 | } 46 | } 47 | 48 | pub(crate) async fn delete_by_comic_path_word( 49 | db: &impl ConnectionTrait, 50 | comic_path_word: &str, 51 | ) -> Result { 52 | Entity::delete_many() 53 | .filter(Column::ComicPathWord.eq(comic_path_word)) 54 | .exec(db) 55 | .await 56 | } 57 | 58 | pub(crate) async fn insert_or_update_info( 59 | db: &impl ConnectionTrait, 60 | model: Model, 61 | ) -> Result<(), DbErr> { 62 | // https://www.sea-ql.org/SeaORM/docs/basic-crud/insert/ 63 | // Performing an upsert statement without inserting or updating any of the row will result in a DbErr::RecordNotInserted error. 64 | // If you want RecordNotInserted to be an Ok instead of an error, call .do_nothing(): 65 | Entity::insert(model.into_active_model()) 66 | .on_conflict( 67 | OnConflict::columns(vec![Column::ComicPathWord, Column::GroupPathWord]) 68 | .do_nothing() 69 | .to_owned(), 70 | ) 71 | .exec(db) 72 | .await?; 73 | Ok(()) 74 | } 75 | 76 | // find_by_comic_path_word order by rank 77 | pub(crate) async fn find_by_comic_path_word(comic_path_word: &str) -> anyhow::Result> { 78 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 79 | let result = Entity::find() 80 | .filter(Column::ComicPathWord.eq(comic_path_word)) 81 | .order_by(Column::GroupRank, Order::Asc) 82 | .all(db.deref()) 83 | .await?; 84 | Ok(result) 85 | } 86 | -------------------------------------------------------------------------------- /native/src/database/cache/image_cache.rs: -------------------------------------------------------------------------------- 1 | use crate::database::cache::CACHE_DATABASE; 2 | use crate::database::{create_index, create_table_if_not_exists, index_exists}; 3 | use sea_orm::entity::prelude::*; 4 | use sea_orm::sea_query::Expr; 5 | use sea_orm::EntityTrait; 6 | use sea_orm::IntoActiveModel; 7 | use sea_orm::QueryOrder; 8 | use sea_orm::QuerySelect; 9 | use std::ops::Deref; 10 | 11 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] 12 | #[sea_orm(table_name = "image_cache")] 13 | pub struct Model { 14 | #[sea_orm(primary_key, auto_increment = false)] 15 | pub cache_key: String, 16 | pub cache_time: i64, 17 | pub url: String, 18 | pub useful: String, 19 | pub extends_field_first: Option, 20 | pub extends_field_second: Option, 21 | pub extends_field_third: Option, 22 | pub local_path: String, 23 | pub image_format: String, 24 | pub image_width: u32, 25 | pub image_height: u32, 26 | } 27 | 28 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 29 | pub enum Relation {} 30 | 31 | impl ActiveModelBehavior for ActiveModel {} 32 | 33 | pub(crate) async fn init() { 34 | let gdb = CACHE_DATABASE.get().unwrap().lock().await; 35 | let db = gdb.deref(); 36 | create_table_if_not_exists(db, Entity).await; 37 | if !index_exists(db, "image_cache", "image_cache_idx_cache_time").await { 38 | create_index( 39 | db, 40 | "image_cache", 41 | vec!["cache_time"], 42 | "image_cache_idx_cache_time", 43 | ) 44 | .await; 45 | } 46 | } 47 | 48 | pub(crate) async fn load_image_by_cache_key(cache_key: &str) -> anyhow::Result> { 49 | Ok(Entity::find_by_id(cache_key) 50 | .one(CACHE_DATABASE.get().unwrap().lock().await.deref()) 51 | .await?) 52 | } 53 | 54 | pub(crate) async fn insert(model: Model) -> anyhow::Result { 55 | Ok(model 56 | .into_active_model() 57 | .insert(CACHE_DATABASE.get().unwrap().lock().await.deref()) 58 | .await?) 59 | } 60 | 61 | pub(crate) async fn update_cache_time(cache_key: &str) -> anyhow::Result<()> { 62 | Entity::update_many() 63 | .col_expr( 64 | Column::CacheTime, 65 | Expr::value(chrono::Local::now().timestamp_millis()), 66 | ) 67 | .filter(Column::CacheKey.eq(cache_key)) 68 | .exec(CACHE_DATABASE.get().unwrap().lock().await.deref()) 69 | .await?; 70 | Ok(()) 71 | } 72 | 73 | pub(crate) async fn take_100_cache(time: i64) -> anyhow::Result> { 74 | Ok(Entity::find() 75 | .filter(Column::CacheTime.lt(time)) 76 | .order_by_asc(Column::CacheTime) 77 | .limit(100) 78 | .all(CACHE_DATABASE.get().unwrap().lock().await.deref()) 79 | .await?) 80 | } 81 | 82 | pub(crate) async fn delete_by_cache_key(cache_key: String) -> anyhow::Result<()> { 83 | Entity::delete_many() 84 | .filter(Column::CacheKey.eq(cache_key)) 85 | .exec(CACHE_DATABASE.get().unwrap().lock().await.deref()) 86 | .await?; 87 | Ok(()) 88 | } 89 | -------------------------------------------------------------------------------- /native/src/database/cache/web_cache.rs: -------------------------------------------------------------------------------- 1 | use crate::copy_client; 2 | use crate::database::cache::CACHE_DATABASE; 3 | use crate::database::{create_index, create_table_if_not_exists, index_exists}; 4 | use sea_orm::entity::prelude::*; 5 | use sea_orm::IntoActiveModel; 6 | use std::convert::TryInto; 7 | use std::future::Future; 8 | use std::ops::Deref; 9 | use std::pin::Pin; 10 | use std::time::Duration; 11 | 12 | #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] 13 | #[sea_orm(table_name = "web_cache")] 14 | pub struct Model { 15 | #[sea_orm(primary_key, auto_increment = false)] 16 | pub cache_key: String, 17 | pub cache_content: String, 18 | pub cache_time: i64, 19 | } 20 | 21 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 22 | pub enum Relation {} 23 | 24 | impl ActiveModelBehavior for ActiveModel {} 25 | 26 | pub(crate) async fn init() { 27 | let gdb = CACHE_DATABASE.get().unwrap().lock().await; 28 | let db = gdb.deref(); 29 | create_table_if_not_exists(db, Entity).await; 30 | if !index_exists(db, "web_cache", "web_cache_idx_cache_time").await { 31 | create_index( 32 | db, 33 | "web_cache", 34 | vec!["cache_time"], 35 | "web_cache_idx_cache_time", 36 | ) 37 | .await; 38 | } 39 | } 40 | 41 | pub(crate) async fn cache_first serde::Deserialize<'de> + serde::Serialize>( 42 | key: String, 43 | expire: Duration, 44 | pin: Pin> + Sync + Send>>, 45 | ) -> anyhow::Result { 46 | let time = chrono::Local::now().timestamp_millis(); 47 | let db = CACHE_DATABASE.get().unwrap().lock().await; 48 | let in_db = Entity::find_by_id(key.clone()).one(db.deref()).await?; 49 | if let Some(ref model) = in_db { 50 | if time < (model.cache_time + expire.as_millis() as i64) { 51 | return Ok(serde_json::from_str(&model.cache_content)?); 52 | } 53 | }; 54 | let t = pin.await?; 55 | let content = serde_json::to_string(&t)?; 56 | if let Some(_) = in_db { 57 | Entity::update_many() 58 | .filter(Column::CacheKey.eq(key.clone())) 59 | .col_expr(Column::CacheTime, Expr::value(time.clone())) 60 | .col_expr(Column::CacheContent, Expr::value(content.clone())) 61 | .exec(db.deref()) 62 | .await?; 63 | } else { 64 | Model { 65 | cache_key: key, 66 | cache_content: content, 67 | cache_time: time, 68 | } 69 | .into_active_model() 70 | .insert(db.deref()) 71 | .await?; 72 | } 73 | Ok(t) 74 | } 75 | 76 | pub(crate) async fn cache_first_map< 77 | T: for<'de> serde::Deserialize<'de> + serde::Serialize, 78 | R: From, 79 | >( 80 | key: String, 81 | expire: Duration, 82 | pin: Pin> + Sync + Send>>, 83 | ) -> anyhow::Result { 84 | Ok(R::from(cache_first(key, expire, pin).await?)) 85 | } 86 | 87 | pub(crate) async fn clean_web_cache_by_time(time: i64) -> anyhow::Result<()> { 88 | Entity::delete_many() 89 | .filter(Column::CacheTime.lt(time)) 90 | .exec(CACHE_DATABASE.get().unwrap().lock().await.deref()) 91 | .await?; 92 | Ok(()) 93 | } 94 | 95 | pub(crate) async fn clean_web_cache_by_like(like: &str) -> anyhow::Result<()> { 96 | Entity::delete_many() 97 | .filter(Column::CacheKey.like(like)) 98 | .exec(CACHE_DATABASE.get().unwrap().lock().await.deref()) 99 | .await?; 100 | Ok(()) 101 | } 102 | -------------------------------------------------------------------------------- /native/src/database/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{get_database_dir, join_paths}; 2 | use sea_orm::prelude::DatabaseConnection; 3 | use sea_orm::{ConnectionTrait, EntityTrait, Schema, Statement}; 4 | use std::time::Duration; 5 | 6 | pub(crate) mod active; 7 | pub(crate) mod cache; 8 | pub(crate) mod download; 9 | pub(crate) mod properties; 10 | 11 | pub(crate) async fn init_database() { 12 | cache::init().await; 13 | properties::init().await; 14 | active::init().await; 15 | download::init().await; 16 | } 17 | 18 | pub(crate) async fn connect_db(path: &str) -> DatabaseConnection { 19 | println!("CONNECT TO DB : {}", path); 20 | let path = join_paths(vec![get_database_dir().as_str(), path]); 21 | println!("DB PATH : {}", path); 22 | let url = format!("sqlite:{}?mode=rwc", path); 23 | let mut opt = sea_orm::ConnectOptions::new(url); 24 | opt.max_connections(20) 25 | .min_connections(5) 26 | .connect_timeout(Duration::from_secs(8)) 27 | .idle_timeout(Duration::from_secs(8)) 28 | .sqlx_logging(true); 29 | sea_orm::Database::connect(opt).await.unwrap() 30 | } 31 | 32 | pub(crate) async fn create_table_if_not_exists(db: &DatabaseConnection, entity: E) 33 | where 34 | E: EntityTrait, 35 | { 36 | if !has_table(db, entity.table_name()).await { 37 | create_table(db, entity).await; 38 | }; 39 | } 40 | 41 | pub(crate) async fn has_table(db: &DatabaseConnection, table_name: &str) -> bool { 42 | let stmt = Statement::from_string( 43 | db.get_database_backend(), 44 | format!( 45 | "SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table' AND name='{}';", 46 | table_name, 47 | ), 48 | ); 49 | let rsp = db.query_one(stmt).await.unwrap().unwrap(); 50 | let count: i32 = rsp.try_get("", "c").unwrap(); 51 | count > 0 52 | } 53 | 54 | pub(crate) async fn create_table(db: &DatabaseConnection, entity: E) 55 | where 56 | E: EntityTrait, 57 | { 58 | let builder = db.get_database_backend(); 59 | let schema = Schema::new(builder); 60 | let stmt = &schema.create_table_from_entity(entity); 61 | let stmt = builder.build(stmt); 62 | db.execute(stmt).await.unwrap(); 63 | } 64 | 65 | pub(crate) async fn index_exists( 66 | db: &DatabaseConnection, 67 | table_name: &str, 68 | index_name: &str, 69 | ) -> bool { 70 | let stmt = Statement::from_string( 71 | db.get_database_backend(), 72 | format!( 73 | "select COUNT(*) AS c from sqlite_master where type='index' AND tbl_name='{}' AND name='{}';", 74 | table_name, index_name, 75 | ), 76 | ); 77 | db.query_one(stmt) 78 | .await 79 | .unwrap() 80 | .unwrap() 81 | .try_get::("", "c") 82 | .unwrap() 83 | > 0 84 | } 85 | 86 | pub(crate) async fn create_index_a( 87 | db: &DatabaseConnection, 88 | table_name: &str, 89 | columns: Vec<&str>, 90 | index_name: &str, 91 | uk: bool, 92 | ) { 93 | let stmt = Statement::from_string( 94 | db.get_database_backend(), 95 | format!( 96 | "CREATE {} INDEX {} ON {}({});", 97 | if uk { "UNIQUE" } else { "" }, 98 | index_name, 99 | table_name, 100 | columns.join(","), 101 | ), 102 | ); 103 | db.execute(stmt).await.unwrap(); 104 | } 105 | 106 | #[allow(dead_code)] 107 | pub(crate) async fn create_index( 108 | db: &DatabaseConnection, 109 | table_name: &str, 110 | columns: Vec<&str>, 111 | index_name: &str, 112 | ) { 113 | create_index_a(db, table_name, columns, index_name, false).await 114 | } 115 | -------------------------------------------------------------------------------- /entry/src/main/ets/pages/Home.ets: -------------------------------------------------------------------------------- 1 | import { colors } from './components/Context' 2 | import { ComicInfo } from './ComicInfo' 3 | import { ComicReader } from './ComicReader' 4 | import { Discovery } from './components/Discovery' 5 | import { materialIconData, materialIconsFontFamily } from './components/MaterialIcons' 6 | import { navStack } from './components/Nav' 7 | import { Rank } from './components/Rank' 8 | import { User } from './components/User' 9 | import { VersionStore, VersionStoreActions, VersionStoreModel, setUiContext } from './components/VersionStore' 10 | import promptAction from '@ohos.promptAction'; 11 | import { hilog } from '@kit.PerformanceAnalysisKit' 12 | 13 | @Entry 14 | @ComponentV2 15 | struct Home { 16 | @Local currentIndex: number = 1 17 | @Local versionState: VersionStoreModel = VersionStore.getState(); 18 | private tabController: TabsController = new TabsController() 19 | 20 | aboutToAppear(): void { 21 | setUiContext(this.getUIContext()); 22 | VersionStore.dispatch(VersionStoreActions.refresh); 23 | } 24 | 25 | aboutToDisappear(): void { 26 | } 27 | 28 | build() { 29 | Navigation(navStack) { 30 | this.tabs() 31 | }.navDestination(this.pageMap) 32 | } 33 | 34 | @Builder 35 | pageMap(name: string) { 36 | if (name == 'pages/ComicInfo') { 37 | ComicInfo() 38 | } else if (name == 'pages/ComicReader') { 39 | ComicReader() 40 | } 41 | } 42 | 43 | @Builder 44 | tabs() { 45 | Column() { 46 | Tabs({ barPosition: BarPosition.End, controller: this.tabController, index: this.currentIndex }) { 47 | TabContent() { 48 | Column() { 49 | Rank() 50 | Blank(1).shadow({ 51 | radius: 3, 52 | color: '#66666666', 53 | }) 54 | } 55 | }.tabBar(this.hotTabMenu()) 56 | 57 | TabContent() { 58 | Column() { 59 | Discovery() 60 | .width('100%') 61 | .height('100%') 62 | Blank(1).shadow({ 63 | radius: 3, 64 | color: '#66666666', 65 | }) 66 | } 67 | }.tabBar(this.discoverTabMenu()) 68 | 69 | TabContent() { 70 | Column() { 71 | User() 72 | Blank(1).shadow({ 73 | radius: 3, 74 | color: '#66666666', 75 | }) 76 | } 77 | }.tabBar(this.bookmarkTabMenu()) 78 | } 79 | .onChange((index: number) => { 80 | this.currentIndex = index 81 | }) 82 | .barHeight(65) 83 | }.width('100%').height('100%') 84 | } 85 | 86 | @Builder 87 | hotTabMenu() { 88 | this.tabMenu( 89 | '排行', 90 | 'local_fire_department', 91 | 0 92 | ) 93 | } 94 | 95 | @Builder 96 | discoverTabMenu() { 97 | this.tabMenu( 98 | '发现', 99 | 'language', 100 | 1 101 | ) 102 | } 103 | 104 | @Builder 105 | bookmarkTabMenu() { 106 | this.tabMenu( 107 | '个人', 108 | 'face', 109 | 2 110 | ) 111 | } 112 | 113 | @Builder 114 | tabMenu(name: string, icon: string, index: number) { 115 | Flex({ 116 | justifyContent: FlexAlign.Center, 117 | alignItems: ItemAlign.Center, 118 | direction: FlexDirection.Column 119 | }) { 120 | Blank(1) 121 | Text(materialIconData(icon)) 122 | .fontFamily(materialIconsFontFamily) 123 | .fontSize(30) 124 | .fontColor(index == this.currentIndex ? colors.authorColor : colors.notActive) 125 | Blank(1) 126 | Text(name) 127 | .fontSize(12) 128 | .fontColor(index == this.currentIndex ? colors.authorColor : colors.notActive) 129 | Blank(1) 130 | }.width('100%').height('100%') 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/Discovery.ets: -------------------------------------------------------------------------------- 1 | import { tags, UiTags, explorer, Tag } from 'native' 2 | import { Loading } from './Loading'; 3 | import { ComicCardList } from './ComicCardList'; 4 | import { ComicListData } from './ComicListData'; 5 | 6 | @Entry 7 | @Component 8 | export struct Discovery { 9 | @State listData: ListData = new ListData('', '', ''); 10 | @State tagsState: number = 0 11 | @State tags: UiTags = { 12 | ordering: [], 13 | theme: [], 14 | top: [] 15 | } 16 | @State ordering: number = 0 17 | @State theme: number = 0 18 | @State top: number = 0 19 | 20 | resetListData() { 21 | this.listData = new ListData( 22 | this.tags.ordering[this.ordering].pathWord, 23 | this.tags.theme[this.theme].pathWord, 24 | this.tags.top[this.top].pathWord, 25 | ) 26 | } 27 | 28 | aboutToAppear(): void { 29 | this.init() 30 | } 31 | 32 | async init() { 33 | try { 34 | this.tags = await tags(); 35 | let newOrdering = new Array(); 36 | this.tags.ordering.forEach(o => { 37 | newOrdering.push({ 38 | name: `${o.name}-倒序`, 39 | pathWord: `-${o.pathWord}`, 40 | }) 41 | newOrdering.push(o) 42 | }); 43 | this.tags.ordering = newOrdering 44 | this.tags.theme.unshift({ 45 | name: '全部', 46 | pathWord: '', 47 | count: 0, 48 | initials: 0, 49 | }) 50 | this.tags.top.unshift({ 51 | name: '全部', 52 | pathWord: '', 53 | }) 54 | this.tagsState = 1 55 | this.resetListData() 56 | } catch (e) { 57 | this.tagsState = 2 58 | } 59 | } 60 | 61 | build() { 62 | if (this.tagsState == 0) { 63 | Loading() 64 | } else if (this.tagsState == 1) { 65 | Flex({ direction: FlexDirection.Column }) { 66 | this.tagsSelector() 67 | ComicCardList({ listData: this.listData }) 68 | } 69 | .width('100%') 70 | .height('100%') 71 | } 72 | } 73 | 74 | @Builder 75 | tagsSelector() { 76 | Flex() { 77 | Select([ 78 | ...this.tags.ordering.map((t) => { 79 | return { 80 | value: t.name, 81 | } as SelectOption 82 | }), 83 | ]) 84 | .selected(this.ordering) 85 | .value(this.tags.ordering[this.ordering].name) 86 | .onSelect((idx, value) => { 87 | this.ordering = idx 88 | this.resetListData() 89 | }) 90 | .flexGrow(1) 91 | .flexShrink(1) 92 | Select([ 93 | ...this.tags.theme.map((t) => { 94 | return { 95 | value: t.name, 96 | } as SelectOption 97 | }), 98 | ]) 99 | .selected(this.theme) 100 | .value(this.tags.theme[this.theme].name) 101 | .onSelect((idx, value) => { 102 | this.theme = idx 103 | this.resetListData() 104 | }) 105 | .flexGrow(1) 106 | .flexShrink(1) 107 | Select([ 108 | ...this.tags.top.map((t) => { 109 | return { 110 | value: t.name, 111 | } as SelectOption 112 | }), 113 | ]) 114 | .selected(this.top) 115 | .value(this.tags.top[this.top].name) 116 | .onSelect((idx, value) => { 117 | this.top = idx 118 | this.resetListData() 119 | }) 120 | .flexGrow(1) 121 | .flexShrink(1) 122 | } 123 | .flexGrow(0) 124 | .flexShrink(0) 125 | .padding({ 126 | top: 10, 127 | bottom: 10, 128 | }) 129 | .shadow({ 130 | radius: 3, 131 | color: '#66666666', 132 | }) 133 | } 134 | } 135 | 136 | class ListData extends ComicListData { 137 | private order: string 138 | private theme: string 139 | private top: string 140 | 141 | constructor(order: string, theme: string, top: string) { 142 | super((offset, limit) => explorer( 143 | this.order.length == 0 ? null : this.order, 144 | this.top.length == 0 ? null : this.top, 145 | this.theme.length == 0 ? null : this.theme, 146 | offset, 147 | limit, 148 | )); 149 | this.order = order 150 | this.theme = theme 151 | this.top = top 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /native/src/database/active/comic_view_log.rs: -------------------------------------------------------------------------------- 1 | use crate::database::active::ACTIVE_DATABASE; 2 | use crate::database::{create_index, create_table_if_not_exists, index_exists}; 3 | use sea_orm::entity::prelude::*; 4 | use sea_orm::QueryOrder; 5 | use sea_orm::QuerySelect; 6 | use sea_orm::{EntityTrait, IntoActiveModel, Set}; 7 | use std::convert::TryInto; 8 | use std::ops::Deref; 9 | 10 | #[derive(Default, Clone, Debug, PartialEq, DeriveEntityModel)] 11 | #[sea_orm(table_name = "comic_view_log")] 12 | pub struct Model { 13 | // comic info 14 | #[sea_orm(primary_key, auto_increment = false)] 15 | pub comic_path_word: String, 16 | pub comic_name: String, 17 | pub comic_authors: String, 18 | pub comic_cover: String, 19 | // chapter info 20 | pub chapter_uuid: String, 21 | pub chapter_name: String, 22 | pub chapter_ordered: i64, 23 | pub chapter_size: i64, 24 | pub chapter_count: i64, 25 | // read info 26 | pub page_rank: i32, 27 | pub view_time: i64, 28 | } 29 | 30 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 31 | pub enum Relation {} 32 | 33 | impl ActiveModelBehavior for ActiveModel {} 34 | 35 | pub(crate) async fn init() { 36 | let db = ACTIVE_DATABASE.get().unwrap().lock().await; 37 | create_table_if_not_exists(db.deref(), Entity).await; 38 | if !index_exists(db.deref(), "comic_view_log", "comic_view_log_idx_view_time").await { 39 | create_index( 40 | db.deref(), 41 | "comic_view_log", 42 | vec!["view_time"], 43 | "comic_view_log_idx_view_time", 44 | ) 45 | .await; 46 | } 47 | } 48 | 49 | pub(crate) async fn view_info(mut model: Model) -> anyhow::Result<()> { 50 | let db = ACTIVE_DATABASE.get().unwrap().lock().await; 51 | if let Some(in_db) = Entity::find_by_id(model.comic_path_word.clone()) 52 | .one(db.deref()) 53 | .await? 54 | { 55 | let mut in_db = in_db.into_active_model(); 56 | in_db.comic_path_word = Set(model.comic_path_word); 57 | in_db.comic_name = Set(model.comic_name); 58 | in_db.comic_authors = Set(model.comic_authors); 59 | in_db.comic_cover = Set(model.comic_cover); 60 | in_db.view_time = Set(chrono::Local::now().timestamp_millis()); 61 | in_db.update(db.deref()).await?; 62 | } else { 63 | model.view_time = chrono::Local::now().timestamp_millis(); 64 | model.into_active_model().insert(db.deref()).await?; 65 | } 66 | Ok(()) 67 | } 68 | 69 | pub(crate) async fn view_page(model: Model) -> anyhow::Result<()> { 70 | let db = ACTIVE_DATABASE.get().unwrap().lock().await; 71 | if let Some(in_db) = Entity::find_by_id(model.comic_path_word.clone()) 72 | .one(db.deref()) 73 | .await? 74 | { 75 | let mut in_db = in_db.into_active_model(); 76 | in_db.comic_path_word = Set(model.comic_path_word); 77 | in_db.chapter_uuid = Set(model.chapter_uuid); 78 | in_db.chapter_name = Set(model.chapter_name); 79 | in_db.chapter_ordered = Set(model.chapter_ordered); 80 | in_db.chapter_size = Set(model.chapter_size); 81 | in_db.chapter_count = Set(model.chapter_count); 82 | in_db.page_rank = Set(model.page_rank); 83 | in_db.view_time = Set(chrono::Local::now().timestamp_millis()); 84 | in_db.update(db.deref()).await?; 85 | } 86 | Ok(()) 87 | } 88 | 89 | pub(crate) async fn load_view_logs(offset: u64, limit: u64) -> anyhow::Result> { 90 | let db = ACTIVE_DATABASE.get().unwrap().lock().await; 91 | Ok(Entity::find() 92 | .order_by_desc(Column::ViewTime) 93 | .offset(offset) 94 | .limit(limit) 95 | .all(db.deref()) 96 | .await?) 97 | } 98 | 99 | pub(crate) async fn count() -> anyhow::Result { 100 | let db = ACTIVE_DATABASE.get().unwrap().lock().await; 101 | let count = Entity::find().count(db.deref()).await?; 102 | Ok(count) 103 | } 104 | 105 | pub(crate) async fn view_log_by_comic_path_word( 106 | path_word: String, 107 | ) -> anyhow::Result> { 108 | let db = ACTIVE_DATABASE.get().unwrap().lock().await; 109 | Ok(Entity::find_by_id(path_word).one(db.deref()).await?) 110 | } 111 | -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/VersionStore.ets: -------------------------------------------------------------------------------- 1 | import { Action, Middleware, MiddlewareStatus, Reducer, StateStore, Store } from "@hadss/state_store"; 2 | import http from '@ohos.net.http'; 3 | import promptAction from '@ohos.promptAction'; 4 | import bundleManager from '@ohos.bundle.bundleManager'; 5 | import { hilog } from "@kit.PerformanceAnalysisKit"; 6 | import { colors } from "./Context"; 7 | import { UIContext } from "@kit.ArkUI"; 8 | import { common, Want } from '@kit.AbilityKit'; 9 | 10 | let uiContext: UIContext; 11 | 12 | export function setUiContext(s: UIContext) { 13 | uiContext = s; 14 | } 15 | 16 | interface ReleaseInfo { 17 | tag_name: string 18 | } 19 | 20 | @ObservedV2 21 | export class VersionStoreModel { 22 | @Trace public loading = false; 23 | @Trace public currentVersion = ''; 24 | @Trace public newVersion = ''; 25 | @Trace public compare = 0; 26 | } 27 | 28 | export class VersionStoreActions { 29 | static refresh: Action = StateStore.createAction('refresh'); 30 | } 31 | 32 | let display = false; 33 | 34 | export const versionMiddleware: Middleware = { 35 | actionType: VersionStoreActions.refresh.type, 36 | beforeAction: (state: VersionStoreModel, action: Action): Action | MiddlewareStatus => { 37 | return MiddlewareStatus.NEXT; 38 | }, 39 | afterAction: (state: VersionStoreModel, action: Action): Action | MiddlewareStatus => { 40 | hilog.info(0x0000, 'StateStore', '2: %{public}s', JSON.stringify(state)); 41 | if (display) { 42 | return MiddlewareStatus.NEXT; 43 | } 44 | if (state.compare > 0) { 45 | display = true; 46 | promptAction.showDialog({ 47 | title: '发现新版本', 48 | message: `当前版本: ${state.currentVersion}\n最新版本: ${state.newVersion}\n\n是否前往下载?`, 49 | buttons: [ 50 | { 51 | text: '取消', 52 | color: '#666666' 53 | }, 54 | { 55 | text: '前往下载', 56 | color: colors.authorColor 57 | } 58 | ] 59 | }).then((result) => { 60 | if (result.index === 1) { 61 | let link = "https://github.com/niuhuan/copi-ohos/releases"; 62 | try { 63 | (uiContext.getHostContext() as common.UIAbilityContext).startAbility({ 64 | uri: link, 65 | }) 66 | } catch (e) { 67 | console.log(e); 68 | } 69 | } 70 | }); 71 | } 72 | return MiddlewareStatus.NEXT; 73 | }, 74 | } 75 | 76 | export const versionStoreReducer: Reducer = (state: VersionStoreModel, action: Action) => { 77 | hilog.info(0x0000, 'StateStore', 'actions: %{public}s', action.type); 78 | switch (action.type) { 79 | case VersionStoreActions.refresh.type: 80 | return async () => { 81 | if (state.loading) { 82 | return; 83 | } 84 | state.loading = true; 85 | try { 86 | let bundleInfo = 87 | await bundleManager.getBundleInfoForSelf(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION); 88 | state.currentVersion = bundleInfo.versionName; 89 | let httpRequest = http.createHttp(); 90 | let response = await httpRequest.request( 91 | "https://api.github.com/repos/niuhuan/copi-ohos/releases/latest", 92 | { 93 | method: http.RequestMethod.GET, 94 | header: { 95 | 'Accept': 'application/vnd.github.v3+json', 96 | 'User-Agent': 'copi-ohos' 97 | } 98 | } 99 | ); 100 | let releaseInfo: ReleaseInfo = JSON.parse(response.result as string); 101 | state.newVersion = releaseInfo.tag_name.substring(1); 102 | state.compare = compareVersions(state.newVersion, state.currentVersion); 103 | hilog.info(0x0000, 'StateStore', 'end: %{public}s', JSON.stringify(state)); 104 | } catch (e) { 105 | hilog.info(0x0000, 'StateStore', 'error: %{public}s', JSON.stringify(e)); 106 | } finally { 107 | state.loading = false; 108 | } 109 | } 110 | default: 111 | break; 112 | } 113 | return null; 114 | }; 115 | 116 | 117 | export const VersionStore: Store = 118 | StateStore.createStore('VersionStore', new VersionStoreModel(), versionStoreReducer, [versionMiddleware]); 119 | 120 | 121 | // 比较版本号,返回1表示version1更新,-1表示version2更新,0表示相同 122 | function compareVersions(version1: string, version2: string): number { 123 | let v1 = version1.split('.').map(Number); 124 | let v2 = version2.split('.').map(Number); 125 | 126 | for (let i = 0; i < Math.max(v1.length, v2.length); i++) { 127 | let num1 = v1[i] || 0; 128 | let num2 = v2[i] || 0; 129 | 130 | if (num1 > num2) { 131 | return 1; 132 | } 133 | if (num1 < num2) { 134 | return -1; 135 | } 136 | } 137 | return 0; 138 | } 139 | -------------------------------------------------------------------------------- /.github/workflows/Release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version to release (e.g., v1.0.0)' 8 | required: true 9 | 10 | jobs: 11 | # Job 1: 检查 Release 是否存在 12 | check-and-create-release: 13 | runs-on: ubuntu-latest 14 | outputs: 15 | upload_required: ${{ steps.check-release.outputs.upload_required }} # 输出是否需要上传 Jar 包 16 | steps: 17 | 18 | # 检出代码库 19 | - name: Checkout repository 20 | uses: actions/checkout@v3 21 | 22 | # 检查 Release 是否存在 23 | - name: Check and create release 24 | id: check-release 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | run: | 28 | RELEASE=$(gh release view ${{ github.event.inputs.version }} --json id -q .id || echo "NOT_FOUND") 29 | if [ "$RELEASE" == "NOT_FOUND" ]; then 30 | echo "Release does not exist. Creating release ${{ github.event.inputs.version }}." 31 | gh release create ${{ github.event.inputs.version }} --notes "Release ${{ github.event.inputs.version }}" --target ${{ github.sha }} 32 | echo "upload_required=true" >> $GITHUB_OUTPUT 33 | else 34 | echo "Release ${{ github.event.inputs.version }} already exists." 35 | gh release view ${{ github.event.inputs.version }} --json assets > release_assets.json 36 | echo release_assets.json : 37 | cat release_assets.json 38 | ASSET_NAME="entry-default-unsigned-${{ github.event.inputs.version }}.hap" 39 | ASSET_EXISTS=$(gh release view ${{ github.event.inputs.version }} --json assets -q ".assets[].name" | grep -w "$ASSET_NAME" || echo "NOT_FOUND") 40 | if [ "$ASSET_EXISTS" == "NOT_FOUND" ]; then 41 | echo "Asset $ASSET_NAME does not exist. Upload is required." 42 | echo "upload_required=true" >> $GITHUB_OUTPUT 43 | else 44 | echo "Asset $ASSET_NAME already exists. No upload is required." 45 | echo "upload_required=false" >> $GITHUB_OUTPUT 46 | fi 47 | fi 48 | echo GITHUB_OUTPUT : 49 | cat $GITHUB_OUTPUT 50 | 51 | 52 | # Job 2: 编译并上传 Hap 文件 53 | build-and-upload: 54 | needs: check-and-create-release # 依赖于 Job 1 55 | if: needs.check-and-create-release.outputs.upload_required == 'true' # 只有当 Release 中的 asset 不存在时才执行 56 | runs-on: ubuntu-latest 57 | container: ghcr.io/sanchuanhehe/harmony-next-pipeline-docker/harmonyos-ci-image:latest 58 | steps: 59 | 60 | # 安装依赖 用于编译 61 | - name: install dependencies 62 | run: | 63 | (type -p wget >/dev/null || (apt update && apt-get install wget -y)) \ 64 | && mkdir -p -m 755 /etc/apt/keyrings \ 65 | && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \ 66 | && cat $out | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ 67 | && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ 68 | && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ 69 | && apt update \ 70 | && apt install gh -y 71 | apt update 72 | apt install -y jq curl xz-utils build-essential rsync gh 73 | 74 | # 安装 rust 工具链 75 | - name: Install rust toolchain 76 | uses: actions-rs/toolchain@v1 77 | with: 78 | toolchain: nightly-2024-12-02 79 | 80 | # 检出代码库 81 | - name: Checkout repository 82 | uses: actions/checkout@v3 83 | 84 | # 构建 Hap 文件 85 | - name: Build Hap file 86 | run: | 87 | export PATH=/opt/harmonyos-tools/command-line-tools/ohpm/bin:$PATH 88 | export PATH=/opt/harmonyos-tools/command-line-tools/bin:$PATH 89 | export OHOS_NDK_HOME=/opt/harmonyos-tools/command-line-tools/sdk/default/openharmony 90 | 91 | export OHOS_BASE_SDK_HOME=$OHOS_NDK_HOME 92 | export OHOS_SDK_NATIVE=$OHOS_NDK_HOME 93 | 94 | export HOS_SDK_HOME=$OHOS_NDK_HOME 95 | export OHOS_SDK_HOME=$OHOS_NDK_HOME 96 | 97 | cargo install -f --git https://github.com/ohos-rs/ohos-rs.git 98 | rustup target add aarch64-unknown-linux-ohos 99 | make all 100 | 101 | # 上传 Hap 文件到 Release 102 | - name: Upload Hap to release 103 | env: 104 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 105 | run: | 106 | echo "Uploading package to release..." 107 | find . -name "*.hap" 108 | ls -l entry/build/default/outputs/default/entry-default-unsigned.hap 109 | git config --global --add safe.directory $GITHUB_WORKSPACE 110 | gh release upload ${{ github.event.inputs.version }} "entry/build/default/outputs/default/entry-default-unsigned.hap#entry-default-unsigned-${{ github.event.inputs.version }}.hap" --clobber 111 | -------------------------------------------------------------------------------- /native/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | use crate::database::init_database; 3 | use crate::database::properties::property; 4 | use base64::Engine; 5 | use copy_client::Client; 6 | use lazy_static::lazy_static; 7 | use once_cell::sync::OnceCell; 8 | use std::sync::Arc; 9 | use tokio::sync::Mutex; 10 | use utils::create_dir_if_not_exists; 11 | use utils::join_paths; 12 | pub mod copy_client; 13 | mod database; 14 | pub mod downloading; 15 | mod exports; 16 | mod udto; 17 | mod utils; 18 | 19 | const OLD_API_URL: &str = "aHR0cHM6Ly93d3cuY29weS1tYW5nYS5jb20="; 20 | const API_URL: &str = "aHR0cHM6Ly93d3cuY29weTIwLmNvbQ=="; 21 | 22 | fn api_url() -> String { 23 | String::from_utf8(base64::prelude::BASE64_STANDARD.decode(API_URL).unwrap()).unwrap() 24 | } 25 | 26 | lazy_static! { 27 | pub(crate) static ref CLIENT: Arc = Arc::new(Client::new( 28 | reqwest::ClientBuilder::new() 29 | .danger_accept_invalid_certs(true) 30 | .connect_timeout(std::time::Duration::from_secs(5)) 31 | .read_timeout(std::time::Duration::from_secs(30)) 32 | .build() 33 | .unwrap(), 34 | api_url() 35 | )); 36 | static ref INIT_ED: Mutex = Mutex::new(false); 37 | } 38 | 39 | static ROOT: OnceCell = OnceCell::new(); 40 | static IMAGE_CACHE_DIR: OnceCell = OnceCell::new(); 41 | static DATABASE_DIR: OnceCell = OnceCell::new(); 42 | static DOWNLOAD_DIR: OnceCell = OnceCell::new(); 43 | 44 | pub async fn init_root(path: &str) { 45 | let mut lock = INIT_ED.lock().await; 46 | if *lock { 47 | return; 48 | } 49 | *lock = true; 50 | println!("Init application with root : {}", path); 51 | ROOT.set(path.to_owned()).unwrap(); 52 | IMAGE_CACHE_DIR 53 | .set(join_paths(vec![path, "image_cache"])) 54 | .unwrap(); 55 | DATABASE_DIR 56 | .set(join_paths(vec![path, "database"])) 57 | .unwrap(); 58 | DOWNLOAD_DIR 59 | .set(join_paths(vec![path, "download"])) 60 | .unwrap(); 61 | create_dir_if_not_exists(ROOT.get().unwrap()); 62 | create_dir_if_not_exists(IMAGE_CACHE_DIR.get().unwrap()); 63 | create_dir_if_not_exists(DATABASE_DIR.get().unwrap()); 64 | create_dir_if_not_exists(DOWNLOAD_DIR.get().unwrap()); 65 | init_database().await; 66 | reset_api().await; 67 | load_api().await; 68 | init_device().await; 69 | *downloading::DOWNLOAD_AND_EXPORT_TO.lock().await = 70 | database::properties::property::load_property("download_and_export_to".to_owned()) 71 | .await 72 | .unwrap(); 73 | *downloading::PAUSE_FLAG.lock().await = 74 | database::properties::property::load_property("download_pause".to_owned()) 75 | .await 76 | .unwrap() 77 | == "true"; 78 | tokio::spawn(downloading::start_download()); 79 | } 80 | 81 | #[allow(dead_code)] 82 | pub(crate) fn get_root() -> &'static String { 83 | ROOT.get().unwrap() 84 | } 85 | 86 | pub(crate) fn get_image_cache_dir() -> &'static String { 87 | IMAGE_CACHE_DIR.get().unwrap() 88 | } 89 | 90 | pub(crate) fn get_database_dir() -> &'static String { 91 | DATABASE_DIR.get().unwrap() 92 | } 93 | 94 | pub(crate) fn get_download_dir() -> &'static String { 95 | DOWNLOAD_DIR.get().unwrap() 96 | } 97 | 98 | #[cfg(not(target_family = "wasm"))] 99 | #[napi_ohos::module_init] 100 | fn init() { 101 | let rt = tokio::runtime::Builder::new_multi_thread() 102 | .enable_all() 103 | .worker_threads(20) 104 | .thread_keep_alive(std::time::Duration::from_secs(60)) 105 | .max_blocking_threads(10) 106 | .build() 107 | .unwrap(); 108 | napi_ohos::bindgen_prelude::create_custom_tokio_runtime(rt); 109 | } 110 | 111 | async fn reset_api() { 112 | let old_api = property::load_property("old_api".to_owned()).await.unwrap(); 113 | let api = property::load_property("api".to_owned()).await.unwrap(); 114 | if api.is_empty() { 115 | return; 116 | } 117 | let replace_from = String::from_utf8( 118 | base64::prelude::BASE64_STANDARD 119 | .decode(OLD_API_URL) 120 | .unwrap(), 121 | ) 122 | .unwrap(); 123 | if !replace_from.eq(&old_api) && replace_from.eq(&api) { 124 | let replace_to = 125 | String::from_utf8(base64::prelude::BASE64_STANDARD.decode(API_URL).unwrap()).unwrap(); 126 | property::save_property("old_api".to_owned(), replace_from) 127 | .await 128 | .unwrap(); 129 | property::save_property("api".to_owned(), replace_to) 130 | .await 131 | .unwrap(); 132 | } 133 | } 134 | 135 | async fn load_api() { 136 | let api = property::load_property("api".to_owned()).await.unwrap(); 137 | if api.is_empty() { 138 | return; 139 | } 140 | CLIENT.set_api_host(api).await; 141 | } 142 | 143 | async fn init_device() { 144 | let mut device = property::load_property("device".to_owned()).await.unwrap(); 145 | if device.is_empty() { 146 | device = copy_client::random_device(); 147 | property::save_property("device".to_owned(), device.clone()) 148 | .await 149 | .unwrap(); 150 | } 151 | let mut device_info = property::load_property("device_info".to_owned()).await.unwrap(); 152 | if device_info.is_empty() { 153 | device_info = copy_client::random_device(); 154 | property::save_property("device_info".to_owned(), device_info.clone()) 155 | .await 156 | .unwrap(); 157 | } 158 | CLIENT.set_device(device, device_info).await; 159 | } 160 | 161 | 162 | 163 | -------------------------------------------------------------------------------- /native/src/database/download/download_comic_chapter.rs: -------------------------------------------------------------------------------- 1 | use crate::database::download::DOWNLOAD_DATABASE; 2 | use crate::database::{create_index, create_table_if_not_exists, index_exists}; 3 | use sea_orm::entity::prelude::*; 4 | use sea_orm::sea_query::OnConflict; 5 | use sea_orm::{DeleteResult, IntoActiveModel, Order, QueryOrder}; 6 | use sea_orm::{UpdateResult}; 7 | use serde_derive::{Deserialize, Serialize}; 8 | use std::ops::Deref; 9 | 10 | pub(crate) const STATUS_INIT: i64 = 0; 11 | pub(crate) const STATUS_FETCH_SUCCESS: i64 = 1; 12 | pub(crate) const STATUS_FETCH_FAILED: i64 = 2; 13 | 14 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] 15 | #[sea_orm(table_name = "download_comic_chapter")] 16 | pub struct Model { 17 | #[sea_orm(primary_key, auto_increment = false)] 18 | pub comic_path_word: String, 19 | #[sea_orm(primary_key, auto_increment = false)] 20 | pub uuid: String, 21 | pub comic_id: String, 22 | pub count: i64, 23 | pub datetime_created: String, 24 | pub group_path_word: String, 25 | pub img_type: i64, 26 | pub index: i64, 27 | pub is_long: bool, 28 | pub name: String, 29 | pub news: String, 30 | pub next: Option, 31 | pub ordered: i64, 32 | pub prev: Option, 33 | pub size: i64, 34 | #[serde(rename = "type")] 35 | pub type_field: i64, 36 | // 37 | pub download_status: i64, 38 | // pub contents: Vec, 39 | } 40 | 41 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 42 | pub enum Relation {} 43 | 44 | impl ActiveModelBehavior for ActiveModel {} 45 | 46 | pub(crate) async fn init() { 47 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 48 | create_table_if_not_exists(db.deref(), Entity).await; 49 | if !index_exists( 50 | db.deref(), 51 | "download_comic_chapter", 52 | "download_comic_chapter_idx_comic_path_word", 53 | ) 54 | .await 55 | { 56 | create_index( 57 | db.deref(), 58 | "download_comic_chapter", 59 | vec!["comic_path_word"], 60 | "download_comic_chapter_idx_comic_path_word", 61 | ) 62 | .await; 63 | } 64 | } 65 | 66 | pub(crate) async fn all_chapter( 67 | comic_path_word: &str, 68 | status: impl Into>, 69 | ) -> anyhow::Result> { 70 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 71 | let mut f = Entity::find().filter(Column::ComicPathWord.eq(comic_path_word)); 72 | if let Some(status) = status.into() { 73 | f = f.filter(Column::DownloadStatus.eq(status)); 74 | } 75 | let list = f.all(db.deref()).await?; 76 | Ok(list) 77 | } 78 | 79 | pub(crate) async fn update_status( 80 | db: &impl ConnectionTrait, 81 | uuid: &str, 82 | status: i64, 83 | ) -> Result { 84 | Entity::update_many() 85 | .col_expr(Column::DownloadStatus, Expr::value(status)) 86 | .filter(Column::Uuid.eq(uuid)) 87 | .exec(db) 88 | .await 89 | } 90 | 91 | pub(crate) async fn is_all_chapter_fetched(comic_path_word: &str) -> anyhow::Result { 92 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 93 | let count = Entity::find() 94 | .filter(Column::ComicPathWord.eq(comic_path_word)) 95 | .filter(Column::DownloadStatus.ne(STATUS_FETCH_SUCCESS)) 96 | .count(db.deref()) 97 | .await?; 98 | Ok(count == 0) 99 | } 100 | 101 | pub(crate) async fn delete_by_comic_path_word( 102 | db: &impl ConnectionTrait, 103 | comic_path_word: &str, 104 | ) -> Result { 105 | Entity::delete_many() 106 | .filter(Column::ComicPathWord.eq(comic_path_word)) 107 | .exec(db) 108 | .await 109 | } 110 | 111 | pub(crate) async fn insert_or_update_info( 112 | db: &impl ConnectionTrait, 113 | model: Model, 114 | ) -> Result<(), DbErr> { 115 | // 不需要更新downloadStatus 116 | let result = Entity::insert(model.into_active_model()) 117 | .on_conflict( 118 | OnConflict::columns(vec![Column::ComicPathWord, Column::Uuid]) 119 | .update_columns(vec![ 120 | Column::ComicId, 121 | Column::Count, 122 | Column::DatetimeCreated, 123 | Column::GroupPathWord, 124 | Column::ImgType, 125 | Column::Index, 126 | Column::IsLong, 127 | Column::Name, 128 | Column::News, 129 | Column::Next, 130 | Column::Ordered, 131 | Column::Prev, 132 | Column::Size, 133 | Column::TypeField, 134 | ]) 135 | .to_owned(), 136 | ) 137 | .exec(db) 138 | .await; 139 | match result { 140 | Ok(_) => Ok(()), 141 | Err(DbErr::RecordNotInserted) => Ok(()), 142 | Err(err) => Err(err), 143 | } 144 | } 145 | 146 | pub async fn in_download_chapter_uuid(comic_path_word: String) -> anyhow::Result> { 147 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 148 | let list = Entity::find() 149 | .filter(Column::ComicPathWord.eq(comic_path_word)) 150 | .all(db.deref()) 151 | .await? 152 | .into_iter() 153 | .map(|v| v.uuid) 154 | .collect::>(); 155 | Ok(list) 156 | } 157 | 158 | pub(crate) async fn reset_failed(db: &impl ConnectionTrait) -> Result<(), DbErr> { 159 | Entity::update_many() 160 | .col_expr(Column::DownloadStatus, Expr::value(STATUS_INIT)) 161 | .filter(Column::DownloadStatus.eq(STATUS_FETCH_FAILED)) 162 | .exec(db) 163 | .await?; 164 | Ok(()) 165 | } 166 | 167 | // find_by_comic_path_word sort by ordered 168 | pub(crate) async fn find_by_comic_path_word(comic_path_word: &str) -> anyhow::Result> { 169 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 170 | let result = Entity::find() 171 | .filter(Column::ComicPathWord.eq(comic_path_word)) 172 | .order_by(Column::Ordered, Order::Asc) 173 | .all(db.deref()) 174 | .await?; 175 | Ok(result) 176 | } 177 | -------------------------------------------------------------------------------- /native/src/database/download/download_comic_page.rs: -------------------------------------------------------------------------------- 1 | use crate::database::download::DOWNLOAD_DATABASE; 2 | use crate::database::{create_index, create_table_if_not_exists, index_exists}; 3 | use sea_orm::entity::prelude::*; 4 | use sea_orm::{ 5 | DeleteResult, InsertResult, IntoActiveModel, Order, QueryOrder, QuerySelect, 6 | UpdateResult, 7 | }; 8 | use serde_derive::{Deserialize, Serialize}; 9 | use std::ops::Deref; 10 | 11 | pub(crate) const STATUS_INIT: i64 = 0; 12 | pub(crate) const STATUS_DOWNLOAD_SUCCESS: i64 = 1; 13 | pub(crate) const STATUS_DOWNLOAD_FAILED: i64 = 2; 14 | 15 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, Default)] 16 | #[sea_orm(table_name = "download_comic_page")] 17 | pub struct Model { 18 | pub comic_path_word: String, 19 | #[sea_orm(primary_key, auto_increment = false)] 20 | pub chapter_uuid: String, 21 | #[sea_orm(primary_key, auto_increment = false)] 22 | pub image_index: i32, 23 | pub url: String, 24 | pub cache_key: String, 25 | pub download_status: i64, 26 | pub width: u32, 27 | pub height: u32, 28 | pub format: String, 29 | } 30 | 31 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 32 | pub enum Relation {} 33 | 34 | impl ActiveModelBehavior for ActiveModel {} 35 | 36 | pub(crate) async fn init() { 37 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 38 | create_table_if_not_exists(db.deref(), Entity).await; 39 | if !index_exists( 40 | db.deref(), 41 | "download_comic_page", 42 | "download_comic_page_idx_comic_path_word", 43 | ) 44 | .await 45 | { 46 | create_index( 47 | db.deref(), 48 | "download_comic_page", 49 | vec!["comic_path_word"], 50 | "download_comic_page_idx_comic_path_word", 51 | ) 52 | .await; 53 | } 54 | if !index_exists( 55 | db.deref(), 56 | "download_comic_page", 57 | "download_comic_page_idx_chapter_uuid", 58 | ) 59 | .await 60 | { 61 | create_index( 62 | db.deref(), 63 | "download_comic_page", 64 | vec!["chapter_uuid"], 65 | "download_comic_page_idx_chapter_uuid", 66 | ) 67 | .await; 68 | } 69 | if !index_exists( 70 | db.deref(), 71 | "download_comic_page", 72 | "download_comic_page_idx_cache_key", 73 | ) 74 | .await 75 | { 76 | create_index( 77 | db.deref(), 78 | "download_comic_page", 79 | vec!["cache_key"], 80 | "download_comic_page_idx_cache_key", 81 | ) 82 | .await; 83 | } 84 | if !index_exists( 85 | db.deref(), 86 | "download_comic_page", 87 | "download_comic_page_idx_url", 88 | ) 89 | .await 90 | { 91 | create_index( 92 | db.deref(), 93 | "download_comic_page", 94 | vec!["url"], 95 | "download_comic_page_idx_url", 96 | ) 97 | .await; 98 | } 99 | } 100 | 101 | pub(crate) async fn save( 102 | db: &impl ConnectionTrait, 103 | model: Model, 104 | ) -> Result, DbErr> { 105 | Entity::insert(model.into_active_model()).exec(db).await 106 | } 107 | 108 | pub(crate) async fn has_download_pic(cache_key: String) -> anyhow::Result> { 109 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 110 | Ok(Entity::find() 111 | .filter(Expr::col(Column::CacheKey).eq(cache_key)) 112 | .limit(1) 113 | .one(db.deref()) 114 | .await?) 115 | } 116 | 117 | pub(crate) async fn fetch( 118 | comic_path_word: &str, 119 | status: i64, 120 | limit: u64, 121 | ) -> anyhow::Result> { 122 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 123 | Ok(Entity::find() 124 | .filter(Expr::col(Column::ComicPathWord).eq(comic_path_word)) 125 | .filter(Column::DownloadStatus.eq(status)) 126 | .limit(limit) 127 | .all(db.deref()) 128 | .await?) 129 | } 130 | 131 | pub(crate) async fn update_status( 132 | db: &impl ConnectionTrait, 133 | chapter_uuid: &str, 134 | image_index: i32, 135 | status: i64, 136 | width: u32, 137 | height: u32, 138 | format: String, 139 | ) -> Result { 140 | Entity::update_many() 141 | .col_expr(Column::DownloadStatus, Expr::value(status)) 142 | .col_expr(Column::Width, Expr::value(width)) 143 | .col_expr(Column::Height, Expr::value(height)) 144 | .col_expr(Column::Format, Expr::value(format)) 145 | .filter(Column::ChapterUuid.eq(chapter_uuid)) 146 | .filter(Column::ImageIndex.eq(image_index)) 147 | .exec(db) 148 | .await 149 | } 150 | 151 | pub(crate) async fn is_all_page_downloaded(comic_path_word: &str) -> anyhow::Result { 152 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 153 | let count = Entity::find() 154 | .filter(Column::ComicPathWord.eq(comic_path_word)) 155 | .filter(Column::DownloadStatus.ne(STATUS_DOWNLOAD_SUCCESS)) 156 | .count(db.deref()) 157 | .await?; 158 | Ok(count == 0) 159 | } 160 | 161 | pub(crate) async fn delete_by_comic_path_word( 162 | db: &impl ConnectionTrait, 163 | comic_path_word: &str, 164 | ) -> Result { 165 | Entity::delete_many() 166 | .filter(Column::ComicPathWord.eq(comic_path_word)) 167 | .exec(db) 168 | .await 169 | } 170 | 171 | pub(crate) async fn reset_failed(db: &impl ConnectionTrait) -> Result<(), DbErr> { 172 | Entity::update_many() 173 | .col_expr(Column::DownloadStatus, Expr::value(STATUS_INIT)) 174 | .filter(Column::DownloadStatus.eq(STATUS_DOWNLOAD_FAILED)) 175 | .exec(db) 176 | .await?; 177 | Ok(()) 178 | } 179 | 180 | // find_by_comic_path_word_and_chapter_uuid sort by image_index 181 | pub(crate) async fn find_by_comic_path_word_and_chapter_uuid( 182 | comic_path_word: &str, 183 | chapter_uuid: &str, 184 | ) -> anyhow::Result> { 185 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 186 | Ok(Entity::find() 187 | .filter(Column::ComicPathWord.eq(comic_path_word)) 188 | .filter(Column::ChapterUuid.eq(chapter_uuid)) 189 | .order_by(Column::ImageIndex, Order::Asc) 190 | .all(db.deref()) 191 | .await?) 192 | } 193 | -------------------------------------------------------------------------------- /entry/src/main/ets/pages/ComicInfo.ets: -------------------------------------------------------------------------------- 1 | import router from '@ohos.router'; 2 | import { 3 | UiChapterData, 4 | UiComicChapter, 5 | UiComicData, 6 | UiComicInExplore, 7 | UiComicQuery, 8 | UiPageComicChapter, 9 | UiViewLog, 10 | comicQuery, 11 | comic, 12 | comicChapters, 13 | findComicViewLog, 14 | Group, 15 | viewComicInfo, 16 | } from 'native' 17 | import { ComicReader, ComicReaderParam } from './ComicReader'; 18 | import { ComicCard } from './components/ComicCard'; 19 | import { Error } from './components/Error'; 20 | import { Loading } from './components/Loading'; 21 | import { navEvents, navNamesJoin, navStack } from './components/Nav'; 22 | import { Uuid } from './components/Uuid'; 23 | 24 | 25 | @Entry 26 | @Component 27 | export struct ComicInfo { 28 | @State exploreComic: UiComicInExplore | null = null 29 | @State comicLoadingState: number = 0 30 | @State comicQuery: UiComicQuery | '' = '' 31 | @State comicData: UiComicData | null = null 32 | @State chapterDataMap: Map> = new Map() 33 | @State viewLog: UiViewLog | null = null 34 | @State namesJoin: string = '' 35 | @State uuid: string = '' 36 | 37 | async reloadViewLog() { 38 | if (this.comicData != null) { 39 | this.viewLog = await findComicViewLog(this.comicData.comic.pathWord); 40 | } 41 | } 42 | 43 | aboutToAppear(): void { 44 | this.namesJoin = navNamesJoin() 45 | this.uuid = Uuid.v4() 46 | navEvents.set(this.uuid, (nj) => { 47 | if (nj == this.namesJoin) { 48 | this.reloadViewLog() 49 | } 50 | }) 51 | } 52 | 53 | aboutToDisappear(): void { 54 | navEvents.delete(this.uuid) 55 | } 56 | 57 | async init() { 58 | this.comicLoadingState = 0 59 | try { 60 | this.comicQuery = await comicQuery(this.exploreComic!.pathWord) 61 | this.comicData = await comic(this.exploreComic!.pathWord); 62 | console.error(`GROUPS : ${this.comicData.groups[0].pathWord}`) 63 | for (let i = 0; i < this.comicData.groups.length; i++) { 64 | let group = this.comicData!.groups[i]; 65 | let cl = new Array() 66 | const limit = 100 67 | let offset = 0 68 | while (true) { 69 | let cc = await comicChapters(this.comicData.comic.pathWord, group.pathWord, limit, offset) 70 | cl = cl.concat(cc.list) 71 | offset += limit 72 | if (cc.limit + cc.offset >= cc.total) { 73 | break 74 | } 75 | } 76 | this.chapterDataMap[group.pathWord] = cl 77 | } 78 | this.viewLog = await findComicViewLog(this.comicData.comic.pathWord); 79 | await viewComicInfo( 80 | this.comicData.comic.pathWord, 81 | this.comicData.comic.name, 82 | this.comicData.comic.author, 83 | this.comicData.comic.cover, 84 | ) 85 | this.comicLoadingState = 1 86 | } catch (e) { 87 | this.comicLoadingState = 2 88 | } 89 | } 90 | 91 | build() { 92 | NavDestination() { 93 | Flex({ direction: FlexDirection.Column }) { 94 | if (null != this.exploreComic) { 95 | ComicCard({ comic: this.exploreComic }) 96 | .flexGrow(0) 97 | .flexShrink(0) 98 | } 99 | if (this.comicLoadingState == 0) { 100 | this.loading() 101 | } else if (this.comicLoadingState == 1) { 102 | this.comic(this.comicData as UiComicData) 103 | } else { 104 | this.error() 105 | } 106 | }.width('100%').height('100%') 107 | } 108 | .title('漫画') 109 | .onBackPressed(() => { 110 | return true 111 | }) 112 | .backButtonIcon($r('sys.symbol.chevron_left')) 113 | .onReady((c) => { 114 | this.exploreComic = c.pathInfo.param as UiComicInExplore 115 | this.init() 116 | }) 117 | .onBackPressed(() => { 118 | navStack.pop() 119 | return true 120 | }) 121 | } 122 | 123 | @Builder 124 | comic(comicData: UiComicData) { 125 | List() { 126 | ListItem().margin({ top: 20 }) 127 | ListItem() { 128 | if (this.viewLog != null && this.viewLog.chapterUuid.length > 0) { 129 | Flex({ justifyContent: FlexAlign.Center }) { 130 | this.continueButton() 131 | } 132 | } 133 | } 134 | 135 | ListItem() { 136 | Flex({ justifyContent: FlexAlign.Center }) { 137 | this.firstChapterButton() 138 | } 139 | } 140 | 141 | ForEach(comicData.groups, (group: Group, groupIdx) => { 142 | ListItem().margin({ top: 20 }) 143 | if (group.pathWord != 'default') 144 | ListItem() { 145 | Text(`${group.name}`) 146 | .padding({ left: 10 }) 147 | } 148 | ListItem() { 149 | Flex({ wrap: FlexWrap.Wrap, alignItems: ItemAlign.Center, justifyContent: FlexAlign.SpaceEvenly }) { 150 | ForEach(this.chapterDataMap[group.pathWord], (chapter: UiComicChapter, chapterIdx) => { 151 | this.chapterButton(chapter) 152 | }) 153 | } 154 | } 155 | }) 156 | }.width('100%').height('100%') 157 | } 158 | 159 | @Builder 160 | continueButton() { 161 | if (this.continueChapter() != null) { 162 | this.chapterButton(this.continueChapter()!, `从 ${this.viewLog!.chapterName} 继续观看`) 163 | } 164 | } 165 | 166 | @Builder 167 | firstChapterButton() { 168 | if (this.firstChapter() != null) { 169 | this.chapterButton(this.firstChapter()!, '从头开始看') 170 | } 171 | } 172 | 173 | firstChapter(): UiComicChapter | null { 174 | for (let i = 0; i < this.comicData!.groups.length; i++) { 175 | const g = this.comicData!.groups[i]!; 176 | const cs: Array = this.chapterDataMap[g.pathWord]!; 177 | for (let j = 0; j < cs.length; j++) { 178 | return cs[j]; 179 | } 180 | } 181 | return null 182 | } 183 | 184 | continueChapter(): UiComicChapter | null { 185 | for (let i = 0; i < this.comicData!.groups.length; i++) { 186 | const g = this.comicData!.groups[i]!; 187 | const cs: Array = this.chapterDataMap[g.pathWord]!; 188 | for (let j = 0; j < cs.length; j++) { 189 | if (cs[j]?.uuid == this.viewLog?.chapterUuid) { 190 | return cs[j]; 191 | } 192 | } 193 | } 194 | return null 195 | } 196 | 197 | @Builder 198 | chapterButton(chapter: UiComicChapter, text?: string) { 199 | Text(text ?? chapter.name) 200 | .fontSize(12) 201 | .margin(10) 202 | .padding({ 203 | left: 18, 204 | right: 18, 205 | top: 10, 206 | bottom: 10, 207 | }) 208 | .backgroundColor(Color.White) 209 | .borderRadius(5) 210 | .shadow({ radius: 3, color: Color.Gray }) 211 | .onClick(() => { 212 | navStack.pushPath(new NavPathInfo( 213 | "pages/ComicReader", 214 | this.comicReaderParam(chapter), 215 | )) 216 | }) 217 | } 218 | 219 | comicReaderParam(chapter: UiComicChapter): ComicReaderParam { 220 | return { 221 | exploreComic: this.exploreComic!, 222 | comicData: this.comicData!, 223 | chapterDataMap: this.chapterDataMap, 224 | chapter, 225 | }; 226 | } 227 | 228 | @Builder 229 | loading() { 230 | Loading() 231 | .flexGrow(1) 232 | .flexShrink(1) 233 | } 234 | 235 | @Builder 236 | error() { 237 | Error({ text: '点击重试' }) 238 | .flexGrow(1) 239 | .flexShrink(1) 240 | .onClick(() => { 241 | this.init() 242 | }) 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /native/src/exports.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | database::download::{download_comic, download_comic_chapter, download_comic_page}, 3 | downloading::{get_cover_path, get_image_path}, 4 | utils::{allowed_file_name, create_dir_if_not_exists, join_paths}, 5 | }; 6 | use anyhow::{Context, Ok, Result}; 7 | use async_trait::async_trait; 8 | use async_zip::{tokio::write::ZipFileWriter, ZipEntryBuilder}; 9 | use futures_util::{lock::Mutex, AsyncWriteExt}; 10 | use tokio::io::AsyncReadExt; 11 | 12 | pub(crate) async fn exports( 13 | uuid_list: Vec, 14 | export_to_folder: String, 15 | exports_type: String, 16 | ) -> Result<()> { 17 | let download_comics = download_comic::find_by_uuid_list(uuid_list.as_slice()).await?; 18 | for ele in &download_comics { 19 | if ele.download_status != download_comic::STATUS_DOWNLOAD_SUCCESS { 20 | return Err(anyhow::anyhow!("comic not downloaded")); 21 | } 22 | } 23 | let exports_type = exports_type.clone(); 24 | for download_comic in &download_comics { 25 | let name = download_comic.name.as_str(); 26 | let mut exporter = match exports_type.as_str() { 27 | "Folder" => FolderExporter::on_start(&export_to_folder, &name).await?, 28 | "Zip" => ZipExporter::on_start(&export_to_folder, &name).await?, 29 | _ => return Err(anyhow::anyhow!("unknown exports type")), 30 | }; 31 | let chapters = 32 | download_comic_chapter::find_by_comic_path_word(download_comic.path_word.as_str()) 33 | .await?; 34 | let download_cover_path = get_cover_path(download_comic); 35 | exporter 36 | .on_cover( 37 | download_cover_path.as_str(), 38 | download_comic.cover_format.as_str(), 39 | ) 40 | .await?; 41 | for chapter in &chapters { 42 | exporter.on_chapter(&chapter.name).await?; 43 | let pages = download_comic_page::find_by_comic_path_word_and_chapter_uuid( 44 | download_comic.path_word.as_str(), 45 | chapter.uuid.as_str(), 46 | ) 47 | .await?; 48 | for page in &pages { 49 | let download_comic_path = get_image_path(&page); 50 | exporter 51 | .on_page(&download_comic_path, &page.format, page.image_index) 52 | .await?; 53 | } 54 | } 55 | exporter.finish().await?; 56 | } 57 | Ok(()) 58 | } 59 | 60 | #[async_trait] 61 | trait Exporter { 62 | async fn on_cover(&mut self, source: &str, format: &str) -> Result<()>; 63 | async fn on_chapter(&mut self, name: &str) -> Result<()>; 64 | async fn on_page(&mut self, source: &str, format: &str, index: i32) -> Result<()>; 65 | async fn finish(mut self: Box) -> Result<()>; 66 | } 67 | 68 | struct FolderExporter { 69 | comic_folder: String, 70 | chaper_folder: Mutex>, 71 | } 72 | 73 | impl FolderExporter { 74 | async fn on_start(export_to_folder: &str, name: &str) -> Result> { 75 | let comic_folder = join_paths(vec![export_to_folder, allowed_file_name(name).as_str()]); 76 | create_dir_if_not_exists(comic_folder.as_str()); 77 | Ok(Box::new(Self { 78 | comic_folder, 79 | chaper_folder: Mutex::new(None), 80 | })) 81 | } 82 | } 83 | 84 | #[async_trait] 85 | impl Exporter for FolderExporter { 86 | async fn on_cover(&mut self, source: &str, format: &str) -> Result<()> { 87 | let cover_path = join_paths(vec![ 88 | self.comic_folder.as_str(), 89 | format!("cover.{}", format).as_str(), 90 | ]); 91 | tokio::fs::copy(source, cover_path.as_str()).await?; 92 | Ok(()) 93 | } 94 | 95 | async fn on_chapter(&mut self, name: &str) -> Result<()> { 96 | let path = join_paths(vec![ 97 | self.comic_folder.as_str(), 98 | allowed_file_name(name).as_str(), 99 | ]); 100 | create_dir_if_not_exists(path.as_str()); 101 | let mut lock = self.chaper_folder.lock().await; 102 | *lock = Some(path); 103 | Ok(()) 104 | } 105 | 106 | async fn on_page(&mut self, source: &str, format: &str, index: i32) -> Result<()> { 107 | let chapter_folder = self.chaper_folder.lock().await; 108 | let cf = chapter_folder 109 | .as_ref() 110 | .with_context(|| "chapter folder not found")?; 111 | let page_path = join_paths(vec![ 112 | cf.as_str(), 113 | format!("{:04}.{}", index, format).as_str(), 114 | ]); 115 | tokio::fs::copy(source, page_path.as_str()).await?; 116 | Ok(()) 117 | } 118 | 119 | async fn finish(mut self: Box) -> Result<()> { 120 | Ok(()) 121 | } 122 | } 123 | 124 | struct ZipExporter { 125 | writer: ZipFileWriter, 126 | chaper_folder: Mutex>, 127 | } 128 | 129 | impl ZipExporter { 130 | async fn on_start(export_to_folder: &str, name: &str) -> Result> { 131 | let comic_folder = join_paths(vec![export_to_folder, allowed_file_name(name).as_str()]); 132 | let file = tokio::fs::File::create(format!("{}.zip", comic_folder).as_str()).await?; 133 | let writer = ZipFileWriter::with_tokio(file); 134 | let comic_folder = join_paths(vec![export_to_folder, allowed_file_name(name).as_str()]); 135 | create_dir_if_not_exists(comic_folder.as_str()); 136 | Ok(Box::new(Self { 137 | writer, 138 | chaper_folder: Mutex::new(None), 139 | })) 140 | } 141 | 142 | async fn push_file(&mut self, source: &str, target: &str) -> Result<()> { 143 | let mut file = tokio::fs::File::open(source).await?; 144 | let builder = ZipEntryBuilder::new(target.into(), async_zip::Compression::Deflate); 145 | let mut entry_writer = self.writer.write_entry_stream(builder).await?; 146 | let mut buf = vec![0; 1 << 8]; 147 | let mut a; 148 | while { 149 | a = file.read(buf.as_mut_slice()).await?; 150 | a > 0 151 | } { 152 | entry_writer.write_all(&buf[0..a]).await?; 153 | } 154 | // tokio::io::copy(&mut file, &mut entry_writer).await?; 155 | entry_writer.close().await?; 156 | Ok(()) 157 | } 158 | } 159 | 160 | #[async_trait] 161 | impl Exporter for ZipExporter { 162 | async fn on_cover(&mut self, source: &str, format: &str) -> Result<()> { 163 | let cover_path = format!("cover.{}", format); 164 | self.push_file(source, cover_path.as_str()).await?; 165 | Ok(()) 166 | } 167 | 168 | async fn on_chapter(&mut self, name: &str) -> Result<()> { 169 | let path = allowed_file_name(name); 170 | create_dir_if_not_exists(path.as_str()); 171 | let mut lock = self.chaper_folder.lock().await; 172 | *lock = Some(path); 173 | // todo: add folder entry 174 | Ok(()) 175 | } 176 | 177 | async fn on_page(&mut self, source: &str, format: &str, index: i32) -> Result<()> { 178 | let chapter_folder = self.chaper_folder.lock().await; 179 | let cf = chapter_folder 180 | .as_ref() 181 | .with_context(|| "chapter folder not found")?; 182 | let page_path = join_paths(vec![ 183 | cf.as_str(), 184 | format!("{:04}.{}", index, format).as_str(), 185 | ]); 186 | drop(chapter_folder); 187 | self.push_file(source, page_path.as_str()).await?; 188 | Ok(()) 189 | } 190 | 191 | async fn finish(mut self: Box) -> Result<()> { 192 | self.writer.close().await?; 193 | Ok(()) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /native/src/database/download/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::database::connect_db; 2 | use crate::udto::UiQueryDownloadComic; 3 | use once_cell::sync::OnceCell; 4 | use sea_orm::{DatabaseConnection, DbErr, TransactionTrait}; 5 | use std::ops::Deref; 6 | use tokio::sync::Mutex; 7 | 8 | pub(crate) mod download_comic; 9 | pub(crate) mod download_comic_chapter; 10 | pub(crate) mod download_comic_group; 11 | pub(crate) mod download_comic_page; 12 | 13 | pub(crate) static DOWNLOAD_DATABASE: OnceCell> = OnceCell::new(); 14 | 15 | pub(crate) async fn init() { 16 | let db = connect_db("download.db").await; 17 | DOWNLOAD_DATABASE.set(Mutex::new(db)).unwrap(); 18 | // init tables 19 | download_comic::init().await; 20 | download_comic_group::init().await; 21 | download_comic_chapter::init().await; 22 | download_comic_page::init().await; 23 | } 24 | 25 | pub(crate) async fn save_chapter_images( 26 | comic_path_word: String, 27 | chapter_uuid: String, 28 | images: Vec, 29 | ) -> anyhow::Result<()> { 30 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 31 | db.transaction(|db| { 32 | Box::pin(async move { 33 | download_comic::add_image_count(db, comic_path_word.as_str(), images.len() as i64) 34 | .await?; 35 | for image in images { 36 | download_comic_page::save(db, image).await?; 37 | } 38 | download_comic_chapter::update_status( 39 | db, 40 | chapter_uuid.as_str(), 41 | download_comic_chapter::STATUS_FETCH_SUCCESS, 42 | ) 43 | .await?; 44 | Ok::<(), DbErr>(()) 45 | }) 46 | }) 47 | .await?; 48 | Ok(()) 49 | } 50 | 51 | pub(crate) async fn chapter_fetch_error(chapter_uuid: String) -> anyhow::Result<()> { 52 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 53 | download_comic_chapter::update_status( 54 | db.deref(), 55 | chapter_uuid.as_str(), 56 | download_comic_chapter::STATUS_FETCH_FAILED, 57 | ) 58 | .await?; 59 | Ok(()) 60 | } 61 | 62 | pub(crate) async fn download_page_success( 63 | comic_path_word: String, 64 | chapter_uuid: String, 65 | idx: i32, 66 | width: u32, 67 | height: u32, 68 | format: String, 69 | ) -> anyhow::Result<()> { 70 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 71 | db.transaction(|db| { 72 | Box::pin(async move { 73 | download_comic_page::update_status( 74 | db, 75 | chapter_uuid.as_str(), 76 | idx, 77 | download_comic_page::STATUS_DOWNLOAD_SUCCESS, 78 | width, 79 | height, 80 | format, 81 | ) 82 | .await?; 83 | download_comic::success_image_count(db, comic_path_word.as_str()).await?; 84 | Ok::<(), DbErr>(()) 85 | }) 86 | }) 87 | .await?; 88 | Ok(()) 89 | } 90 | 91 | pub async fn download_page_failed(chapter_uuid: String, idx: i32) -> anyhow::Result<()> { 92 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 93 | download_comic_page::update_status( 94 | db.deref(), 95 | chapter_uuid.as_str(), 96 | idx, 97 | download_comic_page::STATUS_DOWNLOAD_FAILED, 98 | 0, 99 | 0, 100 | "".to_string(), 101 | ) 102 | .await?; 103 | Ok(()) 104 | } 105 | 106 | pub(crate) async fn remove_all(comic_path_word: String) -> anyhow::Result<()> { 107 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 108 | db.transaction(|db| { 109 | Box::pin(async move { 110 | download_comic::delete_by_comic_path_word(db, comic_path_word.as_str()).await?; 111 | download_comic_group::delete_by_comic_path_word(db, comic_path_word.as_str()).await?; 112 | download_comic_chapter::delete_by_comic_path_word(db, comic_path_word.as_str()).await?; 113 | download_comic_page::delete_by_comic_path_word(db, comic_path_word.as_str()).await?; 114 | Ok::<(), DbErr>(()) 115 | }) 116 | }) 117 | .await?; 118 | Ok(()) 119 | } 120 | 121 | pub async fn append_download(data: UiQueryDownloadComic) -> anyhow::Result<()> { 122 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 123 | db.transaction(|db| { 124 | Box::pin(async move { 125 | download_comic::insert_or_update_info( 126 | db, 127 | download_comic::Model { 128 | cover_cache_key: crate::downloading::url_to_cache_key(data.cover.as_str()), 129 | path_word: data.path_word, 130 | alias: data.alias, 131 | author: data.author, 132 | b_404: data.b_404, 133 | b_hidden: data.b_hidden, 134 | ban: data.ban, 135 | brief: data.brief, 136 | close_comment: data.close_comment, 137 | close_roast: data.close_roast, 138 | cover: data.cover, 139 | datetime_updated: data.datetime_updated, 140 | females: data.females, 141 | free_type: data.free_type, 142 | img_type: data.img_type, 143 | males: data.males, 144 | name: data.name, 145 | popular: data.popular, 146 | reclass: data.reclass, 147 | region: data.region, 148 | restrict: data.restrict1, 149 | seo_baidu: data.seo_baidu, 150 | status: data.status, 151 | theme: data.theme, 152 | uuid: data.uuid, 153 | append_time: chrono::Local::now().timestamp(), 154 | cover_download_status: 0, 155 | cover_format: "".to_string(), 156 | cover_width: 0, 157 | cover_height: 0, 158 | image_count: 0, 159 | image_count_success: 0, 160 | download_status: 0, 161 | }, 162 | ) 163 | .await?; 164 | for g in data.groups { 165 | download_comic_group::insert_or_update_info( 166 | db, 167 | download_comic_group::Model { 168 | comic_path_word: g.comic_path_word, 169 | group_path_word: g.group_path_word, 170 | count: g.count, 171 | name: g.name, 172 | group_rank: g.group_rank, 173 | }, 174 | ) 175 | .await?; 176 | } 177 | for c in data.chapters { 178 | download_comic_chapter::insert_or_update_info( 179 | db, 180 | download_comic_chapter::Model { 181 | comic_path_word: c.comic_path_word, 182 | uuid: c.uuid, 183 | comic_id: c.comic_id, 184 | count: c.count, 185 | datetime_created: c.datetime_created, 186 | group_path_word: c.group_path_word, 187 | img_type: c.img_type, 188 | index: c.index, 189 | is_long: c.is_long, 190 | name: c.name, 191 | news: c.news, 192 | next: c.next, 193 | ordered: c.ordered, 194 | prev: None, 195 | size: c.size, 196 | type_field: c.type_field, 197 | download_status: 0, 198 | }, 199 | ) 200 | .await?; 201 | } 202 | Ok::<(), DbErr>(()) 203 | }) 204 | }) 205 | .await?; 206 | Ok(()) 207 | } 208 | 209 | pub async fn reset_fail_downloads() -> anyhow::Result<()> { 210 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 211 | db.transaction(|db| { 212 | Box::pin(async move { 213 | download_comic::reset_failed(db).await?; 214 | download_comic_chapter::reset_failed(db).await?; 215 | download_comic_page::reset_failed(db).await?; 216 | Ok::<(), DbErr>(()) 217 | }) 218 | }) 219 | .await?; 220 | Ok(()) 221 | } 222 | -------------------------------------------------------------------------------- /entry/src/main/ets/pages/ComicReader.ets: -------------------------------------------------------------------------------- 1 | import { 2 | ChapterImage, 3 | Comic, 4 | UiChapterData, 5 | UiComicChapter, 6 | UiComicData, 7 | UiComicInExplore, 8 | UiComicQuery, 9 | comicChapterData, 10 | viewChapterPage, 11 | } from "native" 12 | import { CachedImage } from "./components/CachedImage" 13 | import { Error } from "./components/Error" 14 | import { Loading } from "./components/Loading" 15 | import { image } from '@kit.ImageKit'; 16 | import { window } from '@kit.ArkUI'; 17 | import { common } from "@kit.AbilityKit"; 18 | import { navStack } from "./components/Nav"; 19 | 20 | @Entry 21 | @Component 22 | export struct ComicReader { 23 | private listScroller: ListScroller = new ListScroller() 24 | private context = getContext(this) as common.UIAbilityContext; 25 | @State param: ComicReaderParam | null = null 26 | @State loadingState: number = 0 27 | @State data: UiChapterData | null = null 28 | @State sizeMap: Map = new Map() 29 | @State toolBarHeight: number = 0 30 | @State fullScreen: boolean = false 31 | @State sliderValue: number = 0 32 | @State sliderInValue: number | undefined = undefined 33 | @State sliderOutValue: number = 0 34 | 35 | async init() { 36 | this.loadingState = 0 37 | let win = await window.getLastWindow(this.context) 38 | const avoidAreaType = window.AvoidAreaType.TYPE_SYSTEM; 39 | const avoidArea = win.getWindowAvoidArea(avoidAreaType); 40 | const height = avoidArea.topRect.height; 41 | this.toolBarHeight = height 42 | try { 43 | this.data = await comicChapterData( 44 | this.param!.exploreComic.pathWord, 45 | this.param!.chapter.uuid, 46 | ) 47 | await viewChapterPage( 48 | this.param!.comicData.comic.pathWord, 49 | this.param!.chapter.uuid, 50 | this.param!.chapter.name, 51 | this.param!.chapter.ordered, 52 | this.param!.chapter.size, 53 | this.param!.chapter.count, 54 | 0 55 | ) 56 | this.loadingState = 1 57 | } catch (e) { 58 | this.loadingState = 2 59 | } 60 | } 61 | 62 | setChapter(chapter: UiComicChapter) { 63 | this.loadingState = 0 64 | this.listScroller.scrollTo({ xOffset: 0, yOffset: 0 }) 65 | this.param!.chapter = chapter 66 | this.data = null 67 | this.sizeMap.clear() 68 | this.sliderValue = 0 69 | this.sliderInValue = undefined 70 | this.init() 71 | } 72 | 73 | build() { 74 | NavDestination() { 75 | if (this.param != null) { 76 | this.content(this.param!) 77 | } 78 | }.onReady(context => { 79 | this.param = context.pathInfo.param! as ComicReaderParam 80 | this.init() 81 | }) 82 | .backgroundColor('#000') 83 | .hideTitleBar(true) 84 | .ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM]) // LayoutSafeAreaEdge 85 | } 86 | 87 | @Builder 88 | content(param: ComicReaderParam) { 89 | if (this.loadingState == 0) { 90 | Loading() 91 | } else if (this.loadingState == 1) { 92 | Stack() { 93 | this.reader(param, this.data!) 94 | if (!this.fullScreen) { 95 | this.barTop() 96 | this.barBottom() 97 | } 98 | if (this.sliderInValue != undefined) { 99 | this.sliding() 100 | } 101 | } 102 | } else { 103 | Error({ text: '点击重试' }) 104 | .flexGrow(1) 105 | .flexShrink(1) 106 | .onClick(() => { 107 | this.init() 108 | }) 109 | } 110 | } 111 | 112 | @Builder 113 | barTop() { 114 | Flex() { 115 | Text() { 116 | SymbolSpan($r('sys.symbol.arrow_left')) 117 | .fontSize(20) 118 | }.fontColor('#fff') 119 | .onClick(() => { 120 | navStack.pop() 121 | }) 122 | }.position({ top: 0 }) 123 | .padding({ 124 | top: 45, 125 | bottom: 25, 126 | left: 30, 127 | right: 20 128 | }) 129 | .backgroundColor('#99000000') 130 | } 131 | 132 | @Builder 133 | barBottom() { 134 | Flex() { 135 | Slider({ 136 | value: this.sliderValue, 137 | min: 0, 138 | max: this.data!.chapter.contents.length - 1, 139 | step: 1, 140 | }) 141 | .onTouch((e) => { 142 | if (e.type == TouchType.Down) { 143 | this.sliderInValue = this.sliderValue 144 | this.sliderOutValue = this.sliderInValue 145 | } 146 | if (e.type == TouchType.Up) { 147 | console.error(`LEAVE ${this.sliderInValue} ${this.sliderOutValue}`) 148 | if (this.sliderOutValue != this.sliderInValue) { 149 | this.sliderValue = this.sliderOutValue 150 | this.listScroller.scrollToIndex(this.sliderValue + 1) 151 | } 152 | this.sliderInValue = undefined 153 | } 154 | }) 155 | .onChange((e) => { 156 | if (this.sliderValue != undefined) { 157 | this.sliderOutValue = e 158 | } 159 | }) 160 | } 161 | .position({ bottom: 0 }) 162 | .padding({ 163 | top: 10, 164 | bottom: 25, 165 | left: 20, 166 | right: 20 167 | }) 168 | .backgroundColor('#99000000') 169 | 170 | } 171 | 172 | @Builder 173 | sliding() { 174 | Row() { 175 | Text(`${this.sliderOutValue! + 1} / ${this.data!.chapter.contents.length}`) 176 | .align(Alignment.Center) 177 | .alignSelf(ItemAlign.Center) 178 | .textAlign(TextAlign.Center) 179 | .fontColor('#FFF') 180 | .fontWeight(FontWeight.Bold) 181 | .fontSize(35) 182 | .borderRadius(8) 183 | .backgroundColor('#99000000') 184 | .padding(30) 185 | }.alignItems(VerticalAlign.Center) 186 | } 187 | 188 | @Builder 189 | reader(param: ComicReaderParam, data: UiChapterData) { 190 | List({ scroller: this.listScroller }) { 191 | ListItem().height(this.toolBarHeight) 192 | ForEach( 193 | data.chapter.contents, 194 | (image: ChapterImage, idx) => { 195 | ListItem() { 196 | CachedImage({ 197 | source: image.url, 198 | useful: 'comic_reader', 199 | extendsFieldFirst: param.exploreComic.pathWord, 200 | extendsFieldSecond: param.chapter.groupPathWord, 201 | extendsFieldThird: param.chapter.uuid, 202 | onSize: { 203 | onSize: (size) => this.sizeMap[image.url] = size 204 | }, 205 | imageWidth: '100%', 206 | ratio: this.sizeMap[image.url] ? this.sizeMap[image.url]!.width / this.sizeMap[image.url].height : 1, 207 | }) 208 | } 209 | } 210 | ) 211 | ListItem() { 212 | Column() { 213 | Text(' 下一章 ') 214 | .padding(40) 215 | .fontSize(35) 216 | .fontColor('#fff') 217 | .backgroundColor('#66999999') 218 | .align(Alignment.Center) 219 | .alignSelf(ItemAlign.Center) 220 | .onClick(() => this.nextChapter()) 221 | }.alignItems(HorizontalAlign.Center) 222 | .width('100%') 223 | } 224 | 225 | ListItem().height(this.toolBarHeight) 226 | } 227 | .width('100%') 228 | .height('100%') 229 | .onScrollIndex((s, e, c) => { 230 | if (this.data != null) { 231 | if (s < 1) { 232 | this.sliderValue = 0 233 | } else if (s >= this.data!.chapter.contents.length) { 234 | this.sliderValue = this.data!.chapter.contents.length - 1 235 | } else { 236 | this.sliderValue = s - 1 237 | } 238 | } 239 | }) 240 | .onClick(() => { 241 | this.fullScreen = !this.fullScreen 242 | }) 243 | } 244 | 245 | nextChapter() { 246 | const next = this.nextChapterValue(); 247 | if (next) { 248 | this.setChapter(next) 249 | } 250 | } 251 | 252 | nextChapterValue(): UiComicChapter | null { 253 | let acc = false 254 | for (let i = 0; i < this.param!.comicData.groups.length; i++) { 255 | let g = this.param!.comicData.groups[i]!; 256 | let cs: Array = this.param!.chapterDataMap[g.pathWord]!; 257 | for (let j = 0; j < cs.length; j++) { 258 | let c = cs[j]!; 259 | if (acc) { 260 | return c 261 | } 262 | if (c.uuid == this.param!.chapter.uuid) { 263 | acc = true 264 | } 265 | } 266 | } 267 | return null 268 | } 269 | } 270 | 271 | export interface ComicReaderParam { 272 | exploreComic: UiComicInExplore 273 | comicData: UiComicData 274 | chapterDataMap: Map> 275 | chapter: UiComicChapter 276 | } -------------------------------------------------------------------------------- /native/src/database/download/download_comic.rs: -------------------------------------------------------------------------------- 1 | use crate::database::create_table_if_not_exists; 2 | use crate::database::download::DOWNLOAD_DATABASE; 3 | use sea_orm::entity::prelude::*; 4 | use sea_orm::sea_query::{OnConflict}; 5 | use sea_orm::{DeleteResult, IntoActiveModel, QuerySelect}; 6 | use sea_orm::{EntityTrait, UpdateResult}; 7 | use serde_derive::{Deserialize, Serialize}; 8 | use std::ops::Deref; 9 | 10 | pub(crate) const STATUS_INIT: i64 = 0; 11 | pub(crate) const STATUS_DOWNLOAD_SUCCESS: i64 = 1; 12 | pub(crate) const STATUS_DOWNLOAD_FAILED: i64 = 2; 13 | pub(crate) const STATUS_DOWNLOAD_DELETING: i64 = 3; 14 | 15 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] 16 | #[sea_orm(table_name = "download_comic")] 17 | pub struct Model { 18 | #[sea_orm(primary_key, auto_increment = false)] 19 | pub path_word: String, 20 | pub alias: Option, 21 | pub author: String, 22 | pub b_404: bool, 23 | pub b_hidden: bool, 24 | pub ban: i64, 25 | pub brief: String, 26 | pub close_comment: bool, 27 | pub close_roast: bool, 28 | pub cover: String, 29 | pub datetime_updated: String, 30 | pub females: String, 31 | pub free_type: String, 32 | pub img_type: i64, 33 | pub males: String, 34 | pub name: String, 35 | pub popular: i64, 36 | pub reclass: String, 37 | pub region: String, 38 | pub restrict: String, 39 | pub seo_baidu: String, 40 | pub status: String, 41 | pub theme: String, 42 | pub uuid: String, 43 | // 44 | pub append_time: i64, 45 | // 46 | pub cover_cache_key: String, 47 | pub cover_download_status: i64, 48 | pub cover_format: String, 49 | pub cover_width: u32, 50 | pub cover_height: u32, 51 | // 52 | pub image_count: i64, 53 | pub image_count_success: i64, 54 | // 55 | pub download_status: i64, 56 | } 57 | 58 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 59 | pub enum Relation {} 60 | 61 | impl ActiveModelBehavior for ActiveModel {} 62 | 63 | pub(crate) async fn init() { 64 | let db = DOWNLOAD_DATABASE.get().unwrap().lock().await; 65 | create_table_if_not_exists(db.deref(), Entity).await; 66 | } 67 | 68 | pub(crate) async fn next_comic(status: i64) -> anyhow::Result> { 69 | Ok(Entity::find() 70 | .filter(Column::DownloadStatus.eq(status)) 71 | .limit(1) 72 | .one(DOWNLOAD_DATABASE.get().unwrap().lock().await.deref()) 73 | .await?) 74 | } 75 | 76 | pub(crate) async fn add_image_count( 77 | db: &impl ConnectionTrait, 78 | path_word: &str, 79 | count: i64, 80 | ) -> Result { 81 | Entity::update_many() 82 | .filter(Column::PathWord.eq(path_word)) 83 | .col_expr( 84 | Column::ImageCount, 85 | Expr::add(Expr::col(Column::ImageCount), count), 86 | ) 87 | .exec(db) 88 | .await 89 | } 90 | 91 | pub(crate) async fn success_image_count( 92 | db: &impl ConnectionTrait, 93 | path_word: &str, 94 | ) -> Result { 95 | Entity::update_many() 96 | .filter(Column::PathWord.eq(path_word)) 97 | .col_expr( 98 | Column::ImageCountSuccess, 99 | Expr::add(Expr::col(Column::ImageCountSuccess), 1), 100 | ) 101 | .exec(db) 102 | .await 103 | } 104 | 105 | pub(crate) async fn download_cover_success( 106 | path_word: &str, 107 | width: u32, 108 | height: u32, 109 | format: &str, 110 | ) -> Result { 111 | Entity::update_many() 112 | .filter(Column::PathWord.eq(path_word)) 113 | .col_expr( 114 | Column::CoverDownloadStatus, 115 | Expr::value(STATUS_DOWNLOAD_SUCCESS), 116 | ) 117 | .col_expr(Column::CoverWidth, Expr::value(width)) 118 | .col_expr(Column::CoverHeight, Expr::value(height)) 119 | .col_expr(Column::CoverFormat, Expr::value(format)) 120 | .exec(DOWNLOAD_DATABASE.get().unwrap().lock().await.deref()) 121 | .await 122 | } 123 | 124 | pub(crate) async fn download_cover_failed(path_word: &str) -> Result { 125 | Entity::update_many() 126 | .filter(Column::PathWord.eq(path_word)) 127 | .col_expr( 128 | Column::CoverDownloadStatus, 129 | Expr::value(STATUS_DOWNLOAD_FAILED), 130 | ) 131 | .exec(DOWNLOAD_DATABASE.get().unwrap().lock().await.deref()) 132 | .await 133 | } 134 | 135 | pub(crate) async fn is_cover_download_success(path_word: &str) -> anyhow::Result { 136 | let model = Entity::find() 137 | .filter(Column::PathWord.eq(path_word)) 138 | .one(DOWNLOAD_DATABASE.get().unwrap().lock().await.deref()) 139 | .await?; 140 | Ok(model 141 | .expect("is_cover_download_success none") 142 | .cover_download_status 143 | == STATUS_DOWNLOAD_SUCCESS) 144 | } 145 | 146 | pub(crate) async fn update_status(path_word: &str, status: i64) -> Result { 147 | Entity::update_many() 148 | .filter(Column::PathWord.eq(path_word)) 149 | .col_expr(Column::DownloadStatus, Expr::value(status)) 150 | .exec(DOWNLOAD_DATABASE.get().unwrap().lock().await.deref()) 151 | .await 152 | } 153 | 154 | pub(crate) async fn next_deleting_comic() -> anyhow::Result> { 155 | Ok(Entity::find() 156 | .filter(Column::DownloadStatus.eq(STATUS_DOWNLOAD_DELETING)) 157 | .limit(1) 158 | .one(DOWNLOAD_DATABASE.get().unwrap().lock().await.deref()) 159 | .await?) 160 | } 161 | 162 | pub(crate) async fn delete_by_comic_path_word( 163 | db: &impl ConnectionTrait, 164 | path_word: &str, 165 | ) -> Result { 166 | Entity::delete_many() 167 | .filter(Column::PathWord.eq(path_word)) 168 | .exec(db) 169 | .await 170 | } 171 | 172 | pub(crate) async fn insert_or_update_info( 173 | db: &impl ConnectionTrait, 174 | model: Model, 175 | ) -> Result<(), DbErr> { 176 | let result = Entity::insert(model.into_active_model()) 177 | .on_conflict( 178 | OnConflict::column(Column::PathWord) 179 | .update_columns(vec![ 180 | Column::Alias, 181 | Column::Author, 182 | Column::B404, 183 | Column::BHidden, 184 | Column::Ban, 185 | Column::Brief, 186 | Column::CloseComment, 187 | Column::CloseRoast, 188 | Column::Cover, 189 | Column::DatetimeUpdated, 190 | Column::Females, 191 | Column::FreeType, 192 | Column::ImgType, 193 | Column::Males, 194 | Column::Name, 195 | Column::Popular, 196 | Column::Reclass, 197 | Column::Region, 198 | Column::Restrict, 199 | Column::SeoBaidu, 200 | Column::Status, 201 | Column::Theme, 202 | Column::Uuid, 203 | Column::DownloadStatus, 204 | Column::AppendTime, 205 | ]) 206 | .to_owned(), 207 | ) 208 | .exec(db) 209 | .await; 210 | // https://www.sea-ql.org/SeaORM/docs/basic-crud/insert/ 211 | // Performing an upsert statement without inserting or updating any of the row will result in a DbErr::RecordNotInserted error. 212 | // If you want RecordNotInserted to be an Ok instead of an error, call .do_nothing(): 213 | if let Err(DbErr::RecordNotInserted) = result { 214 | return Ok(()); 215 | } 216 | result?; 217 | Ok(()) 218 | } 219 | 220 | pub(crate) async fn reset_failed(db: &impl ConnectionTrait) -> Result<(), DbErr> { 221 | Entity::update_many() 222 | .col_expr(Column::DownloadStatus, Expr::value(STATUS_INIT)) 223 | .filter(Column::DownloadStatus.eq(STATUS_DOWNLOAD_FAILED)) 224 | .exec(db) 225 | .await?; 226 | Ok(()) 227 | } 228 | 229 | pub(crate) async fn has_download_cover(cache_key: String) -> anyhow::Result> { 230 | let model = Entity::find() 231 | .filter(Column::CoverCacheKey.eq(cache_key)) 232 | .filter(Column::CoverDownloadStatus.eq(STATUS_DOWNLOAD_SUCCESS)) 233 | .limit(1) 234 | .one(DOWNLOAD_DATABASE.get().unwrap().lock().await.deref()) 235 | .await?; 236 | Ok(model) 237 | } 238 | 239 | pub(crate) async fn all() -> anyhow::Result> { 240 | let models = Entity::find() 241 | .all(DOWNLOAD_DATABASE.get().unwrap().lock().await.deref()) 242 | .await?; 243 | Ok(models) 244 | } 245 | 246 | pub(crate) async fn find_by_uuid_list(uuid_list: &[String]) -> anyhow::Result> { 247 | let models = Entity::find() 248 | .filter(Column::Uuid.is_in(uuid_list)) 249 | .all(DOWNLOAD_DATABASE.get().unwrap().lock().await.deref()) 250 | .await?; 251 | Ok(models) 252 | } 253 | -------------------------------------------------------------------------------- /native/src/downloading.rs: -------------------------------------------------------------------------------- 1 | use crate::database::download; 2 | use crate::database::download::{download_comic, download_comic_chapter, download_comic_page}; 3 | use crate::udto::UiQueryDownloadComic; 4 | use crate::utils::{create_dir_if_not_exists, join_paths}; 5 | use crate::{get_download_dir, CLIENT}; 6 | use anyhow::Context; 7 | use itertools::Itertools; 8 | use lazy_static::lazy_static; 9 | use std::collections::VecDeque; 10 | use std::ops::Deref; 11 | use std::sync::Arc; 12 | use tokio::sync::Mutex; 13 | 14 | pub(crate) fn get_image_path(model: &download_comic_page::Model) -> String { 15 | join_paths(vec![ 16 | get_download_dir().as_str(), 17 | model.comic_path_word.as_str(), 18 | model.chapter_uuid.as_str(), 19 | model.image_index.to_string().as_str(), 20 | ]) 21 | } 22 | 23 | pub(crate) fn get_cover_path(model: &download_comic::Model) -> String { 24 | join_paths(vec![ 25 | get_download_dir().as_str(), 26 | model.path_word.as_str(), 27 | "cover", 28 | ]) 29 | } 30 | 31 | lazy_static! { 32 | pub(crate) static ref RESTART_FLAG: Mutex = Mutex::new(false); 33 | pub(crate) static ref DOWNLOAD_AND_EXPORT_TO: Mutex = Mutex::new("".to_owned()); 34 | pub(crate) static ref DOWNLOAD_THREAD: Mutex = Mutex::new(3); 35 | pub(crate) static ref PAUSE_FLAG: Mutex = Mutex::new(false); 36 | } 37 | 38 | async fn need_restart() -> bool { 39 | *RESTART_FLAG.lock().await.deref() 40 | } 41 | 42 | async fn set_restart() { 43 | let mut restart_flag = RESTART_FLAG.lock().await; 44 | if *restart_flag.deref() { 45 | *restart_flag = false; 46 | } 47 | drop(restart_flag); 48 | } 49 | 50 | async fn download_pause() -> bool { 51 | let pause_flag = PAUSE_FLAG.lock().await; 52 | let pausing = *pause_flag.deref(); 53 | drop(pause_flag); 54 | if pausing { 55 | tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; 56 | } 57 | pausing 58 | } 59 | 60 | pub(crate) async fn download_is_pause() -> bool { 61 | let pause_flag = PAUSE_FLAG.lock().await; 62 | return *pause_flag; 63 | } 64 | 65 | pub(crate) async fn download_set_pause(pause: bool) { 66 | let mut pause_flag = PAUSE_FLAG.lock().await; 67 | *pause_flag = pause; 68 | drop(pause_flag); 69 | set_restart().await; 70 | crate::database::properties::property::save_property( 71 | "download_pause".to_owned(), 72 | pause.to_string(), 73 | ) 74 | .await 75 | .expect("save download_pause"); 76 | } 77 | 78 | pub async fn start_download() { 79 | loop { 80 | process_deleting().await; 81 | tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; 82 | // 检测是否暂停 83 | while download_pause().await {} 84 | // 检测重启flag, 已经重启, 赋值false 85 | set_restart().await; 86 | // 下载下一个漫画 87 | let _ = down_next_comic().await; 88 | if need_restart().await { 89 | continue; 90 | } 91 | } 92 | } 93 | 94 | async fn process_deleting() { 95 | while let Some(comic) = download_comic::next_deleting_comic() 96 | .await 97 | .expect("next_deleting_comic") 98 | { 99 | let comic_dir = join_paths(vec![get_download_dir().as_str(), comic.path_word.as_str()]); 100 | let _ = tokio::fs::remove_dir_all(comic_dir.as_str()).await; 101 | download::remove_all(comic.path_word) 102 | .await 103 | .expect("remove_all"); 104 | } 105 | } 106 | 107 | async fn down_next_comic() -> anyhow::Result<()> { 108 | // 检测重启flag 109 | if need_restart().await { 110 | return Ok(()); 111 | } 112 | // 113 | if let Some(comic) = download_comic::next_comic(download_comic::STATUS_INIT) 114 | .await 115 | .expect("next_comic") 116 | { 117 | let comic_dir = join_paths(vec![get_download_dir().as_str(), comic.path_word.as_str()]); 118 | create_dir_if_not_exists(comic_dir.as_str()); 119 | if comic.cover_download_status == download_comic::STATUS_INIT { 120 | down_cover(&comic).await; 121 | } 122 | if need_restart().await { 123 | return Ok(()); 124 | } 125 | let chapters = download_comic_chapter::all_chapter( 126 | comic.path_word.as_str(), 127 | download_comic_chapter::STATUS_INIT, 128 | ) 129 | .await 130 | .expect("all_chapter"); 131 | for chapter in &chapters { 132 | if need_restart().await { 133 | return Ok(()); 134 | } 135 | let _ = fetch_chapter(&chapter).await; 136 | } 137 | download_images(comic.path_word.clone()).await; 138 | if need_restart().await { 139 | return Ok(()); 140 | } 141 | // sum 142 | setup_download_status(comic.path_word).await; 143 | } 144 | Ok(()) 145 | } 146 | 147 | async fn down_cover(comic: &download_comic::Model) { 148 | if let Ok(data) = CLIENT.download_image(comic.cover.as_str()).await { 149 | if let Ok(format) = image::guess_format(&data) { 150 | let format = if let Some(format) = format.extensions_str().first() { 151 | format.to_string() 152 | } else { 153 | "".to_string() 154 | }; 155 | if let Ok(image_) = image::load_from_memory(&data) { 156 | let width = image_.width(); 157 | let height = image_.height(); 158 | let path = get_cover_path(comic); 159 | tokio::fs::write(path.as_str(), data) 160 | .await 161 | .expect("write image"); 162 | download_comic::download_cover_success( 163 | comic.path_word.as_str(), 164 | width, 165 | height, 166 | format.as_str(), 167 | ) 168 | .await 169 | .expect("download_cover_success"); 170 | return; 171 | } 172 | } 173 | } 174 | download_comic::download_cover_failed(comic.path_word.as_str()) 175 | .await 176 | .expect("download_cover_failed"); 177 | } 178 | 179 | async fn fetch_chapter(chapter: &download_comic_chapter::Model) -> anyhow::Result<()> { 180 | match CLIENT 181 | .comic_chapter_data(chapter.comic_path_word.as_str(), chapter.uuid.as_str()) 182 | .await 183 | { 184 | Ok(data) => { 185 | let mut urls = Vec::with_capacity(data.chapter.contents.len()); 186 | for _ in 0..data.chapter.words.len() { 187 | urls.push("".to_owned()); 188 | } 189 | for i in 0..data.chapter.words.len() { 190 | let idx = *data.chapter.words.get(i).with_context(|| "words")? as usize; 191 | let url = data.chapter.contents.get(i).with_context(|| "contents")?; 192 | urls[idx] = url.url.clone(); 193 | } 194 | let mut idx = 0; 195 | let mut images = vec![]; 196 | for _ in data.chapter.contents { 197 | images.push(download_comic_page::Model { 198 | comic_path_word: chapter.comic_path_word.clone(), 199 | chapter_uuid: chapter.uuid.clone(), 200 | cache_key: url_to_cache_key(urls[idx].as_str()), 201 | url: urls[idx].clone(), 202 | image_index: { 203 | let tmp = idx; 204 | idx += 1; 205 | tmp 206 | } as i32, 207 | ..Default::default() 208 | }); 209 | } 210 | download::save_chapter_images( 211 | chapter.comic_path_word.clone(), 212 | chapter.uuid.clone(), 213 | images, 214 | ) 215 | .await 216 | .expect("save_chapter_images") 217 | } 218 | Err(_) => download::chapter_fetch_error(chapter.uuid.clone()) 219 | .await 220 | .expect("chapter_fetch_error"), 221 | }; 222 | Ok(()) 223 | } 224 | 225 | async fn download_images(comic_path_word: String) { 226 | let comic_dir = join_paths(vec![get_download_dir().as_str(), comic_path_word.as_str()]); 227 | loop { 228 | if need_restart().await { 229 | break; 230 | } 231 | // 拉取 232 | let pages = download_comic_page::fetch( 233 | comic_path_word.as_str(), 234 | download_comic_chapter::STATUS_INIT, 235 | 100, 236 | ) 237 | .await 238 | .expect("pages"); 239 | if pages.is_empty() { 240 | break; 241 | } 242 | // 243 | let mut chapters = vec![]; 244 | for page in &pages { 245 | if !chapters.contains(&page.chapter_uuid) { 246 | chapters.push(page.chapter_uuid.clone()); 247 | } 248 | } 249 | for x in chapters { 250 | let chapter_dir = join_paths(vec![comic_dir.as_str(), x.as_str()]); 251 | create_dir_if_not_exists(&chapter_dir); 252 | } 253 | // 获得线程数 254 | let dtl = DOWNLOAD_THREAD.lock().await; 255 | let d = *dtl; 256 | drop(dtl); 257 | // 多线程下载 258 | let pages = Arc::new(Mutex::new(VecDeque::from(pages))); 259 | let results = futures_util::future::join_all( 260 | num_iter::range(0, d) 261 | .map(|_| download_line(pages.clone())) 262 | .collect_vec(), 263 | ) 264 | .await; 265 | // 266 | for x in results { 267 | if let Err(e) = x { 268 | println!("download_line error: {:?}", e); 269 | } 270 | } 271 | } 272 | } 273 | 274 | async fn download_line( 275 | deque: Arc>>, 276 | ) -> anyhow::Result<()> { 277 | loop { 278 | if need_restart().await { 279 | break; 280 | } 281 | let mut model_stream = deque.lock().await; 282 | let model = model_stream.pop_back(); 283 | drop(model_stream); 284 | if let Some(image) = model { 285 | let _ = download_image(image).await; 286 | } else { 287 | break; 288 | } 289 | } 290 | Ok(()) 291 | } 292 | 293 | async fn download_image(image: download_comic_page::Model) { 294 | if let Ok(data) = CLIENT.download_image(image.url.as_str()).await { 295 | if let Ok(format) = image::guess_format(&data) { 296 | let format = if let Some(format) = format.extensions_str().first() { 297 | format.to_string() 298 | } else { 299 | "".to_string() 300 | }; 301 | if let Ok(image_) = image::load_from_memory(&data) { 302 | let width = image_.width(); 303 | let height = image_.height(); 304 | let path = get_image_path(&image); 305 | tokio::fs::write(path.as_str(), data) 306 | .await 307 | .expect("write image"); 308 | download::download_page_success( 309 | image.comic_path_word, 310 | image.chapter_uuid, 311 | image.image_index, 312 | width, 313 | height, 314 | format, 315 | ) 316 | .await 317 | .expect("download_page_success"); 318 | return; 319 | } 320 | } 321 | } 322 | download::download_page_failed(image.chapter_uuid.clone(), image.image_index) 323 | .await 324 | .expect("download_page_failed"); 325 | } 326 | 327 | async fn setup_download_status(comic_path_word: String) { 328 | let comic_status = if download_comic::is_cover_download_success(comic_path_word.as_str()) 329 | .await 330 | .expect("is_cover_download_success") 331 | && download_comic_chapter::is_all_chapter_fetched(comic_path_word.as_str()) 332 | .await 333 | .expect("is_all_chapter_fetched") 334 | && download_comic_page::is_all_page_downloaded(comic_path_word.as_str()) 335 | .await 336 | .expect("is_all_page_downloaded") 337 | { 338 | download_comic::STATUS_DOWNLOAD_SUCCESS 339 | } else { 340 | download_comic::STATUS_DOWNLOAD_FAILED 341 | }; 342 | download_comic::update_status(comic_path_word.as_str(), comic_status) 343 | .await 344 | .expect("update_status"); 345 | } 346 | 347 | pub(crate) fn url_to_cache_key(url_str: &str) -> String { 348 | let u = url::Url::parse(url_str); 349 | if let Ok(u) = u { 350 | u.path().to_string() 351 | } else { 352 | "".to_string() 353 | } 354 | } 355 | 356 | pub(crate) async fn delete_download_comic(comic_path_word: String) -> anyhow::Result<()> { 357 | download_comic::update_status( 358 | comic_path_word.as_str(), 359 | download_comic::STATUS_DOWNLOAD_DELETING, 360 | ) 361 | .await 362 | .expect("update_status"); 363 | set_restart().await; 364 | Ok(()) 365 | } 366 | 367 | pub async fn append_download(data: UiQueryDownloadComic) -> anyhow::Result<()> { 368 | download::append_download(data.clone()).await?; 369 | Ok(()) 370 | } 371 | 372 | pub async fn reset_fail_downloads() -> anyhow::Result<()> { 373 | download::reset_fail_downloads().await?; 374 | set_restart().await; 375 | Ok(()) 376 | } 377 | -------------------------------------------------------------------------------- /native/src/copy_client/dtos.rs: -------------------------------------------------------------------------------- 1 | use linked_hash_map::LinkedHashMap; 2 | use napi_derive_ohos::napi; 3 | use serde_derive::{Deserialize, Serialize}; 4 | use serde_json::Value; 5 | 6 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 7 | pub struct Response { 8 | pub code: u16, 9 | pub message: String, 10 | pub results: Value, 11 | } 12 | 13 | #[napi(object)] 14 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 15 | pub struct Tags { 16 | pub ordering: Vec, 17 | pub theme: Vec, 18 | pub top: Vec, 19 | } 20 | 21 | #[napi(object)] 22 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 23 | pub struct Tag { 24 | pub name: String, 25 | pub path_word: String, 26 | } 27 | 28 | #[napi(object)] 29 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 30 | pub struct Theme { 31 | pub count: i64, 32 | pub initials: i64, 33 | pub name: String, 34 | pub path_word: String, 35 | } 36 | 37 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 38 | pub struct Page { 39 | pub list: Vec, 40 | pub total: i64, 41 | pub limit: i64, 42 | pub offset: i64, 43 | } 44 | 45 | #[napi(object)] 46 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 47 | pub struct ComicInSearch { 48 | pub name: String, 49 | pub alias: Option, 50 | pub path_word: String, 51 | pub cover: String, 52 | pub ban: i64, 53 | pub img_type: i64, 54 | pub author: Vec, 55 | pub popular: i64, 56 | } 57 | 58 | #[napi(object)] 59 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 60 | pub struct Author { 61 | pub name: String, 62 | pub alias: Option, 63 | pub path_word: String, 64 | } 65 | 66 | #[napi(object)] 67 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 68 | pub struct RankItem { 69 | pub comic: ComicInList, 70 | pub date_type: i64, 71 | pub popular: i64, 72 | pub rise_num: i64, 73 | pub rise_sort: i64, 74 | pub sort: i64, 75 | pub sort_last: i64, 76 | } 77 | 78 | #[napi(object)] 79 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 80 | pub struct ComicInList { 81 | pub author: Vec, 82 | pub cover: String, 83 | pub females: Vec, 84 | pub img_type: i64, 85 | pub males: Vec, 86 | pub name: String, 87 | pub path_word: String, 88 | pub popular: i64, 89 | } 90 | 91 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 92 | pub struct ComicData { 93 | pub comic: Comic, 94 | pub groups: LinkedHashMap, 95 | pub is_lock: bool, 96 | pub is_login: bool, 97 | pub is_mobile_bind: bool, 98 | pub is_vip: bool, 99 | pub popular: i64, 100 | } 101 | 102 | #[napi(object)] 103 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 104 | pub struct Comic { 105 | pub alias: Option, 106 | pub author: Vec, 107 | pub b_404: bool, 108 | pub b_hidden: bool, 109 | #[serde(default)] 110 | pub ban: i64, 111 | pub brief: String, 112 | pub close_comment: bool, 113 | pub close_roast: bool, 114 | pub cover: String, 115 | pub datetime_updated: String, 116 | pub females: Vec, 117 | pub free_type: ClassifyItem, 118 | pub img_type: i64, 119 | pub last_chapter: LastChapter, 120 | pub males: Vec, 121 | pub name: String, 122 | pub path_word: String, 123 | pub popular: i64, 124 | pub reclass: ClassifyItem, 125 | pub region: ClassifyItem, 126 | pub restrict: ClassifyItem, 127 | pub seo_baidu: String, 128 | pub status: ClassifyItem, 129 | pub theme: Vec, 130 | pub uuid: String, 131 | } 132 | 133 | #[napi(object)] 134 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 135 | pub struct LastChapter { 136 | pub name: String, 137 | pub uuid: String, 138 | } 139 | 140 | #[napi(object)] 141 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 142 | pub struct ClassifyItem { 143 | pub display: String, 144 | pub value: i64, 145 | } 146 | 147 | #[napi(object)] 148 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 149 | pub struct Group { 150 | pub count: i64, 151 | pub name: String, 152 | pub path_word: String, 153 | } 154 | 155 | #[napi(object)] 156 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 157 | pub struct ComicChapter { 158 | pub comic_id: String, 159 | pub comic_path_word: String, 160 | pub count: i64, 161 | pub datetime_created: String, 162 | pub group_path_word: String, 163 | pub img_type: i64, 164 | pub index: i64, 165 | pub name: String, 166 | pub news: String, 167 | pub next: Option, 168 | pub ordered: i64, 169 | pub prev: Option, 170 | pub size: i64, 171 | #[serde(rename = "type")] 172 | pub type_field: i64, 173 | pub uuid: String, 174 | } 175 | 176 | #[napi(object)] 177 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 178 | pub struct ComicQuery { 179 | pub browse: Option, 180 | pub collect: Option, 181 | pub is_lock: bool, 182 | pub is_login: bool, 183 | pub is_mobile_bind: bool, 184 | pub is_vip: bool, 185 | } 186 | 187 | #[napi(object)] 188 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 189 | pub struct Browse { 190 | pub chapter_id: String, 191 | pub chapter_name: String, 192 | pub chapter_uuid: String, 193 | pub comic_id: String, 194 | pub comic_uuid: String, 195 | pub path_word: String, 196 | } 197 | 198 | #[napi(object)] 199 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 200 | pub struct ChapterData { 201 | pub chapter: ChapterAndContents, 202 | pub comic: ChapterComicInfo, 203 | pub is_lock: bool, 204 | pub is_login: bool, 205 | pub is_mobile_bind: bool, 206 | pub is_vip: bool, 207 | pub show_app: bool, 208 | } 209 | 210 | #[napi(object)] 211 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 212 | pub struct ChapterAndContents { 213 | pub comic_id: String, 214 | pub comic_path_word: String, 215 | pub contents: Vec, 216 | pub count: i64, 217 | pub datetime_created: String, 218 | pub group_path_word: String, 219 | pub img_type: i64, 220 | pub index: i64, 221 | pub is_long: bool, 222 | pub name: String, 223 | pub news: String, 224 | pub next: Option, 225 | pub ordered: i64, 226 | pub prev: Option, 227 | pub size: i64, 228 | #[serde(rename = "type")] 229 | pub type_field: i64, 230 | pub uuid: String, 231 | pub words: Vec, 232 | } 233 | 234 | #[napi(object)] 235 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 236 | pub struct ChapterImage { 237 | pub url: String, 238 | } 239 | 240 | #[napi(object)] 241 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 242 | pub struct ChapterComicInfo { 243 | pub name: String, 244 | pub path_word: String, 245 | pub restrict: ClassifyItem, 246 | pub uuid: String, 247 | } 248 | 249 | #[napi(object)] 250 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 251 | pub struct RecommendItem { 252 | #[serde(rename = "type")] 253 | pub type_field: i64, 254 | pub comic: ComicInList, 255 | } 256 | 257 | #[napi(object)] 258 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 259 | pub struct ComicInExplore { 260 | pub name: String, 261 | pub path_word: String, 262 | pub free_type: ClassifyItem, 263 | pub females: Vec, 264 | pub males: Vec, 265 | pub author: Vec, 266 | pub cover: String, 267 | pub popular: i64, 268 | pub datetime_updated: Option, 269 | } 270 | 271 | #[napi(object)] 272 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 273 | pub struct SexualOrientation { 274 | pub name: String, 275 | pub path_word: String, 276 | pub gender: i64, 277 | } 278 | 279 | #[napi(object)] 280 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 281 | pub struct RegisterResult { 282 | pub user_id: String, 283 | pub uuid: String, 284 | pub datetime_created: String, 285 | pub token: Option, 286 | pub nickname: String, 287 | pub avatar: String, 288 | pub invite_code: Option, 289 | } 290 | 291 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 292 | pub struct LoginResult { 293 | pub token: String, 294 | pub user_id: String, 295 | pub username: String, 296 | pub nickname: String, 297 | pub avatar: String, 298 | #[serde(default)] 299 | pub is_authenticated: bool, 300 | pub datetime_created: String, 301 | #[serde(default)] 302 | pub b_verify_email: bool, 303 | pub email: Option, 304 | pub mobile: Option, 305 | pub mobile_region: Option, 306 | #[serde(default)] 307 | pub point: i64, 308 | #[serde(default)] 309 | pub comic_vip: i64, 310 | pub comic_vip_end: Option, 311 | pub comic_vip_start: Option, 312 | #[serde(default)] 313 | pub cartoon_vip: i64, 314 | pub cartoon_vip_end: Option, 315 | pub cartoon_vip_start: Option, 316 | pub ads_vip_end: Option, 317 | #[serde(default)] 318 | pub close_report: bool, 319 | #[serde(default)] 320 | pub downloads: i64, 321 | #[serde(default)] 322 | pub vip_downloads: i64, 323 | #[serde(default)] 324 | pub reward_downloads: i64, 325 | pub invite_code: Option, 326 | pub invited: Option, 327 | #[serde(default)] 328 | pub b_sstv: bool, 329 | #[serde(default)] 330 | pub scy_answer: bool, 331 | } 332 | 333 | #[napi(object)] 334 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 335 | pub struct MemberInfo { 336 | pub user_id: String, 337 | pub username: String, 338 | pub nickname: String, 339 | pub avatar: String, 340 | #[serde(default)] 341 | pub is_authenticated: bool, 342 | pub datetime_created: String, 343 | #[serde(default)] 344 | pub b_verify_email: bool, 345 | pub email: Option, 346 | pub mobile: Option, 347 | pub mobile_region: Option, 348 | #[serde(default)] 349 | pub point: i64, 350 | #[serde(default)] 351 | pub comic_vip: i64, 352 | pub comic_vip_end: Option, 353 | pub comic_vip_start: Option, 354 | #[serde(default)] 355 | pub cartoon_vip: i64, 356 | pub cartoon_vip_end: Option, 357 | pub cartoon_vip_start: Option, 358 | pub ads_vip_end: Option, 359 | #[serde(default)] 360 | pub close_report: bool, 361 | #[serde(default)] 362 | pub downloads: i64, 363 | #[serde(default)] 364 | pub vip_downloads: i64, 365 | #[serde(default)] 366 | pub reward_downloads: i64, 367 | pub invite_code: Option, 368 | pub invited: Option, 369 | #[serde(default)] 370 | pub b_sstv: bool, 371 | #[serde(default)] 372 | pub scy_answer: bool, 373 | pub day_downloads_refresh: String, 374 | #[serde(default)] 375 | pub day_downloads: i64, 376 | } 377 | 378 | #[napi(object)] 379 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 380 | pub struct CollectedComic { 381 | pub uuid: i64, 382 | pub name: Option, 383 | pub b_folder: bool, 384 | pub folder_id: Option, 385 | pub last_browse: Option, 386 | pub comic: CollectedComicInfo, 387 | } 388 | 389 | #[napi(object)] 390 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 391 | pub struct LastBrowse { 392 | pub last_browse_id: String, 393 | pub last_browse_name: String, 394 | } 395 | 396 | #[napi(object)] 397 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 398 | pub struct CollectedComicInfo { 399 | pub uuid: String, 400 | pub b_display: bool, 401 | pub name: String, 402 | pub path_word: String, 403 | pub females: Vec, 404 | pub males: Vec, 405 | pub author: Vec, 406 | pub theme: Vec, 407 | pub cover: String, 408 | pub status: i64, 409 | pub popular: i64, 410 | pub datetime_updated: String, 411 | pub last_chapter_id: String, 412 | pub last_chapter_name: String, 413 | } 414 | 415 | #[napi(object)] 416 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 417 | pub struct Comment { 418 | pub id: i64, 419 | pub create_at: String, 420 | pub user_id: String, 421 | pub user_name: String, 422 | pub user_avatar: String, 423 | pub comment: String, 424 | pub count: i64, 425 | pub parent_id: Option, 426 | pub parent_user_id: Option, 427 | pub parent_user_name: Option, 428 | } 429 | 430 | #[napi(object)] 431 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 432 | pub struct Roast { 433 | pub id: i64, 434 | pub create_at: String, 435 | pub user_id: String, 436 | pub user_name: String, 437 | pub user_avatar: String, 438 | pub comment: String, 439 | } 440 | 441 | #[napi(object)] 442 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 443 | pub struct BrowseComic { 444 | pub id: i64, 445 | pub last_chapter_id: String, 446 | pub last_chapter_name: String, 447 | pub comic: BrowseComicComic, 448 | } 449 | 450 | #[napi(object)] 451 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 452 | pub struct BrowseComicComic { 453 | pub uuid: String, 454 | pub b_display: bool, 455 | pub name: String, 456 | pub path_word: String, 457 | pub females: Vec, 458 | pub males: Vec, 459 | pub author: Vec, 460 | pub theme: Vec, 461 | pub cover: String, 462 | pub status: i64, 463 | pub popular: i64, 464 | pub datetime_updated: String, 465 | pub last_chapter_id: String, 466 | pub last_chapter_name: String, 467 | } 468 | -------------------------------------------------------------------------------- /entry/src/main/ets/pages/components/Settings.ets: -------------------------------------------------------------------------------- 1 | import { getApiHost, setApiHost, saveProperty } from "native"; 2 | import { colors } from "./Context"; 3 | import { materialIconData, materialIconsFontFamily } from "./MaterialIcons"; 4 | import { VersionStore, VersionStoreModel, VersionStoreActions } from "./VersionStore"; 5 | import { hilog } from "@kit.PerformanceAnalysisKit"; 6 | import { loadProperty } from "native"; 7 | import { LoginStore, LoginStoreModel, LoginStoreActions } from "./LoginStorage"; 8 | 9 | @Entry 10 | @ComponentV2 11 | export struct Settings { 12 | @Local versionState: VersionStoreModel = VersionStore.getState(); 13 | @Local loginStore: LoginStoreModel = LoginStore.getState(); 14 | @Local api: string = ""; 15 | @Local apiIsEditing: boolean = false; 16 | @Local apiTemp: string = ""; 17 | @Local username: string = ""; 18 | @Local password: string = ""; 19 | @Local usernameIsEditing: boolean = false; 20 | @Local usernameTemp: string = ""; 21 | @Local passwordIsEditing: boolean = false; 22 | @Local passwordTemp: string = ""; 23 | 24 | async confirmUsernameSet(username: string) { 25 | await saveProperty("username", username); 26 | this.username = username; 27 | this.usernameIsEditing = false; 28 | } 29 | 30 | async confirmPasswordSet(password: string) { 31 | await saveProperty("password", password); 32 | this.password = password; 33 | this.passwordIsEditing = false; 34 | } 35 | 36 | async initState() { 37 | this.api = await getApiHost(); 38 | this.username = await loadProperty("username"); 39 | this.password = await loadProperty("password"); 40 | } 41 | 42 | async confirmApiSet(host: string) { 43 | await setApiHost(host); 44 | this.api = host; 45 | this.apiIsEditing = false; 46 | } 47 | 48 | aboutToAppear(): void { 49 | this.initState() 50 | } 51 | 52 | build() { 53 | Column() { 54 | // 标题栏 55 | Row() { 56 | Text("网络和账户") 57 | .fontSize(20) 58 | .fontWeight(FontWeight.Bold) 59 | .fontColor(colors.authorColor) 60 | } 61 | .width('100%') 62 | .height(20) 63 | .margin({ top: 16 }) 64 | .padding({ left: 16, right: 16 }) 65 | .justifyContent(FlexAlign.Center) 66 | .alignItems(VerticalAlign.Center) 67 | 68 | // API设置卡片 69 | Column() { 70 | Row() { 71 | Text(materialIconData('api')) 72 | .fontFamily(materialIconsFontFamily) 73 | .fontSize(24) 74 | .fontColor(colors.authorColor) 75 | .margin({ right: 12 }) 76 | Text("API设置") 77 | .fontSize(16) 78 | .fontWeight(FontWeight.Medium) 79 | } 80 | .width('100%') 81 | .padding({ 82 | top: 16, 83 | bottom: 16, 84 | left: 16, 85 | right: 16 86 | }) 87 | 88 | if (this.apiIsEditing) { 89 | // 编辑模式 90 | Column() { 91 | TextInput({ placeholder: '请输入API地址', text: this.apiTemp }) 92 | .width('100%') 93 | .height(40) 94 | .margin({ bottom: 12 }) 95 | .onChange((value: string) => { 96 | this.apiTemp = value; 97 | }) 98 | 99 | Row() { 100 | Button('取消') 101 | .backgroundColor('#F5F5F5') 102 | .fontColor('#666666') 103 | .onClick(() => { 104 | this.apiIsEditing = false; 105 | this.apiTemp = this.api; 106 | }) 107 | .margin({ right: 12 }) 108 | .flexGrow(1) 109 | 110 | Button('保存') 111 | .backgroundColor(colors.authorColor) 112 | .fontColor(Color.White) 113 | .onClick(() => { 114 | if (this.apiTemp.trim().length > 0) { 115 | this.confirmApiSet(this.apiTemp.trim()); 116 | } 117 | }) 118 | .flexGrow(1) 119 | } 120 | .width('100%') 121 | } 122 | .padding({ left: 16, right: 16, bottom: 16 }) 123 | } else { 124 | // 显示模式 125 | Row() { 126 | Text(this.api) 127 | .fontSize(14) 128 | .fontColor('#666666') 129 | .flexGrow(1) 130 | 131 | Button() { 132 | Row() { 133 | Text(materialIconData('edit')) 134 | .fontFamily(materialIconsFontFamily) 135 | .fontSize(16) 136 | .fontColor(colors.authorColor) 137 | Text('编辑') 138 | .fontSize(14) 139 | .fontColor(colors.authorColor) 140 | .margin({ left: 4 }) 141 | } 142 | } 143 | .backgroundColor(Color.Transparent) 144 | .onClick(() => { 145 | this.apiTemp = this.api; 146 | this.apiIsEditing = true; 147 | }) 148 | } 149 | .width('100%') 150 | .padding({ left: 16, right: 16, bottom: 16 }) 151 | } 152 | } 153 | .width('100%') 154 | .backgroundColor(Color.White) 155 | .borderRadius(8) 156 | .margin({ top: 16, left: 16, right: 16 }) 157 | .shadow({ 158 | radius: 4, 159 | color: '#0000000A', 160 | offsetY: 2 161 | }) 162 | 163 | Divider().strokeWidth(1).color('#F5F5F5').margin({ left: 16, right: 16 }); 164 | 165 | // Username 166 | if (this.usernameIsEditing) { 167 | Column() { 168 | Row() { 169 | Text("用户名") 170 | .fontSize(16) 171 | .fontWeight(FontWeight.Medium) 172 | .margin({ right: 12 }) 173 | TextInput({ placeholder: '请输入用户名', text: this.usernameTemp }) 174 | .layoutWeight(1) 175 | .onChange((value: string) => { 176 | this.usernameTemp = value; 177 | }) 178 | }.padding({ top: 16, bottom: 12, left: 16, right: 16 }) 179 | Row() { 180 | Button('取消') 181 | .backgroundColor('#F5F5F5') 182 | .fontColor('#666666') 183 | .onClick(() => { 184 | this.usernameIsEditing = false; 185 | }) 186 | .margin({ right: 12 }) 187 | .flexGrow(1) 188 | Button('保存') 189 | .backgroundColor(colors.authorColor) 190 | .fontColor(Color.White) 191 | .onClick(() => { 192 | if (this.usernameTemp.trim().length > 0) { 193 | this.confirmUsernameSet(this.usernameTemp.trim()); 194 | } 195 | }) 196 | .flexGrow(1) 197 | } 198 | .width('100%') 199 | .padding({ left: 16, right: 16, bottom: 16 }) 200 | } 201 | } else { 202 | Row() { 203 | Text("用户名") 204 | .fontSize(16) 205 | .fontWeight(FontWeight.Medium) 206 | .margin({ right: 12 }) 207 | Text(this.username) 208 | .fontSize(14) 209 | .fontColor('#666666') 210 | .flexGrow(1) 211 | Button() { 212 | Row() { 213 | Text(materialIconData('edit')) 214 | .fontFamily(materialIconsFontFamily) 215 | .fontSize(16) 216 | .fontColor(colors.authorColor) 217 | Text('编辑') 218 | .fontSize(14) 219 | .fontColor(colors.authorColor) 220 | .margin({ left: 4 }) 221 | } 222 | } 223 | .backgroundColor(Color.Transparent) 224 | .onClick(() => { 225 | this.usernameTemp = this.username; 226 | this.usernameIsEditing = true; 227 | }) 228 | } 229 | .width('100%') 230 | .padding({ top: 16, bottom: 16, left: 16, right: 16 }) 231 | } 232 | 233 | Divider().strokeWidth(1).color('#F5F5F5').margin({ left: 16, right: 16 }); 234 | 235 | // Password 236 | if (this.passwordIsEditing) { 237 | Column() { 238 | Row() { 239 | Text("密码") 240 | .fontSize(16) 241 | .fontWeight(FontWeight.Medium) 242 | .margin({ right: 12 }) 243 | TextInput({ placeholder: '请输入密码', text: this.passwordTemp }) 244 | .type(InputType.Password) 245 | .layoutWeight(1) 246 | .onChange((value: string) => { 247 | this.passwordTemp = value; 248 | }) 249 | }.padding({ top: 16, bottom: 12, left: 16, right: 16 }) 250 | Row() { 251 | Button('取消') 252 | .backgroundColor('#F5F5F5') 253 | .fontColor('#666666') 254 | .onClick(() => { 255 | this.passwordIsEditing = false; 256 | }) 257 | .margin({ right: 12 }) 258 | .flexGrow(1) 259 | Button('保存') 260 | .backgroundColor(colors.authorColor) 261 | .fontColor(Color.White) 262 | .onClick(() => { 263 | if (this.passwordTemp.trim().length > 0) { 264 | this.confirmPasswordSet(this.passwordTemp.trim()); 265 | } 266 | }) 267 | .flexGrow(1) 268 | } 269 | .width('100%') 270 | .padding({ left: 16, right: 16, bottom: 16 }) 271 | } 272 | } else { 273 | Row() { 274 | Text("密码") 275 | .fontSize(16) 276 | .fontWeight(FontWeight.Medium) 277 | .margin({ right: 12 }) 278 | Text("********") 279 | .fontSize(14) 280 | .fontColor('#666666') 281 | .flexGrow(1) 282 | Button() { 283 | Row() { 284 | Text(materialIconData('edit')) 285 | .fontFamily(materialIconsFontFamily) 286 | .fontSize(16) 287 | .fontColor(colors.authorColor) 288 | Text('编辑') 289 | .fontSize(14) 290 | .fontColor(colors.authorColor) 291 | .margin({ left: 4 }) 292 | } 293 | } 294 | .backgroundColor(Color.Transparent) 295 | .onClick(() => { 296 | this.passwordTemp = this.password; 297 | this.passwordIsEditing = true; 298 | }) 299 | } 300 | .width('100%') 301 | .padding({ top: 16, bottom: 16, left: 16, right: 16 }) 302 | } 303 | 304 | // 登录状态 305 | Row() { 306 | Text("登录状态") 307 | .fontSize(16) 308 | .fontWeight(FontWeight.Medium) 309 | .margin({ right: 12 }) 310 | 311 | if (this.loginStore.loginInfo.state === -1) { 312 | Row() { 313 | LoadingProgress().width(16).height(16).color(colors.authorColor) 314 | Text("登录中...") 315 | .fontSize(14) 316 | .fontColor('#666666') 317 | .margin({ left: 8 }) 318 | } 319 | } else if (this.loginStore.loginInfo.state === 1) { 320 | Text("登录成功") 321 | .fontSize(14) 322 | .fontColor(colors.authorColor) 323 | } else if (this.loginStore.loginInfo.state === 0) { 324 | Text("未登录") 325 | .fontSize(14) 326 | .fontColor('#666666') 327 | } else { 328 | Text(`登录失败: ${this.loginStore.loginInfo.message}`) 329 | .fontSize(14) 330 | .fontColor('#FF0000') 331 | } 332 | } 333 | .width('100%') 334 | .padding({ top: 16, bottom: 16, left: 16, right: 16 }) 335 | 336 | if (this.loginStore.loginInfo.state !== 1) { 337 | Button('登录') 338 | .width('100%') 339 | .height(40) 340 | .backgroundColor(this.loginStore.loginInfo.state === -1 ? '#E0E0E0' : colors.authorColor) 341 | .fontColor(Color.White) 342 | .enabled(this.loginStore.loginInfo.state !== -1) 343 | .onClick(() => { 344 | LoginStore.dispatch(LoginStoreActions.login); 345 | }) 346 | .margin({ left: 16, right: 16, bottom: 16 }) 347 | } 348 | 349 | // 版本信息卡片 350 | Column() { 351 | Row() { 352 | Text(materialIconData('info')) 353 | .fontFamily(materialIconsFontFamily) 354 | .fontSize(24) 355 | .fontColor(colors.authorColor) 356 | .margin({ right: 12 }) 357 | Text("版本信息") 358 | .fontSize(16) 359 | .fontWeight(FontWeight.Medium) 360 | } 361 | .width('100%') 362 | .padding({ 363 | top: 16, 364 | bottom: 16, 365 | left: 16, 366 | right: 16 367 | }) 368 | 369 | Column() { 370 | Row() { 371 | Text("当前版本") 372 | .fontSize(14) 373 | .fontColor('#666666') 374 | .flexGrow(1) 375 | Text(this.versionState.currentVersion) 376 | .fontSize(14) 377 | .fontColor('#666666') 378 | } 379 | .width('100%') 380 | .padding({ bottom: 12 }) 381 | 382 | Row() { 383 | Text("最新版本") 384 | .fontSize(14) 385 | .fontColor('#666666') 386 | .flexGrow(1) 387 | if (this.versionState.loading) { 388 | LoadingProgress() 389 | .width(16) 390 | .height(16) 391 | .color(colors.authorColor) 392 | } else { 393 | Row() { 394 | Text(this.versionState.newVersion) 395 | .fontSize(14) 396 | .fontColor(this.versionState.compare > 0 ? colors.authorColor : '#666666') 397 | if (this.versionState.compare > 0) { 398 | Badge({ 399 | count: 1, 400 | position: BadgePosition.RightTop, 401 | style: { color: colors.authorColor, fontSize: 12, badgeSize: 16 } 402 | }) 403 | } 404 | } 405 | } 406 | } 407 | .width('100%') 408 | .padding({ bottom: 16 }) 409 | } 410 | .padding({ left: 16, right: 16, bottom: 16 }) 411 | } 412 | .width('100%') 413 | .backgroundColor(Color.White) 414 | .borderRadius(8) 415 | .margin({ top: 16, left: 16, right: 16 }) 416 | .shadow({ 417 | radius: 4, 418 | color: '#0000000A', 419 | offsetY: 2 420 | }) 421 | } 422 | .width('100%') 423 | .height('100%') 424 | .backgroundColor('#F5F5F5') 425 | } 426 | } -------------------------------------------------------------------------------- /native/src/copy_client/client.rs: -------------------------------------------------------------------------------- 1 | pub use super::types::*; 2 | use super::{Browse, Comment, Roast}; 3 | use crate::copy_client::{ 4 | BrowseComic, ChapterData, CollectedComic, ComicChapter, ComicData, ComicInExplore, 5 | ComicInSearch, ComicQuery, LoginResult, MemberInfo, Page, RankItem, RecommendItem, 6 | RegisterResult, Response, Tags, 7 | }; 8 | use base64::Engine; 9 | use chrono::Datelike; 10 | use rand::prelude::IndexedRandom; 11 | use rand::Rng; 12 | use std::ops::Deref; 13 | use std::sync::Arc; 14 | use tokio::sync::Mutex; 15 | 16 | pub struct Client { 17 | agent: Mutex>, 18 | api_host: Mutex>, 19 | token: Mutex>, 20 | device: Mutex>, 21 | device_info: Mutex>, 22 | } 23 | 24 | impl Client { 25 | pub fn new(agent: impl Into>, api_host: impl Into) -> Self { 26 | Self { 27 | agent: Mutex::new(agent.into()), 28 | api_host: Mutex::new(Arc::new(api_host.into())), 29 | token: Mutex::new(Arc::new(String::new())), 30 | device: Mutex::new(Arc::new("".to_string())), 31 | device_info: Mutex::new(Arc::new("".to_string())), 32 | } 33 | } 34 | 35 | pub async fn set_device(&self, device: impl Into, device_info: impl Into) { 36 | let mut lock = self.device.lock().await; 37 | *lock = Arc::new(device.into()); 38 | let mut info_lock = self.device_info.lock().await; 39 | *info_lock = Arc::new(device_info.into()); 40 | } 41 | 42 | pub async fn set_agent(&self, agent: impl Into>) { 43 | let mut lock = self.agent.lock().await; 44 | *lock = agent.into(); 45 | } 46 | 47 | pub async fn set_api_host(&self, api_host: impl Into) { 48 | let mut lock = self.api_host.lock().await; 49 | *lock = Arc::new(api_host.into()); 50 | } 51 | 52 | pub async fn api_host_string(&self) -> Arc { 53 | let api_host = self.api_host.lock().await; 54 | api_host.clone() 55 | } 56 | 57 | pub async fn set_token(&self, token: impl Into) { 58 | let mut lock = self.token.lock().await; 59 | *lock = Arc::new(token.into()); 60 | } 61 | 62 | pub async fn get_token(&self) -> Arc { 63 | let token = self.token.lock().await; 64 | token.clone() 65 | } 66 | 67 | pub async fn request serde::Deserialize<'de>>( 68 | &self, 69 | method: reqwest::Method, 70 | path: &str, 71 | mut params: serde_json::Value, 72 | ) -> Result { 73 | let obj = params.as_object_mut().expect("query must be object"); 74 | let device_lock = self.device.lock().await; 75 | let device = device_lock.deref().deref().clone(); 76 | drop(device_lock); 77 | let device_info_lock = self.device_info.lock().await; 78 | let device_info = device_info_lock.deref().deref().clone(); 79 | drop(device_info_lock); 80 | if !path.ends_with("/login") && !path.ends_with("/register") { 81 | if let reqwest::Method::POST = method { 82 | obj.insert( 83 | "authorization".to_string(), 84 | serde_json::Value::String(format!("Token {}", self.get_token().await.as_str())), 85 | ); 86 | obj.insert( 87 | "referer".to_string(), 88 | serde_json::Value::String("com.copymanga.app-2.3.0".to_string()), 89 | ); 90 | obj.insert( 91 | "userAgent".to_string(), 92 | serde_json::Value::String("COPY/2.3.0".to_string()), 93 | ); 94 | obj.insert( 95 | "source".to_string(), 96 | serde_json::Value::String("copyApp".to_string()), 97 | ); 98 | obj.insert( 99 | "webp".to_string(), 100 | serde_json::Value::String("1".to_string()), 101 | ); 102 | obj.insert( 103 | "version".to_string(), 104 | serde_json::Value::String("2.3.0".to_string()), 105 | ); 106 | obj.insert( 107 | "region".to_string(), 108 | serde_json::Value::String("1".to_string()), 109 | ); 110 | obj.insert( 111 | "accept".to_string(), 112 | serde_json::Value::String("application/json".to_string()), 113 | ); 114 | obj.insert( 115 | "device".to_string(), 116 | serde_json::Value::String(device.clone()), 117 | ); 118 | obj.insert( 119 | "umString".to_string(), 120 | serde_json::Value::String("b4c89ca4104ea9a97750314d791520ac".to_string()), 121 | ); 122 | obj.insert( 123 | "deviceInfo".to_string(), 124 | serde_json::Value::String(device_info.clone()), 125 | ); 126 | obj.insert( 127 | "isGoogle".to_string(), 128 | serde_json::Value::String("false".to_string()), 129 | ); 130 | obj.insert( 131 | "platform".to_string(), 132 | serde_json::Value::String("3".to_string()), 133 | ); 134 | } 135 | } 136 | let agent_lock = self.agent.lock().await; 137 | let agent = agent_lock.clone(); 138 | drop(agent_lock); 139 | let request = agent.request( 140 | method.clone(), 141 | format!("{}{}", &self.api_host_string().await.as_str(), path), 142 | ); 143 | let request = request 144 | .header( 145 | "authorization", 146 | format!("Token {}", self.get_token().await.as_str()), 147 | ) 148 | .header("referer", "com.copymanga.app-2.3.0") 149 | .header("user-agent", "COPY/2.3.0") 150 | .header("source", "copyApp") 151 | .header("webp", "1") 152 | .header("version", "2.3.0") 153 | .header("region", "1") 154 | .header("platform", "3") 155 | .header("accept", "application/json") 156 | .header("device", device) 157 | .header("umstring", "b4c89ca4104ea9a97750314d791520ac") 158 | .header("deviceinfo", device_info) 159 | .header("dt", Self::dt()); 160 | let request = match method { 161 | reqwest::Method::GET => request.query(&obj), 162 | _ => request.form(&obj), 163 | }; 164 | let response = request.send().await?; 165 | let status = response.status(); 166 | let text = response.text().await?; 167 | if status.as_u16() == 404 { 168 | return Err(Error::message("404 Not found")); 169 | } 170 | println!("RESPONSE : {} {}", status, text); 171 | let value = serde_json::from_str(text.as_str())?; 172 | if let serde_json::Value::Object(value) = value { 173 | if value.len() == 1 { 174 | if let Some(serde_json::Value::String(detal)) = value.get("detail") { 175 | return Err(Error::message(detal.to_string())); 176 | } 177 | } 178 | } 179 | let response: Response = serde_json::from_str(text.as_str())?; 180 | if response.code != 200 { 181 | return Err(Error::message(response.message)); 182 | } 183 | Ok(serde_json::from_value(response.results)?) 184 | } 185 | 186 | fn dt() -> String { 187 | let now = chrono::Local::now(); 188 | format!("{}.{}.{}", now.year(), now.month(), now.day(),) 189 | } 190 | 191 | pub async fn register(&self, username: &str, password: &str) -> Result { 192 | self.request( 193 | reqwest::Method::POST, 194 | "/api/v3/register", 195 | serde_json::json!({ 196 | "username": username, 197 | "password": password, 198 | "source": "freeSite", 199 | "version": "2023.08.14", 200 | "platform": 3, 201 | }), 202 | ) 203 | .await 204 | } 205 | 206 | pub async fn login(&self, username: &str, password: &str) -> Result { 207 | let salt = chrono::Local::now().timestamp_millis() % (u16::MAX as i64); 208 | let password_b64 = 209 | base64::prelude::BASE64_STANDARD.encode(format!("{}-{}", password, salt).as_bytes()); 210 | self.request( 211 | reqwest::Method::POST, 212 | "/api/v3/login", 213 | serde_json::json!({ 214 | "username": username, 215 | "password": password_b64, 216 | "salt": salt, 217 | "source": "freeSite", 218 | "version": "2023.08.14", 219 | "platform": 3, 220 | }), 221 | ) 222 | .await 223 | } 224 | 225 | pub async fn member_info(&self) -> Result { 226 | self.request( 227 | reqwest::Method::GET, 228 | "/api/v3/member/info", 229 | serde_json::json!({ 230 | "platform": 3, 231 | }), 232 | ) 233 | .await 234 | } 235 | 236 | pub async fn tags(&self) -> Result { 237 | self.request( 238 | reqwest::Method::GET, 239 | "/api/v3/h5/filter/comic/tags", 240 | serde_json::json!({ 241 | "platform": 3, 242 | }), 243 | ) 244 | .await 245 | } 246 | 247 | pub async fn comic_search( 248 | &self, 249 | q_type: &str, 250 | q: &str, 251 | offset: u64, 252 | limit: u64, 253 | ) -> Result> { 254 | self.request( 255 | reqwest::Method::GET, 256 | "/api/v3/search/comic", 257 | serde_json::json!({ 258 | "platform": 3, 259 | "limit": limit, 260 | "offset": offset, 261 | "q": q, 262 | "q_type": q_type, 263 | }), 264 | ) 265 | .await 266 | } 267 | 268 | pub async fn comic_rank( 269 | &self, 270 | date_type: &str, 271 | offset: u64, 272 | limit: u64, 273 | ) -> Result> { 274 | self.request( 275 | reqwest::Method::GET, 276 | "/api/v3/ranks", 277 | serde_json::json!({ 278 | "platform": 3, 279 | "date_type": date_type, 280 | "offset": offset, 281 | "limit": limit, 282 | }), 283 | ) 284 | .await 285 | } 286 | 287 | pub async fn comic(&self, path_word: &str) -> Result { 288 | self.request( 289 | reqwest::Method::GET, 290 | format!("/api/v3/comic2/{path_word}").as_str(), 291 | serde_json::json!({ 292 | "platform": 3, 293 | }), 294 | ) 295 | .await 296 | } 297 | 298 | pub async fn comic_chapter( 299 | &self, 300 | comic_path_word: &str, 301 | group_path_word: &str, 302 | limit: u64, 303 | offset: u64, 304 | ) -> Result> { 305 | self.request( 306 | reqwest::Method::GET, 307 | format!("/api/v3/comic/{comic_path_word}/group/{group_path_word}/chapters").as_str(), 308 | serde_json::json!({ 309 | "offset": offset, 310 | "limit": limit, 311 | "platform": 3, 312 | }), 313 | ) 314 | .await 315 | } 316 | 317 | pub async fn comic_query(&self, path_word: &str) -> Result { 318 | self.request( 319 | reqwest::Method::GET, 320 | format!("/api/v3/comic2/{path_word}/query").as_str(), 321 | serde_json::json!({ 322 | "platform": 3, 323 | }), 324 | ) 325 | .await 326 | } 327 | 328 | pub async fn comic_chapter_data( 329 | &self, 330 | comic_path_word: &str, 331 | chapter_uuid: &str, 332 | ) -> Result { 333 | self.request( 334 | reqwest::Method::GET, 335 | format!("/api/v3/comic/{comic_path_word}/chapter2/{chapter_uuid}").as_str(), 336 | serde_json::json!({ 337 | "platform": 3, 338 | }), 339 | ) 340 | .await 341 | } 342 | 343 | pub async fn recommends(&self, offset: u64, limit: u64) -> Result> { 344 | self.request( 345 | reqwest::Method::GET, 346 | "/api/v3/recs", 347 | serde_json::json!({ 348 | "pos": 3200102, 349 | "limit": limit, 350 | "offset": offset, 351 | "platform": 3, 352 | }), 353 | ) 354 | .await 355 | } 356 | 357 | pub async fn explore_by_author_name( 358 | &self, 359 | author_name: &str, 360 | ordering: Option<&str>, 361 | offset: u64, 362 | limit: u64, 363 | ) -> Result> { 364 | let mut params = serde_json::json!({ 365 | "offset": offset, 366 | "limit": limit, 367 | "q": author_name, 368 | "free_type": 1, 369 | "platform": 3, 370 | }); 371 | if let Some(ordering) = ordering { 372 | params["ordering"] = serde_json::json!(ordering); 373 | } 374 | self.request(reqwest::Method::GET, "/api/v3/comics", params) 375 | .await 376 | } 377 | 378 | pub async fn explore_by_author( 379 | &self, 380 | author: &str, 381 | ordering: Option<&str>, 382 | offset: u64, 383 | limit: u64, 384 | ) -> Result> { 385 | let mut params = serde_json::json!({ 386 | "offset": offset, 387 | "limit": limit, 388 | "author": author, 389 | "free_type": 1, 390 | "platform": 3, 391 | }); 392 | if let Some(ordering) = ordering { 393 | params["ordering"] = serde_json::json!(ordering); 394 | } 395 | self.request(reqwest::Method::GET, "/api/v3/comics", params) 396 | .await 397 | } 398 | 399 | pub async fn explore( 400 | &self, 401 | ordering: Option<&str>, 402 | top: Option<&str>, 403 | theme: Option<&str>, 404 | offset: u64, 405 | limit: u64, 406 | ) -> Result> { 407 | let mut params = serde_json::json!({ 408 | "offset": offset, 409 | "limit": limit, 410 | "platform": 3, 411 | "_update": true, 412 | }); 413 | if let Some(ordering) = ordering { 414 | params["ordering"] = serde_json::json!(ordering); 415 | } 416 | if let Some(top) = top { 417 | params["top"] = serde_json::json!(top); 418 | } 419 | if let Some(theme) = theme { 420 | params["theme"] = serde_json::json!(theme); 421 | } 422 | self.request(reqwest::Method::GET, "/api/v3/comics", params) 423 | .await 424 | } 425 | 426 | pub async fn collect(&self, comic_id: &str, is_collect: bool) -> Result<()> { 427 | self.request( 428 | reqwest::Method::POST, 429 | format!("/api/v3/member/collect/comic").as_str(), 430 | serde_json::json!({ 431 | "comic_id": comic_id, 432 | "is_collect": if is_collect { 1 } else { 0 }, 433 | }), 434 | ) 435 | .await 436 | } 437 | 438 | pub async fn collected_comics( 439 | &self, 440 | free_type: i64, 441 | ordering: &str, 442 | offset: u64, 443 | limit: u64, 444 | ) -> Result> { 445 | self.request( 446 | reqwest::Method::GET, 447 | "/api/v3/member/collect/comics", 448 | serde_json::json!({ 449 | "free_type": free_type, 450 | "limit": limit, 451 | "offset": offset, 452 | "_update": true, 453 | "ordering": ordering, 454 | "platform": 3, 455 | }), 456 | ) 457 | .await 458 | } 459 | 460 | pub async fn download_image(&self, url: &str) -> Result { 461 | let agent_lock = self.agent.lock().await; 462 | let agent = agent_lock.clone(); 463 | drop(agent_lock); 464 | Ok(agent.get(url).send().await?.bytes().await?) 465 | } 466 | 467 | pub async fn roasts(&self, chapter_id: &str) -> Result> { 468 | self.request( 469 | reqwest::Method::GET, 470 | "/api/v3/roasts", 471 | serde_json::json!({ 472 | "chapter_id": chapter_id, 473 | "limit": 10, 474 | "offset": 0, 475 | "platform": 3, 476 | }), 477 | ) 478 | .await 479 | } 480 | 481 | pub async fn comments( 482 | &self, 483 | comic_id: &str, 484 | reply_id: Option<&str>, 485 | offset: u64, 486 | limit: u64, 487 | ) -> Result> { 488 | self.request( 489 | reqwest::Method::GET, 490 | "/api/v3/comments", 491 | serde_json::json!({ 492 | "comic_id": comic_id, 493 | "reply_id": if let Some(reply_id) = reply_id { reply_id } else { "" }, 494 | "limit": limit, 495 | "offset": offset, 496 | "platform": 3, 497 | }), 498 | ) 499 | .await 500 | } 501 | 502 | pub async fn comment( 503 | &self, 504 | comic_id: &str, 505 | comment: &str, 506 | reply_id: Option<&str>, 507 | ) -> Result<()> { 508 | self.request( 509 | reqwest::Method::POST, 510 | "/api/v3/member/comment", 511 | serde_json::json!({ 512 | "comic_id": comic_id, 513 | "comment": comment, 514 | "reply_id": if let Some(reply_id) = reply_id { reply_id } else { "" }, 515 | "platform": 3, 516 | }), 517 | ) 518 | .await 519 | } 520 | 521 | pub async fn browser(&self, offset: u64, limit: u64) -> Result> { 522 | self.request( 523 | reqwest::Method::GET, 524 | "/api/v3/member/browse/comics", 525 | serde_json::json!({ 526 | "limit": limit, 527 | "offset": offset, 528 | "platform": 3, 529 | }), 530 | ) 531 | .await 532 | } 533 | } 534 | 535 | pub fn random_device() -> String { 536 | format!( 537 | "{}{}{}{}.{}{}{}{}{}{}.{}{}{}", 538 | (b'A' + rand::random::() % 26) as char, 539 | (b'A' + rand::random::() % 26) as char, 540 | (b'0' + rand::random::() % 10) as char, 541 | (b'A' + rand::random::() % 26) as char, 542 | (b'0' + rand::random::() % 10) as char, 543 | (b'0' + rand::random::() % 10) as char, 544 | (b'0' + rand::random::() % 10) as char, 545 | (b'0' + rand::random::() % 10) as char, 546 | (b'0' + rand::random::() % 10) as char, 547 | (b'0' + rand::random::() % 10) as char, 548 | (b'0' + rand::random::() % 10) as char, 549 | (b'0' + rand::random::() % 10) as char, 550 | (b'0' + rand::random::() % 10) as char, 551 | ) 552 | } 553 | 554 | fn random_device_info() -> String { 555 | random_android_ua() 556 | } 557 | 558 | const ANDROID_VERSIONS: &[&str] = &[ 559 | "4.4", "5.0", "5.1", "6.0", "7.0", "7.1", "8.0", "8.1", "9", "10", "11", "12", "12.1", "13", 560 | "14", "15", 561 | ]; 562 | 563 | // 常见设备名,包括模拟器和主流品牌型号 564 | const DEVICES: &[&str] = &[ 565 | "Android SDK built for arm64", 566 | "Android SDK built for x86", 567 | "Pixel 7 Pro", 568 | "Pixel 7", 569 | "Pixel 6 Pro", 570 | "Pixel 6", 571 | "Pixel 5", 572 | "Pixel 4 XL", 573 | "Pixel 4a", 574 | "Pixel 3", 575 | "Redmi Note 12 Pro", 576 | "Redmi Note 11", 577 | "Redmi K60", 578 | "Redmi 10X", 579 | "MI 13", 580 | "MI 12", 581 | "MI 11 Ultra", 582 | "MI 10", 583 | "MI 9", 584 | "HUAWEI Mate 60 Pro", 585 | "HUAWEI P60", 586 | "HUAWEI nova 12", 587 | "HUAWEI Mate 40", 588 | "HUAWEI P40", 589 | "HUAWEI Mate X5", 590 | "OPPO Find X7", 591 | "OPPO Reno11", 592 | "OPPO A78", 593 | "Vivo X100", 594 | "Vivo S18", 595 | "Vivo Y100", 596 | "OnePlus 12", 597 | "OnePlus 11", 598 | "OnePlus 9 Pro", 599 | "realme GT5", 600 | "realme 12 Pro", 601 | "Samsung Galaxy S24", 602 | "Samsung Galaxy S23 Ultra", 603 | "Samsung Galaxy S22", 604 | "Samsung Galaxy Note10+", 605 | "Meizu 21 Pro", 606 | "Meizu 20", 607 | "Lenovo Legion Y70", 608 | "Lenovo K12", 609 | "Sony Xperia 1V", 610 | "Sony Xperia 10V", 611 | ]; 612 | 613 | // 常见 Build 前缀(按 Android 版本/厂商编译习惯) 614 | const BUILD_PREFIXES: &[&str] = &[ 615 | "AE3A", 616 | "TP1A", 617 | "UP1A", 618 | "SP1A", 619 | "RQ2A", 620 | "QQ3A", 621 | "RP1A", 622 | "QP1A", 623 | "RKQ1", 624 | "PKQ1", 625 | "SQ3A", 626 | "TQ3A", 627 | "UQ1A", 628 | "VQ1A", 629 | "WW", 630 | "HMKQ1", 631 | "V12.5.2.0", 632 | "V13.0.1.0", 633 | "V14.0.4.0", 634 | ]; 635 | 636 | fn random_build_id() -> String { 637 | let mut rng = rand::rng(); 638 | let prefix = BUILD_PREFIXES.choose(&mut rng).unwrap(); 639 | let year = rng.random_range(20..=25); 640 | let month = rng.random_range(1..=12); 641 | let day = rng.random_range(1..=28); 642 | format!( 643 | "{}.{}{:02}{:02}.{:03}", 644 | prefix, 645 | year, 646 | month, 647 | day, 648 | rng.random_range(1..=999) 649 | ) 650 | } 651 | 652 | fn random_fire_fox_version() -> String { 653 | let mut rng = rand::rng(); 654 | let version = rng.random_range(85..=140); 655 | format!("{}", version) 656 | } 657 | 658 | fn random_android_ua() -> String { 659 | let mut rng = rand::rng(); 660 | let android_version = ANDROID_VERSIONS.choose(&mut rng).unwrap(); 661 | let device = DEVICES.choose(&mut rng).unwrap(); 662 | let build_id = random_build_id(); 663 | let firefox_version = random_fire_fox_version(); 664 | format!( 665 | "Android {}; {} Build/{}/{}.0", 666 | android_version, device, build_id, firefox_version 667 | ) 668 | } 669 | --------------------------------------------------------------------------------