inital commit (v0.3)

This commit is contained in:
lamacchinadesiderante 2023-01-07 21:40:21 +01:00
commit 26092fdc35
96 changed files with 18775 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
index
.github
target
*.csv
.git
.vscode/*
.idea
.DS_Store

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
/target
/index
*.csv
/release
/zlib-searcher
.vscode
index_0.6.zip

4765
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

33
Cargo.toml Normal file
View File

@ -0,0 +1,33 @@
[workspace]
members = [
"crates/zlib-searcher",
"crates/zlib-searcher-core",
"crates/zlib-searcher-desktop",
]
[workspace.package]
edition = "2021"
authors = ["zu1k <i@zu1k.com>"]
description = "search z-library index."
homepage = "https://github.com/zlib-searcher/zlib-searcher"
repository = "https://github.com/zlib-searcher/zlib-searcher"
license = "MIT"
exclude = [".github/", "index/", "frontend/"]
[profile.release]
strip = true
lto = true
opt-level = 3
codegen-units = 1
[workspace.dependencies]
anyhow = "1.0"
env_logger = "0.10"
log = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_with = "2.0"
zlib-searcher-core = { path = "crates/zlib-searcher-core" }

10
Cross.yaml Normal file
View File

@ -0,0 +1,10 @@
[build.env]
passthrough = [
"RUSTFLAGS"
]
[target.mips-unknown-linux-musl]
image = "rustembedded/cross:mips-unknown-linux-musl-0.2.1"
[target.mipsel-unknown-linux-musl]
image = "rustembedded/cross:mipsel-unknown-linux-musl-0.2.1"

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:19-bullseye as frontend
COPY . /source
RUN cd /source/frontend && npm install && npm run build
FROM rust:1.65-buster as backend
COPY . /source
COPY --from=frontend /source/frontend/dist /source/frontend/dist
RUN cd /source && cargo build --release -p zlib-searcher
FROM ubuntu:22.04
COPY --from=backend /source/target/release/zlib-searcher /zlib-searcher
CMD ["/zlib-searcher", "run", "-b", "0.0.0.0:7070"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 zlib-searcher's authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

27
Makefile Normal file
View File

@ -0,0 +1,27 @@
NAME=zlib-searcher
PREFIX ?= /usr/local/bin
TARGET ?= debug
.PHONY: all frontend_preinstall frontend build clean
all: build
frontend_preinstall:
pnpm -C frontend install
frontend:
pnpm -C frontend run build
build: frontend
ifeq (${TARGET}, release)
cargo build -p zlib-searcher --release
else
cargo build -p zlib-searcher
endif
clean:
cargo clean
rm -rf release
releases:
cd scripts && ./build_release.sh -a a

131
README.md Normal file
View File

@ -0,0 +1,131 @@
# Millelibri project
This is a fork from zlib-searcher project. Future goals:
- improve search indexes (language)
- add books
- expand file types
# zlib(libgen) searcher
[![GitHub stars](https://img.shields.io/github/stars/zlib-searcher/zlib-searcher)](https://github.com/zlib-searcher/zlib-searcher/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/zlib-searcher/zlib-searcher)](https://github.com/zlib-searcher/zlib-searcher/network)
[![Release](https://img.shields.io/github/release/zlib-searcher/zlib-searcher)](https://github.com/zlib-searcher/zlib-searcher/releases)
[![GitHub issues](https://img.shields.io/github/issues/zlib-searcher/zlib-searcher)](https://github.com/zlib-searcher/zlib-searcher/issues)
[![GitHub license](https://img.shields.io/github/license/zlib-searcher/zlib-searcher)](https://github.com/zlib-searcher/zlib-searcher/blob/master/LICENSE)
Search `zlib`/`libgen` index to get `ipfs_cid`.
We don't save and provide files, we provide search.
I hope everyone have a copy of the index locally, so that no need to rely on any centralized service.
## Deploy with Docker
```
git clone https://github.com/zlib-searcher/zlib-searcher.git && cd zlib-searcher
wget https://github.com/zlib-searcher/zlib-searcher/releases/download/0.6.0/index_0.6.zip && unzip index_0.6.zip
docker-compose up -d
```
Now `zlib-searcher` it will listen to `0.0.0.0:7070`.
## Usage
### 1. Download the pre-compiled binary from [Release](https://github.com/zlib-searcher/zlib-searcher/releases).
Or you can compile by yourself. Refer to [Build from source](#build-from-source) for instructions.
### 2. Download the `index` file that has been created.
We will give the corresponding `index` download links for each version in the release page.
Or you can make your own via `zlib-searcher index`.
Extract the `index` folder to the same level as the program, it should look like the following:
```
zlib_searcher_dir
├── index
│   ├── some index files...
│   └── meta.json
└── zlib-searcher
```
### 3. Run `zlib-searcher run`, it will listen to `127.0.0.1:7070`.
Access http://127.0.0.1:7070/ to use webui, or you can use the original api.
#### original search api
You can search by the following fields:
- title
- author
- publisher
- extension
- language
- isbn
- zlib_id
Examples:
- `http://127.0.0.1:7070/search?limit=30&query=余华`
- `http://127.0.0.1:7070/search?limit=30&query=title:机器学习 extension:azw3 publisher:清华`
- `http://127.0.0.1:7070/search?limit=30&query=zlib_id:18557063`
- `http://127.0.0.1:7070/search?limit=30&query=isbn:9787302423287`
## Build from source
### 1. Build `zlib-searcher`
First build frontend
```bash
make frontend_preinstall frontend
```
Then build zlib-searcher
```bash
TARGET=release make
# move the compiled binary to the project root directory
mv target/release/zlib-searcher .
```
### 2. Build `index`
Download `zlib_index_books.csv.zip` and `libgen_index_books.csv.zip` and extract the `csv` files to the project root directory.
Then run `zlib-searcher index`. You may need to `rm index/*` first.
If you have other csv files, you can run `zlib-searcher index -f *.csv` to index them.
The finally folder structure should look like this:
```
zlib_searcher_dir // in the example above, it is project root directory.
├── index
│   ├── some index files...
│   └── meta.json
└── zlib-searcher
```
## Raw data
We downloaded `libgen` sql and `zlib` sql and exported the necessary data from them.
```
id, title, author, publisher, extension, filesize, language, year, pages, isbn, ipfs_cid
```
This raw data is used to generate our `index`, you can download the raw data from here:
- [zlib_index_books.csv.zip](https://github.com/zlib-searcher/zlib-searcher/releases/download/0.4.0/zlib_index_books.csv.zip)
- [libgen_index_books.csv.zip](https://github.com/zlib-searcher/zlib-searcher/releases/download/0.4.0/libgen_index_books.csv.zip)
## License
**zlib-searcher** © [zlib-searcher's authors](https://github.com/zlib-searcher/zlib-searcher/graphs/contributors), Released under the [MIT](./LICENSE) License.

View File

@ -0,0 +1,31 @@
[package]
name = "zlib-searcher-core"
version = "0.7.0"
edition.workspace = true
authors.workspace = true
description.workspace = true
homepage.workspace = true
repository.workspace = true
license.workspace = true
[dependencies]
anyhow = { workspace = true }
env_logger = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_with = { workspace = true }
tantivy = { version = "0.18", default-features = false, features = ["mmap"] }
cang-jie = "0.14"
jieba-rs = { version = "0.6", features = ["default-dict"] }
csv = "1.1"
indicatif = "0.17"
sysinfo = { version = "0.27", default-features = false }
[features]
default = ["best-size"]
best-size = ["tantivy/brotli-compression"]
best-speed = ["tantivy/lz4-compression"]

View File

@ -0,0 +1,101 @@
use crate::{Book, Searcher};
use indicatif::{ProgressBar, ProgressIterator, ProgressStyle};
use log::info;
use std::{
fs::File,
io::{BufRead, BufReader},
path::Path,
};
use sysinfo::{System, SystemExt};
use tantivy::doc;
fn get_memory_arena_num_bytes() -> usize {
let sys = System::new_all();
let available_memory = sys.available_memory() as usize;
let cpu_num = sys.cpus().len();
info!("Your system has cpu {cpu_num} cores and {available_memory} Bytes available");
let chunk_size = 1024 * 1024 * 1024; // 1GB
let total_num_chunk = available_memory / chunk_size;
let s = if total_num_chunk < 2 {
// <2G
available_memory - 100 * 1024 * 1024 // available_memory-100MB
} else {
// >2G
available_memory * (total_num_chunk - 1) // available_memory-1GB
};
let num_threads = std::cmp::min(cpu_num, 8);
let s = std::cmp::min(s, num_threads * 4293967294);
info!("Using {num_threads} threads and {s} Bytes to do index");
s
}
impl Searcher {
pub fn index(&mut self, csv_file: impl AsRef<Path>) {
let mut writer = self.index.writer(get_memory_arena_num_bytes()).unwrap();
let file = File::open(&csv_file).unwrap();
let reader = BufReader::new(file);
let mut rdr = csv::ReaderBuilder::new()
.has_headers(false)
.from_reader(reader);
let line_count = BufReader::new(File::open(&csv_file).unwrap())
.lines()
.count();
let style = ProgressStyle::default_bar()
.template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}")
.unwrap();
let bar = ProgressBar::new(line_count as u64)
.with_message(format!("Indexing {}", csv_file.as_ref().to_str().unwrap()))
.with_style(style);
for result in rdr.deserialize::<Book>().progress_with(bar) {
match result {
Ok(item) => {
if let Err(err) = writer.add_document(doc!(
self.id => item.id,
self.title => item.title,
self.author => item.author,
self.publisher => item.publisher,
self.extension => item.extension,
self.filesize => item.filesize,
self.language => item.language,
self.year => item.year,
self.pages => item.pages,
self.isbn => item.isbn,
self.ipfs_cid => item.ipfs_cid,
)) {
println!("{err}");
}
}
Err(err) => {
println!("{err}");
}
}
}
writer.commit().unwrap();
writer.wait_merging_threads().expect("merge complete");
}
}
#[test]
fn test_csv_der() {
let file = File::open("zlib_index_books.csv").unwrap();
let reader = BufReader::new(file);
let mut rdr = csv::ReaderBuilder::new()
.has_headers(false)
.from_reader(reader);
for result in rdr.records() {
if let Err(err) = result {
println!("{err:?}");
break;
}
}
println!("{:?}", rdr.position());
}

View File

@ -0,0 +1,152 @@
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DefaultOnError, DefaultOnNull};
use tantivy::{schema::*, store::Compressor, Index};
use tokenizer::{get_tokenizer, META_DATA_TOKENIZER};
pub mod index;
pub mod search;
mod tokenizer;
#[serde_as]
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Book {
pub id: u64,
pub title: String,
#[serde_as(deserialize_as = "DefaultOnNull")]
pub author: String,
#[serde_as(deserialize_as = "DefaultOnNull")]
pub publisher: String,
#[serde_as(deserialize_as = "DefaultOnNull")]
pub extension: String,
#[serde_as(deserialize_as = "DefaultOnError")]
pub filesize: u64,
#[serde_as(deserialize_as = "DefaultOnNull")]
pub language: String,
#[serde_as(deserialize_as = "DefaultOnError")]
pub year: u64,
#[serde_as(deserialize_as = "DefaultOnError")]
pub pages: u64,
#[serde_as(deserialize_as = "DefaultOnNull")]
pub isbn: String,
#[serde_as(deserialize_as = "DefaultOnNull")]
pub ipfs_cid: String,
}
impl From<(&Schema, Document)> for Book {
fn from((schema, doc): (&Schema, Document)) -> Self {
macro_rules! get_field_text {
($field:expr) => {
doc.get_first(schema.get_field($field).unwrap())
.unwrap()
.as_text()
.unwrap_or_default()
.to_owned()
};
}
macro_rules! get_field_u64 {
($field:expr) => {
doc.get_first(schema.get_field($field).unwrap())
.unwrap()
.as_u64()
.unwrap_or_default()
};
}
Book {
id: get_field_u64!("id"),
title: get_field_text!("title"),
author: get_field_text!("author"),
publisher: get_field_text!("publisher"),
extension: get_field_text!("extension"),
filesize: get_field_u64!("filesize"),
language: get_field_text!("language"),
year: get_field_u64!("year"),
pages: get_field_u64!("pages"),
isbn: get_field_text!("isbn"),
ipfs_cid: get_field_text!("ipfs_cid"),
}
}
}
pub struct Searcher {
index: Index,
schema: Schema,
// fields
id: Field,
title: Field,
author: Field,
publisher: Field,
extension: Field,
filesize: Field,
language: Field,
year: Field,
pages: Field,
isbn: Field,
ipfs_cid: Field,
}
impl Searcher {
pub fn new(index_dir: impl AsRef<Path>) -> Self {
let text_indexing = TextFieldIndexing::default()
.set_tokenizer(META_DATA_TOKENIZER)
.set_index_option(IndexRecordOption::WithFreqsAndPositions);
let text_options = TextOptions::default()
.set_indexing_options(text_indexing)
.set_stored();
let mut schema_builder = Schema::builder();
let id = schema_builder.add_u64_field("id", INDEXED | STORED);
let title = schema_builder.add_text_field("title", text_options.clone());
let author = schema_builder.add_text_field("author", text_options.clone());
let publisher = schema_builder.add_text_field("publisher", text_options);
let extension = schema_builder.add_text_field("extension", STRING | STORED);
let filesize = schema_builder.add_u64_field("filesize", STORED);
let language = schema_builder.add_text_field("language", TEXT | STORED);
let year = schema_builder.add_u64_field("year", STORED);
let pages = schema_builder.add_u64_field("pages", STORED);
let isbn = schema_builder.add_text_field("isbn", TEXT | STORED);
let ipfs_cid = schema_builder.add_text_field("ipfs_cid", STORED);
let schema = schema_builder.build();
// open or create index
let index_dir = index_dir.as_ref();
let mut index = Index::open_in_dir(index_dir).unwrap_or_else(|_| {
std::fs::create_dir_all(index_dir).expect("create index directory");
Index::create_in_dir(index_dir, schema.clone()).unwrap()
});
#[cfg(feature = "best-size")]
{
index.settings_mut().docstore_compression = Compressor::Brotli; // size: 2.1G, size is best
}
#[cfg(feature = "best-speed")]
{
index.settings_mut().docstore_compression = Compressor::Lz4; // size: 3.1G, speed is best
}
index
.tokenizers()
.register(META_DATA_TOKENIZER, get_tokenizer());
_ = index.set_default_multithread_executor();
Self {
index,
schema,
id,
title,
author,
publisher,
extension,
filesize,
language,
year,
pages,
isbn,
ipfs_cid,
}
}
}

View File

@ -0,0 +1,29 @@
use crate::{Book, Searcher};
use tantivy::{collector::TopDocs, query::QueryParser};
impl Searcher {
pub fn search(&self, query: &str, limit: usize) -> Vec<Book> {
let reader = self.index.reader().unwrap();
let searcher = reader.searcher();
let mut query_parser = QueryParser::for_index(
&self.index,
vec![self.title, self.author, self.publisher, self.isbn],
);
query_parser.set_conjunction_by_default();
let query = query_parser.parse_query(query).unwrap();
let top_docs = searcher
.search(&query, &TopDocs::with_limit(limit))
.unwrap();
top_docs
.iter()
.map(|d| {
let doc = searcher.doc(d.1).unwrap();
let item: Book = (&self.schema, doc).into();
item
})
.collect()
}
}

View File

@ -0,0 +1,18 @@
use std::sync::Arc;
use cang_jie::{CangJieTokenizer, TokenizerOption};
use jieba_rs::Jieba;
use tantivy::tokenizer::{AsciiFoldingFilter, LowerCaser, RemoveLongFilter, TextAnalyzer};
pub const META_DATA_TOKENIZER: &str = "meta_data_tokenizer";
pub fn get_tokenizer() -> TextAnalyzer {
let cangjie = CangJieTokenizer {
worker: Arc::new(Jieba::new()),
option: TokenizerOption::ForSearch { hmm: false },
};
TextAnalyzer::from(cangjie)
.filter(RemoveLongFilter::limit(20))
.filter(AsciiFoldingFilter)
.filter(LowerCaser)
}

View File

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/index/

View File

@ -0,0 +1,38 @@
[package]
name = "zlib-searcher-desktop"
version = "0.7.0"
edition = "2021"
authors = ["Wybxc <wybxc@qq.com>", "zu1k <i@zu1k.com>"]
description = "search z-library index."
homepage = "https://github.com/zlib-searcher/zlib-searcher"
repository = "https://github.com/zlib-searcher/zlib-searcher"
license = "MIT"
[build-dependencies]
tauri-build = { version = "1.2.1", features = [] }
[dependencies]
zlib-searcher-core = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }
env_logger = { workspace = true }
log = { workspace = true }
serde_json = "1.0"
tauri = { version = "1.2.1", features = ["dialog-open", "shell-open"] }
tokio = { version = "1", features = ["sync", "parking_lot"] }
confy = "0.5"
dunce = "1.0"
[features]
# by default Tauri runs in production mode
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
default = ["custom-protocol"]
# this feature is used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -0,0 +1,121 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use log::info;
use std::{error::Error, path::PathBuf};
use zlib_searcher_core::{Book, Searcher};
const VERSION: &str = env!("CARGO_PKG_VERSION");
use serde::{Deserialize, Serialize};
use tauri::State;
use tokio::sync::Mutex;
#[derive(Clone, Debug, Serialize, Deserialize)]
struct AppConfig {
pub index_dir: PathBuf,
pub ipfs_gateways: Vec<String>,
}
fn get_dir(name: &str) -> Option<PathBuf> {
let dir = std::env::current_exe().ok()?.parent()?.join(name);
std::fs::create_dir_all(&dir).ok()?;
let dir = dunce::canonicalize(dir).ok()?;
Some(dir)
}
impl Default for AppConfig {
fn default() -> Self {
let index_dir = get_dir("index").unwrap_or_else(|| PathBuf::from("index"));
Self {
index_dir,
ipfs_gateways: vec![],
}
}
}
impl AppConfig {
const APP_NAME: &'static str = "zlib-searcher-desktop";
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
let config = confy::load(Self::APP_NAME, None)?;
Ok(config)
}
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
confy::store(Self::APP_NAME, None, self)?;
Ok(())
}
pub fn configuration_file_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(confy::get_configuration_file_path(Self::APP_NAME, None)?)
}
}
#[tauri::command]
async fn get_config(config: State<'_, Mutex<AppConfig>>) -> Result<AppConfig, String> {
Ok(config.lock().await.clone())
}
#[tauri::command]
async fn set_config(
new_config: AppConfig,
config: State<'_, Mutex<AppConfig>>,
searcher: tauri::State<'_, Mutex<Searcher>>,
) -> Result<(), String> {
let mut config = config.lock().await;
// reload searcher if index_dir changed
if config.index_dir != new_config.index_dir {
info!("index_dir changed, reloading searcher");
let mut searcher = searcher.lock().await;
*searcher = Searcher::new(new_config.index_dir.clone());
}
*config = new_config;
config.save().map_err(|e| e.to_string())?;
info!("Config saved: {:?}", config);
Ok(())
}
#[tauri::command]
async fn search(
searcher: tauri::State<'_, Mutex<Searcher>>,
query: String,
limit: usize,
) -> Result<Vec<Book>, ()> {
info!("Search: {}", query);
Ok(searcher.lock().await.search(&query, limit))
}
#[tauri::command]
fn version() -> String {
VERSION.to_string()
}
fn main() -> Result<(), Box<dyn Error>> {
env_logger::init();
let config = AppConfig::load()?;
let searcher = Mutex::new(Searcher::new(&config.index_dir));
let config = Mutex::new(config);
info!(
"load config from {:?}",
AppConfig::configuration_file_path()?
);
tauri::Builder::default()
.manage(config)
.manage(searcher)
.invoke_handler(tauri::generate_handler![
version, search, get_config, set_config
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Ok(())
}

View File

@ -0,0 +1,71 @@
{
"build": {
"beforeBuildCommand": "cd ../frontend && pnpm run build",
"beforeDevCommand": "cd ../frontend && pnpm run dev",
"devPath": "http://localhost:5173/",
"distDir": "../../frontend/dist"
},
"package": {
"productName": "zLib Searcher",
"version": "0.1.0"
},
"tauri": {
"allowlist": {
"all": false,
"shell": {
"open": true
},
"dialog": {
"open": true
}
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"deb": {
"depends": []
},
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "com.github.zlib-searcher",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"security": {
"csp": null
},
"updater": {
"active": false
},
"windows": [
{
"fullscreen": false,
"height": 900,
"resizable": true,
"title": "zLib Searcher",
"width": 1500
}
]
}
}

View File

@ -0,0 +1,34 @@
[package]
name = "zlib-searcher"
version = "0.7.0"
edition.workspace = true
authors.workspace = true
description.workspace = true
homepage.workspace = true
repository.workspace = true
license.workspace = true
default-run = "zlib-searcher"
[dependencies]
zlib-searcher-core = { workspace = true }
anyhow = { workspace = true }
env_logger = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_with = { workspace = true }
actix-web = "4"
actix-web-static-files = "4.0"
static-files = "0.2"
csv = "1.1"
clap = { version = "4", features = ["derive"] }
[build-dependencies]
static-files = "0.2"
[features]
default = ["best-size"]
best-size = ["zlib-searcher-core/best-size"]
best-speed = ["zlib-searcher-core/best-speed"]

View File

@ -0,0 +1,6 @@
use static_files::resource_dir;
fn main() -> std::io::Result<()> {
println!("cargo:rerun-if-changed=../../frontend/dist");
resource_dir("../../frontend/dist").build()
}

View File

@ -0,0 +1,61 @@
use std::{fs::File, io::BufReader};
use zlib_searcher_core::Book;
fn main() {
let mut writer = csv::Writer::from_path("zlib_libgen_chinese_books.csv").unwrap();
let mut filter_csv = |path: &str| {
let file = File::open(path).unwrap();
let reader = BufReader::new(file);
let mut rdr = csv::ReaderBuilder::new()
.has_headers(false)
.from_reader(reader);
for result in rdr.deserialize::<Book>() {
match result {
Ok(ref book) => {
if is_chinese_title(book) {
if let Err(err) = writer.serialize(book) {
println!("err: {err}");
}
}
}
Err(err) => {
println!("{err}");
}
}
}
println!("{:?}", rdr.position());
};
filter_csv("zlib_index_books.csv");
filter_csv("libgen_index_books.csv");
}
fn is_chinese_title(book: &Book) -> bool {
let chinese_char_count = book.title.matches(is_chinese_char).count();
chinese_char_count as f32 / book.title.len() as f32 > 0.3
}
#[inline(always)]
const fn is_chinese_char(c: char) -> bool {
matches!(c as u32,
0x4E00..=0x9FA5 |
0x9FA6..=0x9FFF |
0x3400..=0x4DB5 |
0x20000..=0x2A6D6 |
0x2A700..=0x2B734 |
0x2B740..=0x2B81D |
0x2F00..=0x2FD5 |
0x2E80..=0x2EF3 |
0xF900..=0xFAD9 |
0x2F800..=0x2FA1D |
0xE815..=0xE86F |
0xE400..=0xE5E8 |
0xE600..=0xE6CF |
0x31C0..=0x31E3 |
0x2FF0..=0x2FFB |
0x3105..=0x3120 |
0x31A0..=0x31BA
)
}

View File

@ -0,0 +1,141 @@
use actix_web::{
get, http::header, middleware::Logger, web, App, HttpResponse, HttpServer, Responder,
};
use actix_web_static_files::ResourceFiles;
use clap::Parser;
use log::{info, LevelFilter};
use serde::{Deserialize, Serialize};
use std::{path::PathBuf, sync::Arc};
use zlib_searcher_core::{Book, Searcher};
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
#[derive(Clone)]
struct AppState {
searcher: Arc<Searcher>,
}
impl AppState {
pub fn init(index_dir: &str) -> Self {
info!("AppState init!");
AppState {
searcher: Arc::new(Searcher::new(index_dir)),
}
}
}
fn default_limit() -> usize {
30
}
#[derive(Deserialize)]
struct SearchQuery {
query: String,
#[serde(default = "default_limit")]
limit: usize,
}
#[derive(Serialize)]
struct SearchResult {
books: Vec<Book>,
}
#[get("/search")]
async fn search(query: web::Query<SearchQuery>, state: web::Data<AppState>) -> impl Responder {
let books = state.searcher.search(&query.query, query.limit);
let result = SearchResult { books };
return HttpResponse::Ok()
.insert_header(header::ContentType::json())
.insert_header((header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"))
.json(result);
}
#[derive(Parser)]
#[clap(author, version, about, long_about)]
struct AppOpts {
#[clap(subcommand)]
subcmd: SubCommand,
}
#[derive(Parser)]
enum SubCommand {
/// run search webserver
Run(Run),
/// index the raw data
Index(Index),
}
#[derive(Parser)]
struct Run {
#[clap(
short,
long,
default_value = "127.0.0.1:7070",
help = "webserver bind address"
)]
bind: String,
}
#[derive(Parser)]
struct Index {
#[clap(short, long, num_args=1.., help = "specify csv file to be indexed")]
file: Vec<PathBuf>,
}
fn main() {
env_logger::builder().filter_level(LevelFilter::Info).init();
let args = AppOpts::parse();
match args.subcmd {
SubCommand::Run(opts) => run(opts).unwrap(),
SubCommand::Index(opts) => index(opts),
}
}
#[actix_web::main]
async fn run(opts: Run) -> std::io::Result<()> {
info!("zlib-searcher webserver started!");
let index_dir = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.join("index")
.to_str()
.unwrap()
.to_string();
let app_state = AppState::init(&index_dir);
HttpServer::new(move || {
let generated = generate();
App::new()
.wrap(Logger::default())
.app_data(web::Data::new(app_state.clone()))
.service(search)
.service(ResourceFiles::new("/", generated))
})
.bind(opts.bind)?
.run()
.await
}
fn index(opts: Index) {
let index_dir = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.join("index")
.to_str()
.unwrap()
.to_string();
let mut searcher = Searcher::new(&index_dir);
if opts.file.is_empty() {
vec!["zlib_index_books.csv", "libgen_index_books.csv"]
.iter()
.for_each(|file| searcher.index(file));
} else {
opts.file.iter().for_each(|file| searcher.index(file));
}
}

21
docker-compose.yml Normal file
View File

@ -0,0 +1,21 @@
version: '3'
services:
zlib:
image: lamacchinadesiderante/millelibri:latest
# image: millelibri:v0.2
# image: millelibri
# build:
# context: .
# dockerfile: ./Dockerfile
restart: always
ports:
- "7070:7070"
volumes:
- ./index:/index

View File

@ -0,0 +1,54 @@
import time
import pandas as pd
import hanlp
import torch
import random
import os
import numpy as np
from tqdm import tqdm
def seed_everything(seed=2022):
'''
设置整个开发环境的seed
'''
random.seed(seed)
os.environ['PYTHONHASHSEED'] = str(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
# some cudnn methods can be random even after fixing the seed
# unless you tell it to be deterministic
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
seed_everything()
"""
专业级本地模型
"""
def Pro_tokenize():
mul_tokenizer = hanlp.load(hanlp.pretrained.tok.UD_TOK_MMINILMV2L12)
df_zlib = pd.read_csv('zlib_index_books.csv', header=None)
df_title = df_zlib.iloc[:, :2].astype(str)
df_title.columns = ["id", "title"]
df_title["title_token"] = None
print("分词中……\n")
# df_title = df_title.head()
total = len(df_title)
for i in tqdm(range(total)):
try:
title = df_title["title"][i]
batch_token_lis = mul_tokenizer(title)
df_title["title_token"][i] = batch_token_lis
except Exception as e:
print(e)
print(len(df_title))
df_title.to_csv("title_token.csv")
print("分词结果保存完成……")
if __name__ == '__main__':
Pro_tokenize()

4
frontend/.dockerignore Normal file
View File

@ -0,0 +1,4 @@
*.log
node_modules
dist
dist-ssr

View File

@ -0,0 +1,2 @@
# .env.production
VITE_BACKEND_BASE_API = 'http://127.0.0.1:7070/'

2
frontend/.env.production Normal file
View File

@ -0,0 +1,2 @@
# .env.production
VITE_BACKEND_BASE_API = ''

26
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
components.d.ts

View File

@ -0,0 +1,4 @@
singleQuote: true
semi: true
printWidth: 100
trailingComma: none

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Millelibri / zlib searcher</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5490
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
frontend/package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "frontend",
"private": true,
"version": "0.2.0",
"type": "module",
"repository": "https://github.com/lamacchinadesiderante/millelibri",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@chakra-ui/react": "^2.4.6",
"@chakra-ui/skip-nav": "^2.0.13",
"@chakra-ui/system": "^2.3.7",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@tanstack/react-table": "^8.7.4",
"@tanstack/table-core": "^8.7.4",
"@tauri-apps/api": "^1.2.0",
"ahooks": "^3.7.4",
"axios": "^1.2.2",
"filesize": "^10.0.6",
"framer-motion": "^7.10.3",
"i18next": "^22.4.6",
"i18next-browser-languagedetector": "^7.0.1",
"lodash": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.41.3",
"react-i18next": "^12.1.1",
"react-icons": "^4.7.1",
"react-intersection-observer": "^9.4.1",
"react-responsive": "^9.0.2"
},
"devDependencies": {
"@babel/core": "^7.20.7",
"@darkobits/vite-plugin-favicons": "^0.1.8",
"@types/lodash": "^4.14.191",
"@types/node": "^18.11.18",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@vitejs/plugin-react": "^2.2.0",
"prettier": "^2.8.1",
"typescript": "^4.9.4",
"vite": "^3.2.5",
"vite-plugin-top-level-await": "^1.2.2"
}
}

5060
frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

67
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,67 @@
import { Flex, HStack, Icon, IconButton, Spacer } from '@chakra-ui/react';
import React, { Suspense, useState } from 'react';
import { SkipNavContent, SkipNavLink } from '@chakra-ui/skip-nav';
import { Book } from './scripts/searcher';
import BooksView from './components/BooksView';
import ColorModeSwitch from './components/ColorModeSwitch';
import ExternalLink from './components/ExternalLink';
import { FaGithub } from 'react-icons/fa';
import Footer from './components/Footer';
import Header from './components/Header';
import LanguageSwitch from './components/LanguageSwitch';
import Search from './components/Search';
import { repository } from '../package.json';
import { useTranslation } from 'react-i18next';
const Main: React.FC = () => {
const [books, setBooks] = useState<Book[]>([]);
return (
<>
<SkipNavContent />
<Search setBooks={setBooks} />
<BooksView books={books} />
</>
);
};
const Settings =
import.meta.env.VITE_TAURI === '1'
? React.lazy(() => import('./components/Settings-tauri'))
: React.lazy(() => import('./components/Settings'));
const App: React.FC = () => {
const { t } = useTranslation();
return (
<Flex direction="column" minH="100vh">
<SkipNavLink>Skip to content</SkipNavLink>
<Header title="Millelibri">
<HStack spacing={{ base: 1, md: 2 }}>
<IconButton
as={ExternalLink}
aria-label={t('nav.repository')}
title={t('nav.repository') ?? ''}
href={repository}
variant="ghost"
icon={<Icon as={FaGithub} boxSize={5} />}
/>
<LanguageSwitch />
<ColorModeSwitch />
<Suspense>
<Settings />
</Suspense>
</HStack>
</Header>
<Main />
<Spacer />
<Footer>
<a href='https://www.lamacchinadesiderante.org'>lamacchinadesiderante.org</a>
</Footer>
</Flex>
);
};
export default App;

View File

@ -0,0 +1,124 @@
import { Card, CardHeader, Heading, Divider, CardBody, CardFooter, GridItem, SimpleGrid, Text, Button, Flex, Icon } from '@chakra-ui/react';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { ipfsGateways } from '../scripts/ipfs';
import { Book, Row } from '../scripts/searcher';
import { filesize as formatFileSize } from 'filesize';
import { TbChevronUp } from 'react-icons/tb';
import ExternalLink from './ExternalLink';
import Description from './Description';
interface IProps {
row: Row
}
const BookDetailsCard: React.FC<IProps> = (props) => {
// const downloadLinkFromIPFS = (gateway: string, book: Book) => {
// return (
// `https://${gateway}/ipfs/${book.ipfs_cid}?filename=` +
// encodeURIComponent(`${book.title}_${book.author}.${book.extension}`)
// );
// }
const { t } = useTranslation();
const { row } = props
const {
id,
title,
author,
publisher,
extension,
filesize,
language,
year,
pages,
isbn,
ipfs_cid
} = row.original;
return (
<Card mt={{ base: 1, md: 2 }} mb={{ base: 2, md: 4 }} mx={{ base: 4, md: 8 }}>
<CardHeader>
<Heading as="h3" fontSize="xl">
{title}
</Heading>
</CardHeader>
<Divider />
<CardBody>
<SimpleGrid columns={{ sm: 1, md: 3, lg: 4 }} spacing={{ base: 2, md: 4 }}>
<Description name={`${t('book.id') ?? 'zlib/libgen id'}: `}>{id}</Description>
<GridItem colSpan={{ sm: 1, md: 2, lg: 3 }}>
<Description name={`${t('book.ipfs_cid') ?? 'IPFS CID'}: `}>
{ipfs_cid}
</Description>
</GridItem>
<Description name={`${t('book.author') ?? 'Author'}: `}>{author}</Description>
<Description name={`${t('book.publisher') ?? 'Publisher'}: `}>
{publisher || t('book.unknown') || 'Unknown'}
</Description>
<Description name={`${t('book.extension') ?? 'Extension'}: `}>
{extension}
</Description>
<Description name={`${t('book.filesize') ?? 'Filesize'}: `}>
{formatFileSize(filesize) as string}
</Description>
<Description name={`${t('book.language') ?? 'Language'}: `}>
<Text as="span" textTransform="capitalize">
{language}
</Text>
</Description>
<Description name={`${t('book.year') ?? 'Year'}: `}>
{year || t('book.unknown') || 'Unknown'}
</Description>
<Description name={`${t('book.pages') ?? 'Pages'}: `}>
{pages || t('book.unknown') || 'Unknown'}
</Description>
<Description name={`${t('book.isbn') ?? 'ISBN'}: `}>
{isbn || t('book.unknown') || 'Unknown'}
</Description>
</SimpleGrid>
</CardBody>
<CardFooter flexDirection="column">
{/* <SimpleGrid columns={{ sm: 2, md: 3, lg: 4, xl: 5 }} spacing={{ base: 2, md: 4 }}>
{ipfsGateways.map((gateway) => (
<Button
as={ExternalLink}
href={downloadLinkFromIPFS(gateway, row.original)}
key={gateway}
variant="outline"
>
{gateway}
</Button>
))}
</SimpleGrid> */}
<Flex><Text fontWeight={'bold'}>{t('disclaimer.nolink_warning')}</Text></Flex>
<Flex justify="flex-end">
<Button
variant="unstyled"
onClick={() => row.toggleExpanded(false)}
color="gray.500"
mt={2}
mb={-2}
>
{t('table.collapse')}
<Icon as={TbChevronUp} boxSize={4} position="relative" top={0.5} left={1} />
</Button>
</Flex>
</CardFooter>
</Card>
);
};
export default BookDetailsCard;

