Initial commit

This commit is contained in:
2025-09-08 14:10:52 +03:00
commit 96f15ab51e
24 changed files with 1848 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
use crate::application::services::books::Books;
use crate::application::config::Config;
use crate::domain::book::Book;
use crate::domain::repository::{BookFilter, Repository};
pub struct Application<R: Repository<Book, BookFilter>> {
pub books: Books<R>,
cfg: Config,
}
impl<R: Repository<Book, BookFilter> +'static> Application<R> {
pub fn new(repo: R) -> Self {
let cfg = Config::new();
Application {
books: Books::new(repo, (&cfg.books_dir).into(), "https://foo.bar/".to_string()),
cfg
}
}
pub fn start(&mut self) -> Result<(), String>{
self.books.add_books_from_path();
if self.cfg.watcher {
return match self.books.watch_dir() {
Ok(_) => {Ok(())}
Err(e) => {
Err(format!("Error start watching books: {}", e))
}
}
}
Ok(())
}
}

15
src/application/config.rs Normal file
View File

@@ -0,0 +1,15 @@
use envman::EnvMan;
#[derive(EnvMan)]
pub struct Config {
#[envman(default = "./")]
pub books_dir: String,
#[envman(default = "true")]
pub watcher: bool,
}
impl Config {
pub fn new() -> Self {
Config::load_from_env().expect("Failed to load configuration")
}
}

View File

@@ -0,0 +1,75 @@
use crate::application::parsers;
use crate::domain::book::Book;
use std::collections::VecDeque;
use std::fs;
use std::path::PathBuf;
pub struct Loader {
root: PathBuf,
}
pub struct LoaderIter {
queue: VecDeque<PathBuf>,
}
impl Loader {
pub fn new(root: PathBuf) -> Self {
Loader { root }
}
}
impl IntoIterator for Loader {
type Item = Book;
type IntoIter = LoaderIter;
fn into_iter(self) -> Self::IntoIter {
let mut queue = VecDeque::new();
queue.push_back(self.root);
LoaderIter { queue }
}
}
impl Iterator for LoaderIter {
type Item = Book;
fn next(&mut self) -> Option<Self::Item> {
while let Some(path) = self.queue.pop_front() {
match path.is_dir() {
true => {
if let Ok(entries) = fs::read_dir(&path) {
for entry in entries.flatten() {
self.queue.push_back(entry.path());
}
}
}
false => {
let book = Self::parse_path(&path);
if book.is_some() {
return book;
}
continue;
}
}
}
None
}
}
impl LoaderIter {
fn parse_path(path: &PathBuf) -> Option<Book> {
match parsers::parse(&path) {
Ok(book) => return Some(book),
Err(err) => {
match err {
parsers::Error::ParseError(err) => {
println!("Failed to load book at {}: {:?}", path.display(), err);
}
_ => {}
}
None
}
}
}
}

View File

@@ -0,0 +1,81 @@
use crate::application::parsers;
use crate::domain::book::Book;
use inotify::{Event, Inotify, WatchMask};
use std::collections::VecDeque;
use std::ffi::OsStr;
use std::io;
use std::path::PathBuf;
const BUFFER_SIZE: usize = 4096;
pub struct Loader {
inotify: Inotify,
}
pub struct LoaderIter<'a> {
inotify: &'a mut Inotify,
buf: Vec<u8>,
queue: VecDeque<Book>,
}
impl Loader {
pub fn new(root: PathBuf) -> io::Result<Self> {
let mask = WatchMask::MODIFY | WatchMask::CREATE;
let inotify = Inotify::init()?;
inotify.watches().add(&root, mask)?;
Ok(Loader { inotify })
}
pub fn iter(&mut self) -> LoaderIter<'_> {
LoaderIter {
inotify: &mut self.inotify,
buf: vec![0u8; BUFFER_SIZE],
queue: VecDeque::new(),
}
}
}
impl<'a> Iterator for LoaderIter<'a> {
type Item = Book;
fn next(&mut self) -> Option<Self::Item> {
if let Some(book) = self.queue.pop_front() {
return Some(book);
}
match self.inotify.read_events_blocking(&mut self.buf) {
Ok(events) => {
for ev in events {
println!("{:?}", ev);
if let Some(book) = Self::process_event(ev) {
println!("{}", book);
self.queue.push_back(book);
}
}
self.queue.pop_front()
}
Err(e) => {
eprintln!("inotify error: {}", e);
None
}
}
}
}
impl<'a> LoaderIter<'a> {
fn process_event(event: Event<&OsStr>) -> Option<Book> {
let path = PathBuf::from(event.name?);
if !path.exists() {
return None;
}
match parsers::parse(&path) {
Ok(book) => Some(book),
Err(err) => {
eprintln!("Failed to parse book from {:?}: {:?}", path, err);
None
}
}
}
}

