diff --git a/src/database/category.rs b/src/database/category.rs deleted file mode 100644 index 274c918..0000000 --- a/src/database/category.rs +++ /dev/null @@ -1,263 +0,0 @@ -use chrono::Utc; -use sea_orm::entity::prelude::*; -use sea_orm::*; -use snafu::prelude::*; - -use std::str::FromStr; - -use crate::database::operator::DatabaseOperator; -use crate::database::{content_folder, operation::*}; -use crate::extractors::normalized_path::*; -use crate::extractors::user::User; -use crate::routes::category::CategoryForm; -use crate::state::AppState; -use crate::state::logger::LoggerError; - -/// A category to store associated files. -/// -/// Each category has a name and an associated path on disk, where -/// symlinks to the content will be created. -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] -#[sea_orm(table_name = "category")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - #[sea_orm(unique)] - pub name: NormalizedPathComponent, - #[sea_orm(unique)] - pub path: NormalizedPathAbsolute, - #[sea_orm(has_many)] - pub content_folders: HasMany, -} - -#[async_trait::async_trait] -impl ActiveModelBehavior for ActiveModel {} - -#[derive(Debug, Snafu)] -#[snafu(visibility(pub))] -pub enum CategoryError { - #[snafu(display("There is already a category called `{name}`"))] - NameTaken { name: String }, - #[snafu(display("The category name is invalid. It must not contain slashes."))] - NameInvalid, - #[snafu(display("There is already a category in dir `{path}`"))] - PathTaken { path: String }, - #[snafu(display("The category path is invalid. It must be an absolute path."))] - PathInvalid, - #[snafu(display("The parent directory does not exist: {path}"))] - ParentDir { path: String }, - #[snafu(display("Other disk error"))] - IO { source: std::io::Error }, - #[snafu(display("Database error"))] - DB { source: sea_orm::DbErr }, - #[snafu(display("The category (ID: {id}) does not exist"))] - IDNotFound { id: i32 }, - #[snafu(display("The category id is invalid: {id}"))] - IDInvalid { id: String }, - #[snafu(display("The category (Name: {name}) does not exist"))] - NameNotFound { name: String }, - #[snafu(display("Failed to save the operation log"))] - Logger { source: LoggerError }, -} - -#[derive(Clone, Debug)] -pub struct CategoryOperator { - pub state: AppState, - pub user: Option, -} - -impl CategoryOperator { - pub fn new(state: AppState, user: Option) -> Self { - Self { state, user } - } - - pub fn db(&self) -> DatabaseOperator { - DatabaseOperator { - state: self.state.clone(), - user: self.user.clone(), - } - } - - /// List categories - /// - /// Should not fail, unless SQLite was corrupted for some reason. - pub async fn list(&self) -> Result, CategoryError> { - Entity::find() - .all(&self.state.database) - .await - .context(DBSnafu) - } - - /// Find one category by ID - /// - /// Fails if: - /// - /// - the requested ID does not exist - pub async fn find_by_id(&self, id: i32) -> Result { - let category = Entity::find_by_id(id) - .one(&self.state.database) - .await - .context(DBSnafu)?; - - match category { - Some(category) => Ok(category), - None => Err(CategoryError::IDNotFound { id }), - } - } - - /// Find one category by stringy ID - /// - /// Fails if: - /// - /// - the requested ID does not exist - /// - the requested ID could not be parsed into an i32 - pub async fn find_by_id_str(&self, id: &str) -> Result { - let id: i32 = id - .parse() - .map_err(|_e| CategoryError::IDInvalid { id: id.to_string() })?; - self.find_by_id(id).await - } - - /// Find one category by Name - /// - /// Should not fail, unless SQLite was corrupted for some reason. - pub async fn find_by_name(&self, name: String) -> Result { - let category = Entity::find() - .filter(Column::Name.contains(name.clone())) - .one(&self.state.database) - .await - .context(DBSnafu)?; - - match category { - Some(category) => Ok(category), - None => Err(CategoryError::NameNotFound { name }), - } - } - - /// List folders for 1 category - /// - /// Should not fail, unless SQLite was corrupted for some reason. - pub async fn list_folders(&self, id: i32) -> Result, CategoryError> { - let category = Entity::find_by_id(id) - .one(&self.state.database) - .await - .context(DBSnafu)?; - - match category { - Some(category) => { - let folders = category - .find_related(content_folder::Entity) - // We only want the top-level folders - .filter(content_folder::Column::ParentId.is_null()) - .all(&self.state.database) - .await - .context(DBSnafu)?; - Ok(folders) - } - None => Err(CategoryError::IDNotFound { id }), - } - } - - /// Delete a category - pub async fn delete(&self, id: i32) -> Result { - let db = &self.state.database; - let category: Option = Entity::find_by_id(id).one(db).await.context(DBSnafu)?; - - match category { - Some(category) => { - let category_clone: Model = category.clone(); - category.delete(db).await.context(DBSnafu)?; - - let operation_log = OperationLog { - user: self.user.clone(), - date: Utc::now(), - table: Table::Category, - operation: OperationType::Delete, - operation_id: OperationId { - object_id: category_clone.id, - name: category_clone.name.to_string(), - }, - operation_form: None, - }; - - self.state - .logger - .write(operation_log) - .await - .context(LoggerSnafu)?; - - Ok(category_clone.name.to_string()) - } - None => Err(CategoryError::IDNotFound { id }), - } - } - - /// Create a new category, creating the corresponding directory. - /// - /// Fails if: - /// - /// - name or path is already taken (they should be unique) - /// - path parent directory does not exist (to avoid completely wrong paths) - pub async fn create(&self, form: &CategoryForm) -> Result { - let name = NormalizedPathComponent::from_str(&form.name) - .map_err(|_e| CategoryError::NameInvalid)?; - let path = NormalizedPathAbsolute::from_str(&form.path) - .map_err(|_e| CategoryError::PathInvalid)?; - - let dir = path.to_path_buf(); - let parent = dir.parent().unwrap(); - - if !tokio::fs::try_exists(parent).await.context(IOSnafu)? { - return Err(CategoryError::ParentDir { - path: parent.to_string(), - }); - } - - // Check duplicates - let list = self.list().await?; - - if list.iter().any(|x| x.name == name) { - return Err(CategoryError::NameTaken { - name: name.to_string(), - }); - } - if list.iter().any(|x| x.path == path) { - return Err(CategoryError::PathTaken { - path: path.to_string(), - }); - } - - let model = ActiveModel { - name: Set(name.clone()), - path: Set(path.clone()), - ..Default::default() - } - .save(&self.state.database) - .await - .context(DBSnafu)?; - - // Should not fail - let model = model.try_into_model().unwrap(); - - let operation_log = OperationLog { - user: self.user.clone(), - date: Utc::now(), - table: Table::Category, - operation: OperationType::Create, - operation_id: OperationId { - object_id: model.id.to_owned(), - name: name.to_string(), - }, - operation_form: Some(Operation::Category(form.clone())), - }; - - self.state - .logger - .write(operation_log) - .await - .context(LoggerSnafu)?; - - Ok(model) - } -} diff --git a/src/database/content.rs b/src/database/content.rs new file mode 100644 index 0000000..dab9b0f --- /dev/null +++ b/src/database/content.rs @@ -0,0 +1,162 @@ +use camino::{Utf8PathBuf, Utf8Path}; +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::*; +use serde::{Deserialize, Serialize}; +use snafu::prelude::*; + +use std::str::FromStr; + +use crate::database::operation::{Operation, OperationLog, OperationType, Table}; +use crate::database::operator::DatabaseOperator; +use crate::extractors::normalized_path::NormalizedPathComponent; +use crate::extractors::user::User; +use crate::state::AppState; +use crate::state::error::AppStateError; + +/// A loaded folder, with all surrounding entities loaded as well: +/// +/// - parents/ancestors, from the topmost to the closest +/// - direct children (non-recursive) +/// +/// On the index page, `folder` is not populated. +#[derive(Clone, Debug)] +pub struct FolderView { + pub ancestors: Vec, + pub children: Vec, + pub folder: Option, +} + +impl FolderView { + /// Loads the top-most folder view, which is not a folder and may not have parents. + pub async fn index(operator: &ContentFolderOperator) -> Result { + let children = operator.list().await?.into_iter().filter(|x| x.parent_id.is_none()).collect(); + + Ok(Self { + ancestors: vec!(), + folder: None, + children, + }) + } + + /// Loads a folder view for a requested folder, if it exists. + /// + /// Fails when: + /// + /// - the requested ID does not exist + // TODO: optimize with custom query + pub async fn from_id(operator: &ContentFolderOperator, id: i32) -> Result { + let list = operator.list().await?; + + if let Some(folder) = list.iter().find(|x| x.id == id) { + Ok(Self { + ancestors: folder.ancestors_from_list(&list).into_iter().cloned().collect(), + children: folder.children_from_list(&list).into_iter().cloned().collect(), + folder: Some(folder.clone()), + }) + } else { + Err(ContentFolderError::NotFound { id }) + } + } +} + +#[derive(Clone, Debug)] +pub struct ContentFolderOperator { + pub state: AppState, + pub user: Option, +} + +impl ContentFolderOperator { + pub fn new(state: AppState, user: Option) -> Self { + Self { state, user } + } + + pub fn db(&self) -> DatabaseOperator { + DatabaseOperator { + state: self.state.clone(), + user: self.user.clone(), + } + } + + /// List content folders + /// + /// Should not fail, unless SQLite was corrupted for some reason. + pub async fn list(&self) -> Result, ContentFolderError> { + Entity::find() + .all(&self.state.database) + .await + .context(DBSnafu) + } + + /// Find one content folder by ID + /// + /// Fails if: + /// + /// - the requested ID does not exist + pub async fn find_by_id(&self, id: i32) -> Result { + let content_folder = Entity::find_by_id(id) + .one(&self.state.database) + .await + .context(DBSnafu)?; + + match content_folder { + Some(category) => Ok(category), + None => Err(ContentFolderError::NotFound { id }), + } + } + + /// Create a new content folder + /// + /// Fails if: + /// + /// - name is already taken (they should be unique in one folder) + /// - path parent directory does not exist (to avoid completely wrong paths) + pub async fn create(&self, parent: Option, name: String) -> Result { + let name = NormalizedPathComponent::from_str(&name) + .map_err(|_e| ContentFolderError::NameInvalid)?; + + let list = self.list().await?; + + let siblings = parent.as_ref().map(|x| x.children_from_list(&list)).unwrap_or(vec!()); + if siblings.iter().any(|x| x.name == name) { + return Err(ContentFolderError::NameTaken { + name: name.to_string(), + }); + } + + let model = ActiveModel { + name: Set(name), + parent_id: Set(parent.as_ref().map(|x| x.id)), + ..Default::default() + } + .save(&self.state.database) + .await + .context(DBSnafu)?; + + // Should not fail + let model = model.try_into_model().unwrap(); + + // If the folder already exists, it's not an error. Maybe it was + // created manually before importing to TorrentManager. + let real_path = model.path_from_list(&self.state.config.media_dir, &list); + if !tokio::fs::try_exists(&real_path).await.context(IOSnafu)? { + tokio::fs::create_dir_all(&real_path).await.context(IOSnafu)?; + } + + let operation_log = OperationLog { + user: self.user.clone(), + date: Utc::now(), + operation: ContentFolderOperation::Create {id: model.id, name: model.name.to_string(), parent: parent.as_ref().map(|x| (x.id, x.name.to_string())) }.into(), + operation_type: OperationType::Create, + table: Table::ContentFolder, + }; + + self.state + .logger + .write(operation_log) + .await + .context(LoggerSnafu)?; + + Ok(model) + } +} diff --git a/src/database/content_folder.rs b/src/database/content_folder.rs deleted file mode 100644 index af0090d..0000000 --- a/src/database/content_folder.rs +++ /dev/null @@ -1,284 +0,0 @@ -use camino::Utf8PathBuf; -use chrono::Utc; -use sea_orm::entity::prelude::*; -use sea_orm::*; -use snafu::prelude::*; - -use std::str::FromStr; - -use crate::database::category::{self, CategoryError}; -use crate::database::operation::{Operation, OperationId, OperationLog, OperationType, Table}; -use crate::database::operator::DatabaseOperator; -use crate::extractors::normalized_path::{NormalizedPathAbsolute, NormalizedPathComponent}; -use crate::extractors::user::User; -use crate::routes::content_folder::ContentFolderForm; -use crate::state::AppState; -use crate::state::logger::LoggerError; - -/// A content folder to store associated files. -/// -/// Each content folder has a name and an associated path on disk, a Category -/// and it can have an Parent Content Folder (None if it's the first folder -/// in category) -#[sea_orm::model] -#[derive(DeriveEntityModel, Clone, Debug, PartialEq, Eq)] -#[sea_orm(table_name = "content_folder")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - pub name: String, - #[sea_orm(unique)] - // TODO: Here the path is absolute, even though it's technically - // relative to the parent folder/category. This is because we want - // to make it easy to look up by path. - // - // This means if we ever implement moving/renaming folders/categories, - // we'll need to recursively change all dependent paths. - // - // Maybe we'd like to revisit this design decision at some point. - pub path: NormalizedPathAbsolute, - pub category_id: i32, - #[sea_orm(belongs_to, from = "category_id", to = "id")] - pub category: HasOne, - pub parent_id: Option, - #[sea_orm(self_ref, relation_enum = "Parent", from = "parent_id", to = "id")] - pub parent: HasOne, -} - -#[async_trait::async_trait] -impl ActiveModelBehavior for ActiveModel {} - -#[derive(Debug, Snafu)] -#[snafu(visibility(pub))] -pub enum ContentFolderError { - #[snafu(display("There is already a content folder called `{name}` in the current folder."))] - NameTaken { name: String }, - #[snafu(display("The folder name is invalid. It must not contain slashes."))] - NameInvalid, - #[snafu(display("The folder path must appear absolute"))] - PathInvalid, - #[snafu(display("The Content Folder (Path: {path}) does not exist"))] - NotFound { path: String }, - #[snafu(display("The content folder id is invalid: {id}"))] - IDInvalid { id: String }, - #[snafu(display("Database error"))] - DB { source: sea_orm::DbErr }, - #[snafu(display("Failed to save the operation log"))] - Logger { source: LoggerError }, - #[snafu(display("Category operation failed"))] - Category { source: CategoryError }, - #[snafu(display("Failed to create the folder on disk"))] - IO { source: std::io::Error }, -} - -#[derive(Clone, Debug)] -pub struct ContentFolderOperator { - pub state: AppState, - pub user: Option, -} - -impl ContentFolderOperator { - pub fn new(state: AppState, user: Option) -> Self { - Self { state, user } - } - - pub fn db(&self) -> DatabaseOperator { - DatabaseOperator { - state: self.state.clone(), - user: self.user.clone(), - } - } - - /// list All child folders for 1 folder - /// - /// Should not fail, unless SQLite was corrupted for some reason. - pub async fn list_child_folders( - &self, - content_folder_id: i32, - ) -> Result, ContentFolderError> { - Entity::find() - .filter(Column::ParentId.eq(content_folder_id)) - .all(&self.state.database) - .await - .context(DBSnafu) - } - - /// Find one Content Folder by path - /// - /// Should not fail, unless SQLite was corrupted for some reason. - pub async fn find_by_path(&self, path: String) -> Result { - let path = NormalizedPathAbsolute::from_str(&path) - .map_err(|_e| ContentFolderError::PathInvalid)?; - - let content_folder = Entity::find_by_path(path.clone()) - .one(&self.state.database) - .await - .context(DBSnafu)?; - - match content_folder { - Some(category) => Ok(category), - None => Err(ContentFolderError::NotFound { - path: path.to_string(), - }), - } - } - - /// Find one content folder by ID - /// - /// Fails if: - /// - /// - the requested ID does not exist - pub async fn find_by_id(&self, id: i32) -> Result { - let content_folder = Entity::find_by_id(id) - .one(&self.state.database) - .await - .context(DBSnafu)?; - - match content_folder { - Some(category) => Ok(category), - None => Err(ContentFolderError::NotFound { - path: id.to_string(), - }), - } - } - - /// Find one content folder by stringy ID - /// - /// Fails if: - /// - /// - the requested ID does not exist - /// - the requested ID could not be parsed into an i32 - pub async fn find_by_id_str(&self, id: &str) -> Result { - let id: i32 = id - .parse() - .map_err(|_e| ContentFolderError::IDInvalid { id: id.to_string() })?; - self.find_by_id(id).await - } - - /// Create a new content folder - /// - /// Fails if: - /// - /// - name is already taken (they should be unique in one folder) - /// - path parent directory does not exist (to avoid completely wrong paths) - pub async fn create(&self, f: &ContentFolderForm) -> Result { - let name = NormalizedPathComponent::from_str(&f.name) - .map_err(|_e| ContentFolderError::NameInvalid)?; - - let category = self - .db() - .category() - .find_by_id(f.category_id) - .await - .context(CategorySnafu)?; - - // Check duplicates in same category/folder - { - let siblings = if let Some(parent_id) = f.parent_id { - self.list_child_folders(parent_id).await? - } else { - self.db() - .category() - .list_folders(f.category_id) - .await - .context(CategorySnafu)? - }; - if siblings.iter().any(|x| x.name == f.name) { - return Err(ContentFolderError::NameTaken { - name: f.name.clone(), - }); - } - } - - // This path is an absolute path, but relative to a category path - let inner_path = if let Some(parent_id) = f.parent_id { - let parent = self.find_by_id(parent_id).await?; - NormalizedPathAbsolute::from_str(&format!("{}/{}", parent.path, name,)).unwrap() - } else { - NormalizedPathAbsolute::from_str(&format!("/{}", name)).unwrap() - }; - - let model = ActiveModel { - name: Set(name.to_string()), - path: Set(inner_path.clone()), - category_id: Set(f.category_id), - parent_id: Set(f.parent_id), - ..Default::default() - } - .save(&self.state.database) - .await - .context(DBSnafu)?; - - let real_path = - NormalizedPathAbsolute::from_str(&format!("{}{}", category.path, inner_path)).unwrap(); - - // TODO: we may want to remove the entry from the DB when creating the folder - // on disk fails. - tokio::fs::create_dir_all(&real_path) - .await - .context(IOSnafu)?; - - // Should not fail - let model = model.try_into_model().unwrap(); - - let operation_log = OperationLog { - user: self.user.clone(), - date: Utc::now(), - table: Table::ContentFolder, - operation: OperationType::Create, - operation_id: OperationId { - object_id: model.id.to_owned(), - name: f.name.to_string(), - }, - operation_form: Some(Operation::ContentFolder(f.clone())), - }; - - self.state - .logger - .write(operation_log) - .await - .context(LoggerSnafu)?; - - Ok(model) - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct PathBreadcrumb { - pub name: String, - pub path: String, -} - -impl PathBreadcrumb { - /// Produce a list of path ancestors, ordered by descending order (parent -> child). - /// - /// This includes the current category/folder. - /// - /// The path may contain leading/trailing slashes, but any sufficiently - /// weirder path may produce unexpected results. - pub fn for_filesystem_path(path: &str) -> Vec { - let path = path.trim_start_matches("/").trim_end_matches("/"); - let mut breadcrumbs = vec![]; - let mut path = Utf8PathBuf::from(path.to_string()); - - log::info!("{path}"); - breadcrumbs.push(PathBreadcrumb { - name: path.file_name().unwrap().to_string(), - path: path.to_string(), - }); - - while path.pop() { - if path.as_str().is_empty() { - break; - } - - log::info!("{:?}", path.file_name()); - breadcrumbs.push(PathBreadcrumb { - name: path.file_name().unwrap().to_string(), - path: path.to_string(), - }); - } - - breadcrumbs.into_iter().rev().collect() - } -} diff --git a/src/database/content_folder/error.rs b/src/database/content_folder/error.rs new file mode 100644 index 0000000..633f139 --- /dev/null +++ b/src/database/content_folder/error.rs @@ -0,0 +1,31 @@ +use snafu::prelude::*; + +use crate::state::error::AppStateError; +use crate::state::logger::LoggerError; + +#[derive(Debug, Snafu)] +#[snafu(visibility(pub))] +pub enum ContentFolderError { + #[snafu(display("There is already a content folder called `{name}` in the current folder."))] + NameTaken { name: String }, + #[snafu(display("The folder name is invalid. It must not contain slashes."))] + NameInvalid, + #[snafu(display("The folder path must appear absolute"))] + PathInvalid, + #[snafu(display("Folder {id} does not exist."))] + NotFound { id: i32 }, + #[snafu(display("The content folder id is invalid: {id}"))] + IDInvalid { id: String }, + #[snafu(display("Database error"))] + DB { source: sea_orm::DbErr }, + #[snafu(display("Failed to save the operation log"))] + Logger { source: LoggerError }, + #[snafu(display("Failed to create the folder on disk"))] + IO { source: std::io::Error }, +} + +impl From for AppStateError { + fn from(e: ContentFolderError) -> Self { + Self::ContentFolder { source: e } + } +} diff --git a/src/database/content_folder/folder_view.rs b/src/database/content_folder/folder_view.rs new file mode 100644 index 0000000..9c8933a --- /dev/null +++ b/src/database/content_folder/folder_view.rs @@ -0,0 +1,63 @@ +use super::*; + +/// A loaded folder, with all surrounding entities loaded as well: +/// +/// - parents/ancestors, from the topmost to the closest +/// - direct children (non-recursive) +/// +/// On the index page, `folder` is not populated. +#[derive(Clone, Debug)] +pub struct FolderView { + pub ancestors: Vec, + pub children: Vec, + pub folder: Option, +} + +impl FolderView { + /// Loads the top-most folder view, which is not a folder and may not have parents. + pub async fn index(operator: &ContentFolderOperator) -> Result { + let children = operator + .list() + .await? + .into_iter() + .filter(|x| x.parent_id.is_none()) + .collect(); + + Ok(Self { + ancestors: vec![], + folder: None, + children, + }) + } + + /// Loads a folder view for a requested folder, if it exists. + /// + /// Fails when: + /// + /// - the requested ID does not exist + // TODO: optimize with custom query + pub async fn from_id( + operator: &ContentFolderOperator, + id: i32, + ) -> Result { + let list = operator.list().await?; + + if let Some(folder) = list.iter().find(|x| x.id == id) { + Ok(Self { + ancestors: folder + .ancestors_from_list(&list) + .into_iter() + .cloned() + .collect(), + children: folder + .children_from_list(&list) + .into_iter() + .cloned() + .collect(), + folder: Some(folder.clone()), + }) + } else { + Err(ContentFolderError::NotFound { id }) + } + } +} diff --git a/src/database/content_folder/mod.rs b/src/database/content_folder/mod.rs new file mode 100644 index 0000000..c956dd6 --- /dev/null +++ b/src/database/content_folder/mod.rs @@ -0,0 +1,10 @@ +mod error; +pub use error::*; +mod folder_view; +pub use folder_view::*; +mod model; +pub use model::*; +mod operation; +pub use operation::*; +mod operator; +pub use operator::*; diff --git a/src/database/content_folder/model.rs b/src/database/content_folder/model.rs new file mode 100644 index 0000000..215a1b2 --- /dev/null +++ b/src/database/content_folder/model.rs @@ -0,0 +1,64 @@ +use camino::{Utf8Path, Utf8PathBuf}; +use sea_orm::entity::prelude::*; + +use crate::extractors::normalized_path::NormalizedPathComponent; + +/// A content folder to store associated files. +/// +/// Each content folder has a name and an associated path on disk, a Category +/// and it can have an Parent Content Folder (None if it's the first folder +/// in category) +#[sea_orm::model] +#[derive(DeriveEntityModel, Clone, Debug, PartialEq, Eq)] +#[sea_orm(table_name = "content_folder")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: NormalizedPathComponent, + pub parent_id: Option, + #[sea_orm(self_ref, relation_enum = "Parent", from = "parent_id", to = "id")] + pub parent: HasOne, +} + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel {} + +impl Model { + #[allow(dead_code)] + pub fn siblings_from_list<'a>(&self, list: &'a [Self]) -> Vec<&'a Self> { + list.iter() + .filter(|x| self.parent_id == x.parent_id) + .collect() + } + + pub fn ancestors_from_list<'a>(&self, list: &'a [Self]) -> Vec<&'a Self> { + // At first we traverse the filetree from the bottom up we'll reverse it later. + let mut ancestors = vec![]; + + let mut parent_id = self.parent_id; + while let Some(id) = parent_id { + // Here we assume the parent cannot have been deleted without the relation being deleted too + // Otherwise, it will crash! + let parent = list.iter().find(|x| x.id == id).unwrap(); + parent_id = parent.parent_id; + ancestors.push(parent); + } + + ancestors.into_iter().rev().collect() + } + + pub fn children_from_list<'a>(&self, list: &'a [Self]) -> Vec<&'a Self> { + list.iter() + .filter(|x| x.parent_id == Some(self.id)) + .collect() + } + + pub fn path_from_list(&self, basedir: &Utf8Path, list: &[Self]) -> Utf8PathBuf { + let mut path = basedir.to_path_buf(); + for folder in self.ancestors_from_list(list) { + path.push(folder.name.as_str()); + } + path.push(self.name.as_str()); + path + } +} diff --git a/src/database/content_folder/operation.rs b/src/database/content_folder/operation.rs new file mode 100644 index 0000000..4eaee5c --- /dev/null +++ b/src/database/content_folder/operation.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +use crate::database::operation::Operation; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum ContentFolderOperation { + Create { + id: i32, + name: String, + parent: Option<(i32, String)>, + }, + UpdateName { + id: i32, + old_name: String, + new_name: String, + }, + UpdateParent { + id: i32, + parent: Option<(i32, String)>, + }, + Delete { + id: i32, + name: String, + }, +} + +impl From for Operation { + fn from(o: ContentFolderOperation) -> Self { + Self::ContentFolder(o) + } +} diff --git a/src/database/content_folder/operator.rs b/src/database/content_folder/operator.rs new file mode 100644 index 0000000..6789f4c --- /dev/null +++ b/src/database/content_folder/operator.rs @@ -0,0 +1,130 @@ +use chrono::Utc; +use sea_orm::*; +use snafu::prelude::*; + +use std::str::FromStr; + +use crate::database::operation::OperationLog; +use crate::database::operation::OperationType; +use crate::database::operation::Table; +use crate::database::operator::DatabaseOperator; +use crate::extractors::normalized_path::NormalizedPathComponent; +use crate::extractors::user::User; +use crate::state::AppState; + +use super::*; + +#[derive(Clone, Debug)] +pub struct ContentFolderOperator { + pub state: AppState, + pub user: Option, +} + +impl ContentFolderOperator { + pub fn new(state: AppState, user: Option) -> Self { + Self { state, user } + } + + pub fn db(&self) -> DatabaseOperator { + DatabaseOperator { + state: self.state.clone(), + user: self.user.clone(), + } + } + + /// List content folders + /// + /// Should not fail, unless SQLite was corrupted for some reason. + pub async fn list(&self) -> Result, ContentFolderError> { + Entity::find() + .all(&self.state.database) + .await + .context(DBSnafu) + } + + /// Find one content folder by ID + /// + /// Fails if: + /// + /// - the requested ID does not exist + pub async fn find_by_id(&self, id: i32) -> Result { + let content_folder = Entity::find_by_id(id) + .one(&self.state.database) + .await + .context(DBSnafu)?; + + match content_folder { + Some(category) => Ok(category), + None => Err(ContentFolderError::NotFound { id }), + } + } + + /// Create a new content folder + /// + /// Fails if: + /// + /// - name is already taken (they should be unique in one folder) + /// - path parent directory does not exist (to avoid completely wrong paths) + pub async fn create( + &self, + parent: Option, + name: String, + ) -> Result { + let name = NormalizedPathComponent::from_str(&name) + .map_err(|_e| ContentFolderError::NameInvalid)?; + + let list = self.list().await?; + + let siblings = parent + .as_ref() + .map(|x| x.children_from_list(&list)) + .unwrap_or(vec![]); + if siblings.iter().any(|x| x.name == name) { + return Err(ContentFolderError::NameTaken { + name: name.to_string(), + }); + } + + let model = ActiveModel { + name: Set(name), + parent_id: Set(parent.as_ref().map(|x| x.id)), + ..Default::default() + } + .save(&self.state.database) + .await + .context(DBSnafu)?; + + // Should not fail + let model = model.try_into_model().unwrap(); + + // If the folder already exists, it's not an error. Maybe it was + // created manually before importing to TorrentManager. + let real_path = model.path_from_list(&self.state.config.media_dir, &list); + if !tokio::fs::try_exists(&real_path).await.context(IOSnafu)? { + tokio::fs::create_dir_all(&real_path) + .await + .context(IOSnafu)?; + } + + let operation_log = OperationLog { + user: self.user.clone(), + date: Utc::now(), + operation: ContentFolderOperation::Create { + id: model.id, + name: model.name.to_string(), + parent: parent.as_ref().map(|x| (x.id, x.name.to_string())), + } + .into(), + operation_type: OperationType::Create, + table: Table::ContentFolder, + }; + + self.state + .logger + .write(operation_log) + .await + .context(LoggerSnafu)?; + + Ok(model) + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs index bf0b773..4fd7b9e 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,5 +1,4 @@ // sea_orm example: https://github.com/SeaQL/sea-orm/blob/master/examples/axum_example/ -pub mod category; pub mod content_folder; pub mod operation; pub mod operator; diff --git a/src/database/operation.rs b/src/database/operation.rs index 666d8d1..ab52b2e 100644 --- a/src/database/operation.rs +++ b/src/database/operation.rs @@ -2,9 +2,8 @@ use chrono::{DateTime, Utc}; use derive_more::Display; use serde::{Deserialize, Serialize}; +use crate::database::content_folder; use crate::extractors::user::User; -use crate::routes::category::CategoryForm; -use crate::routes::content_folder::ContentFolderForm; /// Type of operation applied to the database. #[derive(Clone, Debug, Display, Serialize, Deserialize)] @@ -14,15 +13,8 @@ pub enum OperationType { Delete, } -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct OperationId { - pub object_id: i32, - pub name: String, -} - #[derive(Clone, Debug, Display, Serialize, Deserialize)] pub enum Table { - Category, ContentFolder, } @@ -32,8 +24,7 @@ pub enum Table { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum Operation { - Category(CategoryForm), - ContentFolder(ContentFolderForm), + ContentFolder(content_folder::ContentFolderOperation), } impl std::fmt::Display for Operation { @@ -46,9 +37,7 @@ impl std::fmt::Display for Operation { pub struct OperationLog { pub user: Option, pub date: DateTime, + pub operation: Operation, + pub operation_type: OperationType, pub table: Table, - pub operation: OperationType, - pub operation_id: OperationId, - // Raw operation parameters - pub operation_form: Option, } diff --git a/src/database/operator.rs b/src/database/operator.rs index 566aaf8..b4115e3 100644 --- a/src/database/operator.rs +++ b/src/database/operator.rs @@ -1,4 +1,4 @@ -use crate::database::{category::CategoryOperator, content_folder::ContentFolderOperator}; +use crate::database::content_folder::ContentFolderOperator; use crate::extractors::user::User; use crate::state::AppState; @@ -13,13 +13,6 @@ impl DatabaseOperator { Self { state, user } } - pub fn category(&self) -> CategoryOperator { - CategoryOperator { - state: self.state.clone(), - user: self.user.clone(), - } - } - pub fn content_folder(&self) -> ContentFolderOperator { ContentFolderOperator { state: self.state.clone(), diff --git a/src/extractors/category_request.rs b/src/extractors/category_request.rs deleted file mode 100644 index 238dbd7..0000000 --- a/src/extractors/category_request.rs +++ /dev/null @@ -1,75 +0,0 @@ -use axum::extract::{FromRequestParts, Path}; -use axum::http::request::Parts; -use snafu::prelude::*; - -use crate::database::category::{self, CategoryOperator}; -use crate::database::content_folder::PathBreadcrumb; -use crate::filesystem::FileSystemEntry; -use crate::state::{AppState, error::*}; - -#[derive(Clone, Debug)] -pub struct CategoriesRequest { - pub children: Vec, -} - -impl FromRequestParts for CategoriesRequest { - type Rejection = AppStateError; - - async fn from_request_parts( - _parts: &mut Parts, - app_state: &AppState, - ) -> Result { - let categories = CategoryOperator::new(app_state.clone(), None) - .list() - .await - .context(CategorySnafu)?; - - let children = FileSystemEntry::from_categories(&categories); - Ok(Self { children }) - } -} - -#[derive(Clone, Debug)] -pub struct CategoryRequest { - pub category: category::Model, - pub breadcrumbs: Vec, - pub children: Vec, -} - -impl FromRequestParts for CategoryRequest { - type Rejection = AppStateError; - - async fn from_request_parts( - parts: &mut Parts, - app_state: &AppState, - ) -> Result { - let Path(category_name) = - as FromRequestParts>::from_request_parts(parts, app_state) - .await - .unwrap(); - - // Read-only operators: no need to extract the current user - let categories = CategoryOperator::new(app_state.clone(), None); - - let category = categories - .find_by_name(category_name.to_string()) - .await - .context(CategorySnafu)?; - - // get all content folders in this category - let content_folders = categories - .list_folders(category.id) - .await - .context(CategorySnafu)?; - - let children = FileSystemEntry::from_content_folders(&category, &content_folders); - - let breadcrumbs = PathBreadcrumb::for_filesystem_path(category.name.as_str()); - - Ok(Self { - category, - children, - breadcrumbs, - }) - } -} diff --git a/src/extractors/folder_request.rs b/src/extractors/folder_request.rs deleted file mode 100644 index 315bce0..0000000 --- a/src/extractors/folder_request.rs +++ /dev/null @@ -1,70 +0,0 @@ -use axum::extract::{FromRequestParts, Path}; -use axum::http::request::Parts; -use snafu::prelude::*; - -use crate::database::category::{self, CategoryOperator}; -use crate::database::content_folder::{self, ContentFolderOperator, PathBreadcrumb}; -use crate::filesystem::FileSystemEntry; -use crate::state::AppState; -use crate::state::error::*; - -#[derive(Clone, Debug)] -pub struct FolderRequest { - pub category: category::Model, - pub folder: content_folder::Model, - pub children: Vec, - pub breadcrumbs: Vec, -} - -impl FromRequestParts for FolderRequest { - type Rejection = AppStateError; - - async fn from_request_parts( - parts: &mut Parts, - app_state: &AppState, - ) -> Result { - // This unwrap will only a category name is set, but no further folder path - // However, that case is handled by a different route (`routes::category::show`). - let Path((_category_name, folder_path)) = as FromRequestParts< - AppState, - >>::from_request_parts(parts, app_state) - .await - .unwrap(); - - // Read-only operators: no need to extract the current user - let category_operator = CategoryOperator::new(app_state.clone(), None); - let content_folder_operator = ContentFolderOperator::new(app_state.clone(), None); - - // get current content folders with Path - let current_content_folder = content_folder_operator - // must format to add "/" in front of path like in DB - .find_by_path(format!("/{}", folder_path)) - .await - .context(ContentFolderSnafu)?; - - // Get all sub content folders of the current folder - let sub_content_folders: Vec = content_folder_operator - .list_child_folders(current_content_folder.id) - .await - .context(ContentFolderSnafu)?; - - let category: category::Model = category_operator - .find_by_id(current_content_folder.category_id) - .await - .context(CategorySnafu)?; - - let children = FileSystemEntry::from_content_folders(&category, &sub_content_folders); - - let breadcrumbs = PathBreadcrumb::for_filesystem_path(&format!( - "{}{}", - category.name, current_content_folder.path - )); - - Ok(Self { - category, - folder: current_content_folder, - children, - breadcrumbs, - }) - } -} diff --git a/src/extractors/mod.rs b/src/extractors/mod.rs index a95eb77..87119f1 100644 --- a/src/extractors/mod.rs +++ b/src/extractors/mod.rs @@ -1,5 +1,3 @@ -pub mod category_request; -pub mod folder_request; pub mod normalized_path; pub mod torrent_list; pub mod user; diff --git a/src/filesystem.rs b/src/filesystem.rs deleted file mode 100644 index 3dc453b..0000000 --- a/src/filesystem.rs +++ /dev/null @@ -1,43 +0,0 @@ -use crate::database::{category, content_folder}; - -#[derive(Clone, Debug)] -pub struct FileSystemEntry { - pub name: String, - pub extra: Option, - pub folder_path: String, -} - -impl FileSystemEntry { - pub fn from_category(category: &category::Model) -> Self { - Self { - name: category.name.to_string(), - extra: Some(category.path.to_string()), - folder_path: category.name.to_string(), - } - } - - pub fn from_categories(categories: &[category::Model]) -> Vec { - categories.iter().map(Self::from_category).collect() - } - - pub fn from_content_folder( - category: &category::Model, - content_folder: &content_folder::Model, - ) -> Self { - Self { - name: content_folder.name.to_string(), - extra: None, - folder_path: format!("{}{}", category.name, content_folder.path), - } - } - - pub fn from_content_folders( - category: &category::Model, - content_folders: &[content_folder::Model], - ) -> Vec { - content_folders - .iter() - .map(|x| Self::from_content_folder(category, x)) - .collect() - } -} diff --git a/src/lib.rs b/src/lib.rs index 7f0c705..4e25348 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,6 @@ use static_serve::embed_assets; pub mod config; pub mod database; pub mod extractors; -pub mod filesystem; pub mod middleware; pub mod migration; pub mod routes; @@ -20,22 +19,18 @@ pub fn router(state: state::AppState) -> Router { Router::new() // Register dynamic routes - .route("/", get(routes::index::index)) - .route("/progress/{view_request}", get(routes::progress::progress)) - .route("/categories", post(routes::category::create)) - .route("/categories/new", get(routes::category::new)) - .route("/categories/{id}/delete", get(routes::category::delete)) - .route("/folders/{category}", get(routes::category::show)) - .route("/folders/{category}", post(routes::category::create_folder)) .route( - "/folders/{category_name}/{*folder_path}", - get(routes::content_folder::show), + "/", + get(|| async { axum::response::Redirect::to("/folders") }), ) + .route("/folders", get(routes::content_folder::index)) + .route("/folders", post(routes::content_folder::create_folder)) + .route("/progress/{view_request}", get(routes::progress::progress)) + .route("/folders/{id}", get(routes::content_folder::show)) .route( - "/folders/{category_name}/{*folder_path}", + "/folders/{id}", post(routes::content_folder::create_subfolder), ) - .route("/folders", get(routes::index::index)) .route("/logs", get(routes::logs::index)) // Register static assets routes .nest("/assets", static_router()) diff --git a/src/migration/m20251110_01_create_table_category.rs b/src/migration/m20251110_01_create_table_category.rs deleted file mode 100644 index 327271f..0000000 --- a/src/migration/m20251110_01_create_table_category.rs +++ /dev/null @@ -1,35 +0,0 @@ -use sea_orm_migration::{prelude::*, schema::*}; - -#[derive(DeriveMigrationName)] -pub struct Migration; - -#[async_trait::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(Category::Table) - .if_not_exists() - .col(pk_auto(Category::Id)) - .col(string(Category::Name).unique_key()) - .col(string(Category::Path).unique_key()) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(Category::Table).to_owned()) - .await - } -} - -#[derive(DeriveIden)] -pub enum Category { - Table, - Id, - Name, - Path, -} diff --git a/src/migration/m20251113_203047_add_content_folder.rs b/src/migration/m20251113_203047_add_content_folder.rs index 2d818a8..b11813e 100644 --- a/src/migration/m20251113_203047_add_content_folder.rs +++ b/src/migration/m20251113_203047_add_content_folder.rs @@ -1,7 +1,5 @@ use sea_orm_migration::{prelude::*, schema::*}; -use super::m20251110_01_create_table_category::Category; - #[derive(DeriveMigrationName)] pub struct Migration; @@ -15,23 +13,11 @@ impl MigrationTrait for Migration { .if_not_exists() .col(pk_auto(ContentFolder::Id)) .col(string(ContentFolder::Name)) - .col(string(ContentFolder::Path)) - .col( - ColumnDef::new(ContentFolder::CategoryId) - .integer() - .not_null(), - ) - .foreign_key( - ForeignKey::create() - .name("fk-content-file-category_id") - .from(ContentFolder::Table, ContentFolder::CategoryId) - .to(Category::Table, Category::Id), - ) - .col(ColumnDef::new(ContentFolder::ParentId).integer()) + .col(ColumnDef::new(ContentFolder::ParentId).integer().null()) .foreign_key( ForeignKey::create() .name("fk-content-folder-parent_id") - .from(ContentFolder::ParentId, ContentFolder::ParentId) + .from(ContentFolder::Table, ContentFolder::ParentId) .to(ContentFolder::Table, ContentFolder::Id), ) .to_owned(), @@ -51,7 +37,5 @@ pub enum ContentFolder { Table, Id, Name, - Path, - CategoryId, ParentId, } diff --git a/src/migration/m20251113_203899_add_uniq_to_content_folder.rs b/src/migration/m20251113_203899_add_uniq_to_content_folder.rs deleted file mode 100644 index 06bf1c9..0000000 --- a/src/migration/m20251113_203899_add_uniq_to_content_folder.rs +++ /dev/null @@ -1,37 +0,0 @@ -use sea_orm_migration::prelude::*; - -use super::m20251113_203047_add_content_folder::ContentFolder; - -#[derive(DeriveMigrationName)] -pub struct Migration; - -#[async_trait::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_index( - Index::create() - .name("content_folder_uniq_path") - .table(ContentFolder::Table) - .col(ContentFolder::Path) - .unique() - .to_owned(), - ) - .await?; - - Ok(()) - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_index( - Index::drop() - .name("content_folder_uniq_path") - .table(ContentFolder::Table) - .to_owned(), - ) - .await?; - - Ok(()) - } -} diff --git a/src/migration/mod.rs b/src/migration/mod.rs index ee2294a..9384250 100644 --- a/src/migration/mod.rs +++ b/src/migration/mod.rs @@ -1,18 +1,12 @@ pub use sea_orm_migration::prelude::*; -mod m20251110_01_create_table_category; mod m20251113_203047_add_content_folder; -mod m20251113_203899_add_uniq_to_content_folder; pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { - vec![ - Box::new(m20251110_01_create_table_category::Migration), - Box::new(m20251113_203047_add_content_folder::Migration), - Box::new(m20251113_203899_add_uniq_to_content_folder::Migration), - ] + vec![Box::new(m20251113_203047_add_content_folder::Migration)] } } diff --git a/src/routes/category.rs b/src/routes/category.rs deleted file mode 100644 index df874a9..0000000 --- a/src/routes/category.rs +++ /dev/null @@ -1,153 +0,0 @@ -use askama::Template; -use askama_web::WebTemplate; -use axum::Form; -use axum::extract::Path; -use axum_extra::extract::CookieJar; -use serde::{Deserialize, Serialize}; - -use crate::database::category; -use crate::database::content_folder::PathBreadcrumb; -use crate::extractors::category_request::{CategoriesRequest, CategoryRequest}; -use crate::filesystem::FileSystemEntry; -use crate::routes::content_folder::ContentFolderForm; -use crate::routes::index::IndexTemplate; -use crate::state::AppStateContext; -use crate::state::flash_message::{ - FallibleTemplate, FlashRedirect, FlashTemplate, OperationStatus, StatusCookie, -}; - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CategoryForm { - pub name: String, - pub path: String, -} - -#[derive(Template, WebTemplate)] -#[template(path = "categories/new.html")] -pub struct NewCategoryTemplate { - /// Global application state - pub state: AppStateContext, - /// Default form with value - pub category_form: Option, -} - -pub async fn new(app_state_context: AppStateContext) -> NewCategoryTemplate { - NewCategoryTemplate { - state: app_state_context, - category_form: None, - } -} - -pub async fn delete( - context: AppStateContext, - Path(id): Path, - jar: CookieJar, -) -> FlashRedirect { - let status = match context.db.category().delete(id).await { - Ok(name) => StatusCookie::success( - jar, - format!("The category {} has been successfully deleted", name), - ), - Err(error) => StatusCookie::error(jar, error.to_string()), - }; - - status.redirect("/categories") -} - -pub async fn create( - context: AppStateContext, - categories: CategoriesRequest, - jar: CookieJar, - Form(form): Form, -) -> Result { - match context.db.category().create(&form).await { - Ok(created) => { - let status = StatusCookie::success( - jar, - format!( - "The category {} has been successfully created (ID {})", - created.name, created.id - ), - ); - let uri = format!("/folders/{}", created.name); - Ok(status.redirect(&uri)) - } - Err(error) => { - let status = OperationStatus::error(error); - Err(status.with_template(IndexTemplate::new(context, categories))) - } - } -} - -#[derive(Template, WebTemplate)] -#[template(path = "categories/show.html")] -pub struct CategoryShowTemplate { - /// Global application state - pub state: AppStateContext, - /// Categories found in database - pub children: Vec, - /// Category - category: category::Model, - /// Operation status for UI confirmation (Cookie) - pub flash: Option, - /// Breadcrumbs navigation - pub breadcrumbs: Vec, -} - -impl CategoryShowTemplate { - fn new(context: AppStateContext, category: CategoryRequest) -> Self { - let CategoryRequest { - breadcrumbs, - category, - children, - } = category; - - Self { - breadcrumbs, - category, - children, - flash: None, - state: context, - } - } -} - -impl FallibleTemplate for CategoryShowTemplate { - fn with_optional_flash(&mut self, flash: Option) { - self.flash = flash; - } -} - -pub async fn show( - context: AppStateContext, - category: CategoryRequest, - status: StatusCookie, -) -> FlashTemplate { - status.with_template(CategoryShowTemplate::new(context, category)) -} - -pub async fn create_folder( - context: AppStateContext, - jar: CookieJar, - category: CategoryRequest, - Form(form): Form, -) -> Result { - match context.db.content_folder().create(&form).await { - Ok(created) => { - let status = StatusCookie::success( - jar, - format!( - "The folder {} has been successfully created (ID: {})", - created.name, created.id - ), - ); - - let uri = format!("/folders/{}{}", category.category.name, created.path); - Ok(status.redirect(&uri)) - } - Err(error) => { - let status = OperationStatus::error(error); - Err(status.with_template(CategoryShowTemplate::new(context, category))) - } - } -} diff --git a/src/routes/content_folder.rs b/src/routes/content_folder.rs index 3ea2788..4d5c031 100644 --- a/src/routes/content_folder.rs +++ b/src/routes/content_folder.rs @@ -1,14 +1,13 @@ use askama::Template; use askama_web::WebTemplate; -use axum::Form; +use axum::extract::Form; +use axum::extract::Path; use axum_extra::extract::CookieJar; use serde::{Deserialize, Serialize}; -use crate::database::content_folder::PathBreadcrumb; -use crate::database::{category, content_folder}; -use crate::extractors::folder_request::FolderRequest; -use crate::filesystem::FileSystemEntry; +use crate::database::content_folder; use crate::state::AppStateContext; +use crate::state::error::AppStateError; use crate::state::flash_message::{ FallibleTemplate, FlashRedirect, FlashTemplate, OperationStatus, StatusCookie, }; @@ -16,8 +15,6 @@ use crate::state::flash_message::{ #[derive(Clone, Debug, Deserialize, Serialize)] pub struct ContentFolderForm { pub name: String, - pub parent_id: Option, - pub category_id: i32, } #[derive(Template, WebTemplate)] @@ -25,33 +22,30 @@ pub struct ContentFolderForm { pub struct ContentFolderShowTemplate { /// Global application state pub state: AppStateContext, - /// current folder - pub folder: content_folder::Model, + /// Current folder, unless we're on the index page + pub folder: Option, /// Folders with parent_id set to current folder - pub children: Vec, - /// Category - pub category: category::Model, - /// BreadCrumb extract from path - pub breadcrumbs: Vec, + // TODO: order by alphanumeric + pub children: Vec, + /// Ancestors leading to this page (breadcrumb) + pub ancestors: Vec, /// Operation status for UI confirmation (Cookie) pub flash: Option, } impl ContentFolderShowTemplate { - fn new(context: AppStateContext, folder: FolderRequest) -> Self { - let FolderRequest { - breadcrumbs, - category, + fn new(context: AppStateContext, folder: content_folder::FolderView) -> Self { + let content_folder::FolderView { + ancestors, children, folder, } = folder; Self { - breadcrumbs, - category, children, flash: None, folder, + ancestors, state: context, } } @@ -65,19 +59,69 @@ impl FallibleTemplate for ContentFolderShowTemplate { pub async fn show( context: AppStateContext, - folder: FolderRequest, + Path(id): Path, status: StatusCookie, -) -> FlashTemplate { - status.with_template(ContentFolderShowTemplate::new(context, folder)) +) -> Result, AppStateError> { + // 404 if requested ID does not exist + let view = content_folder::FolderView::from_id(&context.db.content_folder(), id).await?; + Ok(status.with_template(ContentFolderShowTemplate::new(context, view))) } +pub async fn index( + context: AppStateContext, + status: StatusCookie, +) -> Result, AppStateError> { + let view = content_folder::FolderView::index(&context.db.content_folder()).await?; + Ok(status.with_template(ContentFolderShowTemplate::new(context, view))) +} + +pub async fn create_folder( + context: AppStateContext, + jar: CookieJar, + Form(form): Form, +) -> Result, AppStateError> { + let view = content_folder::FolderView::index(&context.db.content_folder()).await?; + match context + .db + .content_folder() + .create(view.folder.clone(), form.name) + .await + { + Ok(created) => { + let status = StatusCookie::success( + jar, + format!( + "The folder {} has been successfully created (ID: {})", + created.name, created.id + ), + ); + + let uri = format!("/folders/{}", created.id); + Ok(Ok(status.redirect(&uri))) + } + Err(error) => { + let status = OperationStatus::error(error); + Ok(Err(status.with_template(ContentFolderShowTemplate::new( + context, view, + )))) + } + } +} + +// TODO: create top-level pub async fn create_subfolder( context: AppStateContext, jar: CookieJar, - folder: FolderRequest, + Path(id): Path, Form(form): Form, -) -> Result { - match context.db.content_folder().create(&form).await { +) -> Result, AppStateError> { + let view = content_folder::FolderView::from_id(&context.db.content_folder(), id).await?; + match context + .db + .content_folder() + .create(view.folder.clone(), form.name) + .await + { Ok(created) => { let status = StatusCookie::success( jar, @@ -87,12 +131,14 @@ pub async fn create_subfolder( ), ); - let uri = format!("/folders/{}{}", folder.category.name, created.path); - Ok(status.redirect(&uri)) + let uri = format!("/folders/{}", created.id); + Ok(Ok(status.redirect(&uri))) } Err(error) => { let status = OperationStatus::error(error); - Err(status.with_template(ContentFolderShowTemplate::new(context, folder))) + Ok(Err(status.with_template(ContentFolderShowTemplate::new( + context, view, + )))) } } } diff --git a/src/routes/index.rs b/src/routes/index.rs deleted file mode 100644 index 80fab3f..0000000 --- a/src/routes/index.rs +++ /dev/null @@ -1,39 +0,0 @@ -use askama::Template; -use askama_web::WebTemplate; - -// TUTORIAL: https://github.com/SeaQL/sea-orm/blob/master/examples/axum_example/ -use crate::extractors::category_request::CategoriesRequest; -use crate::filesystem::FileSystemEntry; -use crate::state::AppStateContext; -use crate::state::flash_message::{FallibleTemplate, OperationStatus}; - -#[derive(Template, WebTemplate)] -#[template(path = "index.html")] -pub struct IndexTemplate { - /// Global application state (errors/warnings) - pub state: AppStateContext, - /// Categories - pub children: Vec, - /// Operation status for UI confirmation - pub flash: Option, -} - -impl FallibleTemplate for IndexTemplate { - fn with_optional_flash(&mut self, flash: Option) { - self.flash = flash; - } -} - -impl IndexTemplate { - pub fn new(context: AppStateContext, categories: CategoriesRequest) -> Self { - Self { - state: context, - flash: None, - children: categories.children, - } - } -} - -pub async fn index(context: AppStateContext, categories: CategoriesRequest) -> IndexTemplate { - IndexTemplate::new(context, categories) -} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 34f239e..cb276de 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,5 +1,3 @@ -pub mod category; pub mod content_folder; -pub mod index; pub mod logs; pub mod progress; diff --git a/src/state/error.rs b/src/state/error.rs index 5123667..e9ad45f 100644 --- a/src/state/error.rs +++ b/src/state/error.rs @@ -5,7 +5,6 @@ use axum::response::{IntoResponse, Response}; use snafu::ErrorCompat; use snafu::prelude::*; -use crate::database::category::CategoryError; use crate::database::content_folder::ContentFolderError; use crate::migration::DbErr as MigrationError; use crate::state::free_space::FreeSpaceError; @@ -28,8 +27,6 @@ pub enum AppStateError { Other { source: Box, }, - #[snafu(display("Category error"))] - Category { source: CategoryError }, #[snafu(display("Error during migration"))] Migration { source: MigrationError }, #[snafu(display("Content folder error"))] diff --git a/src/state/logger.rs b/src/state/logger.rs index b84389c..245c497 100644 --- a/src/state/logger.rs +++ b/src/state/logger.rs @@ -130,9 +130,9 @@ mod tests { use tokio::task::JoinSet; use super::*; + use crate::database::content_folder::ContentFolderOperation; use crate::database::operation::*; use crate::extractors::user::User; - use crate::routes::category::CategoryForm; #[tokio::test] async fn many_writers() { @@ -145,16 +145,14 @@ mod tests { let operation_log = OperationLog { user: Some(User("foo".to_string())), date: Utc::now(), - table: Table::Category, - operation: OperationType::Create, - operation_id: OperationId { + table: Table::ContentFolder, + operation_type: OperationType::Create, + operation: ContentFolderOperation::Create { + id: 1, name: "object".to_string(), - object_id: 1, - }, - operation_form: Some(Operation::Category(CategoryForm { - name: "object".to_string(), - path: "/path".to_string(), - })), + parent: None, + } + .into(), }; for _i in 0..100 { @@ -185,16 +183,14 @@ mod tests { let operation_log = OperationLog { user: Some(User("foo".to_string())), date: Utc::now(), - table: Table::Category, - operation: OperationType::Create, - operation_id: OperationId { + table: Table::ContentFolder, + operation_type: OperationType::Create, + operation: ContentFolderOperation::Create { + id: 1, name: "object".to_string(), - object_id: 1, - }, - operation_form: Some(Operation::Category(CategoryForm { - name: "object".to_string(), - path: "/path".to_string(), - })), + parent: None, + } + .into(), }; for i in 0..200 { diff --git a/templates/base.html b/templates/base.html index 9e5a1b9..5153c55 100755 --- a/templates/base.html +++ b/templates/base.html @@ -15,7 +15,23 @@ {% include "menus/header.html" %}
- {% block errors %}{% endblock %} + {% block errors %} + {% if flash is defined %} + {% if let Some(flash) = flash %} +
+ {% for message in flash.message.messages() %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endif %} + {% if state.free_space.free_space_percent < 5 %} +
+ The disks are almost full : {{ state.free_space.free_space_gib }} GiB left +
+ {% endif %} + + {% endblock %} {% block main %}{% endblock %}
diff --git a/templates/categories/dropdown_actions.html b/templates/categories/dropdown_actions.html deleted file mode 100644 index 56791e2..0000000 --- a/templates/categories/dropdown_actions.html +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/templates/categories/form.html b/templates/categories/form.html deleted file mode 100644 index ef33d07..0000000 --- a/templates/categories/form.html +++ /dev/null @@ -1,35 +0,0 @@ -
-
- - -
- -
- - -
- -
- -
-
- diff --git a/templates/categories/new.html b/templates/categories/new.html deleted file mode 100644 index 79a998b..0000000 --- a/templates/categories/new.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -
-
-