View File

@ -0,0 +1,30 @@
import MediaQuery from 'react-responsive'
import { Book } from '../scripts/searcher';
import React from 'react';
import MobileDataList from './MobileDataList';
import DesktopDataList from './DesktopDataList';
import { MEDIA_QUERY_DESKTOP_STARTS, MEDIA_QUERY_MOBILE_ENDS } from '../constants/mediaquery';
export interface BooksViewProps {
books: Book[];
}
const BooksView: React.FC<BooksViewProps> = ({ books }) => {
return (
<>
<MediaQuery minWidth={MEDIA_QUERY_DESKTOP_STARTS}>
<DesktopDataList books={books} />
</MediaQuery>
<MediaQuery maxWidth={MEDIA_QUERY_MOBILE_ENDS}>
<MobileDataList books={books} />
</MediaQuery>
</>
);
};
export default BooksView;

View File

@ -0,0 +1,24 @@
import { Icon, IconButton, useColorMode } from '@chakra-ui/react';
import { TbMoon, TbSun } from 'react-icons/tb';
import React from 'react';
import { useTranslation } from 'react-i18next';
const ColorModeSwitch: React.FC = () => {
const { colorMode, toggleColorMode } = useColorMode();
const { t } = useTranslation();
return (
<IconButton
aria-label={colorMode === 'light' ? t('nav.toggle_dark') : t('nav.toggle_light')}
title={(colorMode === 'light' ? t('nav.toggle_dark') : t('nav.toggle_light')) ?? ''}
icon={
colorMode === 'light' ? <Icon as={TbSun} boxSize={5} /> : <Icon as={TbMoon} boxSize={5} />
}
onClick={toggleColorMode}
variant="ghost"
/>
);
};
export default ColorModeSwitch;