View File

@@ -0,0 +1,2 @@
pub mod fs;
pub mod inotify;

5
src/application/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod services;
mod loaders;
mod parsers;
mod config;
pub mod application;

View File

@@ -0,0 +1,16 @@
use crate::domain::book::Book;
use std::path::PathBuf;
mod rs;
#[derive(Debug)]
pub enum Error {
NotSupported,
ParseError(String),
}
pub fn parse(path: &PathBuf) -> Result<Book, Error> {
match path.extension().and_then(|s| s.to_str()) {
Some("rs") => rs::parse(path).map_err(Error::ParseError),
Some(_) | None => Err(Error::NotSupported),
}
}

View File

@@ -0,0 +1,15 @@
use crate::domain::author::Author;
use crate::domain::book::Book;
use std::path::PathBuf;
pub fn parse(path: &PathBuf) -> Result<Book, String> {
let mut book = Book::new();
book.title = path.to_string_lossy().to_string();
let mut author = Author::new();
author.first_name = path.extension().unwrap().to_string_lossy().to_string();
book.author.push(author);
return Ok(book);
}

View File

@@ -0,0 +1,87 @@
use crate::application::loaders::fs;
use crate::application::loaders::inotify;
use crate::domain::book::Book;
use crate::domain::feed::{BooksFeed, Entry};
use crate::domain::repository::{BookFilter, Repository};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::{io, thread};
use url::Url;
const AUTHOR_URL_PREFIX: &str = "author";
pub struct Books<R: Repository<Book, BookFilter>> {
pub repo: Arc<Mutex<R>>,
root: PathBuf,
base_url: Url,
}
impl<R: Repository<Book, BookFilter>> Books<R>
where
R: 'static,
{
pub fn new(repo: R, root: PathBuf, base_url: String) -> Self {
Books {
repo: Arc::new(Mutex::new(repo)),
root,
base_url: Url::parse(&base_url).unwrap(),
}
}
fn book_entries(&self, filter: BookFilter) -> Vec<Entry> {
let mut res = self
.repo
.lock()
.unwrap()
.filter(filter)
.map(|book| Entry::from(&book))
.collect::<Vec<Entry>>();
for entry in &mut res {
for author in &mut entry.author {
author.url = self.build_author_url(author.url.as_str()).to_string();
}
}
res
}
fn build_author_url(&self, author_url: &str) -> Url {
let mut url = self.base_url.clone();
match url.join([AUTHOR_URL_PREFIX, author_url].join("/").as_str()) {
Ok(u) => url = u,
Err(err) => {
println!("{}", err);
}
}
url
}
pub fn books_feed(&self, filter: BookFilter) -> BooksFeed {
let mut feed: BooksFeed = Default::default();
feed.entry = self.book_entries(filter);
feed
}
pub fn add_books_from_path(&mut self) {
let iter = fs::Loader::new(PathBuf::from(&self.root));
self.repo.lock().unwrap().bulk_add(iter);
}
pub fn watch_dir(&mut self) -> Result<(), io::Error> {
let root = self.root.clone();
let repo = Arc::clone(&self.repo);
let mut loader = inotify::Loader::new(root.clone())?;
thread::spawn(move || loop {
for book in loader.iter() {
repo.lock().unwrap().add(book);
}
});
Ok(())
}
}

View File

@@ -0,0 +1 @@
pub mod books;

85
src/domain/author.rs Normal file
View File