Create Category

- Back to list -
- -
-
- {% include "categories/form.html" %} -
-
-
-{% endblock %} diff --git a/templates/categories/show.html b/templates/categories/show.html deleted file mode 100644 index eda09b1..0000000 --- a/templates/categories/show.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "layouts/file_system_base.html" %} - -{% block folder_title %} - {{ category.name }} -{% endblock%} - -{% block actions_buttons %} - {% include "content_folders/dropdown_actions.html" %} -{% endblock %} - -{% block content_folder_form %} -
-
- - -
- - - -
- -
-
-{% endblock %} diff --git a/templates/content_folders/form.html b/templates/content_folders/form.html new file mode 100644 index 0000000..2a98971 --- /dev/null +++ b/templates/content_folders/form.html @@ -0,0 +1,20 @@ +
+
+ + +
+ + {% if folder.is_some() %} + + {% endif %} + +
+ +
+
+ diff --git a/templates/content_folders/show.html b/templates/content_folders/show.html index da22153..ff392b8 100644 --- a/templates/content_folders/show.html +++ b/templates/content_folders/show.html @@ -1,30 +1,93 @@ -{% extends "layouts/file_system_base.html" %} +{% extends "base.html" %} -{% block folder_title %} - {{ folder.name }} -{% endblock%} +{% block main %} +
+