View File

@ -0,0 +1,70 @@
import React from 'react';
import MediaQuery from 'react-responsive'
import { Button, Card, Flex, Box } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { BookFilterElement } from '../constants/book';
import { MEDIA_QUERY_DESKTOP_STARTS, MEDIA_QUERY_MOBILE_ENDS } from '../constants/mediaquery';
interface IProps {
setSortingElement(element: string): void
activeFilter: string
currentDirection: string
elements: BookFilterElement[]
}
const DataFilter: React.FC<IProps> = (props) => {
const { setSortingElement, activeFilter, currentDirection, elements } = props
const { t } = useTranslation();
const getSortingArrow = (sortButton: string) => {
if (sortButton !== activeFilter) {
return ''
} else {
return currentDirection == 'asc' ? <span>&nbsp; &#8595;</span> : <span>&nbsp; &#8593;</span>
}
}
const getBox = (elem: BookFilterElement, key: number) => {
return (
<Box key={key} flex={elem.flex} p='4'>
<Button
size={'xs'}
justifyContent={'space-between'}
onClick={() => { elem.sortable && setSortingElement(elem.field) }}>
{t(`book.${elem.field}`)} {getSortingArrow(elem.field)}
</Button>
</Box>
)
}
const getFlexWrapper = () => {
return (
<Flex justifyContent={'space-between'}>
{elements.map((elem, key) => {
return getBox(elem, key)
})}
</Flex>
)
}
return (
<>
<MediaQuery minWidth={MEDIA_QUERY_DESKTOP_STARTS}>
<Card backgroundColor={'transparent'} mt={{ base: 0, md: 4 }} mb={{ base: 0, md: 4 }} mx={{ base: 4, md: 8 }}>
{getFlexWrapper()}
</Card>
</MediaQuery>
<MediaQuery maxWidth={MEDIA_QUERY_MOBILE_ENDS}>
{getFlexWrapper()}
</MediaQuery>
</>
);
}
export default DataFilter

