├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .rusty-hook.toml ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── Makefile.toml ├── README.md ├── bacon.toml ├── data_test ├── images │ ├── 1.jpg │ ├── 2.jpg │ └── 3.jpg └── weebcentral │ ├── chapter_page.txt │ ├── chapter_page_images.txt │ ├── full_chapters.txt │ ├── home_page.txt │ ├── home_page_v2.txt │ ├── manga_page.txt │ ├── search_page_no_more_result.txt │ └── search_page_paginated.txt ├── docs └── anilist.md ├── flake.lock ├── flake.nix ├── public ├── images │ ├── home.png │ ├── manga_page.png │ ├── reader.png │ └── search.png └── mangadex_support.jpg ├── rust-toolchain.toml ├── rustfmt.toml ├── scripts └── precommit.sh └── src ├── backend.rs ├── backend ├── cache.rs ├── cache │ └── in_memory.rs ├── database.rs ├── database_scheme.md ├── error_log.rs ├── html_parser.rs ├── html_parser │ └── scraper.rs ├── manga_downloader.rs ├── manga_downloader │ ├── cbz_downloader.rs │ ├── epub_downloader.rs │ ├── pdf_downloader.rs │ └── raw_images.rs ├── manga_provider.rs ├── manga_provider │ ├── mangadex.rs │ ├── mangadex │ │ ├── api_responses.rs │ │ ├── filter.rs │ │ └── filter_widget.rs │ ├── manganato.rs │ ├── manganato │ │ ├── filter_state.rs │ │ ├── filter_widget.rs │ │ └── response.rs │ ├── weebcentral.rs │ └── weebcentral │ │ ├── filter_state.rs │ │ ├── filter_widget.rs │ │ └── response.rs ├── migration.rs ├── release_notifier.rs ├── secrets.rs ├── secrets │ └── keyring.rs ├── tracker.rs ├── tracker │ └── anilist.rs └── tui.rs ├── cli.rs ├── common.rs ├── config.rs ├── global.rs ├── lib.rs ├── logger.rs ├── main.rs ├── utils.rs ├── view.rs └── view ├── app.rs ├── pages.rs ├── pages ├── feed.rs ├── home.rs ├── manga.rs ├── reader.rs └── search.rs ├── tasks.rs ├── tasks └── manga.rs ├── widgets.rs └── widgets ├── feed.rs ├── home.rs ├── manga.rs ├── reader.rs └── search.rs /.dockerignore: -------------------------------------------------------------------------------- 1 | target/ 2 | *.rs.bk 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | groups: 13 | cargo-dependencies: 14 | patterns: ["*"] 15 | - package-ecosystem: "github-actions" 16 | directory: "/" 17 | schedule: 18 | interval: "weekly" 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | build: 13 | strategy: 14 | matrix: 15 | targets: 16 | - target: x86_64-unknown-linux-gnu 17 | os: ubuntu-latest 18 | - target: x86_64-apple-darwin 19 | os: macos-14 20 | - target: aarch64-apple-darwin 21 | os: macos-14 22 | runs-on: ${{ matrix.targets.os }} 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Install dependencies (Linux only) 28 | if: runner.os == 'Linux' 29 | run: sudo apt install libdbus-1-dev pkg-config 30 | 31 | - name: Setup 32 | run: rustup target add ${{ matrix.targets.target }} 33 | - name: Build 34 | run: cargo build --release --target ${{ matrix.targets.target }} 35 | - name: Set release version 36 | run: echo "RELEASE_VERSION=${GITHUB_REF_NAME#v}" >> ${GITHUB_ENV} 37 | - name: Archive 38 | run: tar -czf manga-tui-${{ env.RELEASE_VERSION }}-${{ matrix.targets.target }}.tar.gz -C target/${{ matrix.targets.target }}/release manga-tui 39 | - name: Checksum 40 | run: shasum -a 256 manga-tui-${{ env.RELEASE_VERSION }}-${{ matrix.targets.target }}.tar.gz 41 | - name: Upload artifact 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: release-${{ matrix.targets.target }} 45 | path: manga-tui-${{ env.RELEASE_VERSION }}-${{ matrix.targets.target }}.tar.gz 46 | if-no-files-found: error 47 | release: 48 | permissions: 49 | contents: write 50 | runs-on: ubuntu-latest 51 | needs: build 52 | steps: 53 | - name: Download artifact 54 | uses: actions/download-artifact@v4 55 | with: 56 | path: releases 57 | pattern: release-* 58 | merge-multiple: true 59 | - name: Checksum 60 | run: sha256sum releases/* > ./releases/checksum.txt 61 | - name: Create Draft Release 62 | uses: softprops/action-gh-release@v2.2.2 63 | with: 64 | draft: true 65 | generate_release_notes: true 66 | make_latest: true 67 | files: | 68 | releases/* 69 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main, develop, dev, "release/**", "hotfix/**"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | RUST_BACKTRACE: full 12 | RUST_TOOLCHAIN_VERSION : "nightly" 13 | 14 | jobs: 15 | check_code_format_and_lint: 16 | name: Check code formatting and linting 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: Swatinem/rust-cache@v2 21 | 22 | - name: setup toolchain 23 | uses: hecrj/setup-rust-action@v2 24 | with: 25 | rust-version: ${{ env.RUST_TOOLCHAIN_VERSION }} 26 | 27 | - name: install_dependencies 28 | run: sudo apt install libdbus-1-dev pkg-config 29 | 30 | - name: check-fmt 31 | run: cargo fmt --check 32 | 33 | - name: clippy 34 | run: cargo clippy -- -D warnings 35 | 36 | 37 | 38 | build_and_test: 39 | strategy: 40 | fail-fast: false 41 | matrix: 42 | os: [ubuntu-latest, windows-latest, macos-latest] 43 | name: Build and test manga tui 44 | runs-on: ${{ matrix.os }} 45 | steps: 46 | - uses: actions/checkout@v4 47 | 48 | - uses: Swatinem/rust-cache@v2 49 | 50 | - name: setup toolchain 51 | uses: hecrj/setup-rust-action@v2 52 | with: 53 | rust-version: ${{ env.RUST_TOOLCHAIN_VERSION }} 54 | 55 | - name: Install dependencies (Linux only) 56 | if: runner.os == 'Linux' 57 | run: sudo apt install libdbus-1-dev pkg-config 58 | 59 | - name: check 60 | run: cargo check --locked 61 | 62 | - name: build 63 | run: cargo build --release --verbose 64 | 65 | - name: Install latest nextest release 66 | uses: taiki-e/install-action@nextest 67 | 68 | - name: Run tests 69 | run: cargo nextest run --no-fail-fast 70 | 71 | - name: run ignored tests 72 | run: cargo nextest run -- --ignored 73 | 74 | build_nix_targets: 75 | name: Build Nix targets 76 | runs-on: ubuntu-latest 77 | permissions: 78 | id-token: "write" 79 | contents: "read" 80 | steps: 81 | - uses: actions/checkout@v4 82 | - name: Check Nix flake inputs 83 | uses: DeterminateSystems/flake-checker-action@v9 84 | - name: Install Nix 85 | uses: DeterminateSystems/nix-installer-action@v16 86 | # according to the repository https://github.com/DeterminateSystems/magic-nix-cache 87 | # The Magic Nix Cache will will stop working on February 1st , 2025 (https://determinate.systems/posts/magic-nix-cache-free-tier-eol/) unless you're on GitHub Enterprise Server. 88 | # - name: Activate Magic Nix Cache 89 | # uses: DeterminateSystems/magic-nix-cache-action@main 90 | # with: 91 | # use-flakehub: false 92 | 93 | - name: Build default package 94 | run: nix build --print-build-logs --verbose 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #Fetched by: add-gitignore cli from: https://raw.githubusercontent.com/github/gitignore/main/Rust.gitignore 2 | # Generated by Cargo 3 | # will have compiled files and executables 4 | debug/ 5 | target/ 6 | result 7 | 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | # MSVC Windows builds of rustc generate these, which store debugging information 13 | *.pdb 14 | 15 | # RustRover 16 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 17 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 18 | # and can be added to the global gitignore or merged into this file. For a more nuclear 19 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 20 | #.idea/ 21 | *.db 22 | *~ 23 | test_results/ 24 | -------------------------------------------------------------------------------- /.rusty-hook.toml: -------------------------------------------------------------------------------- 1 | [hooks] 2 | pre-commit = "./scripts/precommit.sh" 3 | 4 | [logging] 5 | verbose = true 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | Thank you for considering contributing. Please review the guidelines below before making a contribution. 4 | 5 | ## Reporting Issues 6 | 7 | Before reporting, please check if an issue with the same content already exists. 8 | 9 | ### Reporting Bugs 10 | 11 | When reporting a bug, please include the following information: 12 | 13 | - Application version 14 | - Terminal emulator and version being used 15 | - Instructions to replicate the bug you found like : do x then y happens 16 | - if posibble an error message found in the `manga-tui-error-logs.txt` file located where the `manga-tui` directory is, if you don't know the location of this directory run: 17 | 18 | 19 | ```shell 20 | manga-tui --data-dir 21 | 22 | # or 23 | 24 | manga-tui -d 25 | ``` 26 | 27 | ### Setting up dev enviroment 28 | 29 | Make sure you have installed [cargo make](https://github.com/sagiegurari/cargo-make) 30 | 31 | After cloning the repository run: 32 | ```shell 33 | cargo make 34 | ``` 35 | It will run all the ci workflow which consists of formatting, checking, building and testing the code (which includes both normal test and ignored tests) 36 | after it is done a directory called `./test_results` will be created which is where the download tests produce their output 37 | 38 | To run only the download test: 39 | ```shell 40 | cargo make download-all 41 | ``` 42 | 43 | Or if you only want to run one download format 44 | ```shell 45 | cargo make download epub 46 | ``` 47 | 48 | ### Suggesting Features 49 | 50 | New features are always welcome but they need to have a issue associated first to discuss ways for a feature to be implemented, what the feature would do and how it would be implemented 51 | 52 | ### Issues related to image rendering 53 | 54 | On terminals which implement image protocols such as [Wezterm](https://wezfurlong.org/wezterm/index.html) [iTerm2](https://iterm2.com/) there may be issues with how images are render, I have only used Wezterm on linux
55 | 56 | `manga-tui` will not render images on any other terminal that does not have image protocol, keep this in mind before making a issue about the image support 57 | 58 | ## Pull Requests 59 | 60 | Before making a pull request, please make an issue and then either fork this repo or make a branch that is intended to solve the issue 61 | 62 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "manga-tui" 3 | version = "0.8.0" 4 | edition = "2021" 5 | authors = ["Josue "] 6 | readme = "README.md" 7 | homepage = "https://github.com/josueBarretogit/manga-tui" 8 | repository = "https://github.com/josueBarretogit/manga-tui" 9 | description = "Terminal-based manga reader and downloader with image rendering support" 10 | keywords = ["cli", "command-line", "terminal", "tui" ] 11 | categories = ["command-line-interface"] 12 | license = "MIT" 13 | exclude = [ 14 | "public/*", 15 | "docs/*", 16 | "data_test/*" 17 | ] 18 | 19 | [dependencies] 20 | # ratatui related dependencies 21 | ratatui = { version = "0.29.0", features = ["all-widgets", "palette", "unstable-widget-ref"] } 22 | ratatui-image = { version = "1.0.5", features = ["rustix"]} 23 | throbber-widgets-tui = "0.8.0" 24 | tui-input = { version = "0.12.0", features = ["crossterm"], default-features = false} 25 | tui-widget-list = "0.13.0" 26 | 27 | crossterm = { version = "0.29.0", features = ["event-stream"] } 28 | directories = "6.0.0" 29 | image = "0.25.4" 30 | reqwest = { version = "0.12.15", features = ["json", "native-tls-alpn", "cookies", "http2", "gzip", "deflate"] } 31 | tokio = { version = "1.44.2", features = ["full"] } 32 | serde = { version = "1.0.219", features = ["derive"] } 33 | strum = "0.26.3" 34 | strum_macros = "0.26" 35 | color-eyre = "0.6.2" 36 | futures = "0.3.31" 37 | bytes = { version = "1", features = ["serde"] } 38 | serde_json = "1.0.140" 39 | once_cell = "1.21.1" 40 | chrono = "0.4.40" 41 | open = "5" 42 | rusqlite = { version = "0.35.0", features = ["bundled"] } 43 | clap = { version = "4.5.37", features = ["derive", "cargo"] } 44 | zip = "4.0.0" 45 | toml = "0.8.20" 46 | epub-builder = "0.8.0" 47 | http = "1.3" 48 | keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } 49 | log = { version = "0.4", features = ["std", "serde"] } 50 | pretty_env_logger = "0.5" 51 | scraper = "0.23.1" 52 | regex = "1.11.1" 53 | lopdf = "0.36.0" 54 | flate2 = "1.1.1" 55 | 56 | [dev-dependencies] 57 | httpmock = "0.7.0-rc.1" 58 | pretty_assertions = "1.4.0" 59 | rusty-hook = "0.11.2" 60 | fake = "4.3.0" 61 | uuid = { version = "1.16.0", features = ["v4", "fast-rng"] } 62 | http = "1.3" 63 | 64 | 65 | [target.'cfg(windows)'.dependencies] 66 | windows-sys = { version = "0.59.0", features = ["Win32_Foundation", "Win32_System_Console", "Win32_UI_HiDpi"]} 67 | 68 | [profile.release] 69 | codegen-units = 1 70 | lto = "fat" 71 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the binary 2 | FROM rust:latest as builder 3 | WORKDIR /app 4 | 5 | # copy over your manifests 6 | COPY ./Cargo.lock ./Cargo.lock 7 | COPY ./Cargo.toml ./Cargo.toml 8 | 9 | COPY ./manga-tui-config.toml ./manga-tui-config.toml 10 | COPY ./src ./src 11 | # Install required native libraries 12 | RUN apt-get update && apt-get install -y \ 13 | libdbus-1-dev pkg-config \ 14 | openssl \ 15 | libssl-dev \ 16 | pkg-config \ 17 | && rm -rf /var/lib/apt/lists/* 18 | 19 | RUN cargo build --release 20 | 21 | # Stage 2: Create a minimal runtime image 22 | FROM debian:latest 23 | WORKDIR /app 24 | COPY --from=builder /app/target/release/manga-tui . 25 | 26 | COPY ./manga-tui-config.toml ./manga-tui-config.toml 27 | COPY ./src ./src 28 | # Install required native libraries 29 | RUN apt-get update && apt-get install -y \ 30 | libdbus-1-dev pkg-config \ 31 | openssl \ 32 | libssl-dev \ 33 | pkg-config \ 34 | && rm -rf /var/lib/apt/lists/* 35 | 36 | 37 | # Set the terminal environment 38 | ENV TERM=xterm-256color 39 | 40 | 41 | # Command to run the application 42 | CMD ["./manga-tui"] 43 | 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2024] [Josue Barreto Portela] 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 | -------------------------------------------------------------------------------- /Makefile.toml: -------------------------------------------------------------------------------- 1 | [tasks.format] 2 | command = "cargo" 3 | args = ["fmt", "--all", "--check"] 4 | 5 | [tasks.clippy] 6 | command = "cargo" 7 | args = ["clippy", "--", "-D", "warnings"] 8 | 9 | [tasks.build] 10 | command = "cargo" 11 | args = ["build"] 12 | 13 | [tasks.test] 14 | command = "cargo" 15 | args = ["test"] 16 | 17 | [tasks.check] 18 | command = "cargo" 19 | args = ["check"] 20 | 21 | [tasks.ignored-test] 22 | command = "cargo" 23 | args = ["test", "--", "--ignored"] 24 | 25 | 26 | [tasks.download-all] 27 | description = "Test all download formats" 28 | command = "cargo" 29 | args = ["test" , "backend::manga_downloader", "--", "--ignored"] 30 | 31 | 32 | [tasks.download] 33 | description = "Test only the specified download format, Example: cargo make download epub" 34 | command = "cargo" 35 | args = ["test" , "backend::manga_downloader::${@}", "--", "--ignored"] 36 | 37 | [tasks.full-ci] 38 | dependencies = [ 39 | "check", 40 | "clippy", 41 | "format", 42 | "build", 43 | "test", 44 | "ignored-test" 45 | ] 46 | 47 | [tasks.default] 48 | alias = "full-ci" 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 📖 Manga-tui 🖥️ 3 |

4 | 5 |

6 | Terminal-based manga reader and downloader written in rust 🦀 7 |

8 |
9 | 10 | test passing 11 | 12 | 13 | crates io downloads 14 | 15 | 16 | downloads 17 | 18 | 19 | License 20 | 21 |
22 | 23 |

24 | 25 | 26 | 27 |

28 | 29 | ## Table of contents 30 | 31 | - [Features](#features) 32 | - [Dependencies](#dependencies) 33 | - [Installation](#installation) 34 | - [Image rendering](#image-rendering) 35 | - [Usage](#usage) 36 | - [Manga providers](#manga-providers) 37 | - [Configuration](#configuration) 38 | - [Motivation](#motivation) 39 | - [Credits](#credits) 40 | 41 | ## Features 42 | 43 | - [Mangadex](https://mangadex.org/) and [Weebcentral](https://weebcentral.com/) are available as manga providers 44 | 45 | - Track your reading history with [anilist integration](./docs/anilist.md) 46 | 47 | - Advanced search (with filters) 48 | 49 | 50 | 51 | - Read manga in your terminal with terminals such as: Wezterm, iTerm2, Kitty, Ghostty 52 | 53 | 54 | 55 | - Reading history is stored locally (with no login required) 56 | 57 | 58 | 59 | - Download manga (available formats: cbz, epub, pdf and raw images) 60 | 61 | 62 | 63 | - Download all chapters of a manga (available formats: cbz, epub, pdf and raw images) 64 | 65 | 66 | 67 | ### Join the [discord](https://discord.gg/jNzuDCH3) server for further help, feature requests or to chat with contributors 68 | 69 | ## Dependencies 70 | 71 | On linux you may need to install the D-bus secret service library 72 | 73 | ### Debian 74 | 75 | ```shell 76 | sudo apt install libdbus-1-dev pkg-config 77 | ``` 78 | 79 | ### Fedora 80 | 81 | ```shell 82 | sudo dnf install dbus-devel pkgconf-pkg-config 83 | ``` 84 | 85 | ### Arch 86 | 87 | ```shell 88 | sudo pacman -S dbus pkgconf 89 | ``` 90 | 91 | ## Installation 92 | 93 | ### Using cargo 94 | 95 | ```shell 96 | cargo install manga-tui --locked 97 | ``` 98 | 99 | ### AUR 100 | 101 | You can install `manga-tui` from the [AUR](https://aur.archlinux.org/packages/manga-tui) with using an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers). 102 | 103 | ```shell 104 | paru -S manga-tui 105 | ``` 106 | 107 | ### Nix 108 | 109 | If you have the [Nix package manager](https://nixos.org/), this repo provides a flake that builds the latest git version from source. 110 | 111 | Simply run the following: 112 | 113 | ```sh 114 | nix run 'github:josueBarretogit/manga-tui' 115 | ``` 116 | 117 | Or, to install persistently: 118 | 119 | ```sh 120 | nix profile install 'github:josueBarretogit/manga-tui' 121 | ``` 122 | 123 | ## Binary release 124 | 125 | Download a binary from the [releases page](https://github.com/josueBarretogit/manga-tui/releases/latest) 126 | 127 | ## Image rendering 128 | 129 | Use a terminal that can render images such as [Wezterm](https://wezfurlong.org/wezterm/index.html) (Personally I recommend using this one It's the one used in the videos), [iTerm2](https://iterm2.com/), [Kitty](https://sw.kovidgoyal.net/kitty/) and [Ghostty](https://ghostty.org/download)
130 | For more information see: [image-support](https://github.com/benjajaja/ratatui-image?tab=readme-ov-file#compatibility-matrix) 131 | 132 | > [!WARNING] 133 | > On windows image display is very buggy, see [this issue](https://github.com/josueBarretogit/manga-tui/issues/26) for more information 134 | 135 | No images will be displayed if the terminal does not have image support (but `manga-tui` will still work as a manga downloader) 136 | 137 | ## Usage 138 | 139 | After installation just run the binary 140 | 141 | ```shell 142 | manga-tui 143 | ``` 144 | 145 | ## Manga providers 146 | 147 | > [!WARNING] 148 | > Expect any manga provider to fail at some point, either due to them closing operations due to a [lawsuit](https://www.japantimes.co.jp/news/2024/04/18/japan/crime-legal/manga-mura-copyright-ruling/) or the provider itself having issues on their end like [manganato](https://github.com/josueBarretogit/manga-tui/issues/132) 149 | 150 | By default when you run `manga-tui` Mangadex will be used 151 | 152 | ```shell 153 | manga-tui 154 | ``` 155 | 156 | If you want to use Weebcentral or any other provider available then run: 157 | 158 | ```shell 159 | manga-tui -p weebcentral 160 | ``` 161 | 162 | ## Configuration 163 | 164 | The config file is located at `XDG_CONFIG_HOME/manga-tui/config.toml`, to know where it is you can run: 165 | 166 | ```shell 167 | manga-tui --config-dir 168 | 169 | # or 170 | 171 | manga-tui -c 172 | ``` 173 | 174 | Which provides the following configuration: 175 | 176 | ```toml 177 | # The format of the manga downloaded 178 | # values: cbz, raw, epub, pdf 179 | # default: "cbz" 180 | download_type = "cbz" 181 | 182 | # Download image quality, low quality means images are compressed and is recommended for slow internet connections 183 | # values: low, high 184 | # default: "low" 185 | image_quality = "low" 186 | 187 | # Pages around the currently selected page to try to prefetch 188 | # values: 0-255 189 | # default: 5 190 | amount_pages = 5 191 | 192 | # Whether or not bookmarking is done automatically, if false you decide which chapter to bookmark 193 | # values: true, false 194 | # default: true 195 | auto_bookmark = true 196 | 197 | # Whether or not downloading a manga counts as reading it on services like anilist 198 | # values: true, false 199 | # default: false 200 | track_reading_when_download = false 201 | 202 | # Enable / disable checking for new updates 203 | # values: true, false 204 | # default: true 205 | check_new_updates = true 206 | 207 | # Sets which manga provider will be used when running manga-tui, 208 | # you can override it by running manga-tui with the -p flag like this: manga-tui -p weebcentral 209 | # values: mangadex, weebcentral 210 | # default: "mangadex" 211 | default_manga_provider = "mangadex" 212 | 213 | # Enable / disable tracking reading history with services like `anilist` 214 | # values: true, false 215 | # default: true 216 | track_reading_history = true 217 | 218 | # Anilist-related config, if you want `manga-tui` to read your anilist credentials from this file then place them here 219 | [anilist] 220 | # Your client id from your anilist account 221 | # leave it as an empty string "" if you don't want to use the config file to read your anilist credentials 222 | # values: string 223 | # default: "" 224 | client_id = "" 225 | 226 | # Your acces token from your anilist account 227 | # leave it as an empty string "" if you don't want to use the config file to read your anilist credentials 228 | # values: string 229 | # default: "" 230 | access_token = "" 231 | ``` 232 | 233 | Manga downloads and reading history is stored in the `manga-tui` directory, to know where it is run: 234 | 235 | ```shell 236 | manga-tui --data-dir 237 | 238 | # or 239 | 240 | manga-tui -d 241 | ``` 242 | 243 | On linux it will output something like: `~/.local/share/manga-tui`
244 | 245 | On the `manga-tui` directory there will be 3 directories 246 | 247 | - `history`, which contains a sqlite database to store reading history 248 | - `mangaDownloads`, where manga will be downloaded 249 | - `errorLogs`, for storing posible errors / bugs 250 | 251 | If you want to change the location of this directory you can set the environment variable `MANGA_TUI_DATA_DIR` to some path pointing to a directory, like:
252 | 253 | ```shell 254 | export MANGA_TUI_DATA_DIR="/home/user/Desktop/mangas" 255 | ``` 256 | 257 | > [!NOTE] 258 | > Mangadex-only feature 259 | By default `manga-tui` will search mangas in english, you can change the language by running: 260 | 261 | ```shell 262 | # `es` corresponds to the Iso code for spanish 263 | manga-tui lang --set 'es' 264 | ``` 265 | 266 | > [!NOTE] 267 | > Mangadex-only feature 268 | Check the available languages and their Iso codes by running: 269 | 270 | ```shell 271 | manga-tui lang --print 272 | ``` 273 | 274 | ## Motivation 275 | 276 | I wanted to make a "How linux user does ..." but for manga, [here is the video](https://www.youtube.com/watch?v=K0FsGRqEc1c) also this is a great excuse to start reading manga again 277 | 278 | ## Credits 279 | 280 | Many thanks to Mangadex for providing the free API please consider supporting them ❤️
281 | Many thanks to the [Ratatui organization](https://github.com/ratatui-org) for making such a good library for making TUI's in rust 🐭
282 | Many thanks to the developer of the [Ratatui-image crate](https://crates.io/crates/ratatui-image) for providing a widget that renders images in the terminal 🖼️
283 | 284 | Consider giving a star to this project ⭐ 285 | -------------------------------------------------------------------------------- /bacon.toml: -------------------------------------------------------------------------------- 1 | # This is a configuration file for the bacon tool 2 | # 3 | # Bacon repository: https://github.com/Canop/bacon 4 | # Complete help on configuration: https://dystroy.org/bacon/config/ 5 | # You can also check bacon's own bacon.toml file 6 | # as an example: https://github.com/Canop/bacon/blob/main/bacon.toml 7 | 8 | default_job = "check" 9 | 10 | [jobs.check] 11 | command = ["cargo", "check", "--color", "always"] 12 | need_stdout = false 13 | 14 | [jobs.check-all] 15 | command = ["cargo", "check", "--all-targets", "--color", "always"] 16 | need_stdout = false 17 | 18 | # Run clippy on the default target 19 | [jobs.clippy] 20 | command = [ 21 | "cargo", "clippy", 22 | "--color", "always", 23 | ] 24 | need_stdout = false 25 | 26 | # Run clippy on all targets 27 | # To disable some lints, you may change the job this way: 28 | # [jobs.clippy-all] 29 | # command = [ 30 | # "cargo", "clippy", 31 | # "--all-targets", 32 | # "--color", "always", 33 | # "--", 34 | # "-A", "clippy::bool_to_int_with_if", 35 | # "-A", "clippy::collapsible_if", 36 | # "-A", "clippy::derive_partial_eq_without_eq", 37 | # ] 38 | # need_stdout = false 39 | [jobs.clippy-all] 40 | command = [ 41 | "cargo", "clippy", 42 | "--all-targets", 43 | "--color", "always", 44 | ] 45 | need_stdout = false 46 | 47 | # This job lets you run 48 | # - all tests: bacon test 49 | # - a specific test: bacon test -- config::test_default_files 50 | # - the tests of a package: bacon test -- -- -p config 51 | [jobs.test] 52 | command = [ 53 | "cargo", "nextest", "run", "--color", "always", 54 | ] 55 | need_stdout = true 56 | 57 | [jobs.doc] 58 | command = ["cargo", "doc", "--color", "always", "--no-deps"] 59 | need_stdout = false 60 | 61 | # If the doc compiles, then it opens in your browser and bacon switches 62 | # to the previous job 63 | [jobs.doc-open] 64 | command = ["cargo", "doc", "--color", "always", "--no-deps", "--open"] 65 | need_stdout = false 66 | on_success = "back" # so that we don't open the browser at each change 67 | 68 | # You can run your application and have the result displayed in bacon, 69 | # *if* it makes sense for this crate. 70 | # Don't forget the `--color always` part or the errors won't be 71 | # properly parsed. 72 | # If your program never stops (eg a server), you may set `background` 73 | # to false to have the cargo run output immediately displayed instead 74 | # of waiting for program's end. 75 | [jobs.run] 76 | command = [ 77 | "cargo", "run", 78 | "--color", "always", 79 | # put launch parameters for your program behind a `--` separator 80 | ] 81 | need_stdout = true 82 | allow_warnings = true 83 | background = true 84 | 85 | # This parameterized job runs the example of your choice, as soon 86 | # as the code compiles. 87 | # Call it as 88 | # bacon ex -- my-example 89 | [jobs.ex] 90 | command = ["cargo", "run", "--color", "always", "--example"] 91 | need_stdout = true 92 | allow_warnings = true 93 | 94 | # You may define here keybindings that would be specific to 95 | # a project, for example a shortcut to launch a specific job. 96 | # Shortcuts to internal functions (scrolling, toggling, etc.) 97 | # should go in your personal global prefs.toml file instead. 98 | [keybindings] 99 | # alt-m = "job:my-job" 100 | c = "job:clippy-all" # comment this to have 'c' run clippy on only the default target 101 | -------------------------------------------------------------------------------- /data_test/images/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josueBarretogit/manga-tui/f5ca0060f22bddf73fd5d343ee88e5fc149f5a09/data_test/images/1.jpg -------------------------------------------------------------------------------- /data_test/images/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josueBarretogit/manga-tui/f5ca0060f22bddf73fd5d343ee88e5fc149f5a09/data_test/images/2.jpg -------------------------------------------------------------------------------- /data_test/images/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josueBarretogit/manga-tui/f5ca0060f22bddf73fd5d343ee88e5fc149f5a09/data_test/images/3.jpg -------------------------------------------------------------------------------- /data_test/weebcentral/chapter_page_images.txt: -------------------------------------------------------------------------------- 1 |
18 | 19 | Page 1 22 | 23 | Page 2 26 | 27 | Page 3 30 | 31 | Page 4 34 | 35 | Page 5 38 | 39 | Page 6 42 | 43 | Page 7 46 | 47 | Page 8 50 | 51 | Page 9 54 | 55 | Page 10 58 | 59 | Page 11 62 | 63 | Page 12 66 | 67 | Page 13 70 | 71 | Page 14 74 | 75 | Page 15 78 | 79 | Page 16 82 | 83 | Page 17 86 | 87 | Page 18 90 | 91 | Page 19 94 | 95 | Page 20 98 | 99 | Page 21 102 | 103 | Page 22 106 | 107 | Page 23 110 | 111 | Page 24 114 | 115 | Page 25 118 | 119 | Page 26 122 | 123 | Page 27 126 | 127 | Page 28 130 | 131 |
132 | -------------------------------------------------------------------------------- /data_test/weebcentral/search_page_no_more_result.txt: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 14 |
15 | 16 |
17 |
19 | Official
20 |
21 | 22 |
23 |
24 | 25 | 27 | 29 | [Oshi No Ko] cover 31 | 32 |
33 |
35 |
[Oshi No Ko] 36 |
37 |
38 | 39 |
40 | 41 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
53 | 54 |
55 |
56 |
57 |
58 | 143 |
144 | -------------------------------------------------------------------------------- /docs/anilist.md: -------------------------------------------------------------------------------- 1 | # Anilist integration (as of v0.5.0) 2 | 3 | ## Steps to set it up (Method 1) 4 | 5 | 1. Login to your anilist account and go to Settings / Developer / Create new client 6 | Name it whatever you like and put in `Redirect URL`the following : `https://anilist.co/api/v2/oauth/pin` 7 | ![image](https://github.com/user-attachments/assets/e0b1ece6-bbee-441e-9d09-0042f6a85ea8) 8 | 9 | 2. Run this command to provide your access token, follow the instructions and make sure you are logged in to your anilist account 10 | 11 | ```shell 12 | ./manga-tui anilist init 13 | ``` 14 | 15 | 3. Run this command to check if everything is setup correctly 16 | 17 | ```shell 18 | ./manga-tui anilist check 19 | ``` 20 | 21 | 4. Now just run `./manga-tui` and read manga as always, you should see your reading history being updated in your anilist account 22 | 23 | 24 | ## If you can't provide your anilist credentials from the terminal following Method 1 25 | 26 | Provide both `client_id` and `access_token` in the `config.toml` 27 | 28 | The config file is located at `XDG_CONFIG_HOME/manga-tui/config.toml`, to know where it is you can run: 29 | 30 | ```shell 31 | manga-tui --config-dir 32 | 33 | # or 34 | 35 | manga-tui -c 36 | ``` 37 | 38 | ```toml 39 | # Enable / disable tracking reading history with services like `anilist` 40 | # values: true, false 41 | # default: true 42 | track_reading_history = true 43 | 44 | # ... 45 | # Anilist-related config, if you want `manga-tui` to read your anilist credentials from this file then place them here 46 | [anilist] 47 | # Your client id from your anilist account 48 | # leave it as an empty string "" if you don't want to use the config file to read your anilist credentials 49 | # values: string 50 | # default: "" 51 | client_id = "" 52 | 53 | # Your acces token from your anilist account 54 | # leave it as an empty string "" if you don't want to use the config file to read your anilist credentials 55 | # values: string 56 | # default: "" 57 | access_token = "" 58 | ``` 59 | 60 | if you wish to stop using your credentials from config file then leave either `client_id` or `access_token` empty 61 | or set `track_reading_history` to `false` 62 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "locked": { 5 | "lastModified": 1730504891, 6 | "narHash": "sha256-Fvieht4pai+Wey7terllZAKOj0YsaDP0e88NYs3K/Lo=", 7 | "owner": "ipetkov", 8 | "repo": "crane", 9 | "rev": "8658adcdad49b8f2c6cbf0cc3cb4b4db988f7638", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "ipetkov", 14 | "repo": "crane", 15 | "type": "github" 16 | } 17 | }, 18 | "flake-utils": { 19 | "inputs": { 20 | "systems": "systems" 21 | }, 22 | "locked": { 23 | "lastModified": 1726560853, 24 | "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", 25 | "owner": "numtide", 26 | "repo": "flake-utils", 27 | "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "numtide", 32 | "repo": "flake-utils", 33 | "type": "github" 34 | } 35 | }, 36 | "nixpkgs": { 37 | "locked": { 38 | "lastModified": 1730200266, 39 | "narHash": "sha256-l253w0XMT8nWHGXuXqyiIC/bMvh1VRszGXgdpQlfhvU=", 40 | "owner": "nixos", 41 | "repo": "nixpkgs", 42 | "rev": "807e9154dcb16384b1b765ebe9cd2bba2ac287fd", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "owner": "nixos", 47 | "ref": "nixos-unstable", 48 | "repo": "nixpkgs", 49 | "type": "github" 50 | } 51 | }, 52 | "root": { 53 | "inputs": { 54 | "crane": "crane", 55 | "flake-utils": "flake-utils", 56 | "nixpkgs": "nixpkgs", 57 | "rust-overlay": "rust-overlay" 58 | } 59 | }, 60 | "rust-overlay": { 61 | "inputs": { 62 | "nixpkgs": [ 63 | "nixpkgs" 64 | ] 65 | }, 66 | "locked": { 67 | "lastModified": 1730514457, 68 | "narHash": "sha256-cjFX208s9pyaOfMvF9xI6WyafyXINqdhMF7b1bMQpLI=", 69 | "owner": "oxalica", 70 | "repo": "rust-overlay", 71 | "rev": "1ff38ca26eb31858e4dfe7fe738b6b3ce5d74922", 72 | "type": "github" 73 | }, 74 | "original": { 75 | "owner": "oxalica", 76 | "repo": "rust-overlay", 77 | "type": "github" 78 | } 79 | }, 80 | "systems": { 81 | "locked": { 82 | "lastModified": 1681028828, 83 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 84 | "owner": "nix-systems", 85 | "repo": "default", 86 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 87 | "type": "github" 88 | }, 89 | "original": { 90 | "owner": "nix-systems", 91 | "repo": "default", 92 | "type": "github" 93 | } 94 | } 95 | }, 96 | "root": "root", 97 | "version": 7 98 | } 99 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Flake for manga-tui, a terminal manga reader and downloader"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | rust-overlay = { 8 | url = "github:oxalica/rust-overlay"; 9 | inputs.nixpkgs.follows = "nixpkgs"; 10 | }; 11 | crane.url = "github:ipetkov/crane"; 12 | }; 13 | 14 | outputs = 15 | { 16 | self, 17 | nixpkgs, 18 | flake-utils, 19 | rust-overlay, 20 | crane, 21 | }: 22 | flake-utils.lib.eachDefaultSystem ( 23 | system: 24 | let 25 | overlays = [ rust-overlay.overlays.default ]; 26 | pkgs = import nixpkgs { inherit system overlays; }; 27 | inherit (pkgs.lib) cleanSource; 28 | 29 | rust = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; 30 | 31 | craneLib = (crane.mkLib nixpkgs.legacyPackages.${system}).overrideToolchain rust; 32 | 33 | commonArgs = { 34 | # src = craneLib.cleanCargoSource self; 35 | # 36 | # use regular nixpkgs lib.cleanSource so that `public` directory 37 | # isn't removed, causing build failure 38 | src = cleanSource self; 39 | strictDeps = true; 40 | nativeBuildInputs = with pkgs; [ 41 | pkg-config 42 | perl #added due to failing workflow: https://github.com/josueBarretogit/manga-tui/actions/runs/13379018167/job/37364084916?pr=114 43 | openssl.dev #added due to failing workflow: https://github.com/josueBarretogit/manga-tui/actions/runs/13379018167/job/37364084916?pr=114 44 | ]; 45 | buildInputs = with pkgs; [ 46 | dbus 47 | openssl #added due to failing workflow: https://github.com/josueBarretogit/manga-tui/actions/runs/13379018167/job/37364084916?pr=114 48 | perl 49 | cacert #added due to failing workflow: https://github.com/josueBarretogit/manga-tui/actions/runs/13379018167/job/37364084916?pr=114 50 | ]; 51 | }; 52 | 53 | cargoArtifacts = craneLib.buildDepsOnly commonArgs; 54 | in 55 | { 56 | packages = rec { 57 | manga-tui = craneLib.buildPackage ( 58 | commonArgs 59 | // { 60 | inherit cargoArtifacts; 61 | } 62 | ); 63 | 64 | default = manga-tui; 65 | }; 66 | 67 | devShells.default = craneLib.devShell { 68 | packages = with pkgs; [ 69 | git 70 | openssl 71 | openssl.dev #added due to failing workflow: https://github.com/josueBarretogit/manga-tui/actions/runs/13379018167/job/37364084916?pr=114 72 | dbus 73 | pkg-config 74 | perl 75 | cacert #added due to failing workflow: https://github.com/josueBarretogit/manga-tui/actions/runs/13379018167/job/37364084916?pr=114 76 | ]; 77 | }; 78 | 79 | formatter = pkgs.nixfmt-rfc-style; 80 | } 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /public/images/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josueBarretogit/manga-tui/f5ca0060f22bddf73fd5d343ee88e5fc149f5a09/public/images/home.png -------------------------------------------------------------------------------- /public/images/manga_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josueBarretogit/manga-tui/f5ca0060f22bddf73fd5d343ee88e5fc149f5a09/public/images/manga_page.png -------------------------------------------------------------------------------- /public/images/reader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josueBarretogit/manga-tui/f5ca0060f22bddf73fd5d343ee88e5fc149f5a09/public/images/reader.png -------------------------------------------------------------------------------- /public/images/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josueBarretogit/manga-tui/f5ca0060f22bddf73fd5d343ee88e5fc149f5a09/public/images/search.png -------------------------------------------------------------------------------- /public/mangadex_support.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josueBarretogit/manga-tui/f5ca0060f22bddf73fd5d343ee88e5fc149f5a09/public/mangadex_support.jpg -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | components = [ "rust-src", "rust-analyzer", "rustfmt", "clippy" ] 4 | profile = "default" 5 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | version = "Two" 3 | 4 | # Enable features 5 | format_code_in_doc_comments = true 6 | normalize_doc_attributes = true 7 | reorder_impl_items = true 8 | wrap_comments = true 9 | 10 | # Set the width of lines 11 | chain_width = 90 12 | comment_width = 132 13 | max_width = 132 14 | use_small_heuristics = "Max" 15 | 16 | # Specific width 17 | struct_lit_width=18 18 | struct_variant_width=35 19 | 20 | # Import handling 21 | group_imports = "StdExternalCrate" 22 | imports_granularity = "Module" 23 | 24 | # Stylish choices 25 | match_block_trailing_comma = true 26 | overflow_delimited_expr = true 27 | -------------------------------------------------------------------------------- /scripts/precommit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | format_rust_files() { 4 | file="$1" 5 | extension="${file##*.}" 6 | if [[ $extension == "rs" ]]; then 7 | echo "Formatting: $file" 8 | rustfmt "$file" --skip-children --unstable-features 9 | fi 10 | } 11 | 12 | stage_files() { 13 | file="$1" 14 | git add "$file" 15 | } 16 | 17 | 18 | main() { 19 | repo_root=$(git rev-parse --show-toplevel) # Get absolute path to repo root 20 | files=$(git diff --cached --name-only --diff-filter=d) 21 | 22 | 23 | for staged_file in $files 24 | do 25 | format_rust_files "$staged_file" 26 | done 27 | 28 | for staged_file in $files 29 | do 30 | stage_files "$staged_file" 31 | done 32 | } 33 | 34 | main 35 | -------------------------------------------------------------------------------- /src/backend.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{create_dir, create_dir_all}; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use manga_tui::exists; 5 | use once_cell::sync::Lazy; 6 | use strum::{Display, EnumIter, IntoEnumIterator}; 7 | 8 | use self::error_log::create_error_logs_files; 9 | use crate::config::{MangaTuiConfig, build_config_file}; 10 | use crate::logger::ILogger; 11 | 12 | pub mod cache; 13 | pub mod database; 14 | pub mod error_log; 15 | pub mod html_parser; 16 | pub mod manga_downloader; 17 | pub mod manga_provider; 18 | pub mod migration; 19 | pub mod release_notifier; 20 | pub mod secrets; 21 | pub mod tracker; 22 | pub mod tui; 23 | 24 | #[derive(Display, EnumIter)] 25 | pub enum AppDirectories { 26 | #[strum(to_string = "mangaDownloads")] 27 | MangaDownloads, 28 | #[strum(to_string = "errorLogs")] 29 | ErrorLogs, 30 | #[strum(to_string = "history")] 31 | History, 32 | } 33 | 34 | static ERROR_LOGS_FILE: &str = "manga-tui-error-logs.txt"; 35 | 36 | static DATABASE_FILE: &str = "manga-tui-history.db"; 37 | 38 | impl AppDirectories { 39 | pub fn get_full_path(self) -> PathBuf { 40 | Self::get_app_directory().join(self.get_path()) 41 | } 42 | 43 | pub fn build_if_not_exists(app_directory: &Path) -> Result<(), std::io::Error> { 44 | for dir in AppDirectories::iter() { 45 | let directory_path = app_directory.join(dir.to_string()); 46 | if !exists!(&directory_path) { 47 | create_dir(directory_path)?; 48 | } 49 | } 50 | Ok(()) 51 | } 52 | 53 | pub fn get_app_directory() -> &'static Path { 54 | APP_DATA_DIR.as_ref().unwrap() 55 | } 56 | 57 | pub fn get_base_directory(self) -> PathBuf { 58 | Self::get_app_directory().join(self.to_string()) 59 | } 60 | 61 | pub fn get_path(self) -> PathBuf { 62 | let base_directory = self.to_string(); 63 | match self { 64 | Self::History => PathBuf::from(base_directory).join(DATABASE_FILE), 65 | Self::ErrorLogs => PathBuf::from(base_directory).join(ERROR_LOGS_FILE), 66 | Self::MangaDownloads => PathBuf::from(base_directory), 67 | } 68 | } 69 | } 70 | 71 | #[cfg(not(test))] 72 | pub static APP_DATA_DIR: Lazy> = Lazy::new(|| { 73 | directories::ProjectDirs::from("", "", "manga-tui").map(|dirs| match std::env::var("MANGA_TUI_DATA_DIR").ok() { 74 | Some(data_dir) => PathBuf::from(data_dir), 75 | None => dirs.data_dir().to_path_buf(), 76 | }) 77 | }); 78 | 79 | #[cfg(test)] 80 | pub static APP_DATA_DIR: Lazy> = Lazy::new(|| Some(PathBuf::from("./test_results/data-directory"))); 81 | 82 | pub fn build_data_dir(logger: &impl ILogger) -> Result> { 83 | let data_dir = APP_DATA_DIR.as_ref(); 84 | match data_dir { 85 | Some(dir) => { 86 | if !exists!(dir) { 87 | create_dir_all(dir)?; 88 | logger.inform(format!("Creating directory: {}", dir.display())); 89 | } 90 | 91 | AppDirectories::build_if_not_exists(dir)?; 92 | 93 | create_error_logs_files(dir)?; 94 | 95 | build_config_file()?; 96 | 97 | Ok(dir.to_path_buf()) 98 | }, 99 | None => Err("data dir could not be found".into()), 100 | } 101 | } 102 | 103 | #[cfg(test)] 104 | mod test { 105 | use std::error::Error; 106 | use std::fs; 107 | use std::thread::sleep; 108 | use std::time::Duration; 109 | 110 | use pretty_assertions::assert_eq; 111 | 112 | use super::*; 113 | use crate::logger::DefaultLogger; 114 | 115 | #[test] 116 | #[ignore] 117 | fn data_directory_is_built() -> Result<(), Box> { 118 | sleep(Duration::from_millis(1000)); 119 | dbg!(build_data_dir(&DefaultLogger).expect("Could not build data directory")); 120 | 121 | let mut amount_directories = 0; 122 | 123 | let directory_built = fs::read_dir(AppDirectories::get_app_directory())?; 124 | 125 | for dir in directory_built { 126 | let dir = dir?; 127 | 128 | let directory_name = dir.file_name(); 129 | 130 | let directory_was_created = 131 | AppDirectories::iter().any(|app_dir| app_dir.to_string() == directory_name.to_string_lossy()); 132 | 133 | assert!(directory_was_created); 134 | 135 | amount_directories += 1; 136 | } 137 | 138 | assert_eq!(3, amount_directories); 139 | 140 | let error_logs_path = dbg!(AppDirectories::ErrorLogs.get_full_path()); 141 | 142 | fs::File::open(error_logs_path).expect("Could not open error logs file"); 143 | 144 | Ok(()) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/backend/cache.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::fmt::{Debug, Display}; 3 | use std::time::Duration; 4 | 5 | pub mod in_memory; 6 | 7 | /// Gives a hint to `Cacher` implementations as to how long entries should last, 8 | /// for in-memory cache it should lasts seconds, 40 seconds or more for the `Long` variant, 20-25 9 | /// seconds for `Medium` and so on 10 | /// for file-based or database cache impĺementations it can last anywhere from minutes to days even 11 | /// so each implementation must know how long the cache should live 12 | #[derive(Debug, PartialEq, Clone)] 13 | pub enum CacheDuration { 14 | LongLong, // longest 15 | Long, 16 | Medium, 17 | Short, 18 | VeryShort, //shortest 19 | } 20 | 21 | #[derive(Debug, PartialEq, Clone)] 22 | pub struct InsertEntry<'a> { 23 | pub id: &'a str, 24 | pub data: &'a [u8], 25 | /// How long this entry will last until it is removed 26 | pub duration: CacheDuration, 27 | } 28 | 29 | #[derive(Debug, PartialEq, Eq, Clone, Default)] 30 | pub struct Entry { 31 | pub data: Vec, 32 | } 33 | 34 | /// Cache which is specially important when scraping sites in order to reduce making requests and 35 | /// thus reduce the chances of being blocked 36 | pub trait Cacher: Send + Sync + Debug { 37 | fn cache(&self, entry: InsertEntry) -> Result<(), Box>; 38 | /// Generally if an entry was found it should be renewed, since it was accessed and is very 39 | /// likely to be accesed again 40 | fn get(&self, id: &str) -> Result, Box>; 41 | } 42 | 43 | #[cfg(test)] 44 | pub mod mock { 45 | use std::sync::Arc; 46 | 47 | use super::Cacher; 48 | 49 | #[derive(Debug)] 50 | pub struct EmptyCache; 51 | impl EmptyCache { 52 | pub fn new_arc() -> Arc { 53 | Arc::new(EmptyCache) 54 | } 55 | } 56 | 57 | impl Cacher for EmptyCache { 58 | fn get(&self, id: &str) -> Result, Box> { 59 | Ok(None) 60 | } 61 | 62 | fn cache(&self, entry: super::InsertEntry) -> Result<(), Box> { 63 | Ok(()) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/backend/cache/in_memory.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::sync::{Arc, Mutex}; 3 | use std::thread::{JoinHandle, spawn}; 4 | use std::time::{Duration, Instant}; 5 | 6 | use super::{CacheDuration, Cacher, Entry, InsertEntry}; 7 | 8 | #[derive(Debug)] 9 | struct EntryDuration(Duration); 10 | 11 | impl EntryDuration { 12 | fn as_duration(&self) -> Duration { 13 | self.0 14 | } 15 | } 16 | 17 | impl From for EntryDuration { 18 | fn from(value: CacheDuration) -> Self { 19 | match value { 20 | CacheDuration::LongLong => EntryDuration(Duration::from_secs(120)), 21 | CacheDuration::Long => EntryDuration(Duration::from_secs(60)), 22 | CacheDuration::Medium => EntryDuration(Duration::from_secs(20)), 23 | CacheDuration::Short => EntryDuration(Duration::from_secs(10)), 24 | CacheDuration::VeryShort => EntryDuration(Duration::from_secs(5)), 25 | } 26 | } 27 | } 28 | 29 | /// Implementation of In-memory cache, the entries stored with this struct will be dropped after the programm's execution 30 | /// and each entry has a time since creation and a duration or `time_to_live` so that older 31 | /// entries are removed and newer ones stay, this cleanup proccess is called Least Recently Used [`LRU`](https://www.geeksforgeeks.org/lru-cache-implementation/) 32 | /// this requires [`Interior mutability`](https://doc.rust-lang.org/book/ch15-05-interior-mutability.html) 33 | /// because the trait `Cacher` requires `Self` to be immutable but we need to persist data 34 | #[derive(Debug)] 35 | pub struct InMemoryCache { 36 | entries: Mutex>, 37 | /// Indicates how much entries should remain in the cache before being cleanup 38 | capacity: usize, 39 | } 40 | 41 | /// Keeping track of `time_since_creation` to know how long the entry has existed and 42 | /// `time_to_live` which indicates how long the entry should exist 43 | #[derive(Debug)] 44 | struct MemoryEntry { 45 | data: Vec, 46 | time_since_creation: Instant, 47 | time_to_live: EntryDuration, 48 | } 49 | 50 | impl MemoryEntry { 51 | fn new(data: Vec, time_to_live: EntryDuration) -> Self { 52 | Self { 53 | data, 54 | time_since_creation: Instant::now(), 55 | time_to_live, 56 | } 57 | } 58 | 59 | /// Returns `true` if the time since creation that has elapsed is greatern than the time it 60 | /// should live 61 | fn is_expired(&self) -> bool { 62 | self.time_since_creation.elapsed() > self.time_to_live.as_duration() 63 | } 64 | } 65 | 66 | impl InMemoryCache { 67 | /// Only way of constructing this struct to make sure when initializing the cleanup task is spawned 68 | pub fn init(capacity: usize) -> Arc { 69 | let cache = Arc::new(Self::new().with_capacity(capacity)); 70 | 71 | Self::start_cleanup_thread(Arc::clone(&cache)); 72 | 73 | cache 74 | } 75 | 76 | fn new() -> Self { 77 | Self { 78 | entries: Mutex::new(HashMap::new()), 79 | capacity: 5, 80 | } 81 | } 82 | 83 | /// Set how many entries to hold before starting the cleanup proccess 84 | pub fn with_capacity(mut self, capacity: usize) -> Self { 85 | self.capacity = capacity; 86 | self 87 | } 88 | 89 | /// Utility mainly for tests, should always remain private 90 | fn with_cached_data(data: HashMap) -> Self { 91 | Self { 92 | entries: Mutex::new(data), 93 | capacity: 5, 94 | } 95 | } 96 | 97 | fn delete_expired_entries(&self) { 98 | let mut entries = self.entries.lock().unwrap(); 99 | if entries.len() > self.capacity { 100 | entries.retain(|_, entry| !entry.is_expired()); 101 | } 102 | } 103 | 104 | fn start_cleanup_thread(cache: Arc) -> JoinHandle<()> { 105 | let tick = Duration::from_millis(500); 106 | spawn(move || { 107 | loop { 108 | cache.delete_expired_entries(); 109 | 110 | std::thread::sleep(tick); 111 | } 112 | }) 113 | } 114 | } 115 | 116 | impl Cacher for InMemoryCache { 117 | fn get(&self, id: &str) -> Result, Box> { 118 | let mut entries = self.entries.lock().unwrap(); 119 | 120 | let entry = entries.get_mut(id); 121 | 122 | Ok(entry.map(|en| { 123 | en.time_since_creation = Instant::now(); 124 | Entry { 125 | data: en.data.to_vec(), 126 | } 127 | })) 128 | } 129 | 130 | fn cache(&self, entry: InsertEntry) -> Result<(), Box> { 131 | let mut entries = self.entries.lock().unwrap(); 132 | 133 | entries.insert(entry.id.to_string(), MemoryEntry::new(entry.data.to_vec(), entry.duration.into())); 134 | 135 | Ok(()) 136 | } 137 | } 138 | 139 | #[cfg(test)] 140 | mod tests { 141 | use std::error::Error; 142 | use std::thread::sleep; 143 | use std::time::{Duration, Instant}; 144 | 145 | use pretty_assertions::assert_eq; 146 | 147 | use super::*; 148 | use crate::backend::cache::{self, Entry, InsertEntry}; 149 | 150 | #[test] 151 | fn it_saves_and_retrieves_data() -> Result<(), Box> { 152 | let in_memory = InMemoryCache::new(); 153 | 154 | let data: InsertEntry = InsertEntry { 155 | id: "entry1", 156 | data: b"some data", 157 | duration: CacheDuration::VeryShort, 158 | }; 159 | 160 | let data2: InsertEntry = InsertEntry { 161 | id: "entry2", 162 | data: b"some data entry2", 163 | duration: CacheDuration::VeryShort, 164 | }; 165 | 166 | in_memory.cache(data.clone())?; 167 | in_memory.cache(data2.clone())?; 168 | 169 | let cache_found1 = in_memory.get("entry1")?; 170 | let cache_found2 = in_memory.get("entry2")?; 171 | 172 | assert_eq!( 173 | Some(Entry { 174 | data: data.data.to_vec() 175 | }), 176 | cache_found1 177 | ); 178 | 179 | assert_eq!( 180 | Some(Entry { 181 | data: data2.data.to_vec() 182 | }), 183 | cache_found2 184 | ); 185 | 186 | Ok(()) 187 | } 188 | 189 | #[test] 190 | fn cached_entry_know_when_they_are_expired() -> Result<(), Box> { 191 | let in_memory = InMemoryCache::with_cached_data(HashMap::from([ 192 | /// 3 seconds have passed and the time to live is only 2 seconds so it should be 193 | /// expired 194 | ("id".to_string(), MemoryEntry { 195 | time_since_creation: Instant::now() - Duration::from_secs(3), 196 | time_to_live: EntryDuration(Duration::from_secs(2)), 197 | data: b"some data".to_vec(), 198 | }), 199 | ("id_not_expired".to_string(), MemoryEntry { 200 | time_since_creation: Instant::now(), 201 | time_to_live: EntryDuration(Duration::from_secs(5)), 202 | data: b"some data 2".to_vec(), 203 | }), 204 | ])); 205 | 206 | { 207 | let entries = in_memory.entries.lock().unwrap(); 208 | 209 | let inserted_entry = entries.get("id").unwrap(); 210 | 211 | assert!(inserted_entry.is_expired()); 212 | } 213 | 214 | { 215 | let entries = in_memory.entries.lock().unwrap(); 216 | 217 | let inserted_entry = entries.get("id_not_expired").unwrap(); 218 | 219 | assert!(!inserted_entry.is_expired()); 220 | } 221 | 222 | Ok(()) 223 | } 224 | 225 | #[test] 226 | fn background_task_removes_expired_entries() -> Result<(), Box> { 227 | let in_memory = Arc::new( 228 | InMemoryCache::with_cached_data(HashMap::from([ 229 | /// 3 seconds have passed and the time to live is only 2 seconds so it should be 230 | /// expired 231 | ("id".to_string(), MemoryEntry { 232 | time_since_creation: Instant::now() - Duration::from_secs(3), 233 | time_to_live: EntryDuration(Duration::from_secs(2)), 234 | data: b"some data".to_vec(), 235 | }), 236 | ("id_should_not_exist".to_string(), MemoryEntry { 237 | time_since_creation: Instant::now() - Duration::from_secs(10), 238 | time_to_live: EntryDuration(Duration::from_secs(2)), 239 | data: b"some data".to_vec(), 240 | }), 241 | ("id_should_live".to_string(), MemoryEntry { 242 | time_since_creation: Instant::now(), 243 | time_to_live: EntryDuration(Duration::from_secs(10)), 244 | data: b"some data 2".to_vec(), 245 | }), 246 | ("id_should_also_live".to_string(), MemoryEntry { 247 | time_since_creation: Instant::now(), 248 | time_to_live: EntryDuration(Duration::from_secs(15)), 249 | data: b"some data 3".to_vec(), 250 | }), 251 | ])) 252 | .with_capacity(3), 253 | ); 254 | 255 | in_memory.delete_expired_entries(); 256 | 257 | let should_not_exist = in_memory.get("id")?.is_none(); 258 | let should_not_exist2 = in_memory.get("id_should_not_exist")?.is_none(); 259 | let should_exist = in_memory.get("id_should_live")?.is_some(); 260 | let should_exist2 = in_memory.get("id_should_also_live")?.is_some(); 261 | 262 | assert!(should_not_exist); 263 | assert!(should_exist); 264 | assert!(should_not_exist2); 265 | assert!(should_exist2); 266 | 267 | Ok(()) 268 | } 269 | 270 | #[test] 271 | fn background_cleanup_task_doesnt_remove_entries_if_cache_capacity_is_not_exceeded() -> Result<(), Box> { 272 | let in_memory = Arc::new( 273 | InMemoryCache::with_cached_data(HashMap::from([ 274 | /// 3 seconds have passed and the time to live is only 2 seconds so it should be 275 | /// expired 276 | ("expired".to_string(), MemoryEntry { 277 | time_since_creation: Instant::now() - Duration::from_secs(3), 278 | time_to_live: EntryDuration(Duration::from_secs(2)), 279 | data: b"some data".to_vec(), 280 | }), 281 | ("expired2".to_string(), MemoryEntry { 282 | time_since_creation: Instant::now() - Duration::from_secs(10), 283 | time_to_live: EntryDuration(Duration::from_secs(2)), 284 | data: b"some data".to_vec(), 285 | }), 286 | ("expired3".to_string(), MemoryEntry { 287 | time_since_creation: Instant::now() - Duration::from_secs(10), 288 | time_to_live: EntryDuration(Duration::from_secs(1)), 289 | data: b"some data 2".to_vec(), 290 | }), 291 | ])) 292 | .with_capacity(5), 293 | ); 294 | 295 | in_memory.delete_expired_entries(); 296 | 297 | let should_exist = in_memory.get("expired")?.is_some(); 298 | let should_exist2 = in_memory.get("expired2")?.is_some(); 299 | 300 | assert!(should_exist); 301 | 302 | assert!(should_exist2); 303 | 304 | Ok(()) 305 | } 306 | 307 | #[test] 308 | fn if_the_entry_is_retrieved_then_time_to_live_should_be_renewed() -> Result<(), Box> { 309 | let in_memory = InMemoryCache::with_cached_data(HashMap::from([("exists".to_string(), MemoryEntry { 310 | time_since_creation: Instant::now() - Duration::from_secs(10), 311 | time_to_live: EntryDuration(Duration::from_secs(5)), 312 | data: b"some data".to_vec(), 313 | })])); 314 | 315 | in_memory.get("exists")?; 316 | 317 | { 318 | let entries = in_memory.entries.lock().unwrap(); 319 | 320 | assert_eq!(entries.get("exists").unwrap().time_since_creation.elapsed().as_secs(), Instant::now().elapsed().as_secs()); 321 | } 322 | 323 | Ok(()) 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /src/backend/database_scheme.md: -------------------------------------------------------------------------------- 1 | # Manga-tui sqlite database 2 | 3 | # app_version 4 | 5 | This table is used to keep track of what version the user has installed on their machines, maybe this will be useful for future updates idk 6 | 7 | - version 8 | - type: TEXT PRIMARY KEY 9 | 10 | 11 | # history_types 12 | 13 | The types of history, which are : `ReadingHistory` and `PlanToRead` 14 | 15 | - id 16 | - type: INTEGER PRIMARY KEY AUTOINCREMENT 17 | - name 18 | - type: TEXT NOT NULL UNIQUE 19 | 20 | # mangas 21 | 22 | To store which mangas the user is reading 23 | 24 | - id 25 | - type : TEXT PRIMARY KEY 26 | - title 27 | - type: TEXT NOT NULL, 28 | - created_at 29 | - type: DATETIME DEFAULT (datetime('now')) 30 | - updated_at 31 | - type: DATETIME DEFAULT (datetime('now')) 32 | - last_read 33 | - type: DATETIME DEFAULT (datetime('now')) 34 | - deleted_at 35 | - type: DATETIME NULL 36 | - img_url 37 | - type: TEXT NULL 38 | 39 | # chapters 40 | 41 | To know which chapters the user has read 42 | 43 | - id 44 | - type: TEXT PRIMARY KEY 45 | - title 46 | - type: TEXT NOT NULL 47 | - manga_id 48 | - type: TEXT NOT NULL 49 | - is_read 50 | - type: BOOLEAN NOT NULL DEFAULT 0 51 | - is_downloaded 52 | - type: BOOLEAN NOT NULL DEFAULT 0 53 | 54 | FOREIGN KEY (manga_id) REFERENCES mangas (id) 55 | 56 | # manga_history_union 57 | 58 | To query mangas that are in reading history or plan to read 59 | 60 | - manga_id 61 | - type: TEXT 62 | - type_id 63 | - type: INTEGER 64 | 65 | PRIMARY KEY (manga_id, type_id), 66 | FOREIGN KEY (manga_id) REFERENCES mangas (id), 67 | FOREIGN KEY (type_id) REFERENCES history_types (id) 68 | -------------------------------------------------------------------------------- /src/backend/error_log.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::fs::{File, OpenOptions, create_dir_all}; 3 | use std::io::Write; 4 | use std::panic::PanicInfo; 5 | use std::path::{Path, PathBuf}; 6 | 7 | use chrono::offset; 8 | use manga_tui::exists; 9 | 10 | use super::AppDirectories; 11 | 12 | pub enum ErrorType<'a> { 13 | Panic(&'a PanicInfo<'a>), 14 | Error(Box), 15 | String(&'a str), 16 | } 17 | 18 | impl<'a> From> for ErrorType<'a> { 19 | fn from(value: Box) -> Self { 20 | Self::Error(value) 21 | } 22 | } 23 | 24 | impl<'a> From for ErrorType<'a> { 25 | fn from(value: String) -> Self { 26 | Self::Error(value.into()) 27 | } 28 | } 29 | 30 | fn get_error_logs_path() -> PathBuf { 31 | let path = AppDirectories::ErrorLogs.get_base_directory(); 32 | 33 | if !exists!(&path) { 34 | create_dir_all(&path).ok(); 35 | } 36 | 37 | AppDirectories::ErrorLogs.get_full_path() 38 | } 39 | 40 | pub fn write_to_error_log(e: ErrorType<'_>) { 41 | let error_file_name = get_error_logs_path(); 42 | 43 | let now = offset::Local::now(); 44 | 45 | let error_format = match e { 46 | ErrorType::Panic(panic_info) => format!("{} | {} | {} \n \n", now, panic_info, panic_info.location().unwrap()), 47 | ErrorType::Error(boxed_err) => format!("{now} | {boxed_err} \n \n"), 48 | ErrorType::String(str) => format!("{now} | {str} \n \n"), 49 | }; 50 | 51 | let error_format_bytes = error_format.as_bytes(); 52 | 53 | if !exists!(&error_file_name) { 54 | let mut error_logs = File::create_new(error_file_name).unwrap(); 55 | 56 | error_logs.write_all(error_format_bytes).unwrap(); 57 | } else { 58 | let mut error_logs = OpenOptions::new().append(true).open(error_file_name).unwrap(); 59 | 60 | error_logs.write_all(error_format_bytes).unwrap(); 61 | } 62 | } 63 | 64 | pub fn create_error_logs_files(base_directory: &Path) -> std::io::Result<()> { 65 | let error_logs_path = base_directory.join(AppDirectories::ErrorLogs.get_path()); 66 | if !exists!(&error_logs_path) { 67 | File::create(error_logs_path)?; 68 | } 69 | Ok(()) 70 | } 71 | -------------------------------------------------------------------------------- /src/backend/html_parser.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | /// Intended to represent html, not just a string 4 | #[derive(Debug, Clone)] 5 | pub struct HtmlElement(String); 6 | 7 | pub mod scraper; 8 | 9 | pub trait ParseHtml: Sized { 10 | type ParseError: Error; 11 | fn parse_html(html: HtmlElement) -> Result; 12 | } 13 | impl HtmlElement { 14 | pub fn new>(raw_str: T) -> Self { 15 | let s: String = raw_str.into(); 16 | Self(s) 17 | } 18 | 19 | pub fn as_str(&self) -> &str { 20 | &self.0 21 | } 22 | } 23 | 24 | pub trait HtmlParser { 25 | fn get_element_children(&self, element: HtmlElement) -> Vec; 26 | fn get_element_by_class(&self, document: &HtmlElement, class: &str) -> HtmlElement; 27 | fn get_element_by_id(&self, document: &HtmlElement, id: &str) -> HtmlElement; 28 | fn get_element_attr(&self, element: HtmlElement, attr: &str) -> Option<&str>; 29 | } 30 | -------------------------------------------------------------------------------- /src/backend/html_parser/scraper.rs: -------------------------------------------------------------------------------- 1 | use scraper::Selector; 2 | 3 | //pub struct Parser; 4 | 5 | pub trait AsSelector { 6 | #![allow(clippy::wrong_self_convention)] 7 | fn as_selector(self) -> Selector; 8 | } 9 | 10 | impl AsSelector for &str { 11 | fn as_selector(self) -> Selector { 12 | Selector::parse(self).unwrap() 13 | } 14 | } 15 | 16 | //impl HtmlParser for Parser { 17 | // fn get_element_by_id(&self, document: &HtmlElement, class: &str) -> HtmlElement { 18 | // let document = html::Html::parse_document(document.as_str()); 19 | // let selector = selector::Selector::parse(class).unwrap(); 20 | // 21 | // let element = document.select(&selector).next().unwrap(); 22 | // 23 | // HtmlElement::new(element.html()) 24 | // } 25 | // 26 | // fn get_element_children(&self, element: HtmlElement) -> Vec { 27 | // vec![] 28 | // } 29 | // 30 | // fn get_element_by_class(&self, document: &HtmlElement, class: &str) -> HtmlElement {} 31 | // 32 | // fn get_attr(&self, element: HtmlElement, attr: &str) -> Option<&str> {} 33 | //} 34 | -------------------------------------------------------------------------------- /src/backend/manga_downloader.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::fs::create_dir_all; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use manga_tui::{SanitizedFilename, exists}; 6 | 7 | use super::manga_provider::{ChapterPage, Languages}; 8 | use crate::config::DownloadType; 9 | 10 | pub mod cbz_downloader; 11 | pub mod epub_downloader; 12 | pub mod pdf_downloader; 13 | pub mod raw_images; 14 | 15 | /// This struct represents a chapter with its data not having characteres that may throw errors 16 | /// when creating files such as `/` or `\` 17 | #[derive(Debug, PartialEq, Clone, Default)] 18 | pub struct ChapterToDownloadSanitized { 19 | pub chapter_id: String, 20 | pub manga_id: String, 21 | pub manga_title: SanitizedFilename, 22 | pub chapter_title: SanitizedFilename, 23 | pub chapter_number: String, 24 | pub volume_number: Option, 25 | pub language: Languages, 26 | pub scanlator: SanitizedFilename, 27 | pub download_type: DownloadType, 28 | pub pages: Vec, 29 | } 30 | 31 | pub trait MangaDownloader { 32 | /// The `base_directory` where the pages will be saved, for `raw_images` 33 | fn make_manga_base_directory_name(&self, base_directory: &Path, chapter: &ChapterToDownloadSanitized) -> PathBuf { 34 | base_directory 35 | .join(format!("{} {}", chapter.manga_title, chapter.manga_id)) 36 | .join(chapter.language.as_human_readable()) 37 | } 38 | fn create_manga_base_directory(&self, base_directory: &Path) -> Result<(), Box> { 39 | if !exists!(base_directory) { 40 | create_dir_all(base_directory)? 41 | } 42 | Ok(()) 43 | } 44 | 45 | fn make_chapter_name(&self, chapter: &ChapterToDownloadSanitized) -> PathBuf { 46 | PathBuf::from(format!( 47 | "Ch {} Vol {} {} {} {}", 48 | chapter.chapter_number, 49 | chapter.volume_number.as_ref().cloned().unwrap_or("none".to_string()), 50 | chapter.chapter_title, 51 | chapter.scanlator, 52 | chapter.chapter_id 53 | )) 54 | } 55 | 56 | fn save_chapter_in_file_system(&self, base_directory: &Path, chapter: ChapterToDownloadSanitized) 57 | -> Result<(), Box>; 58 | } 59 | 60 | #[cfg(test)] 61 | mod tests { 62 | use super::*; 63 | 64 | #[derive(Debug)] 65 | struct MockMangaDownloader {} 66 | 67 | impl MangaDownloader for MockMangaDownloader { 68 | fn save_chapter_in_file_system( 69 | &self, 70 | _base_directory: &Path, 71 | _chapter: ChapterToDownloadSanitized, 72 | ) -> Result<(), Box> { 73 | Ok(()) 74 | } 75 | } 76 | 77 | #[test] 78 | fn default_implementation_makes_manga_directory_name() { 79 | let downloader = MockMangaDownloader {}; 80 | 81 | let test_chapter: ChapterToDownloadSanitized = ChapterToDownloadSanitized { 82 | chapter_id: "chapter id".to_string(), 83 | manga_id: "manga id".to_string(), 84 | manga_title: "some manga title".to_string().into(), 85 | chapter_title: "some chapter title".to_string().into(), 86 | chapter_number: "3".to_string(), 87 | volume_number: None, 88 | language: Languages::default(), 89 | scanlator: "".to_string().into(), 90 | download_type: crate::config::DownloadType::Cbz, 91 | pages: vec![], 92 | }; 93 | 94 | let expected = Path::new("./test/some manga title manga id/English"); 95 | let result = downloader.make_manga_base_directory_name(Path::new("./test"), &test_chapter); 96 | 97 | assert_eq!(expected, result); 98 | } 99 | 100 | #[test] 101 | fn default_implementation_make_chapter_name() { 102 | let downloader = MockMangaDownloader {}; 103 | 104 | let test_chapter: ChapterToDownloadSanitized = ChapterToDownloadSanitized { 105 | chapter_id: "chapter id".to_string(), 106 | manga_id: "manga id".to_string(), 107 | manga_title: "some manga title".to_string().into(), 108 | chapter_title: "some chapter title".to_string().into(), 109 | chapter_number: "3".to_string(), 110 | volume_number: Some(3.to_string()), 111 | language: Languages::default(), 112 | scanlator: "some scanlator".to_string().into(), 113 | download_type: crate::config::DownloadType::Cbz, 114 | pages: vec![], 115 | }; 116 | 117 | let expected = Path::new("Ch 3 Vol 3 some chapter title some scanlator chapter id"); 118 | let result = downloader.make_chapter_name(&test_chapter); 119 | 120 | assert_eq!(expected, result); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/backend/manga_downloader/cbz_downloader.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::Write; 3 | 4 | use zip::ZipWriter; 5 | use zip::write::SimpleFileOptions; 6 | 7 | use super::MangaDownloader; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct CbzDownloader {} 11 | 12 | impl CbzDownloader { 13 | pub fn new() -> Self { 14 | Self {} 15 | } 16 | } 17 | 18 | impl MangaDownloader for CbzDownloader { 19 | fn save_chapter_in_file_system( 20 | &self, 21 | base_directory: &std::path::Path, 22 | chapter: super::ChapterToDownloadSanitized, 23 | ) -> Result<(), Box> { 24 | let base_directory = self.make_manga_base_directory_name(base_directory, &chapter); 25 | 26 | self.create_manga_base_directory(&base_directory)?; 27 | 28 | let cbz_filename = format!("{}.cbz", self.make_chapter_name(&chapter).display()); 29 | 30 | let cbz_path = base_directory.join(&cbz_filename); 31 | 32 | let cbz_file = File::create(&cbz_path)?; 33 | 34 | let mut zip = ZipWriter::new(cbz_file); 35 | 36 | let options = SimpleFileOptions::default() 37 | .compression_method(zip::CompressionMethod::Deflated) 38 | .unix_permissions(0o755); 39 | 40 | for (index, chap) in chapter.pages.into_iter().enumerate() { 41 | let file_name = format!("{}.{}", index + 1, chap.extension); 42 | 43 | zip.start_file(file_name, options).ok(); 44 | 45 | zip.write_all(&chap.bytes).ok(); 46 | } 47 | zip.finish()?; 48 | 49 | Ok(()) 50 | } 51 | } 52 | 53 | #[cfg(test)] 54 | mod tests { 55 | use std::error::Error; 56 | 57 | use fake::Fake; 58 | use fake::faker::name::en::Name; 59 | use uuid::Uuid; 60 | 61 | use super::*; 62 | use crate::backend::AppDirectories; 63 | use crate::backend::manga_downloader::ChapterToDownloadSanitized; 64 | use crate::backend::manga_provider::{ChapterPage, Languages}; 65 | use crate::config::DownloadType; 66 | 67 | #[test] 68 | #[ignore] 69 | fn it_downloads_a_chapter() -> Result<(), Box> { 70 | let chapter: ChapterToDownloadSanitized = ChapterToDownloadSanitized { 71 | chapter_id: Uuid::new_v4().to_string(), 72 | manga_id: Uuid::new_v4().to_string(), 73 | manga_title: Name().fake::().into(), 74 | chapter_title: Name().fake::().into(), 75 | chapter_number: "2".to_string(), 76 | volume_number: Some("3".to_string()), 77 | language: Languages::default(), 78 | scanlator: Name().fake::().into(), 79 | download_type: DownloadType::Cbz, 80 | pages: vec![ 81 | ChapterPage { 82 | bytes: include_bytes!("../../../data_test/images/1.jpg").to_vec().into(), 83 | extension: "jpg".to_string(), 84 | }, 85 | ChapterPage { 86 | bytes: include_bytes!("../../../data_test/images/2.jpg").to_vec().into(), 87 | extension: "jpg".to_string(), 88 | }, 89 | ChapterPage { 90 | bytes: include_bytes!("../../../data_test/images/3.jpg").to_vec().into(), 91 | extension: "jpg".to_string(), 92 | }, 93 | ], 94 | }; 95 | 96 | let downloader = CbzDownloader::new(); 97 | 98 | downloader.save_chapter_in_file_system(&AppDirectories::MangaDownloads.get_full_path(), chapter)?; 99 | 100 | Ok(()) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/backend/manga_downloader/epub_downloader.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | 3 | use epub_builder::{EpubBuilder, EpubContent, ZipLibrary}; 4 | 5 | use super::MangaDownloader; 6 | /// xml template to build epub files 7 | static EPUB_FILE_TEMPLATE: &str = r#" 8 | 9 | 10 | 11 | 12 | Panel 13 | 14 | 15 | 16 |
17 | Panel 18 |
19 | 20 | 21 | "#; 22 | 23 | #[derive(Debug, Clone)] 24 | pub struct EpubDownloader {} 25 | 26 | impl EpubDownloader { 27 | pub fn new() -> Self { 28 | Self {} 29 | } 30 | } 31 | 32 | impl MangaDownloader for EpubDownloader { 33 | fn save_chapter_in_file_system( 34 | &self, 35 | base_directory: &std::path::Path, 36 | chapter: super::ChapterToDownloadSanitized, 37 | ) -> Result<(), Box> { 38 | let base_directory = self.make_manga_base_directory_name(base_directory, &chapter); 39 | 40 | self.create_manga_base_directory(&base_directory)?; 41 | 42 | let epub_path = base_directory.join(format!("{}.epub", self.make_chapter_name(&chapter).display())); 43 | 44 | let mut epub_file = File::create(&epub_path)?; 45 | 46 | let mut epub_builder = EpubBuilder::new(ZipLibrary::new()?)?; 47 | 48 | epub_builder.epub_version(epub_builder::EpubVersion::V30); 49 | 50 | epub_builder.metadata("title", chapter.manga_title.to_string()).ok(); 51 | 52 | for (index, chap) in chapter.pages.into_iter().enumerate() { 53 | let file_name = format!("{}.{}", index + 1, chap.extension); 54 | let image_path = format!("data/{file_name}"); 55 | 56 | let mime_type = format!("image/{}", chap.extension); 57 | 58 | if index == 0 { 59 | epub_builder.add_cover_image(&image_path, chap.bytes.as_ref(), &mime_type).ok(); 60 | } 61 | 62 | epub_builder.add_resource(&image_path, chap.bytes.as_ref(), &mime_type).ok(); 63 | 64 | let xml_file_path = format!("{index}.xhtml"); 65 | 66 | epub_builder 67 | .add_content( 68 | EpubContent::new(xml_file_path, EPUB_FILE_TEMPLATE.replace("REPLACE_IMAGE_SOURCE", &image_path).as_bytes()) 69 | .title(&file_name), 70 | ) 71 | .ok(); 72 | } 73 | 74 | epub_builder.generate(&mut epub_file)?; 75 | 76 | Ok(()) 77 | } 78 | } 79 | #[cfg(test)] 80 | mod tests { 81 | use std::error::Error; 82 | 83 | use fake::Fake; 84 | use fake::faker::name::en::Name; 85 | use uuid::Uuid; 86 | 87 | use super::*; 88 | use crate::backend::AppDirectories; 89 | use crate::backend::manga_downloader::ChapterToDownloadSanitized; 90 | use crate::backend::manga_provider::{ChapterPage, Languages}; 91 | use crate::config::DownloadType; 92 | 93 | #[test] 94 | #[ignore] 95 | fn it_downloads_a_chapter() -> Result<(), Box> { 96 | let chapter: ChapterToDownloadSanitized = ChapterToDownloadSanitized { 97 | chapter_id: Uuid::new_v4().to_string(), 98 | manga_id: Uuid::new_v4().to_string(), 99 | manga_title: Name().fake::().into(), 100 | chapter_title: Name().fake::().into(), 101 | chapter_number: "2".to_string(), 102 | volume_number: Some("3".to_string()), 103 | language: Languages::default(), 104 | scanlator: Name().fake::().into(), 105 | download_type: DownloadType::Cbz, 106 | pages: vec![ 107 | ChapterPage { 108 | bytes: include_bytes!("../../../data_test/images/1.jpg").to_vec().into(), 109 | extension: "jpg".to_string(), 110 | }, 111 | ChapterPage { 112 | bytes: include_bytes!("../../../data_test/images/2.jpg").to_vec().into(), 113 | extension: "jpg".to_string(), 114 | }, 115 | ChapterPage { 116 | bytes: include_bytes!("../../../data_test/images/3.jpg").to_vec().into(), 117 | extension: "jpg".to_string(), 118 | }, 119 | ], 120 | }; 121 | 122 | let downloader = EpubDownloader::new(); 123 | 124 | downloader.save_chapter_in_file_system(&AppDirectories::MangaDownloads.get_full_path(), chapter)?; 125 | 126 | Ok(()) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/backend/manga_downloader/pdf_downloader.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::{BufWriter, Cursor, Write}; 3 | use std::path::Path; 4 | 5 | use flate2::Compression; 6 | use flate2::write::ZlibEncoder; 7 | use image::{DynamicImage, GenericImageView, ImageFormat}; 8 | use lopdf::{Document, Object, Stream, dictionary}; 9 | 10 | use super::MangaDownloader; 11 | 12 | pub struct PdfDownloader {} 13 | 14 | impl PdfDownloader { 15 | pub fn new() -> Self { 16 | Self {} 17 | } 18 | } 19 | 20 | impl MangaDownloader for PdfDownloader { 21 | fn save_chapter_in_file_system( 22 | &self, 23 | base_directory: &Path, 24 | chapter: super::ChapterToDownloadSanitized, 25 | ) -> Result<(), Box> { 26 | let base_directory = self.make_manga_base_directory_name(base_directory, &chapter); 27 | self.create_manga_base_directory(&base_directory)?; 28 | 29 | let pdf_path = base_directory.join(format!("{}.pdf", self.make_chapter_name(&chapter).display())); 30 | 31 | let mut doc = Document::with_version("1.7"); 32 | let mut pages = Vec::new(); 33 | let page_width = 595.0; 34 | 35 | for (index, page) in chapter.pages.iter().enumerate() { 36 | let img = image::load_from_memory(&page.bytes)?; 37 | let (img_width, img_height) = img.dimensions(); 38 | let mut img_data = Vec::new(); 39 | let filter; 40 | let color_space = if img.color().has_color() { "DeviceRGB" } else { "DeviceGray" }; 41 | 42 | match page.extension.as_str() { 43 | "jpg" | "jpeg" => { 44 | let mut cursor = Cursor::new(Vec::new()); 45 | img.write_to(&mut cursor, ImageFormat::Jpeg)?; 46 | img_data = cursor.into_inner(); 47 | filter = "DCTDecode"; 48 | }, 49 | "png" | "webp" => { 50 | let raw_img = if img.color().has_color() { img.to_rgb8().into_raw() } else { img.to_luma8().into_raw() }; 51 | let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast()); 52 | encoder.write_all(&raw_img)?; 53 | img_data = encoder.finish()?; 54 | filter = "FlateDecode"; 55 | }, 56 | _ => return Err(format!("Unsupported image format: {}", page.extension).into()), 57 | } 58 | 59 | let img_obj = Stream::new( 60 | dictionary! { 61 | "Type" => "XObject", 62 | "Subtype" => "Image", 63 | "Width" => img_width as i64, 64 | "Height" => img_height as i64, 65 | "ColorSpace" => color_space, 66 | "BitsPerComponent" => 8, 67 | "Filter" => filter, 68 | "Length" => img_data.len() as i64 69 | }, 70 | img_data, 71 | ); 72 | let img_id = doc.add_object(img_obj); 73 | 74 | let scale_factor = page_width / img_width as f32; 75 | let scaled_w = page_width; 76 | let scaled_h = img_height as f32 * scale_factor; 77 | 78 | let contents = Stream::new(dictionary! {}, format!("q {scaled_w} 0 0 {scaled_h} 0 0 cm /Im Do Q\n").into_bytes()); 79 | 80 | let contents_id = doc.add_object(contents); 81 | 82 | let page_dict = dictionary! { 83 | "Type" => "Page", 84 | "MediaBox" => vec![0.into(), 0.into(), scaled_w.into(), scaled_h.into()], 85 | "Resources" => dictionary! { 86 | "XObject" => dictionary! { "Im" => img_id } 87 | }, 88 | "Contents" => contents_id, 89 | }; 90 | 91 | let page_id = doc.add_object(page_dict); 92 | pages.push(page_id); 93 | } 94 | 95 | let pages_id = doc.add_object(dictionary! { 96 | "Type" => "Pages", 97 | "Kids" => pages.iter().map(|&p| p.into()).collect::>(), 98 | "Count" => pages.len() as i32, 99 | }); 100 | 101 | let catalog_id = doc.add_object(dictionary! { 102 | "Type" => "Catalog", 103 | "Pages" => pages_id, 104 | }); 105 | 106 | doc.trailer.set("Root", catalog_id); 107 | 108 | let mut file = File::create(pdf_path)?; 109 | doc.save_to(&mut BufWriter::new(file))?; 110 | 111 | Ok(()) 112 | } 113 | } 114 | 115 | #[cfg(test)] 116 | mod tests { 117 | use std::error::Error; 118 | use std::fs; 119 | use std::path::PathBuf; 120 | 121 | use fake::Fake; 122 | use fake::faker::name::en::Name; 123 | use lopdf::Document; 124 | use uuid::Uuid; 125 | 126 | use super::*; 127 | use crate::backend::AppDirectories; 128 | use crate::backend::manga_downloader::ChapterToDownloadSanitized; 129 | use crate::backend::manga_provider::{ChapterPage, Languages}; 130 | use crate::config::DownloadType; 131 | 132 | #[test] 133 | #[ignore] 134 | fn it_downloads_a_chapter() -> Result<(), Box> { 135 | let chapter: ChapterToDownloadSanitized = ChapterToDownloadSanitized { 136 | chapter_id: Uuid::new_v4().to_string(), 137 | manga_id: Uuid::new_v4().to_string(), 138 | manga_title: Name().fake::().into(), 139 | chapter_title: Name().fake::().into(), 140 | chapter_number: "2".to_string(), 141 | volume_number: Some("3".to_string()), 142 | language: Languages::default(), 143 | scanlator: Name().fake::().into(), 144 | download_type: DownloadType::Pdf, 145 | pages: vec![ 146 | ChapterPage { 147 | bytes: include_bytes!("../../../data_test/images/1.jpg").to_vec().into(), 148 | extension: "jpg".to_string(), 149 | }, 150 | ChapterPage { 151 | bytes: include_bytes!("../../../data_test/images/2.jpg").to_vec().into(), 152 | extension: "jpg".to_string(), 153 | }, 154 | ChapterPage { 155 | bytes: include_bytes!("../../../data_test/images/3.jpg").to_vec().into(), 156 | extension: "jpg".to_string(), 157 | }, 158 | ], 159 | }; 160 | 161 | let downloader = PdfDownloader::new(); 162 | let base_path = AppDirectories::MangaDownloads.get_full_path(); 163 | 164 | downloader.save_chapter_in_file_system(&base_path, chapter.clone())?; 165 | 166 | Ok(()) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/backend/manga_downloader/raw_images.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::Write; 3 | use std::path::PathBuf; 4 | 5 | use super::MangaDownloader; 6 | 7 | #[derive(Debug, Clone)] 8 | pub struct RawImagesDownloader {} 9 | 10 | impl RawImagesDownloader { 11 | pub fn new() -> Self { 12 | Self {} 13 | } 14 | } 15 | 16 | impl MangaDownloader for RawImagesDownloader { 17 | /// Overwriting the default implementation in order to make the chapter the `base_directory` 18 | fn make_manga_base_directory_name( 19 | &self, 20 | base_directory: &std::path::Path, 21 | chapter: &super::ChapterToDownloadSanitized, 22 | ) -> PathBuf { 23 | base_directory 24 | .join(format!("{} {}", chapter.manga_title, chapter.manga_id)) 25 | .join(chapter.language.as_human_readable()) 26 | .join(self.make_chapter_name(chapter)) 27 | } 28 | 29 | fn save_chapter_in_file_system( 30 | &self, 31 | base_directory: &std::path::Path, 32 | chapter: super::ChapterToDownloadSanitized, 33 | ) -> Result<(), Box> { 34 | let base_directory = self.make_manga_base_directory_name(base_directory, &chapter); 35 | 36 | self.create_manga_base_directory(&base_directory)?; 37 | 38 | for (index, chap) in chapter.pages.into_iter().enumerate() { 39 | let file_name = base_directory.join(format!("{}.{}", index + 1, chap.extension)); 40 | let mut maybe_file = File::create(file_name); 41 | 42 | if let Ok(file) = maybe_file.as_mut() { 43 | file.write_all(&chap.bytes).ok(); 44 | } 45 | } 46 | Ok(()) 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use std::error::Error; 53 | 54 | use fake::Fake; 55 | use fake::faker::name::en::Name; 56 | use uuid::Uuid; 57 | 58 | use super::*; 59 | use crate::backend::AppDirectories; 60 | use crate::backend::manga_downloader::ChapterToDownloadSanitized; 61 | use crate::backend::manga_provider::{ChapterPage, Languages}; 62 | use crate::config::DownloadType; 63 | 64 | #[test] 65 | #[ignore] 66 | fn it_downloads_a_chapter() -> Result<(), Box> { 67 | let chapter: ChapterToDownloadSanitized = ChapterToDownloadSanitized { 68 | chapter_id: Uuid::new_v4().to_string(), 69 | manga_id: Uuid::new_v4().to_string(), 70 | manga_title: Name().fake::().into(), 71 | chapter_title: Name().fake::().into(), 72 | chapter_number: "2".to_string(), 73 | volume_number: Some("3".to_string()), 74 | language: Languages::default(), 75 | scanlator: Name().fake::().into(), 76 | download_type: DownloadType::Cbz, 77 | pages: vec![ 78 | ChapterPage { 79 | bytes: include_bytes!("../../../data_test/images/1.jpg").to_vec().into(), 80 | extension: "jpg".to_string(), 81 | }, 82 | ChapterPage { 83 | bytes: include_bytes!("../../../data_test/images/2.jpg").to_vec().into(), 84 | extension: "jpg".to_string(), 85 | }, 86 | ChapterPage { 87 | bytes: include_bytes!("../../../data_test/images/3.jpg").to_vec().into(), 88 | extension: "jpg".to_string(), 89 | }, 90 | ], 91 | }; 92 | 93 | let downloader = RawImagesDownloader::new(); 94 | 95 | let base_path = AppDirectories::MangaDownloads.get_full_path(); 96 | 97 | downloader.save_chapter_in_file_system(&base_path, chapter)?; 98 | 99 | Ok(()) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/backend/manga_provider/mangadex/filter_widget.rs: -------------------------------------------------------------------------------- 1 | use ratatui::Frame; 2 | use ratatui::buffer::Buffer; 3 | use ratatui::layout::{Constraint, Layout, Rect}; 4 | use ratatui::style::{Color, Style, Stylize}; 5 | use ratatui::text::{Line, Span}; 6 | use ratatui::widgets::{Block, HighlightSpacing, List, ListItem, ListState, Paragraph, StatefulWidget, Tabs, Widget, Wrap}; 7 | 8 | use super::StatefulWidgetFrame; 9 | use super::filter::*; 10 | use crate::backend::manga_provider::FiltersWidget; 11 | use crate::global::CURRENT_LIST_ITEM_STYLE; 12 | use crate::utils::{render_search_bar, set_filter_tags_style}; 13 | 14 | #[derive(Clone)] 15 | pub struct MangadexFilterWidget { 16 | pub _style: Style, 17 | } 18 | 19 | impl FiltersWidget for MangadexFilterWidget { 20 | type FilterState = MangadexFilterProvider; 21 | } 22 | 23 | impl From for Line<'_> { 24 | fn from(value: MangaFilters) -> Self { 25 | Line::from(value.to_string()) 26 | } 27 | } 28 | 29 | impl From for ListItem<'_> { 30 | fn from(value: FilterListItem) -> Self { 31 | let line = 32 | if value.is_selected { Line::from(format!("🟡 {} ", value.name)).fg(Color::Yellow) } else { Line::from(value.name) }; 33 | ListItem::new(line) 34 | } 35 | } 36 | 37 | impl From for ListItem<'_> { 38 | fn from(value: ListItemId) -> Self { 39 | let line = 40 | if value.is_selected { Line::from(format!("🟡 {} ", value.name)).fg(Color::Yellow) } else { Line::from(value.name) }; 41 | ListItem::new(line) 42 | } 43 | } 44 | 45 | impl From for ListItem<'_> { 46 | fn from(value: TagListItem) -> Self { 47 | let line = match value.state { 48 | TagListItemState::Included => Line::from(format!(" {} ", value.name).black().on_green()), 49 | TagListItemState::Excluded => Line::from(format!(" {} ", value.name).black().on_red()), 50 | TagListItemState::NotSelected => Line::from(value.name), 51 | }; 52 | 53 | ListItem::new(line) 54 | } 55 | } 56 | 57 | impl StatefulWidgetFrame for MangadexFilterWidget { 58 | type State = MangadexFilterProvider; 59 | 60 | fn render(&mut self, area: Rect, frame: &mut Frame<'_>, state: &mut Self::State) { 61 | let buf = frame.buffer_mut(); 62 | let [tabs_area, current_filter_area] = Layout::vertical([Constraint::Percentage(20), Constraint::Percentage(80)]) 63 | .margin(2) 64 | .areas(area); 65 | 66 | let tabs: Vec> = FILTERS 67 | .iter() 68 | .map(|filters| { 69 | let num_filters = match filters { 70 | MangaFilters::ContentRating => state.content_rating.num_filters_active(), 71 | MangaFilters::SortBy => state.sort_by_state.num_filters_active(), 72 | MangaFilters::Languages => state.lang_state.num_filters_active(), 73 | MangaFilters::PublicationStatus => state.publication_status.num_filters_active(), 74 | MangaFilters::MagazineDemographic => state.magazine_demographic.num_filters_active(), 75 | MangaFilters::Tags => state.tags_state.num_filters_active(), 76 | MangaFilters::Authors => state.author_state.num_filters_active(), 77 | MangaFilters::Artists => state.artist_state.num_filters_active(), 78 | }; 79 | 80 | Line::from(vec![ 81 | filters.to_string().into(), 82 | " ".into(), 83 | if num_filters != 0 { 84 | Span::raw(format!("{num_filters}+")).bold().underlined().style(Color::Yellow) 85 | } else { 86 | "".into() 87 | }, 88 | ]) 89 | }) 90 | .collect(); 91 | 92 | Tabs::new(tabs) 93 | .select(state.id_filter) 94 | .highlight_style(Style::default().fg(Color::Yellow)) 95 | .render(tabs_area, buf); 96 | 97 | if let Some(filter) = FILTERS.get(state.id_filter) { 98 | match filter { 99 | MangaFilters::PublicationStatus => { 100 | render_filter_list( 101 | state.publication_status.items.clone(), 102 | current_filter_area, 103 | buf, 104 | &mut state.publication_status.state, 105 | ); 106 | }, 107 | MangaFilters::ContentRating => { 108 | render_filter_list( 109 | state.content_rating.items.clone(), 110 | current_filter_area, 111 | buf, 112 | &mut state.content_rating.state, 113 | ); 114 | }, 115 | MangaFilters::SortBy => { 116 | render_filter_list(state.sort_by_state.items.clone(), current_filter_area, buf, &mut state.sort_by_state.state); 117 | }, 118 | MangaFilters::Tags => self.render_tags_list(current_filter_area, frame, state), 119 | MangaFilters::MagazineDemographic => { 120 | render_filter_list( 121 | state.magazine_demographic.items.clone(), 122 | current_filter_area, 123 | buf, 124 | &mut state.magazine_demographic.state, 125 | ); 126 | }, 127 | MangaFilters::Authors => { 128 | let [list_area, input_area] = 129 | Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(current_filter_area); 130 | 131 | match state.author_state.items.as_mut() { 132 | Some(authors) => { 133 | render_filter_list(authors.clone(), list_area, buf, &mut state.author_state.state); 134 | }, 135 | None => { 136 | Paragraph::new("Search authors").render(list_area, buf); 137 | }, 138 | } 139 | 140 | let input_help = if state.is_typing { 141 | Line::from(vec![ 142 | "Press ".into(), 143 | " ".bold().yellow(), 144 | "to search ".into(), 145 | " ".bold().yellow(), 146 | "to stop typing".into(), 147 | ]) 148 | } else { 149 | Line::from(vec!["Press".into(), " ".bold().yellow(), "to search authors".into()]) 150 | }; 151 | 152 | render_search_bar(state.is_typing, input_help, &state.author_state.search_bar, frame, input_area); 153 | }, 154 | MangaFilters::Artists => { 155 | let [list_area, input_area] = 156 | Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(current_filter_area); 157 | 158 | match state.artist_state.items.as_mut() { 159 | Some(authors) => { 160 | render_filter_list(authors.clone(), list_area, buf, &mut state.artist_state.state); 161 | }, 162 | None => { 163 | Paragraph::new("Search artist").render(list_area, buf); 164 | }, 165 | } 166 | 167 | let input_help = if state.is_typing { 168 | Line::from(vec![ 169 | "Press ".into(), 170 | " ".bold().yellow(), 171 | "to search ".into(), 172 | " ".bold().yellow(), 173 | "to stop typing".into(), 174 | ]) 175 | } else { 176 | Line::from(vec!["Press".into(), " ".bold().yellow(), "to search artists".into()]) 177 | }; 178 | 179 | render_search_bar(state.is_typing, input_help, &state.artist_state.search_bar, frame, input_area); 180 | }, 181 | MangaFilters::Languages => { 182 | render_filter_list(state.lang_state.items.clone(), current_filter_area, buf, &mut state.lang_state.state); 183 | }, 184 | } 185 | } 186 | } 187 | } 188 | 189 | impl MangadexFilterWidget { 190 | pub fn new() -> Self { 191 | Self { 192 | _style: Style::default(), 193 | } 194 | } 195 | 196 | fn render_tags_list(&mut self, area: Rect, frame: &mut Frame<'_>, state: &mut MangadexFilterProvider) { 197 | let buf = frame.buffer_mut(); 198 | let [list_area, input_area] = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area); 199 | 200 | let [input_area, current_tags_area] = Layout::vertical([Constraint::Fill(1), Constraint::Fill(1)]).areas(input_area); 201 | 202 | if let Some(tags) = state.tags_state.tags.as_ref().cloned() { 203 | let tags_filtered: Vec> = tags 204 | .iter() 205 | .filter(|tag| tag.state != TagListItemState::NotSelected) 206 | .map(|tag| set_filter_tags_style(tag)) 207 | .collect(); 208 | 209 | Paragraph::new(Line::from(tags_filtered)) 210 | .block(Block::bordered()) 211 | .wrap(Wrap { trim: true }) 212 | .render(current_tags_area, buf); 213 | 214 | if state.tags_state.is_filter_empty() { 215 | render_tags_list(tags, list_area, buf, &mut state.tags_state.state); 216 | } else { 217 | let filtered_tags: Vec = tags 218 | .iter() 219 | .filter_map(|tag| { 220 | if tag.name.to_lowercase().contains(&state.tags_state.filter_input.value().to_lowercase()) { 221 | return Some(tag.clone()); 222 | } 223 | None 224 | }) 225 | .collect(); 226 | 227 | render_tags_list(filtered_tags, list_area, buf, &mut state.tags_state.state); 228 | } 229 | 230 | let input_help = if state.is_typing { 231 | Line::from(vec!["Press ".into(), " ".bold().yellow(), "to stop typing".into()]) 232 | } else { 233 | Line::from(vec!["Press".into(), " ".bold().yellow(), "to filter tags".into()]) 234 | }; 235 | 236 | render_search_bar(state.is_typing, input_help, &state.tags_state.filter_input, frame, input_area); 237 | } 238 | } 239 | } 240 | 241 | fn render_filter_list<'a, T>(items: T, area: Rect, buf: &mut Buffer, state: &mut ListState) 242 | where 243 | T: IntoIterator, 244 | T::Item: Into>, 245 | { 246 | let list_block = Block::bordered().title(Line::from(vec![ 247 | " Up/Down ".into(), 248 | " / ".bold().yellow(), 249 | " Select ".into(), 250 | "".bold().yellow(), 251 | ])); 252 | let list = List::new(items) 253 | .block(list_block) 254 | .highlight_spacing(HighlightSpacing::Always) 255 | .highlight_style(*CURRENT_LIST_ITEM_STYLE); 256 | 257 | StatefulWidget::render(list, area, buf, state); 258 | } 259 | 260 | fn render_tags_list<'a, T>(items: T, area: Rect, buf: &mut Buffer, state: &mut ListState) 261 | where 262 | T: IntoIterator, 263 | T::Item: Into>, 264 | { 265 | let list_block = Block::bordered().title(Line::from(vec![ 266 | " Up/Down ".into(), 267 | " / ".bold().yellow(), 268 | " toggle include tag ".into(), 269 | "".bold().yellow(), 270 | " toggle exclude tag ".into(), 271 | "".bold().yellow(), 272 | ])); 273 | let list = List::new(items) 274 | .block(list_block) 275 | .highlight_spacing(HighlightSpacing::Always) 276 | .highlight_style(*CURRENT_LIST_ITEM_STYLE); 277 | StatefulWidget::render(list, area, buf, state); 278 | } 279 | -------------------------------------------------------------------------------- /src/backend/manga_provider/manganato/filter_state.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::KeyCode; 2 | 3 | use crate::backend::manga_provider::{EventHandler, FiltersHandler}; 4 | use crate::backend::tui::Events; 5 | 6 | #[derive(Debug, Clone)] 7 | pub struct ManganatoFilterState {} 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct ManganatoFiltersProvider { 11 | is_open: bool, 12 | filter: ManganatoFilterState, 13 | } 14 | 15 | impl ManganatoFiltersProvider { 16 | pub fn new(filter: ManganatoFilterState) -> Self { 17 | Self { 18 | is_open: false, 19 | filter, 20 | } 21 | } 22 | } 23 | 24 | impl EventHandler for ManganatoFiltersProvider { 25 | fn handle_events(&mut self, events: crate::backend::tui::Events) { 26 | #![allow(clippy::single_match)] 27 | match events { 28 | Events::Key(key) => match key.code { 29 | KeyCode::Char('f') => self.toggle(), 30 | _ => {}, 31 | }, 32 | _ => {}, 33 | } 34 | } 35 | } 36 | 37 | impl FiltersHandler for ManganatoFiltersProvider { 38 | type InnerState = ManganatoFilterState; 39 | 40 | fn toggle(&mut self) { 41 | self.is_open = !self.is_open; 42 | } 43 | 44 | fn is_open(&self) -> bool { 45 | self.is_open 46 | } 47 | 48 | fn is_typing(&self) -> bool { 49 | false 50 | } 51 | 52 | fn get_state(&self) -> &Self::InnerState { 53 | &self.filter 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/backend/manga_provider/manganato/filter_widget.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::Margin; 2 | use ratatui::widgets::Widget; 3 | 4 | use super::filter_state::ManganatoFiltersProvider; 5 | use crate::backend::manga_provider::FiltersWidget; 6 | use crate::view::widgets::StatefulWidgetFrame; 7 | 8 | /// TODO: implement manganato filters in future release 9 | #[derive(Debug, Clone)] 10 | pub struct ManganatoFilterWidget {} 11 | 12 | impl FiltersWidget for ManganatoFilterWidget { 13 | type FilterState = ManganatoFiltersProvider; 14 | } 15 | 16 | impl StatefulWidgetFrame for ManganatoFilterWidget { 17 | type State = ManganatoFiltersProvider; 18 | 19 | fn render(&mut self, area: ratatui::prelude::Rect, frame: &mut ratatui::Frame<'_>, _state: &mut Self::State) { 20 | let buf = frame.buffer_mut(); 21 | "no filters available on manganato".render( 22 | area.inner(Margin { 23 | horizontal: 2, 24 | vertical: 2, 25 | }), 26 | buf, 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/backend/manga_provider/weebcentral/filter_state.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::KeyCode; 2 | 3 | use crate::backend::manga_provider::{EventHandler, FiltersHandler}; 4 | use crate::backend::tui::Events; 5 | 6 | #[derive(Debug, Clone, Default)] 7 | pub struct WeebcentralFilterState {} 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct WeebcentralFiltersProvider { 11 | is_open: bool, 12 | filter: WeebcentralFilterState, 13 | } 14 | 15 | impl WeebcentralFiltersProvider { 16 | pub fn new(filter: WeebcentralFilterState) -> Self { 17 | Self { 18 | is_open: false, 19 | filter, 20 | } 21 | } 22 | } 23 | 24 | impl EventHandler for WeebcentralFiltersProvider { 25 | fn handle_events(&mut self, events: crate::backend::tui::Events) { 26 | #![allow(clippy::single_match)] 27 | match events { 28 | Events::Key(key) => match key.code { 29 | KeyCode::Char('f') => self.toggle(), 30 | _ => {}, 31 | }, 32 | _ => {}, 33 | } 34 | } 35 | } 36 | 37 | impl FiltersHandler for WeebcentralFiltersProvider { 38 | type InnerState = WeebcentralFilterState; 39 | 40 | fn toggle(&mut self) { 41 | self.is_open = !self.is_open; 42 | } 43 | 44 | fn is_open(&self) -> bool { 45 | self.is_open 46 | } 47 | 48 | fn is_typing(&self) -> bool { 49 | false 50 | } 51 | 52 | fn get_state(&self) -> &Self::InnerState { 53 | &self.filter 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/backend/manga_provider/weebcentral/filter_widget.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::Margin; 2 | use ratatui::widgets::Widget; 3 | 4 | use super::filter_state::WeebcentralFiltersProvider; 5 | use crate::backend::manga_provider::FiltersWidget; 6 | use crate::view::widgets::StatefulWidgetFrame; 7 | 8 | /// TODO: implement Weebcentral filters in future release 9 | #[derive(Debug, Clone)] 10 | pub struct WeebcentralFilterWidget {} 11 | 12 | impl WeebcentralFilterWidget { 13 | pub fn new() -> Self { 14 | Self {} 15 | } 16 | } 17 | 18 | impl FiltersWidget for WeebcentralFilterWidget { 19 | type FilterState = WeebcentralFiltersProvider; 20 | } 21 | 22 | impl StatefulWidgetFrame for WeebcentralFilterWidget { 23 | type State = WeebcentralFiltersProvider; 24 | 25 | fn render(&mut self, area: ratatui::prelude::Rect, frame: &mut ratatui::Frame<'_>, _state: &mut Self::State) { 26 | let buf = frame.buffer_mut(); 27 | "no filters available on Weebcentral".render( 28 | area.inner(Margin { 29 | horizontal: 2, 30 | vertical: 2, 31 | }), 32 | buf, 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/backend/release_notifier.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::time::Duration; 3 | 4 | use http::header::ACCEPT; 5 | use http::{HeaderMap, HeaderValue, StatusCode}; 6 | use reqwest::{Client, Url}; 7 | use serde_json::Value; 8 | 9 | use crate::global::APP_USER_AGENT; 10 | use crate::logger::ILogger; 11 | 12 | #[derive(Debug)] 13 | pub struct ReleaseNotifier { 14 | github_url: Url, 15 | client: Client, 16 | } 17 | 18 | pub static GITHUB_URL: &str = "https://api.github.com/repos/josueBarretogit/manga-tui"; 19 | 20 | impl ReleaseNotifier { 21 | pub fn new(github_url: Url) -> Self { 22 | let mut default_headers = HeaderMap::new(); 23 | 24 | default_headers.insert("X-GitHub-Api-Version", HeaderValue::from_static("2022-11-28")); 25 | default_headers.insert(ACCEPT, HeaderValue::from_static("application/vnd.github+json")); 26 | 27 | let client = Client::builder() 28 | .timeout(Duration::from_secs(10)) 29 | .default_headers(default_headers) 30 | .user_agent(&*APP_USER_AGENT) 31 | .build() 32 | .unwrap(); 33 | 34 | Self { github_url, client } 35 | } 36 | 37 | async fn get_latest_release(&self) -> Result> { 38 | let endpoint = format!("{}/releases/latest", self.github_url); 39 | 40 | let response = self.client.get(endpoint).send().await?; 41 | 42 | if response.status() != StatusCode::OK { 43 | return Err(format!( 44 | "could not retrieve latest manga-tui version, more details about the api response : \n {response:#?} " 45 | ) 46 | .into()); 47 | } 48 | 49 | let response: Value = response.json().await?; 50 | 51 | let response = response.get("name").cloned().unwrap(); 52 | 53 | Ok(response.as_str().unwrap().to_string()) 54 | } 55 | 56 | /// returns `true` if there is a new version 57 | fn new_version(&self, latest: &str, current: &str) -> bool { 58 | latest != current 59 | } 60 | 61 | pub async fn check_new_releases(self, logger: &impl ILogger) -> Result<(), Box> { 62 | logger.inform("Checking for updates"); 63 | 64 | let latest_release = self.get_latest_release().await?; 65 | let current_version = format!("v{}", env!("CARGO_PKG_VERSION")); 66 | 67 | if self.new_version(&latest_release, ¤t_version) { 68 | let github_url = format!("https://github.com/josueBarretogit/manga-tui/releases/tag/{latest_release}"); 69 | logger.inform(format!("There is a new version : {latest_release} to update go to the releases page: {github_url} ")); 70 | tokio::time::sleep(Duration::from_secs(2)).await; 71 | } else { 72 | logger.inform("Up to date"); 73 | } 74 | 75 | Ok(()) 76 | } 77 | } 78 | 79 | #[cfg(test)] 80 | mod tests { 81 | use httpmock::Method::GET; 82 | use httpmock::MockServer; 83 | use pretty_assertions::assert_str_eq; 84 | use serde_json::json; 85 | 86 | use super::*; 87 | 88 | #[tokio::test] 89 | async fn it_get_latest_version_from_github() -> Result<(), Box> { 90 | let server = MockServer::start_async().await; 91 | let notifier = ReleaseNotifier::new(server.base_url().parse()?); 92 | 93 | let release = "v0.4.0"; 94 | 95 | let request = server 96 | .mock_async(|when, then| { 97 | when.method(GET) 98 | .header("X-GitHub-Api-Version", "2022-11-28") 99 | .header("Accept", "application/vnd.github+json") 100 | .header("User-Agent", &*APP_USER_AGENT) 101 | .path_contains("releases/latest"); 102 | then.status(200).json_body(json!({ "name" : release })); 103 | }) 104 | .await; 105 | 106 | let latest_release = notifier.get_latest_release().await?; 107 | 108 | request.assert_async().await; 109 | 110 | assert_str_eq!(release, latest_release); 111 | 112 | Ok(()) 113 | } 114 | 115 | #[test] 116 | fn it_compares_latest_version_from_current_version() -> Result<(), Box> { 117 | let notifier = ReleaseNotifier::new("http:/localhost".parse()?); 118 | 119 | let latest_version = "v0.5.0"; 120 | let current = "v0.4.0"; 121 | 122 | let new_version = notifier.new_version(latest_version, current); 123 | 124 | assert!(new_version); 125 | 126 | let latest_version = "v1.5.0"; 127 | let current = "v0.4.2"; 128 | 129 | let new_version = notifier.new_version(latest_version, current); 130 | 131 | assert!(new_version); 132 | 133 | let latest_version = "v1.5.0"; 134 | let current = "v1.5.0"; 135 | 136 | let new_version = notifier.new_version(latest_version, current); 137 | 138 | assert!(!new_version); 139 | 140 | Ok(()) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/backend/secrets.rs: -------------------------------------------------------------------------------- 1 | pub mod keyring; 2 | 3 | use std::collections::HashMap; 4 | use std::error::Error; 5 | 6 | /// Abstraction of the service which stores sensitive data, or secrets 7 | pub trait SecretStorage { 8 | /// Store the secret which will be identified by the `secret_name` and the secret itself is the 9 | /// `value` 10 | fn save_secret, S: Into>(&self, secret_name: T, value: S) -> Result<(), Box>; 11 | 12 | fn get_secret>(&self, secret_name: T) -> Result, Box>; 13 | 14 | fn remove_secret>(&self, secret_name: T) -> Result<(), Box>; 15 | 16 | fn save_multiple_secrets, S: Into>(&self, values: HashMap) -> Result<(), Box> { 17 | for (name, value) in values { 18 | self.save_secret(name, value)? 19 | } 20 | Ok(()) 21 | } 22 | 23 | fn remove_multiple_secrets>(&self, values: impl Iterator) -> Result<(), Box> { 24 | for name in values { 25 | self.remove_secret(name)? 26 | } 27 | Ok(()) 28 | } 29 | 30 | fn get_multiple_secrets>( 31 | &self, 32 | secrets_names: impl Iterator, 33 | ) -> Result, Box> { 34 | let mut secrets_collected: HashMap = HashMap::new(); 35 | 36 | for secrets in secrets_names { 37 | let secret_to_find: String = secrets.into(); 38 | if let Some(secret) = self.get_secret(secret_to_find.clone())? { 39 | secrets_collected.insert(secret_to_find, secret); 40 | } 41 | } 42 | 43 | Ok(secrets_collected) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/backend/secrets/keyring.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::string; 3 | 4 | use clap::crate_name; 5 | use keyring::Entry; 6 | use strum::Display; 7 | 8 | use super::SecretStorage; 9 | 10 | /// The [`keyring`](https://crates.io/crates/keyring) secret storage provider which uses each operating 11 | /// sistem's secret service to store sensitive data 12 | /// in order to get the data, it is neccesary to include the `service_name` in order to retrieve the 13 | /// secrets stored with this service 14 | #[derive(Debug)] 15 | pub struct KeyringStorage { 16 | service_name: &'static str, 17 | } 18 | 19 | impl KeyringStorage { 20 | pub fn new() -> Self { 21 | Self { 22 | service_name: crate_name!(), 23 | } 24 | } 25 | } 26 | 27 | impl SecretStorage for KeyringStorage { 28 | fn save_secret, S: Into>(&self, secret_name: T, value: S) -> Result<(), Box> { 29 | let secret = Entry::new(self.service_name, &secret_name.into())?; 30 | 31 | let secret_as_string: String = value.into(); 32 | 33 | secret.set_secret(secret_as_string.as_bytes())?; 34 | 35 | Ok(()) 36 | } 37 | 38 | fn get_secret>(&self, secret_name: T) -> Result, Box> { 39 | let secret = Entry::new(self.service_name, &secret_name.into())?; 40 | 41 | match secret.get_secret() { 42 | Ok(secret_as_bytes) => Ok(Some(String::from_utf8(secret_as_bytes)?)), 43 | Err(keyring::Error::NoEntry) => Ok(None), 44 | Err(e) => Err(Box::new(e)), 45 | } 46 | } 47 | 48 | fn remove_secret>(&self, secret_name: T) -> Result<(), Box> { 49 | let secret = Entry::new(self.service_name, secret_name.as_ref())?; 50 | 51 | secret.delete_credential()?; 52 | 53 | Ok(()) 54 | } 55 | } 56 | 57 | #[cfg(test)] 58 | mod tests { 59 | use std::collections::HashMap; 60 | use std::error::Error; 61 | 62 | use keyring::{mock, set_default_credential_builder}; 63 | 64 | use super::*; 65 | 66 | //#[test] 67 | // this test fails, Even if setting the mock credential builder and it is not well documented 68 | // how to test [keyring](https://docs.rs/keyring/latest/keyring/mock/index.html) 69 | //fn it_stores_credentials() -> Result<(), Box> { 70 | // set_default_credential_builder(mock::default_credential_builder()); 71 | // let id = "some_string".to_string(); 72 | // let code = "some_string".to_string(); 73 | // let secret = "some_string".to_string(); 74 | // 75 | // let mut storage = KeyringStorage::new(); 76 | // 77 | // storage.save_multiple_secrets(HashMap::from([ 78 | // ("id".to_string(), id.clone()), 79 | // ("code".to_string(), code.clone()), 80 | // ("secret".to_string(), secret.clone()), 81 | // ]))?; 82 | // 83 | // let id_stored = storage.get_secret("id")?.unwrap(); 84 | // assert_eq!(id_stored, id); 85 | // 86 | // let code_stored = storage.get_secret("code")?.unwrap(); 87 | // assert_eq!(code_stored, code); 88 | // 89 | // let secret_stored = storage.get_secret("secret")?.unwrap(); 90 | // assert_eq!(secret_stored, secret); 91 | // 92 | // Ok(()) 93 | //} 94 | } 95 | -------------------------------------------------------------------------------- /src/backend/tracker.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use futures::Future; 4 | use manga_tui::SearchTerm; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | pub mod anilist; 8 | 9 | #[derive(Debug, Deserialize, Serialize, Default, PartialEq, Eq)] 10 | pub struct MangaToTrack { 11 | pub id: String, 12 | } 13 | 14 | #[derive(Debug, Default, PartialEq, Eq)] 15 | pub struct MarkAsRead<'a> { 16 | pub id: &'a str, 17 | pub chapter_number: u32, 18 | pub volume_number: Option, 19 | } 20 | 21 | #[derive(Debug, Default, PartialEq, Eq)] 22 | pub struct PlanToReadArgs<'a> { 23 | pub id: &'a str, 24 | } 25 | 26 | pub trait MangaTracker: Send + Clone + 'static { 27 | fn search_manga_by_title( 28 | &self, 29 | title: SearchTerm, 30 | ) -> impl Future, Box>> + Send; 31 | 32 | /// Implementors may require api key / account token in order to perform this operation 33 | fn mark_manga_as_read_with_chapter_count( 34 | &self, 35 | manga: MarkAsRead<'_>, 36 | ) -> impl Future>> + Send; 37 | 38 | /// Implementors may require api key / account token in order to perform this operation 39 | fn mark_manga_as_plan_to_read( 40 | &self, 41 | manga_to_plan_to_read: PlanToReadArgs<'_>, 42 | ) -> impl Future>> + Send; 43 | } 44 | 45 | async fn update_reading_progress( 46 | manga_title: SearchTerm, 47 | chapter_number: u32, 48 | volume_number: Option, 49 | tracker: impl MangaTracker, 50 | ) -> Result<(), Box> { 51 | let response = tracker.search_manga_by_title(manga_title).await?; 52 | if let Some(manga) = response { 53 | tracker 54 | .mark_manga_as_read_with_chapter_count(MarkAsRead { 55 | id: &manga.id, 56 | chapter_number, 57 | volume_number, 58 | }) 59 | .await?; 60 | } 61 | Ok(()) 62 | } 63 | 64 | async fn update_plan_to_read(manga_title: SearchTerm, tracker: impl MangaTracker) -> Result<(), Box> { 65 | let response = tracker.search_manga_by_title(manga_title).await?; 66 | if let Some(manga) = response { 67 | tracker.mark_manga_as_plan_to_read(PlanToReadArgs { id: &manga.id }).await?; 68 | } 69 | Ok(()) 70 | } 71 | 72 | pub fn track_manga(tracker: Option, manga_title: String, chapter_number: u32, volume_number: Option, on_error: F) 73 | where 74 | T: MangaTracker, 75 | F: Fn(String) + Send + 'static, 76 | { 77 | if let Some(tracker) = tracker { 78 | tokio::spawn(async move { 79 | let title = SearchTerm::trimmed(&manga_title); 80 | if let Some(search_term) = title { 81 | let response = update_reading_progress(search_term, chapter_number, volume_number, tracker).await; 82 | if let Err(e) = response { 83 | on_error(e.to_string()); 84 | } 85 | } 86 | }); 87 | } 88 | } 89 | 90 | pub fn track_manga_plan_to_read(tracker: Option, manga_title: String, on_error: F) 91 | where 92 | T: MangaTracker, 93 | F: Fn(String) + Send + 'static, 94 | { 95 | if let Some(tracker) = tracker { 96 | tokio::spawn(async move { 97 | let title = SearchTerm::trimmed(&manga_title); 98 | if let Some(search_term) = title { 99 | let response = update_plan_to_read(search_term, tracker).await; 100 | if let Err(e) = response { 101 | on_error(e.to_string()); 102 | } 103 | } 104 | }); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/backend/tui.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::time::Duration; 3 | 4 | use crossterm::event::{KeyEvent, MouseEvent}; 5 | use futures::{FutureExt, StreamExt}; 6 | use ratatui::Terminal; 7 | use ratatui::backend::Backend; 8 | use ratatui_image::picker::{Picker, ProtocolType}; 9 | use tokio::sync::mpsc::UnboundedSender; 10 | use tokio::task::JoinHandle; 11 | 12 | use super::manga_provider::{ChapterToRead, Manga, MangaProvider}; 13 | use super::tracker::MangaTracker; 14 | use crate::view::app::{App, AppState, MangaToRead}; 15 | use crate::view::widgets::Component; 16 | 17 | pub enum Action { 18 | Quit, 19 | } 20 | 21 | /// These are the events this app will listen to 22 | #[derive(Clone, Debug, PartialEq)] 23 | pub enum Events { 24 | Tick, 25 | Key(KeyEvent), 26 | Mouse(MouseEvent), 27 | GoToMangaPage(Manga), 28 | GoBackMangaPage, 29 | GoToHome, 30 | GoSearchPage, 31 | GoFeedPage, 32 | Error(String), 33 | ReadChapter(ChapterToRead, MangaToRead), 34 | } 35 | 36 | #[cfg(unix)] 37 | fn get_picker() -> Option { 38 | Picker::from_termios() 39 | .ok() 40 | .map(|mut picker| { 41 | picker.guess_protocol(); 42 | picker 43 | }) 44 | .filter(|picker| picker.protocol_type != ProtocolType::Halfblocks) 45 | } 46 | 47 | #[cfg(target_os = "windows")] 48 | fn get_picker() -> Option { 49 | use windows_sys::Win32::System::Console::GetConsoleWindow; 50 | use windows_sys::Win32::UI::HiDpi::GetDpiForWindow; 51 | 52 | struct FontSize { 53 | pub width: u16, 54 | pub height: u16, 55 | } 56 | impl Default for FontSize { 57 | fn default() -> Self { 58 | FontSize { 59 | width: 17, 60 | height: 38, 61 | } 62 | } 63 | } 64 | 65 | let size: FontSize = match unsafe { GetDpiForWindow(GetConsoleWindow()) } { 66 | 96 => FontSize { 67 | width: 9, 68 | height: 20, 69 | }, 70 | 120 => FontSize { 71 | width: 12, 72 | height: 25, 73 | }, 74 | 144 => FontSize { 75 | width: 14, 76 | height: 32, 77 | }, 78 | _ => FontSize::default(), 79 | }; 80 | 81 | let mut picker = Picker::new((size.width, size.height)); 82 | 83 | let protocol = picker.guess_protocol(); 84 | 85 | if protocol == ProtocolType::Halfblocks { 86 | return None; 87 | } 88 | Some(picker) 89 | } 90 | 91 | ///Start app's main loop 92 | pub async fn run_app( 93 | mut terminal: Terminal, 94 | api_client: T, 95 | manga_tracker: Option, 96 | filter_state: T::FiltersHandler, 97 | filter_widget: T::Widget, 98 | ) -> Result<(), Box> { 99 | let mut app = App::new(api_client, manga_tracker, get_picker(), filter_state, filter_widget); 100 | 101 | let tick_rate = std::time::Duration::from_millis(250); 102 | 103 | let main_event_handle = handle_events(tick_rate, app.global_event_tx.clone()); 104 | 105 | while app.state == AppState::Runnning { 106 | terminal.draw(|f| { 107 | app.render(f.area(), f); 108 | })?; 109 | 110 | app.listen_to_event().await; 111 | 112 | app.update_based_on_action(); 113 | } 114 | 115 | main_event_handle.abort(); 116 | 117 | Ok(()) 118 | } 119 | 120 | pub fn handle_events(tick_rate: Duration, event_tx: UnboundedSender) -> JoinHandle<()> { 121 | tokio::spawn(async move { 122 | let mut reader = crossterm::event::EventStream::new(); 123 | let mut tick_interval = tokio::time::interval(tick_rate); 124 | 125 | loop { 126 | let delay = tick_interval.tick(); 127 | let event = reader.next().fuse(); 128 | tokio::select! { 129 | maybe_event = event => { 130 | match maybe_event { 131 | Some(Ok(evt)) => { 132 | match evt { 133 | crossterm::event::Event::Key(key) => { 134 | if key.kind == crossterm::event::KeyEventKind::Press { 135 | event_tx.send(Events::Key(key)).ok(); 136 | } 137 | }, 138 | crossterm::event::Event::Mouse(mouse_event) => { 139 | event_tx.send(Events::Mouse(mouse_event)).ok(); 140 | } 141 | _ => {} 142 | } 143 | } 144 | Some(Err(_)) => { 145 | 146 | } 147 | None => {} 148 | 149 | } 150 | 151 | } 152 | _ = delay => { 153 | event_tx.send(Events::Tick).ok(); 154 | } 155 | } 156 | } 157 | }) 158 | } 159 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::error::Error; 3 | use std::fmt::Display; 4 | use std::future::Future; 5 | use std::io::BufRead; 6 | use std::process::exit; 7 | 8 | use clap::{Parser, Subcommand, crate_version}; 9 | use serde::{Deserialize, Serialize}; 10 | use strum::{Display, IntoEnumIterator}; 11 | 12 | use crate::backend::APP_DATA_DIR; 13 | use crate::backend::error_log::write_to_error_log; 14 | use crate::backend::manga_provider::{Languages, MangaProviders}; 15 | use crate::backend::secrets::SecretStorage; 16 | use crate::backend::secrets::keyring::KeyringStorage; 17 | use crate::backend::tracker::anilist::{self, BASE_ANILIST_API_URL}; 18 | use crate::config::{MangaTuiConfig, get_config_directory_path, read_config_file}; 19 | use crate::global::PREFERRED_LANGUAGE; 20 | use crate::logger::{ILogger, Logger}; 21 | 22 | fn read_input(mut input_reader: impl BufRead, logger: &impl ILogger, message: &str) -> Result> { 23 | logger.inform(message); 24 | let mut input_provided = String::new(); 25 | input_reader.read_line(&mut input_provided)?; 26 | Ok(input_provided) 27 | } 28 | 29 | #[derive(Subcommand, Clone, Copy)] 30 | pub enum AnilistCommand { 31 | /// setup anilist client to be able to sync reading progress 32 | Init, 33 | /// check wheter or not anilist is setup correctly 34 | Check, 35 | } 36 | 37 | #[derive(Subcommand, Clone)] 38 | pub enum Commands { 39 | Lang { 40 | #[arg(short, long)] 41 | print: bool, 42 | #[arg(short, long)] 43 | set: Option, 44 | }, 45 | 46 | Anilist { 47 | #[command(subcommand)] 48 | command: AnilistCommand, 49 | }, 50 | } 51 | 52 | #[derive(Parser, Clone)] 53 | #[command(version = crate_version!())] 54 | pub struct CliArgs { 55 | #[command(subcommand)] 56 | pub command: Option, 57 | #[arg(short, long)] 58 | pub data_dir: bool, 59 | #[arg(short, long)] 60 | pub config_dir: bool, 61 | #[arg(short = 'p', long = "provider")] 62 | pub manga_provider: Option, 63 | } 64 | 65 | pub struct AnilistCredentialsProvided<'a> { 66 | pub access_token: &'a str, 67 | pub client_id: &'a str, 68 | } 69 | 70 | #[derive(Debug, Display, Clone, Copy)] 71 | pub enum AnilistCredentials { 72 | #[strum(to_string = "anilist_client_id")] 73 | ClientId, 74 | #[strum(to_string = "anilist_secret")] 75 | Secret, 76 | #[strum(to_string = "anilist_code")] 77 | Code, 78 | #[strum(to_string = "anilist_access_token")] 79 | AccessToken, 80 | } 81 | 82 | impl From for String { 83 | fn from(value: AnilistCredentials) -> Self { 84 | value.to_string() 85 | } 86 | } 87 | 88 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] 89 | pub struct Credentials { 90 | pub access_token: String, 91 | pub client_id: String, 92 | } 93 | 94 | pub fn check_anilist_credentials_are_stored(secret_provider: impl SecretStorage) -> Result, Box> { 95 | let credentials = 96 | secret_provider.get_multiple_secrets([AnilistCredentials::ClientId, AnilistCredentials::AccessToken].into_iter())?; 97 | 98 | let client_id = credentials.get(&AnilistCredentials::ClientId.to_string()).cloned(); 99 | let access_token = credentials.get(&AnilistCredentials::AccessToken.to_string()).cloned(); 100 | 101 | match client_id.zip(access_token) { 102 | Some((id, token)) => { 103 | if id.is_empty() || token.is_empty() { 104 | return Ok(None); 105 | } 106 | 107 | Ok(Some(Credentials { 108 | access_token: token, 109 | client_id: id.parse().unwrap(), 110 | })) 111 | }, 112 | None => Ok(None), 113 | } 114 | } 115 | 116 | impl CliArgs { 117 | pub fn new() -> Self { 118 | Self { 119 | config_dir: false, 120 | command: None, 121 | data_dir: false, 122 | manga_provider: Some(MangaProviders::default()), 123 | } 124 | } 125 | 126 | pub fn with_command(mut self, command: Commands) -> Self { 127 | self.command = Some(command); 128 | self 129 | } 130 | 131 | pub fn print_available_languages() { 132 | println!("The available languages are:"); 133 | Languages::iter().filter(|lang| *lang != Languages::Unkown).for_each(|lang| { 134 | println!("{} {} | iso code : {}", lang.as_emoji(), lang.as_human_readable().to_lowercase(), lang.as_iso_code()) 135 | }); 136 | } 137 | 138 | pub fn init_anilist( 139 | &self, 140 | mut input_reader: impl BufRead, 141 | storage: &mut impl SecretStorage, 142 | logger: impl ILogger, 143 | ) -> Result<(), Box> { 144 | let client_id = read_input(&mut input_reader, &logger, "Provide your client id")?; 145 | let client_id = client_id.trim(); 146 | 147 | let anilist_retrieve_access_token_url = 148 | format!("https://anilist.co/api/v2/oauth/authorize?client_id={client_id}&response_type=token"); 149 | 150 | let open_in_browser_message = format!("Opening {anilist_retrieve_access_token_url} to get the access token "); 151 | 152 | logger.inform(open_in_browser_message); 153 | 154 | open::that(anilist_retrieve_access_token_url)?; 155 | 156 | let access_token = read_input(&mut input_reader, &logger, "Enter the access token")?; 157 | let access_token = access_token.trim(); 158 | 159 | self.save_anilist_credentials( 160 | AnilistCredentialsProvided { 161 | access_token, 162 | client_id, 163 | }, 164 | storage, 165 | )?; 166 | 167 | logger.inform("Anilist was correctly setup :D"); 168 | 169 | Ok(()) 170 | } 171 | 172 | fn save_anilist_credentials( 173 | &self, 174 | credentials: AnilistCredentialsProvided<'_>, 175 | storage: &mut impl SecretStorage, 176 | ) -> Result<(), Box> { 177 | storage.save_multiple_secrets(HashMap::from([ 178 | (AnilistCredentials::AccessToken.to_string(), credentials.access_token.to_string()), 179 | (AnilistCredentials::ClientId.to_string(), credentials.client_id.to_string()), 180 | ]))?; 181 | Ok(()) 182 | } 183 | 184 | async fn check_anilist_token(&self, token_checker: &impl AnilistTokenChecker, token: String) -> Result> { 185 | token_checker.verify_token(token).await 186 | } 187 | 188 | async fn check_anilist_status(&self, logger: &impl ILogger, config: MangaTuiConfig) -> Result<(), Box> { 189 | let storage = KeyringStorage::new(); 190 | logger.inform("Checking client id and access token are stored"); 191 | 192 | let credentials_are_stored = config 193 | .check_anilist_credentials() 194 | .or_else(|| check_anilist_credentials_are_stored(storage).ok().flatten()); 195 | 196 | if credentials_are_stored.is_none() { 197 | logger.warn( 198 | "The client id or the access token are empty, run `manga-tui anilist init` to store your anilist credentials \n or you can store your credentials in your config file", 199 | ); 200 | exit(0) 201 | } 202 | 203 | let credentials = credentials_are_stored.unwrap(); 204 | 205 | logger.inform("Checking your access token is valid, this may take a while"); 206 | 207 | let anilist = anilist::Anilist::new(BASE_ANILIST_API_URL.parse().unwrap()) 208 | .with_token(credentials.access_token.clone()) 209 | .with_client_id(credentials.client_id); 210 | 211 | let access_token_is_valid = self.check_anilist_token(&anilist, credentials.access_token).await?; 212 | 213 | if access_token_is_valid { 214 | logger.inform("Everything is setup correctly :D"); 215 | } else { 216 | logger.error("The anilist access token is not valid, please run `manga-tui anilist init` to set a new one \n or you can store your credentials in your config file".into()); 217 | exit(0) 218 | } 219 | 220 | Ok(()) 221 | } 222 | 223 | /// This method should only return `Ok(())` it the app should keep running, otherwise `exit` 224 | pub async fn proccess_args(self) -> Result<(), Box> { 225 | if self.data_dir { 226 | let app_dir = APP_DATA_DIR.as_ref().unwrap(); 227 | println!("{}", app_dir.to_str().unwrap()); 228 | exit(0) 229 | } 230 | 231 | if self.config_dir { 232 | println!("{}", get_config_directory_path().display()); 233 | exit(0) 234 | } 235 | 236 | match &self.command { 237 | Some(command) => match command { 238 | Commands::Lang { print, set } => { 239 | if *print { 240 | Self::print_available_languages(); 241 | exit(0) 242 | } 243 | 244 | match set { 245 | Some(lang) => { 246 | println!( 247 | "WARNING: deprecated function this will be part of the config file in future releases, and only applies to mangadex" 248 | ); 249 | let try_lang = Languages::try_from_iso_code(lang.as_str()); 250 | 251 | if try_lang.is_none() { 252 | println!( 253 | "`{}` is not a valid ISO language code, run `{} lang --print` to list available languages and their ISO codes", 254 | lang, 255 | env!("CARGO_BIN_NAME") 256 | ); 257 | 258 | exit(0) 259 | } 260 | 261 | PREFERRED_LANGUAGE.set(try_lang.unwrap()).unwrap(); 262 | }, 263 | None => { 264 | PREFERRED_LANGUAGE.set(Languages::default()).unwrap(); 265 | }, 266 | } 267 | Ok(()) 268 | }, 269 | 270 | Commands::Anilist { command } => match command { 271 | AnilistCommand::Init => { 272 | let mut storage = KeyringStorage::new(); 273 | self.init_anilist(std::io::stdin().lock(), &mut storage, Logger)?; 274 | exit(0) 275 | }, 276 | AnilistCommand::Check => { 277 | let logger = Logger; 278 | 279 | let config = read_config_file()?; 280 | if let Err(e) = self.check_anilist_status(&logger, config).await { 281 | logger.error(format!("Some error ocurred, more details \n {e}").into()); 282 | write_to_error_log(e.into()); 283 | exit(1); 284 | } else { 285 | exit(0) 286 | } 287 | }, 288 | }, 289 | }, 290 | None => { 291 | PREFERRED_LANGUAGE.set(Languages::default()).unwrap(); 292 | Ok(()) 293 | }, 294 | } 295 | } 296 | } 297 | 298 | pub trait AnilistTokenChecker { 299 | fn verify_token(&self, token: String) -> impl Future>> + Send; 300 | } 301 | 302 | #[cfg(test)] 303 | mod tests { 304 | use std::collections::HashMap; 305 | use std::error::Error; 306 | use std::sync::RwLock; 307 | 308 | use pretty_assertions::assert_eq; 309 | use uuid::Uuid; 310 | 311 | use super::*; 312 | 313 | #[derive(Default)] 314 | struct MockStorage { 315 | secrets_stored: RwLock>, 316 | } 317 | 318 | impl MockStorage { 319 | fn with_data(secrets_stored: HashMap) -> Self { 320 | Self { 321 | secrets_stored: RwLock::new(secrets_stored), 322 | } 323 | } 324 | } 325 | 326 | impl SecretStorage for MockStorage { 327 | fn save_secret, S: Into>(&self, name: T, value: S) -> Result<(), Box> { 328 | self.secrets_stored.write().unwrap().insert(name.into(), value.into()); 329 | Ok(()) 330 | } 331 | 332 | fn get_secret>(&self, secret_name: T) -> Result, Box> { 333 | Ok(self.secrets_stored.read().unwrap().get(&secret_name.into()).cloned()) 334 | } 335 | 336 | fn remove_secret>(&self, secret_name: T) -> Result<(), Box> { 337 | match self.secrets_stored.write().unwrap().remove(secret_name.as_ref()) { 338 | Some(_val) => Ok(()), 339 | None => Err("secret did not exist".into()), 340 | } 341 | } 342 | } 343 | 344 | #[test] 345 | fn it_saves_anilist_access_token_and_user_id() { 346 | let cli = CliArgs::new(); 347 | let acess_token = Uuid::new_v4().to_string(); 348 | let user_id = "120398".to_string(); 349 | 350 | let mut storage = MockStorage::default(); 351 | 352 | cli.save_anilist_credentials( 353 | AnilistCredentialsProvided { 354 | access_token: &acess_token, 355 | client_id: &user_id, 356 | }, 357 | &mut storage, 358 | ) 359 | .expect("should not fail"); 360 | 361 | let secrets = storage.secrets_stored.read().unwrap(); 362 | 363 | let (secret_name, token) = secrets.get_key_value("anilist_access_token").unwrap(); 364 | 365 | assert_eq!("anilist_access_token", secret_name); 366 | assert_eq!(acess_token, *token); 367 | 368 | let (secret_name, value) = secrets.get_key_value("anilist_client_id").unwrap(); 369 | 370 | assert_eq!("anilist_client_id", secret_name); 371 | assert_eq!(user_id.parse::().unwrap(), value.parse::().unwrap()); 372 | } 373 | 374 | #[derive(Debug)] 375 | struct AnilistCheckerTest { 376 | should_fail: bool, 377 | invalid_token: bool, 378 | } 379 | 380 | impl AnilistCheckerTest { 381 | fn succesful() -> Self { 382 | Self { 383 | should_fail: false, 384 | invalid_token: false, 385 | } 386 | } 387 | 388 | fn failing() -> Self { 389 | Self { 390 | should_fail: true, 391 | invalid_token: true, 392 | } 393 | } 394 | } 395 | impl AnilistTokenChecker for AnilistCheckerTest { 396 | async fn verify_token(&self, _token: String) -> Result> { 397 | if self.invalid_token { 398 | return Ok(false); 399 | } 400 | 401 | Ok(true) 402 | } 403 | } 404 | 405 | #[tokio::test] 406 | async fn it_checks_acess_token_is_valid() -> Result<(), Box> { 407 | let cli = CliArgs::new(); 408 | 409 | let anilist_checker = AnilistCheckerTest::succesful(); 410 | 411 | let token_is_valid = cli.check_anilist_token(&anilist_checker, "some_token".to_string()).await?; 412 | 413 | assert!(token_is_valid); 414 | 415 | let anilist_checker = AnilistCheckerTest::failing(); 416 | 417 | let token_is_valid = cli.check_anilist_token(&anilist_checker, "some_token".to_string()).await?; 418 | 419 | assert!(!token_is_valid); 420 | Ok(()) 421 | } 422 | 423 | #[test] 424 | fn it_check_anilist_credentials_are_stored() -> Result<(), Box> { 425 | let expected_credentials = [ 426 | ("anilist_client_id".to_string(), "some_id".to_string()), 427 | ("anilist_access_token".to_string(), "some_token".to_string()), 428 | ]; 429 | 430 | let storage = MockStorage::with_data(HashMap::from(expected_credentials)); 431 | 432 | let credentials = 433 | check_anilist_credentials_are_stored(storage)?.expect("anilist credentials which should be stored actually aren't"); 434 | 435 | assert_eq!("some_id", credentials.client_id); 436 | assert_eq!("some_token", credentials.access_token); 437 | 438 | Ok(()) 439 | } 440 | } 441 | -------------------------------------------------------------------------------- /src/common.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fmt::Display; 3 | 4 | use ratatui::layout::Rect; 5 | use ratatui_image::protocol::Protocol; 6 | 7 | #[derive(Default)] 8 | pub struct ImageState { 9 | /// save the image loaded for a manga, it will be retrieved by it's id 10 | image_state: HashMap>, 11 | img_area: Rect, 12 | } 13 | 14 | impl ImageState { 15 | pub fn insert_manga(&mut self, fixed_protocol: Box, id_manga: String) { 16 | self.image_state.insert(id_manga, fixed_protocol); 17 | } 18 | 19 | pub fn get_img_area(&self) -> Rect { 20 | self.img_area 21 | } 22 | 23 | /// After a manga is rendered it will be know what area the covers fits into 24 | pub fn set_area(&mut self, area: Rect) { 25 | self.img_area = area; 26 | } 27 | 28 | /// get the image cover state given the manga id 29 | pub fn get_image_state(&mut self, id: &str) -> Option<&mut Box> { 30 | self.image_state.get_mut(id) 31 | } 32 | 33 | pub fn is_empty(&self) -> bool { 34 | self.image_state.is_empty() 35 | } 36 | } 37 | 38 | pub fn format_error_message_tracking_reading_history( 39 | chapter: A, 40 | manga_title: B, 41 | error: C, 42 | ) -> String { 43 | format!( 44 | "Could not track reading progress of chapter : {chapter} \n of manga : {manga_title}, more details about the error : \n ERROR | {error}" 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/global.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | use once_cell::sync::{Lazy, OnceCell}; 4 | use ratatui::style::{Style, Stylize}; 5 | 6 | use crate::backend::manga_provider::Languages; 7 | 8 | pub static PREFERRED_LANGUAGE: OnceCell = OnceCell::new(); 9 | 10 | pub static INSTRUCTIONS_STYLE: Lazy