File System

-{% block actions_buttons %} - {% include "content_folders/dropdown_actions.html" %} -{% endblock %} +
+
+ +
-{% block content_folder_form %} -
-
- - -
- - - - -
- +
+ {% block actions_buttons %} + {% include "content_folders/dropdown_actions.html" %} + {% endblock actions_buttons %} +
-
+ +
+
+
+
+

{% block folder_title %}{% if let Some(folder) = folder %}{{ folder.name }}{% endif %}{% endblock folder_title %}

+
+ {% if breadcrumbs is defined %} + Go up + {% endif %} +
+
+ +
    + {% block system_list %} + {% if children.is_empty() %} + {% include "shared/empty_state.html" %} + {% endif %} + + {% for child in children %} +
  • + +
    +
    +
    + +
    {{ child.name }}
    +
    +
    +
    +

    + +

    +
    +
    +
    +
  • + {% endfor %} + {% endblock system_list %} +
+
+
+
+
+ + + {% endblock %} diff --git a/templates/index.html b/templates/index.html deleted file mode 100755 index 8756f12..0000000 --- a/templates/index.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "layouts/file_system_base.html" %} - -{% block folder_title %} - All categories -{% endblock%} - -{% block actions_buttons %} - {% include "categories/dropdown_actions.html" %} -{% endblock actions_buttons %} - -{% block system_list %} - {{ super() }} - - -{% endblock %} diff --git a/templates/layouts/file_system_base.html b/templates/layouts/file_system_base.html deleted file mode 100644 index c141922..0000000 --- a/templates/layouts/file_system_base.html +++ /dev/null @@ -1,105 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -
-