View File

@ -0,0 +1,19 @@
import { Text } from '@chakra-ui/react';
interface DescriptionProps {
name: string;
children: React.ReactNode;
}
export const Description: React.FC<DescriptionProps> = ({ name, children }) => {
return (
<Text whiteSpace="normal" wordBreak="break-all">
<Text as="span" fontWeight="bold">
{name}
</Text>
<Text as="span">{children}</Text>
</Text>
);
};
export default Description;

View File

@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react';
import { bookDisplayDesktopElements } from '../constants/book';
import { MAX_RESULTS_PER_PAGE } from '../constants/pagination';
import { Book } from '../scripts/searcher';
import DataFilter from './DataFilter';
import DesktopDataListElement from './DesktopDataListElement';
import Paginator from './Paginator';
interface IProps {
books: Book[]
}
const DesktopDataList: React.FC<IProps> = (props) => {
const { books } = props;
const [sortBy, setSortBy] = useState<string>('title')
const [direction, setDirection] = useState<string>('asc')
const [currentIndex, setCurrentIndex] = useState<number>(0)
useEffect(() => {
setCurrentIndex(0)
}, [books])
const getMaxIndex = (): number => {
const maxIndex = String(books.length / MAX_RESULTS_PER_PAGE)
const res = books.length > MAX_RESULTS_PER_PAGE ? parseInt(maxIndex) : 1
return res
}
const setPreviousIndex = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
}
}
const setNextIndex = () => {
if (currentIndex < getMaxIndex()) {
setCurrentIndex(currentIndex + 1)
}
}
const getCurrentSliceStart = ():number => {
return currentIndex == 0 ? 0 : currentIndex * MAX_RESULTS_PER_PAGE
}
const getCurrentSliceEnd = ():number => {
const maxIndex = getMaxIndex()
return currentIndex == maxIndex ? books.length - 1 : (currentIndex + 1) * MAX_RESULTS_PER_PAGE
}
const handleSorting = (sortingElement: string) => {
if (sortBy == sortingElement) {
setDirection(direction == 'asc' ? 'desc' : 'asc')
} else {
setSortBy(sortingElement)
}
}
const sortBooksFunction = (a: any, b: any) => {
var nameA = a[sortBy].toString().toUpperCase(); // ignore upper and lowercase
var nameB = b[sortBy].toString().toUpperCase(); // ignore upper and lowercase
if (nameA < nameB) {
return -1; //nameA comes first
}
if (nameA > nameB) {
return 1; // nameB comes first
}
return 0; // names must be equal
}
const getSortedBooks = (): Book[] => {
return (direction == 'asc') ? books.sort(sortBooksFunction) : books.sort(sortBooksFunction).reverse()
}
return (
<>
{books.length > 0 &&
<DataFilter
elements={bookDisplayDesktopElements}
activeFilter={sortBy}
currentDirection={direction}
setSortingElement={handleSorting} />}
{getSortedBooks().slice(getCurrentSliceStart(), getCurrentSliceEnd()).map((book, key) => {
return <DesktopDataListElement key={key} book={book} />
})}
{books.length > 0 &&
<Paginator
maxIndex={getMaxIndex()}
setIndex={setCurrentIndex}
setPrevious={setPreviousIndex}
setNext={setNextIndex}
currentIndex={currentIndex} />}
</>
);
};
export default DesktopDataList;

View File