@@ -0,0 +1,85 @@
use std::fmt;
use uuid::Uuid;
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Author {
pub id: Uuid,
pub first_name: String,
pub last_name: Option<String>,
pub middle_name: Option<String>,
}
impl Author {
pub fn new() -> Author {
Author{
id: Uuid::new_v4(),
first_name: "".to_string(),
last_name: None,
middle_name: None,
}
}
pub fn name_contains(&self, name: &str) -> bool {
let name = name.to_lowercase();
let first_name = self.first_name.to_lowercase();
let last_name = self.last_name.as_ref().map(|s| s.to_lowercase());
let middle_name = self.middle_name.as_ref().map(|s| s.to_lowercase());
name.contains(&first_name) ||
last_name.map_or(false, |s| s.contains(&name)) ||
middle_name.map_or(false, |s| s.contains(&name))
}
pub fn uniq_id(&self) -> Uuid {
let mut parts = Vec::new();
if let Some(ref last) = self.last_name {
if !last.is_empty() {
parts.push(last.as_str());
}
}
if !self.first_name.is_empty() {
parts.push(self.first_name.as_str());
}
if let Some(ref middle) = self.middle_name {
if !middle.is_empty() {
parts.push(middle.as_str());
}
}
Uuid::new_v5(&Uuid::NAMESPACE_URL, parts.join(" ").as_bytes())
}
}
impl Default for Author {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for Author {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut parts = Vec::new();
if let Some(ref last) = self.last_name {
if !last.is_empty() {
parts.push(last.as_str());
}
}
if !self.first_name.is_empty() {
parts.push(self.first_name.as_str());
}
if let Some(ref middle) = self.middle_name {
if !middle.is_empty() {
parts.push(middle.as_str());
}
}
write!(f, "{}", parts.join(" "))
}
}

65
src/domain/book.rs Normal file
View File

@@ -0,0 +1,65 @@
use crate::domain::author;
use std::fmt;
use uuid::Uuid;
#[derive(Clone, PartialEq, Eq)]
pub struct Book {
pub id: Uuid,
pub title: String,
pub author: Vec<author::Author>,
pub language: String,
pub description: String,
pub tags: Vec<String>,
pub published_at: String,
pub publisher: String,
pub updated: String,
}
impl Book {
pub fn new() -> Book {
Book {
id: Uuid::new_v4(),
title: "".to_string(),
author: Default::default(),
language: "".to_string(),
description: "".to_string(),
tags: vec![],
published_at: "".to_string(),
publisher: "".to_string(),
updated: "".to_string(),
}
}
pub fn uniq_id(&self) -> Uuid {
let mut parts = Vec::new();
parts.push(self.title.as_str());
let authors = self.author.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>()
.join(";");
parts.push(authors.as_str());
parts.push(self.language.as_str());
parts.push(self.publisher.as_str());
parts.push(self.published_at.as_str());
Uuid::new_v5(&Uuid::NAMESPACE_OID, parts.join("-").as_bytes())
}
pub fn same(&self, other: &Book) -> bool {
self.uniq_id() == other.uniq_id()
}
}
impl fmt::Display for Book {
fn fmt(&self, f: &mut std::fmt::Formatter) -> fmt::Result {
let authors = self.author.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>()
.join(";");
write!(f, "{} by {}", self.title, authors)
}
}

101
src/domain/feed.rs Normal file
View File