File System

- - {% block alert_message %} - {% include "shared/alert_operation_status.html" %} - {% endblock alert_message %} - - {% if state.free_space.free_space_percent < 5 %} -
- The disks are almost full : {{ state.free_space.free_space_gib }} GiB left -
- {% endif %} - -
-
- -
- -
- {% block actions_buttons %}{% endblock actions_buttons %} -
-
- -
-
-
-
-

{% block folder_title %}{% endblock folder_title %}

-
- {% if breadcrumbs is defined %} - Go up - {% endif %} -
-
- - -
-
-
-
- - - -{% endblock %} diff --git a/templates/logs/index.html b/templates/logs/index.html index a832eb6..0c7393d 100644 --- a/templates/logs/index.html +++ b/templates/logs/index.html @@ -8,19 +8,12 @@

Logs

{% for log in logs %}
- {% match log.operation_form %} - {% when Some(operation_form) %} -
- - {% include "logs/log_line.html" %} - -

{{ operation_form }}

-
- {% when None %} -

- {% include "logs/log_line.html" %} -

- {% endmatch %} +
+ + {% include "logs/log_line.html" %} + +

{{ log.operation }}

+
{% endfor %} diff --git a/templates/logs/log_line.html b/templates/logs/log_line.html index 95b7d42..0e53f64 100644 --- a/templates/logs/log_line.html +++ b/templates/logs/log_line.html @@ -1,6 +1,6 @@ {{ log.date.format("%Y-%m-%d %H:%M:%S").to_string() }} {% if let Some(log_user) = log.user %}{{ log_user }}{% endif %} {{ log.operation }} - on table {{ log.table }}: {{ log.operation_id.name }} ({{ log.operation_id.object_id }}) + on table {{ log.table }}: {{ log.operation }} diff --git a/templates/shared/alert_operation_status.html b/templates/shared/alert_operation_status.html deleted file mode 100644 index 16d8151..0000000 --- a/templates/shared/alert_operation_status.html +++ /dev/null @@ -1,9 +0,0 @@ -{% if let Some(flash) = flash %} -
- {% for message in flash.message.messages() %} -

{{ message }}

- {% endfor %} -
-{% endif %} - -