@ -0,0 +1,82 @@
import { Card, Flex, Box, Text, Tag } from '@chakra-ui/react';
import React, { useState } from 'react';
import { colorSchemes } from '../constants/color';
import { Book } from '../scripts/searcher';
import BookDetailsCard from './BooksDetailsCard';
import { filesize as formatFileSize } from 'filesize';
interface IProps {
book: Book
}
const DesktopDataListElement: React.FC<IProps> = (props) => {
const { book } = props
const languageColorScheme = colorSchemes[book.language.length % colorSchemes.length];
const extensionColorScheme = colorSchemes[book.extension.charCodeAt(0) % colorSchemes.length];
const [isOpen, setIsOpen] = useState<boolean>(false)
return (
<>
<div onClick={() => { setIsOpen(true) }}>
<Card cursor={'pointer'} backgroundColor={'transparent'} mt={{ base: 0, md: 2 }} mb={{ base: 2, md: 4 }} mx={{ base: 4, md: 8 }}>
<Flex justifyContent={'space-between'} alignItems={'center'}>
<Box flex={10} p='4'>
<Text marginBottom={1} fontSize="lg">
{book.title}
</Text>
</Box>
<Box flex={6} p='4'>
<Text marginBottom={1} color={'gray.600'} fontSize="xs">
{book.author}
</Text>
</Box>
<Box flex={4} p='4'>
<Text marginBottom={1} color={'gray.400'} fontSize="xs">
{book.publisher}
</Text>
</Box>
<Box flex={2} p='4'>
<Tag colorScheme={extensionColorScheme}>{book.extension}</Tag>
</Box>
<Box flex={3} p='4'>
<Text marginBottom={1} color={'gray.400'} fontSize="xs">
{formatFileSize(book.filesize) as string}
</Text>
</Box>
<Box flex={2} p='4'>
<Tag colorScheme={languageColorScheme} textTransform="capitalize">{book.language}</Tag>
</Box>
<Box flex={2} p='4'>
<Text marginBottom={1} color={'gray.400'} fontSize="xs">
{book.year}
</Text>
</Box>
<Box flex={2} p='4'>
<Text marginBottom={1} color={'gray.400'} fontSize="xs">
{book.pages}
</Text>
</Box>
</Flex>
</Card>
</div>
{isOpen &&
<BookDetailsCard row={{
original: book,
toggleExpanded: setIsOpen
}} />}
</>
);
};
export default DesktopDataListElement;

View File

@ -0,0 +1,19 @@
import { Link, LinkProps } from '@chakra-ui/react';
import React from 'react';
import { open } from '@tauri-apps/api/shell';
const ExternalLink = React.forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
return (
<Link
{...props}
ref={ref}
onClick={(e) => {
e.preventDefault();
props.href && open(props.href);
}}
></Link>
);
});
export default ExternalLink;

View File

@ -0,0 +1,19 @@
import { Link, LinkProps } from '@chakra-ui/react';
import React, { Suspense } from 'react';
const ExternalLinkInner =
import.meta.env.VITE_TAURI === '1'
? React.lazy(() => import('./ExternalLink-tauri'))
: React.Fragment;
const ExternalLink = React.forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
if (import.meta.env.VITE_TAURI === '1')
return (
<Suspense fallback={<Link {...props} ref={ref} isExternal></Link>}>
<ExternalLinkInner {...props} ref={ref} />
</Suspense>
);
return <Link {...props} ref={ref} isExternal></Link>;
});
export default ExternalLink;

View File

@ -0,0 +1,16 @@
import { Box } from '@chakra-ui/react';
import React from 'react';
export interface FooterProps {
children: React.ReactNode;
}
const Footer: React.FC<FooterProps> = ({ children }) => {
return (
<Box mt={2} mb={6} w="full" textAlign="center">
{children}
</Box>
);
};
export default Footer;

View File

@ -0,0 +1,48 @@
import { Box, Flex, Heading, Spacer, useColorMode } from '@chakra-ui/react';
import React, { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
export interface HeaderProps {
title: string;
children: React.ReactNode;
}
const Header: React.FC<HeaderProps> = ({ title, children }) => {
const { ref, inView } = useInView({ threshold: 0 });
const [bgColor, setBgColor] = React.useState('transparent');
const { colorMode } = useColorMode();
useEffect(() => {
if (!inView) {
setBgColor(colorMode === 'light' ? 'white' : 'blue.900');
} else {
setBgColor('transparent');
}
}, [inView, colorMode]);
return (
<>
<Flex
px={{base: 4, md: 8}}
py={3}
mb={2}
w="full"
position="sticky"
top={0}
zIndex="sticky"
transition="background-color 0.2s ease-in-out"
bgColor={bgColor}
boxShadow={!inView ? 'sm' : 'none'}
alignItems={'center'}
>
<h1>{title}</h1>
<Spacer />
<Box>{children}</Box>
</Flex>
<Box ref={ref} />
</>
);
};
export default Header;

View File

@ -0,0 +1,44 @@
import {
Icon,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuItemOption,
MenuList,
MenuOptionGroup
} from '@chakra-ui/react';
import { IoLanguage } from 'react-icons/io5';
import React from 'react';
import { useTranslation } from 'react-i18next';
const LanguageSwitch: React.FC = () => {
const { t, i18n } = useTranslation();
return (
<Menu>
<MenuButton
as={IconButton}
aria-label={t('nav.toggle_language') ?? ''}
title={t('nav.toggle_language') ?? ''}
icon={<Icon as={IoLanguage} boxSize={5} />}
variant="ghost"
/>
<MenuList>
<MenuOptionGroup
defaultValue={i18n.language}
type="radio"
onChange={(value) => i18n.changeLanguage(value as string)}
>
<MenuItemOption value="en">English</MenuItemOption>
<MenuItemOption value="zh-CN"></MenuItemOption>
<MenuItemOption value="fr">French</MenuItemOption>
<MenuItemOption value="it">Italian</MenuItemOption>
</MenuOptionGroup>
</MenuList>
</Menu>
);
};
export default LanguageSwitch;

View File

@ -0,0 +1,99 @@
import React, { useState } from 'react';
import { bookDisplayMobileElements } from '../constants/book';
import { MAX_RESULTS_PER_PAGE } from '../constants/pagination';
import { Book } from '../scripts/searcher';
import DataFilter from './DataFilter';
import MobileDataListElement from './MobileDataListElement';
import Paginator from './Paginator';
interface IProps {
books: Book[]
}
const MobileDataList: React.FC<IProps> = (props) => {
const { books } = props;
const [sortBy, setSortBy] = useState<string>('title')
const [direction, setDirection] = useState<string>('asc')
const [currentIndex, setCurrentIndex] = useState<number>(0)
const getMaxIndex = (): number => {
const maxIndex = String(books.length / MAX_RESULTS_PER_PAGE)
const res = books.length > MAX_RESULTS_PER_PAGE ? parseInt(maxIndex) : 1
return res
}
const setPreviousIndex = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
}
}
const setNextIndex = () => {
if (currentIndex < getMaxIndex()) {
setCurrentIndex(currentIndex + 1)
}
}
const getCurrentSliceStart = (): number => {
return currentIndex == 0 ? 0 : currentIndex * MAX_RESULTS_PER_PAGE
}
const getCurrentSliceEnd = (): number => {
const maxIndex = getMaxIndex()
return currentIndex == maxIndex ? books.length - 1 : (currentIndex + 1) * MAX_RESULTS_PER_PAGE
}
const handleSorting = (sortingElement: string) => {
if (sortBy == sortingElement) {
setDirection(direction == 'asc' ? 'desc' : 'asc')
} else {
setSortBy(sortingElement)
}
}
const sortBooksFunction = (a: Book, b: Book) => {
var nameA = a[sortBy == 'title' ? 'title' : 'author'].toUpperCase(); // ignore upper and lowercase
var nameB = b[sortBy == 'title' ? 'title' : 'author'].toUpperCase(); // ignore upper and lowercase
if (nameA < nameB) {
return -1; //nameA comes first
}
if (nameA > nameB) {
return 1; // nameB comes first
}
return 0; // names must be equal
}
const getSortedBooks = (): Book[] => {
return (direction == 'asc') ? books.sort(sortBooksFunction) : books.sort(sortBooksFunction).reverse()
}
return (
<>
{books.length > 0 &&
<DataFilter
elements={bookDisplayMobileElements}
activeFilter={sortBy}
currentDirection={direction}
setSortingElement={handleSorting} />}
{getSortedBooks().slice(getCurrentSliceStart(), getCurrentSliceEnd()).map((book, key) => {
return <MobileDataListElement key={key} book={book} />
})}
{books.length > 0 &&
<Paginator
maxIndex={getMaxIndex()}
setIndex={setCurrentIndex}
setPrevious={setPreviousIndex}
setNext={setNextIndex}
currentIndex={currentIndex} />}
</>
);
};
export default MobileDataList;

View File

@ -0,0 +1,52 @@
import { Card, CardHeader, Tag, Text } from '@chakra-ui/react';
import React, { useState } from 'react';
import { colorSchemes } from '../constants/color';
import { Book } from '../scripts/searcher';
import BookDetailsCard from './BooksDetailsCard';
interface IProps {
book: Book
}
const MobileDataListElement: React.FC<IProps> = (props) => {
const { book } = props
const languageColorScheme = colorSchemes[book.language.length % colorSchemes.length];
const extensionColorScheme = colorSchemes[book.extension.charCodeAt(0) % colorSchemes.length];
const [isOpen, setIsOpen] = useState<boolean>(false)
return (
<>
<div onClick={() => { setIsOpen(true) }}>
<Card backgroundColor={'transparent'} mt={{ base: 0, md: 2 }} mb={{ base: 2, md: 4 }} mx={{ base: 4, md: 8 }}>
<CardHeader>
<Text marginBottom={1} fontSize="lg">
{book.title}
</Text>
<Text marginBottom={1} color={'gray.400'} fontSize="xs">
{book.author}
</Text>
<div>
<Tag colorScheme={languageColorScheme} textTransform="capitalize">{book.language}</Tag>
{" "}
<Tag colorScheme={extensionColorScheme}>{book.extension}</Tag>
</div>
</CardHeader>
</Card>
</div>
{isOpen &&
<BookDetailsCard row={{
original: book,
toggleExpanded: setIsOpen
}} />}
</>
);
};
export default MobileDataListElement;

View File

@ -0,0 +1,81 @@
import React from 'react';
import { Flex, IconButton, Icon, Text, IconButtonProps } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { TbChevronLeft, TbChevronRight, TbChevronsLeft, TbChevronsRight } from 'react-icons/tb';
export interface IProps {
currentIndex: number
maxIndex: number
setIndex(index: number): void
setPrevious():void
setNext():void
}
const Paginator: React.FC<IProps> = (props) => {
const { currentIndex, maxIndex, setIndex, setPrevious, setNext } = props
const { t } = useTranslation()
return (
<Flex w="full" mt={4} pr={8} justify="flex-end" wrap="wrap">
<IconButton
aria-label={t('table.first_page')}
title={t('table.first_page') ?? ''}
icon={<Icon as={TbChevronsLeft} />}
mr={1}
display={{ base: 'none', md: 'inline-flex' }}
onClick={() => setIndex(0)}
disabled={currentIndex == 0}
/>
<IconButton
aria-label={t('table.previous_page')}
title={t('table.previous_page') ?? ''}
icon={<Icon as={TbChevronLeft} />}
mr={1}
onClick={() => setPrevious()}
disabled={currentIndex == 0}
/>
{
Array.from(Array(maxIndex).keys()).map((pageIndex) => {
const title = t('table.page', { page: pageIndex + 1 });
const disabled = currentIndex === pageIndex;
const style: Partial<IconButtonProps> = disabled ? { colorScheme: 'blue' } : {};
return (
<IconButton
aria-label={title}
title={title}
key={pageIndex}
icon={<Text>{pageIndex + 1}</Text>}
mr={1}
onClick={() => setIndex(pageIndex)}
disabled={disabled}
{...style}
/>
);
})
}
<IconButton
aria-label={t('table.next_page')}
title={t('table.next_page') ?? ''}
icon={<Icon as={TbChevronRight} />}
mr={{ base: 0, md: 1 }}
onClick={() => setNext()}
disabled={currentIndex + 1 == maxIndex}
/>
<IconButton
aria-label={t('table.last_page')}
title={t('table.last_page') ?? ''}
icon={<Icon as={TbChevronsRight} />}
display={{ base: 'none', md: 'inline-flex' }}
onClick={() => setIndex(maxIndex - 1)}
disabled={currentIndex + 1 == maxIndex}
/>
</Flex>
);
};
export default Paginator;