@@ -0,0 +1,101 @@
use serde::{Deserialize, Serialize};
use crate::domain::author;
use crate::domain::book::Book;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename = "feed")]
pub struct BooksFeed {
#[serde(rename = "@xmlns", default = "default_atom_ns")]
pub xmlns: String,
#[serde(rename = "@xmlns:dc", default = "default_dc_ns")]
pub xmlns_dc: String,
pub id: String,
pub title: String,
pub updated: String,
#[serde(default)]
pub author: Option<Author>,
#[serde(default)]
pub entry: Vec<Entry>,
#[serde(default)]
pub link: Vec<Link>,
}
impl Default for BooksFeed {
fn default() -> Self {
BooksFeed {
xmlns: default_atom_ns(),
xmlns_dc: default_dc_ns(),
id: Default::default(),
title: Default::default(),
updated: Default::default(),
author: Default::default(),
entry: Default::default(),
link: Default::default(),
}
}
}
fn default_atom_ns() -> String {
"http://www.w3.org/2005/Atom".to_string()
}
fn default_dc_ns() -> String {
"http://purl.org/dc/terms/".to_string()
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Author {
pub name: String,
pub url: String
}
impl From<author::Author> for Author {
fn from(value: author::Author) -> Self {
Author {
name: value.to_string(),
url: value.id.to_string()
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Link {
#[serde(rename = "rel", default)]
pub rel: Option<String>,
#[serde(rename = "href")]
pub href: String,
#[serde(rename = "type", default)]
pub media_type: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename = "entry")]
pub struct Entry {
pub title: String,
pub id: String,
pub updated: String,
pub author: Vec<Author>,
#[serde(rename = "dc:language", default)]
pub language: Option<String>,
#[serde(rename = "dc:issued", default)]
pub issued: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub link: Vec<Link>, // acquisition, cover, etc.
}
impl From<&Book> for Entry {
fn from(book: &Book) -> Self {
Entry{
title: book.title.clone(),
id: book.id.to_string().clone(),
updated: book.updated.clone(),
author: book.author.clone().into_iter().map(|a| a.into()).collect(),
language: (!book.language.is_empty()).then(|| book.language.clone()),
issued: (!book.published_at.is_empty()).then(|| book.published_at.clone()),
summary: (!book.description.is_empty()).then(|| book.description.clone()),
link: vec![],
}
}
}

4
src/domain/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod author;
pub mod book;
pub mod feed;
pub mod repository;

24
src/domain/repository.rs Normal file
View File

@@ -0,0 +1,24 @@
pub trait Repository<T, F>: Send + Sync + Sized{
fn add(&mut self, item: T);
fn bulk_add<I>(&mut self, items: I) where I: IntoIterator<Item = T>;
fn remove(&mut self, item: T);
fn get(&self, id: String) -> Option<T>;
fn update(&mut self, item: T);
fn filter(&self, filter: F) -> Box<dyn Iterator<Item = T>>;
}
pub struct BookFilter {
pub author: Option<AuthorFilter>,
pub title: Option<String>,
pub language: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
pub published_at: Option<String>,
pub publisher: Option<String>,
pub updated: Option<String>,
}
pub struct AuthorFilter {
pub id: Option<String>,
pub name: Option<String>,
}

View File

@@ -0,0 +1 @@
pub mod repository;

View File

@@ -0,0 +1,251 @@
use crate::domain::author::Author;
use crate::domain::repository::{BookFilter, Repository};
use crate::domain::{author, book};
use std::collections::HashMap;
use uuid::Uuid;
#[derive(Clone)]
struct Book {
id: Uuid,
title: String,
author: Vec<String>,
language: String,
description: String,
tags: Vec<String>,
published_at: String,
publisher: String,
updated: String,
}
impl From<book::Book> for Book {
fn from(book: book::Book) -> Self {
Book {
id: book.id,
title: book.title,
author: book.author.iter().map(|a| a.id.to_string()).collect(),
language: book.language,
description: book.description,
tags: book.tags,
published_at: book.published_at,
publisher: book.publisher,
updated: book.updated,
}
}
}
impl Into<book::Book> for Book {
fn into(self) -> book::Book {
book::Book {
id: self.id,
title: self.title,
author: self
.author
.iter()
.map(|a| {
let mut na: author::Author = Default::default();
if let Ok(id) = Uuid::parse_str(a) {
na.id = id;
}
na
})
.collect(),
language: self.language,
description: self.description,
tags: self.tags,
published_at: self.published_at,
publisher: self.publisher,
updated: self.updated,
}
}
}
pub struct BookRepository {
books: Vec<Book>,
authors: HashMap<Uuid, Author>,
author_uniques: HashMap<String, Uuid>,
}
impl BookRepository {
pub fn new() -> Self {
BookRepository {
books: vec![],
authors: HashMap::new(),
author_uniques: HashMap::new(),
}
}
fn populate_authors(&self, book: &mut book::Book) {
for a in &mut book.author {
if let Some(stored) = self.authors.get(&a.id) {
a.first_name = stored.first_name.clone();
a.last_name = stored.last_name.clone();
a.middle_name = stored.middle_name.clone();
}
}
}
fn extract_authors(&mut self, item: &mut book::Book) {
for a in item.author.iter_mut() {
let uniq = a.uniq_id().to_string();
if let Some(&id) = self.author_uniques.get(&uniq) {
a.id = id;
} else {
self.author_uniques.insert(uniq, a.id);
}
self.authors.insert(a.id, a.clone());
}
}
}
impl Repository<book::Book, BookFilter> for BookRepository {
fn add(&mut self, mut item: book::Book) {
if self.get(item.id.to_string()).is_some() {
return;
}
self.extract_authors(&mut item);
self.books.push(item.into());
}
fn bulk_add<I>(&mut self, items: I)
where
I: IntoIterator<Item = book::Book>,
{
items.into_iter().for_each(|item| {
if self.get(item.id.to_string()).is_none() {
self.add(item)
}
});
}
fn remove(&mut self, item: book::Book) {
self.books.retain(|book| book.id != item.id);
}
fn get(&self, id: String) -> Option<book::Book> {
let id_uuid: Uuid = id.parse().unwrap();
let mut book: Option<book::Book> = self
.books
.iter()
.cloned()
.find(|x| x.id.eq(&id_uuid))
.and_then(|x| Some(x.into()));
if book.is_none() {
return None;
}
if let Some(book) = book.as_mut() {
self.populate_authors(book);
}
book
}
fn update(&mut self, mut item: book::Book) {
self.extract_authors(&mut item);
for res in &mut self.books {
if res.id == item.id {
*res = item.into();
break;
}
}
}
fn filter(&self, f: BookFilter) -> Box<dyn Iterator<Item = book::Book>> {
let mut author_ids: Vec<String> = vec![];
if let Some(author) = f.author {
if let Some(id) = author.id {
author_ids.push(id);
}
if let Some(name) = author.name {
for (id, author) in self.authors.iter() {
if author.first_name.contains(&name)
|| (author.last_name.is_some()
&& author.clone().last_name.unwrap().contains(&name))
|| (author.middle_name.is_some()
&& author.clone().middle_name.unwrap().contains(&name))
{
author_ids.push(id.to_string());
}
}
}
}
if author_ids.is_empty() {
return Box::new(std::iter::empty::<book::Book>())
}
let mut res = self
.books
.iter()
.filter(move |book| {
// Фильтр по названию
if let Some(ref search_title) = f.title {
if !book.title.contains(search_title) {
return false;
}
}
// Фильтр по описанию
if let Some(ref descr) = f.description {
if !book.description.contains(descr) {
return false;
}
}
// Фильтр по языку
if let Some(ref lang) = f.language {
if !book.language.eq(lang) {
return false;
}
}
// Фильтр по тегам
if let Some(ref tags) = f.tags {
if !tags.iter().all(|tag| book.tags.contains(tag)) {
return false;
}
}
// Фильтр по издателю
if let Some(ref publisher) = f.publisher {
if !book.publisher.eq(publisher) {
return false;
}
}
// Фильтр по датам (пример, можно доработать)
if let Some(ref published_at) = f.published_at {
if book.published_at != *published_at {
return false;
}
}
if let Some(ref updated) = f.updated {
if book.updated != *updated {
return false;
}
}
if !author_ids.is_empty() {
if !book.author.iter().all(|x| author_ids.contains(x)) {
return false;
}
}
true
})
.cloned()
.map(Into::into)
.collect::<Vec<book::Book>>();
for book in &mut res {
self.populate_authors(book);
}
Box::new(res.into_iter())
}
}

View File

@@ -0,0 +1 @@
pub mod books;

View File

@@ -0,0 +1 @@
pub mod inmem;

14
src/lib.rs Normal file
View File

@@ -0,0 +1,14 @@
use crate::application::application::Application;
use crate::infrastructure::repository::inmem::books::BookRepository;
pub mod domain;
mod application;
pub mod infrastructure;
pub fn demo() -> Application<BookRepository> {
let mut app = Application::new(BookRepository::new());
app.start().expect("Application initialization failed");
app
}

46
src/main.rs Normal file
View File

@@ -0,0 +1,46 @@
use opds::demo;
use opds::domain::repository::{AuthorFilter, Repository};
use opds::domain::repository::BookFilter;
use quick_xml::se::to_string as to_xml_string;
use std::thread::sleep;
use std::time::Duration;
fn main() {
let app = demo();
let filter = BookFilter {
author: Some(AuthorFilter{
id: None,
name: Some("rs".to_string()),
}),
title: Some("service".to_string()),
language: None,
description: None,
tags: None,
published_at: None,
publisher: None,
updated: None,
};
let res = app.books.books_feed(filter);
println!("{}", to_xml_string(&res).unwrap());
if let Some(book) = res.entry.iter().next() {
let book = app.books.repo.lock().unwrap().get(book.id.to_string().clone());
println!("{:?}", book.unwrap().author);
}
sleep(Duration::new(10, 0));
let filter = BookFilter {
author: None,
title: Some("foo".to_string()),
language: None,
description: None,
tags: None,
published_at: None,
publisher: None,
updated: None,
};
println!("{}", to_xml_string(&app.books.books_feed(filter)).unwrap());
}