View File

@ -0,0 +1,138 @@
import { GridItem, Icon, SimpleGrid } from '@chakra-ui/react';
import React, { useState } from 'react';
import {
TbBook2,
TbBuilding,
TbFileDescription,
TbHash,
TbReportSearch,
TbUserCircle
} from 'react-icons/tb';
import search, { Book } from '../scripts/searcher';
import { IoLanguage } from 'react-icons/io5';
import SearchInput from './SearchInput';
import { useDebounceEffect } from 'ahooks';
import { useTranslation } from 'react-i18next';
import SearchLanguage from './SearchLanguage';
function constructQuery(parts: Record<string, string>): string {
return Object.keys(parts)
.map((key) =>
parts[key]
.split(' ')
.filter((s) => s !== '')
.map((s) => `${key}:"${s}"`)
)
.flat()
.join('');
}
export interface SearchProps {
setBooks: (books: Book[]) => void;
}
const Search: React.FC<SearchProps> = ({ setBooks }) => {
const { t } = useTranslation();
const [title, setTitle] = useState<string>('');
const [author, setAuthor] = useState<string>('');
const [publisher, setPublisher] = useState<string>('');
const [extension, setExtension] = useState<string>('');
const [language, setLanguage] = useState<string>('');
const [isbn, setISBN] = useState<string>('');
const [complexQuery, setComplexQuery] = useState<string>('');
const [showLanguageDropdown, setShowLanguageDropdown] = useState<boolean>(true)
const handleLanguageChange = (language: string) => {
if (language == 'input') {
setShowLanguageDropdown(false)
} else {
setLanguage(language)
}
}
const handleLanguageReset = () => {
setShowLanguageDropdown(true)
setLanguage('')
}
useDebounceEffect(
() => {
const query = complexQuery
? complexQuery
: constructQuery({ title, author, publisher, extension, language, isbn });
search(query, 100).then((books) => {
setBooks(books);
});
},
[title, author, publisher, extension, language, isbn, complexQuery],
{ wait: 300 }
);
return (
<SimpleGrid
columns={{ sm: 1, md: 2, lg: 3 }}
spacing={{ base: 2, md: 4 }}
px={{ base: 4, md: 8 }}
>
<SearchInput
icon={<Icon as={TbBook2} />}
placeholder={t('book.title')}
value={title}
onChange={setTitle}
/>
<SearchInput
icon={<Icon as={TbUserCircle} />}
placeholder={t('book.author')}
value={author}
onChange={setAuthor}
/>
<SearchInput
icon={<Icon as={TbBuilding} />}
placeholder={t('book.publisher')}
value={publisher}
onChange={setPublisher}
/>
<SearchInput
icon={<Icon as={TbFileDescription} />}
placeholder={t('book.extension')}
value={extension}
onChange={setExtension}
/>
{!showLanguageDropdown && (<SearchInput
icon={<Icon as={IoLanguage} />}
placeholder={t('book.language')}
value={language}
onChange={handleLanguageChange}
onClear={handleLanguageReset}
/>)}
{showLanguageDropdown && (<SearchLanguage
icon={<Icon as={IoLanguage} />}
placeholder={t('book.language')}
value={language}
onChange={handleLanguageChange}
/>)}
<SearchInput
icon={<Icon as={TbHash} />}
placeholder={t('book.isbn')}
value={isbn}
onChange={setISBN}
/>
<GridItem colSpan={{ sm: 1, md: 2, lg: 3 }}>
<SearchInput
icon={<Icon as={TbReportSearch} />}
placeholder={t('search.complex')}
value={complexQuery}
onChange={setComplexQuery}
/>
</GridItem>
</SimpleGrid>
);
};
export default Search;

View File

@ -0,0 +1,60 @@
import {
Icon,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
useControllableState
} from '@chakra-ui/react';
import React from 'react';
import { TbCircleX } from 'react-icons/tb';
import { useTranslation } from 'react-i18next';
interface SearchInputProps {
icon: React.ReactNode;
placeholder: string;
value?: string;
onChange?: (value: string) => void;
onClear?: () => void;
}
const SearchInput: React.FC<SearchInputProps> = ({ placeholder, icon, value, onChange, onClear }) => {
const [controlledValue, setControlledValue] = useControllableState({
value,
onChange,
defaultValue: ''
});
const { t } = useTranslation();
return (
<InputGroup>
<InputLeftElement pointerEvents="none" children={icon} />
<Input
type="text"
aria-label={placeholder}
placeholder={placeholder}
value={controlledValue}
onChange={(e) => setControlledValue(e.target.value)}
/>
<InputRightElement>
{value === '' ? null : (
<IconButton
aria-label={t('input.clear')}
tabIndex={-1}
title={t('input.clear') ?? ''}
icon={<Icon as={TbCircleX} color="GrayText" />}
variant="unstyled"
onClick={() => {
setControlledValue('')
onClear && onClear()
}}
/>
)}
</InputRightElement>
</InputGroup>
);
};
export default SearchInput;

View File

@ -0,0 +1,53 @@
import {
Icon,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
Select,
useControllableState
} from '@chakra-ui/react';
import React from 'react';
import { TbCircleX } from 'react-icons/tb';
import { useTranslation } from 'react-i18next';
interface SearchInputProps {
icon: React.ReactNode;
placeholder: string;
value?: string;
onChange?: (value: string) => void;
}
const SearchLanguage: React.FC<SearchInputProps> = ({ placeholder, icon, value, onChange }) => {
const [controlledValue, setControlledValue] = useControllableState({
value,
onChange,
defaultValue: ''
});
const { t } = useTranslation();
return (
<InputGroup>
<InputLeftElement pointerEvents="none" children={icon} />
<Select defaultValue={''} className={value !== '' ? 'active' : ''} placeholder={placeholder} onChange={(e) => setControlledValue(e.target.value)}>
<option value={"Chinese"}>{t('languages.chinese')}</option>
<option value={"English"}>{t('languages.english')}</option>
<option value={"French"}>{t('languages.french')}</option>
<option value={"German"}>{t('languages.german')}</option>
<option value={"Japanese"}>{t('languages.japanese')}</option>
<option value={"Italian"}>{t('languages.italian')}</option>
<option value={"Portuguese"}>{t('languages.portuguese')}</option>
<option value={"Spanish"}>{t('languages.spanish')}</option>
<option value={"Other"}>{t('languages.other')}</option>
<option value={"input"}>{t('languages.input')}</option>
</Select>
</InputGroup>
);
};
export default SearchLanguage;

View File

@ -0,0 +1,153 @@
import {
Button,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
Icon,
IconButton,
Input,
InputRightElement,
Stack,
Textarea,
useDisclosure
} from '@chakra-ui/react';
import { TbFolder, TbHelp, TbSettings } from 'react-icons/tb';
import React from 'react';
import { invoke } from '@tauri-apps/api';
import { open } from '@tauri-apps/api/dialog';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { SettingsItem } from './SettingsItem';
import RootContext from '../store';
import { parseIpfsGateways } from '../scripts/ipfs';
interface Config {
index_dir: string;
ipfs_gateways: string;
}
const Settings: React.FC = () => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const btnRef = React.useRef<HTMLButtonElement | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors }
} = useForm<Config>();
const [submitting, setSubmitting] = React.useState(false);
const rootContext = React.useContext(RootContext);
React.useEffect(() => {
isOpen &&
invoke('get_config').then((conf) => {
const config = conf as {
index_dir: string;
ipfs_gateways: string[];
};
setValue('index_dir', config.index_dir, { shouldValidate: true });
setValue('ipfs_gateways', config.ipfs_gateways.join('\n'), { shouldValidate: true });
});
}, [isOpen]);
const onSubmit = async (newConfig: Config) => {
setSubmitting(true);
const ipfsGateways: string[]= parseIpfsGateways(newConfig.ipfs_gateways);
const config = {
index_dir: newConfig.index_dir,
ipfs_gateways: ipfsGateways
};
await invoke('set_config', { newConfig: config });
rootContext.ipfs_gateways = ipfsGateways;
onClose();
setSubmitting(false);
};
return (
<>
<IconButton
ref={btnRef}
aria-label={t('settings.title')}
title={t('settings.title') ?? ''}
icon={<Icon as={TbSettings} boxSize={5} />}
onClick={onOpen}
variant="ghost"
/>
<Drawer isOpen={isOpen} placement="right" size="md" onClose={onClose} finalFocusRef={btnRef}>
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader>{t('settings.title')}</DrawerHeader>
<DrawerBody>
<form id="settings-form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={4}>
<SettingsItem
label={t('settings.index_dir')}
help={t('settings.index_dir_help') ?? undefined}
error={errors.index_dir?.message}
element={
<Input
{...register('index_dir', { required: t('settings.index_dir_required') ?? true })}
aria-invalid={errors.index_dir ? 'true' : 'false'}
/>
}
rightElement={
<InputRightElement>
<IconButton
aria-label={t('settings.index_dir_browse')}
title={t('settings.index_dir_browse') ?? ''}
tabIndex={-1}
icon={<Icon as={TbFolder} />}
variant="unstyled"
pt={1}
onClick={async () => {
const selected = (await open({
defaultPath: watch('index_dir'),
directory: true,
multiple: false
})) as string | null;
if (selected) setValue('index_dir', selected, { shouldValidate: true });
}}
/>
</InputRightElement>
}
/>
<SettingsItem
label={t('settings.ipfs_gateways')}
help={t('settings.ipfs_gateways_help') ?? undefined}
error={errors.ipfs_gateways?.message}
element={
<Textarea
{...register('ipfs_gateways')}
aria-invalid={errors.ipfs_gateways ? 'true' : 'false'}
/>
}
/>
</Stack>
</form>
</DrawerBody>
<DrawerFooter>
<Button variant="outline" mr={3} onClick={onClose}>
{t('settings.cancel')}
</Button>
<Button colorScheme="blue" type="submit" form="settings-form" isLoading={submitting}>
{t('settings.save')}
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
);
};
export default Settings;

View File

@ -0,0 +1,109 @@
import {
Button,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
Icon,
IconButton,
Stack,
Textarea,
useDisclosure
} from '@chakra-ui/react';
import { TbSettings } from 'react-icons/tb';
import React from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { SettingsItem } from './SettingsItem';
import RootContext from '../store';
import { parseIpfsGateways } from '../scripts/ipfs';
interface Config {
ipfs_gateways: string;
}
const Settings: React.FC = () => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const btnRef = React.useRef<HTMLButtonElement | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors }
} = useForm<Config>();
const [submitting, setSubmitting] = React.useState(false);
const rootContext = React.useContext(RootContext);
React.useEffect(() => {
if (isOpen) {
const ipfsGateways: string[] = JSON.parse(localStorage.getItem('ipfs_gateways') || '[]');
setValue('ipfs_gateways', ipfsGateways.join('\n'));
}
}, [isOpen]);
const onSubmit = async (newConfig: Config) => {
setSubmitting(true);
const ipfsGateways: string[] = parseIpfsGateways(newConfig.ipfs_gateways);
localStorage.setItem('ipfs_gateways', JSON.stringify(ipfsGateways));
rootContext.ipfs_gateways = ipfsGateways;
onClose();
setSubmitting(false);
};
return (
<>
<IconButton
ref={btnRef}
aria-label={t('settings.title')}
title={t('settings.title') ?? ''}
icon={<Icon as={TbSettings} boxSize={5} />}
onClick={onOpen}
variant="ghost"
/>
<Drawer isOpen={isOpen} placement="right" size="md" onClose={onClose} finalFocusRef={btnRef}>
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader>{t('settings.title')}</DrawerHeader>
<DrawerBody>
<form id="settings-form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={4}>
<SettingsItem
label={t('settings.ipfs_gateways')}
help={t('settings.ipfs_gateways_help') ?? undefined}
error={errors.ipfs_gateways?.message}
element={
<Textarea
{...register('ipfs_gateways')}
aria-invalid={errors.ipfs_gateways ? 'true' : 'false'}
/>
}
/>
</Stack>
</form>
</DrawerBody>
<DrawerFooter>
<Button variant="outline" mr={3} onClick={onClose}>
{t('settings.cancel')}
</Button>
<Button colorScheme="blue" type="submit" form="settings-form" isLoading={submitting}>
{t('settings.save')}
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
);
};
export default Settings;

View File

@ -0,0 +1,38 @@
import { FormControl, FormErrorMessage, FormLabel, Icon, InputGroup, InputProps, Tooltip, Text } from "@chakra-ui/react";
import React from "react";
import { TbHelp } from "react-icons/tb";
export interface SettingsItemProps extends InputProps {
label: string;
help?: string;
error?: string;
element: React.ReactNode;
leftElement?: React.ReactNode;
rightElement?: React.ReactNode;
}
export const SettingsItem: React.FC<SettingsItemProps> =
({ label, help, error, element, leftElement, rightElement }) => {
console.log(label, error);
return (
<FormControl isInvalid={error ? true : false}>
<FormLabel>
{label}{' '}
{help && (
<Tooltip hasArrow label={help}>
<Text as="span">
<Icon as={TbHelp}></Icon>
</Text>
</Tooltip>
)}
</FormLabel>
<InputGroup>
{leftElement}
{element}
{rightElement}
</InputGroup>
{error && <FormErrorMessage>{error}</FormErrorMessage>}
</FormControl>
);
}
;

View File

@ -0,0 +1,61 @@
export interface BookFilterElement {
field: string,
flex: number,
sortable: boolean
}
export const bookDisplayDesktopElements: BookFilterElement[] = [
{
field: 'title',
flex: 10,
sortable: true
},
{
field: 'author',
flex: 6,
sortable: true
},
{
field: 'publisher',
flex: 4,
sortable: true
},
{
field: 'extension',
flex: 2,
sortable: true
},
{
field: 'filesize',
flex: 3,
sortable: false
},
{
field: 'language',
flex: 2,
sortable: true
},
{
field: 'year',
flex: 2,
sortable: true
},
{
field: 'pages',
flex: 2,
sortable: false
}
]
export const bookDisplayMobileElements: BookFilterElement[] = [
{
field: 'title',
flex: 0,
sortable: true
},
{
field: 'author',
flex: 0,
sortable: true
}
]

View File

@ -0,0 +1,12 @@
export const colorSchemes = [
'red',
'orange',
'yellow',
'green',
'teal',
'blue',
'cyan',
'purple',
'pink',
'gray'
];

View File

@ -0,0 +1,2 @@
export const MEDIA_QUERY_MOBILE_ENDS = 899
export const MEDIA_QUERY_DESKTOP_STARTS = 900

View File

@ -0,0 +1 @@
export const MAX_RESULTS_PER_PAGE = 20

Binary file not shown.

View File

@ -0,0 +1,62 @@
{
"en": {
"translation": {
"settings": {
"title": "Settings",
"index_dir": "Index directory",
"index_dir_help": "The directory where the index is stored.",
"index_dir_required": "The index directory is required",
"index_dir_browse": "Browse",
"ipfs_gateways": "IPFS Gateways",
"ipfs_gateways_help": "IPFS Gateways List, line break",
"cancel": "Cancel",
"save": "Save"
}
}
},
"zh-CN": {
"translation": {
"settings": {
"title": "设置",
"index_dir": "索引目录",
"index_dir_help": "存储索引的目录。",
"index_dir_required": "索引目录不能为空",
"index_dir_browse": "浏览",
"ipfs_gateways": "IPFS 网关",
"ipfs_gateways_help": "IPFS 网关列表,一行一个",
"cancel": "取消",
"save": "保存"
}
}
},
"fr": {
"translation": {
"settings": {
"title": "Paramètres",
"index_dir": "Répertoire de l'index",
"index_dir_help": "Le répertoire où l'index est stocké.",
"index_dir_required": "Le répertoire de l'index est requis",
"index_dir_browse": "Parcourir",
"ipfs_gateways": "Passerelles IPFS",
"ipfs_gateways_help": "Liste des passerelles IPFS, saut de ligne",
"cancel": "Annuler",
"save": "Enregistrer"
}
}
},
"it": {
"translation": {
"settings": {
"title": "Impostazioni",
"index_dir": "Directory del file index",
"index_dir_help": "La directory dove è salvato il file index",
"index_dir_required": "Il file index è obbligatorio",
"index_dir_browse": "Cerca",
"ipfs_gateways": "Gateway IPFS",
"ipfs_gateways_help": "Elenco dei gateway IPFS, interruzione di riga",
"cancel": "Annulla",
"save": "Salva"
}
}
}
}

232
frontend/src/i18n.json Normal file
View File

@ -0,0 +1,232 @@
{
"en": {
"translation": {
"nav": {
"repository": "GitHub Repository",
"toggle_dark": "Toggle to Dark Mode",
"toggle_light": "Toggle to Light Mode",
"toggle_language": "Toggle Language"
},
"input": {
"clear": "Clear"
},
"book": {
"id": "zlib/libgen id",
"title": "Title",
"author": "Author",
"publisher": "Publisher",
"extension": "Extension",
"filesize": "Filesize",
"language": "Language",
"year": "Year",
"pages": "Pages",
"isbn": "ISBN",
"ipfs_cid": "IPFS CID",
"unknown": "Unknown"
},
"table": {
"sort_asc": "Sort ascending",
"sort_desc": "Sort descending",
"not_sorted": "Not sorted",
"filter": "Filter",
"no_data": "No data",
"first_page": "First page",
"last_page": "Last page",
"next_page": "Next page",
"previous_page": "Previous page",
"page": "Page {{page}}",
"collapse": "Collapse"
},
"search": {
"complex": "Complex search"
},
"settings": {
"title": "Settings",
"ipfs_gateways": "IPFS Gateways",
"ipfs_gateways_help": "IPFS Gateways List, line break",
"cancel": "Cancel",
"save": "Save"
},
"languages": {
"chinese": "Chinese",
"english": "English",
"french": "French",
"german": "German",
"japanese": "Japanese",
"italian": "Italian",
"portuguese": "Portuguese",
"spanish": "Spanish",
"other": "Other / Unknown",
"input": "Input..."
},
"disclaimer": {
"nolink_warning": "WARNING: This platform does not host any kind of link to copyrighted material. It just displays CID related to IPFS resources"
}
}
},
"zh-CN": {
"translation": {
"nav": {
"repository": "GitHub 仓库",
"toggle_dark": "切换到暗黑模式",
"toggle_light": "切换到亮色模式",
"toggle_language": "切换语言"
},
"input": {
"clear": "清空"
},
"book": {
"id": "zlib/libgen id",
"title": "书名",
"author": "作者",
"publisher": "出版社",
"extension": "扩展名",
"filesize": "文件大小",
"language": "语言",
"year": "年份",
"pages": "页数",
"isbn": "ISBN",
"ipfs_cid": "IPFS CID",
"unknown": "未知"
},
"table": {
"sort_asc": "升序排序",
"sort_desc": "降序排序",
"not_sorted": "未排序",
"filter": "过滤",
"no_data": "无数据",
"first_page": "第一页",
"last_page": "最后一页",
"next_page": "下一页",
"previous_page": "上一页",
"page": "第 {{page}} 页",
"collapse": "收起"
},
"search": {
"complex": "复杂搜索"
},
"settings": {
"title": "设置",
"ipfs_gateways": "IPFS 网关",
"ipfs_gateways_help": "IPFS 网关列表,一行一个",
"cancel": "取消",
"save": "保存"
}
}
},
"fr": {
"translation": {
"nav": {
"repository": "Dépôt GitHub",
"toggle_dark": "Basculer en mode sombre",
"toggle_light": "Basculer en mode clair",
"toggle_language": "Basculer la langue"
},
"input": {
"clear": "Effacer"
},
"book": {
"id": "ID zlib/libgen",
"title": "Titre",
"author": "Auteur",
"publisher": "Éditeur",
"extension": "Extension",
"filesize": "Taille du fichier",
"language": "Langue",
"year": "Année",
"pages": "Pages",
"isbn": "ISBN",
"ipfs_cid": "CID IPFS",
"unknown": "Inconnu"
},
"table": {
"sort_asc": "Trier par ordre croissant",
"sort_desc": "Trier par ordre décroissant",
"not_sorted": "Non trié",
"filter": "Filtrer",
"no_data": "Aucune donnée",
"first_page": "Première page",
"last_page": "Dernière page",
"next_page": "Page suivante",
"previous_page": "Page précédente",
"page": "Page {{page}}",
"collapse": "Replier"
},
"search": {
"complex": "Recherche complexe"
},
"settings": {
"title": "Paramètres",
"ipfs_gateways": "Passerelles IPFS",
"ipfs_gateways_help": "Liste des passerelles IPFS, saut de ligne",
"cancel": "Annuler",
"save": "Enregistrer"
}
}
},
"it": {
"translation": {
"nav": {
"repository": "Repo GitHub",
"toggle_dark": "Modalità scura",
"toggle_light": "Modalità chiara",
"toggle_language": "Lingua"
},
"input": {
"clear": "pulisci"
},
"book": {
"id": "ID zlib/libgen",
"title": "Titolo",
"author": "Autore",
"publisher": "Editore",
"extension": "Estensione",
"filesize": "Dimensione file",
"language": "Lingua",
"year": "Anno",
"pages": "Pagina",
"isbn": "ISBN",
"ipfs_cid": "CID IPFS",
"unknown": "Sconosciuto"
},
"table": {
"sort_asc": "Ordine crescente",
"sort_desc": "Ordine decrescente",
"not_sorted": "Non ordinato",
"filter": "Filtra",
"no_data": "Nessun risultato",
"first_page": "Prima pagina",
"last_page": "Ultima pagina",
"next_page": "Pagina seguente",
"previous_page": "Pagina precedente",
"page": "Pagina {{page}}",
"collapse": "Richiudi"
},
"search": {
"complex": "Ricerca dettagliata"
},
"settings": {
"title": "Impostazioni",
"ipfs_gateways": "Gateway IPFS",
"ipfs_gateways_help": "Elenco dei gateway IPFS, interruzione di riga",
"cancel": "Annulla",
"save": "Salva"
},
"languages": {
"chinese": "Cinese",
"english": "Inglese",
"french": "Francese",
"german": "Tedesco",
"italian": "Italiano",
"japanese": "Giapponese",
"portuguese": "Portoghese",
"spanish": "Spagnolo",
"other": "Altro / Sconosciuto",
"input": "Scrivi..."
},
"disclaimer": {
"nolink_warning": "IMPORTANTE: Questa piattaforma non ospita nessun tipo di link a materiale protetto da copyright. Solo CID relativi a risorse IPFS."
}
}
}
}

41
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,41 @@
import './style.css';
import * as ReactDOM from 'react-dom/client';
import App from './App';
import { ChakraProvider } from '@chakra-ui/react';
import LanguageDetector from 'i18next-browser-languagedetector';
import React from 'react';
import i18n from 'i18next';
import i18nResource from './i18n.json';
import { initReactI18next } from 'react-i18next';
import merge from 'lodash/merge';
import theme from './theme';
import RootContext, {initRootContext} from './store';
const resources =
import.meta.env.VITE_TAURI === '1'
? merge(i18nResource, (await import('./i18n-tauri.json')).default)
: i18nResource;
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
lng: 'it', // default language
// debug: true,
interpolation: { escapeValue: false }
});
const rootElement = document.getElementById('app')!;
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<ChakraProvider theme={theme}>
<RootContext.Provider value={initRootContext}>
<App />
</RootContext.Provider>
</ChakraProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,30 @@
interface TauriConfig {
index_dir: string;
ipfs_gateways: string[];
}
export const ipfsGateways: string[] = [
'cloudflare-ipfs.com',
'dweb.link',
'ipfs.io',
'gateway.pinata.cloud'
];
export default async function getIpfsGateways() {
if (import.meta.env.VITE_TAURI === '1') {
import('@tauri-apps/api').then(api => {
api.invoke('get_config').then((conf) => {
const config = conf as TauriConfig;
return config.ipfs_gateways;
});
})
return <string[]>[];
} else {
const ipfsGateways: string[] = JSON.parse(localStorage.getItem('ipfs_gateways') || '[]');
return ipfsGateways;
}
}
export function parseIpfsGateways(text: string) {
return text.split('\n').filter(g => g.length);
}

View File

@ -0,0 +1,12 @@
import type { Book } from './searcher';
import axios from 'axios';
const http = axios.create({
baseURL: import.meta.env.VITE_BACKEND_BASE_API,
timeout: 5000
});
export default async function search(query: string, limit: number) {
const response = await http.get(`search?limit=${limit}&query=${query}`);
return response.data.books as Book[];
}

View File

@ -0,0 +1,7 @@
import type { Book } from './searcher';
import { invoke } from '@tauri-apps/api';
export default async function search(query: string, limit: number) {
const response = await invoke('search', { query, limit });
return response as Book[];
}

View File

@ -0,0 +1,26 @@
export interface Book {
id: number;
title: string;
author: string;
publisher?: string;
extension: string;
filesize: number;
language: string;
year?: number;
pages?: number;
isbn: string;
ipfs_cid: string;
}
export interface Row {
original: Book;
toggleExpanded: (arg0: boolean) => void;
}
export default async function search(query: string, limit: number) {
if (import.meta.env.VITE_TAURI === '1') {
return await import('./searcher-tauri').then(({ default: search }) => search(query, limit));
} else {
return await import('./searcher-browser').then(({ default: search }) => search(query, limit));
}
}

9
frontend/src/store.ts Normal file
View File

@ -0,0 +1,9 @@
import React from "react";
export const initRootContext = {
ipfs_gateways: <string[]>[]
};
const RootContext = React.createContext(initRootContext);
export default RootContext;

41
frontend/src/style.css Normal file
View File

@ -0,0 +1,41 @@
#app {
min-height: 100vh;
}
::-webkit-scrollbar {
display: none;
}
select.chakra-select {
padding-inline-start: var(--chakra-space-9);
color: var(--chakra-colors-chakra-placeholder-color);
}
select.chakra-select.active {
padding-inline-start: var(--chakra-space-9);
color: var(--chakra-colors-whiteAlpha-900);
}
.chakra-ui-light select.chakra-select {
color: var(--chakra-colors-gray-400);
}
.chakra-ui-light select.chakra-select.active {
color: var(--chakra-colors-gray-800);
}
.chakra-heading {
overflow: hidden;
text-overflow: ellipsis;
}
@font-face {
font-family: 'Lemon Yellow Sun';
src: url("./font/LemonYellowSun.otf") format("opentype");
}
h1 {
font-family: 'Lemon Yellow Sun' !important;
font-size: 32px !important;
font-weight: 700 !important;
}

10
frontend/src/theme.ts Normal file
View File

@ -0,0 +1,10 @@
import { extendTheme, type ThemeConfig } from '@chakra-ui/react';
const config: ThemeConfig = {
initialColorMode: 'light',
useSystemColorMode: true
};
const theme = extendTheme({ config });
export default theme;

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

33
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": false,
"skipLibCheck": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

34
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,34 @@
import { defineConfig } from 'vite';
import faviconsPlugin from '@darkobits/vite-plugin-favicons';
import react from '@vitejs/plugin-react';
import topLevelAwait from 'vite-plugin-top-level-await';
// https://vitejs.dev/config/
export default defineConfig(() => {
if (process.env.TAURI_PLATFORM) {
process.env.VITE_TAURI = '1';
} else {
process.env.VITE_TAURI = '0';
}
return {
plugins: [
process.env.VITE_TAURI === '1' ? topLevelAwait() : null,
react(),
process.env.VITE_TAURI === '0'
? faviconsPlugin({
icons: { favicons: { source: '../crates/zlib-searcher-desktop/icons/icon.png' } }
})
: null
],
build: {
rollupOptions: {
output: {
manualChunks: {
'chakra-ui': ['@chakra-ui/react']
}
}
}
}
};
});

3
rust-toolchain Normal file
View File

@ -0,0 +1,3 @@
[toolchain]
channel = "nightly"
components = ["rustfmt", "clippy"]

4
rustfmt.toml Normal file
View File

@ -0,0 +1,4 @@
version = "Two"
indent_style = "Block"
imports_granularity = "Crate"

20
scripts/build.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash
NAME=zlib-searcher
targets=(x86_64-unknown-linux-gnu x86_64-unknown-linux-musl)
targets_win=(x86_64-pc-windows-gnu)
for target in "${targets[@]}"
do
echo $target
cargo build --release --target $target -p zlib-searcher
pushd target/$target/release/ && zip zlib-searcher-$target.zip $NAME && mv zlib-searcher-$target.zip ../../ && popd
done
for target in "${targets_win[@]}"
do
echo $target
cargo build --release --target $target -p zlib-searcher
pushd target/$target/release/ && zip zlib-searcher-$target.zip $NAME.exe && mv zlib-searcher-$target.zip ../../ && popd
done

2
scripts/build_best_speed.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash
cargo build --release --no-default-features --features best-speed --target x86_64-unknown-linux-musl -p zlib-searcher

119
scripts/build_release.sh Executable file
View File

@ -0,0 +1,119 @@
#!/bin/bash
CUR_DIR=$( cd $( dirname $0 ) && pwd )
project=zlib-searcher
targets=()
features=()
ALL_TARGETS="
i686-unknown-linux-musl
x86_64-pc-windows-gnu
x86_64-unknown-linux-gnu
x86_64-unknown-linux-musl
armv7-unknown-linux-musleabihf
armv7-unknown-linux-gnueabihf
arm-unknown-linux-gnueabi
arm-unknown-linux-gnueabihf
arm-unknown-linux-musleabi
arm-unknown-linux-musleabihf
aarch64-unknown-linux-gnu
aarch64-unknown-linux-musl
"
while getopts "p:t:a:f" opt; do
case $opt in
p)
project=($OPTARG)
;;
t)
targets+=($OPTARG)
;;
a)
targets+=($ALL_TARGETS)
;;
f)
features+=($OPTARG)
;;
?)
echo "Usage: $(basename $0) [-t <target-triple>] [-f features]"
;;
esac
done
features+=${EXTRA_FEATURES}
if [[ "${#targets[@]}" == "0" ]]; then
echo "Specifying compile target with -t <target-triple>"
exit 1
fi
function build() {
cd "$CUR_DIR/.."
TARGET=$1
RELEASE_DIR="target/${TARGET}/release"
TARGET_FEATURES="${features[@]}"
if [[ "${TARGET_FEATURES}" != "" ]]; then
echo "* Building ${project} package for ${TARGET} with features \"${TARGET_FEATURES}\" ..."
cross build --target "${TARGET}" \
--default-features=false
--features "${TARGET_FEATURES}" \
-p zlib-searcher \
--release
else
echo "* Building ${project} package for ${TARGET} ..."
cross build --target "${TARGET}" \
-p zlib-searcher \
--release
fi
if [[ $? != "0" ]]; then
exit 1
fi
PKG_DIR="${CUR_DIR}/../release"
mkdir -p "${PKG_DIR}"
if [[ "$TARGET" == *"-windows-"* ]]; then
PKG_NAME="${project}-${TARGET}.zip"
PKG_PATH="${PKG_DIR}/${PKG_NAME}"
echo "* Packaging ZIP in ${PKG_PATH} ..."
cd ${RELEASE_DIR}
zip ${PKG_PATH} ${project}.exe
if [[ $? != "0" ]]; then
exit 1
fi
cd "${PKG_DIR}"
shasum -a 256 "${PKG_NAME}" > "${PKG_NAME}.sha256"
else
PKG_NAME="${project}-${TARGET}.tar.gz"
PKG_PATH="${PKG_DIR}/${PKG_NAME}"
cd ${RELEASE_DIR}
echo "* Packaging gz in ${PKG_PATH} ..."
tar -czf ${PKG_PATH} ${project}
if [[ $? != "0" ]]; then
exit 1
fi
cd "${PKG_DIR}"
shasum -a 256 "${PKG_NAME}" > "${PKG_NAME}.sha256"
fi
echo "* Done build package ${PKG_NAME}"
}
for target in "${targets[@]}"; do
cargo clean;
build "$target";
done