diff --git a/src/database/category.rs b/src/database/category.rs index a434462..e48beb8 100644 --- a/src/database/category.rs +++ b/src/database/category.rs @@ -4,7 +4,7 @@ use sea_orm::entity::prelude::*; use sea_orm::*; use snafu::prelude::*; -use crate::database::operation::*; +use crate::database::{content_folder, operation::*}; use crate::extractors::normalized_path::*; use crate::extractors::user::User; use crate::routes::category::CategoryForm; @@ -15,6 +15,7 @@ use crate::state::logger::LoggerError; /// /// 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 { @@ -24,11 +25,10 @@ pub struct Model { pub name: NormalizedPathComponent, #[sea_orm(unique)] pub path: NormalizedPathAbsolute, + #[sea_orm(has_many)] + pub content_folders: HasMany, } -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - #[async_trait::async_trait] impl ActiveModelBehavior for ActiveModel {} @@ -74,7 +74,24 @@ impl CategoryOperator { .context(DBSnafu) } + /// Find one category by ID + /// + /// Should not fail, unless SQLite was corrupted for some reason. + 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 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())) @@ -88,6 +105,28 @@ impl CategoryOperator { } } + /// 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) + .all(&self.state.database) + .await + .context(DBSnafu)?; + Ok(folders) + } + None => Err(CategoryError::IDNotFound { id }), + } + } + /// Delete a category pub async fn delete(&self, id: i32, user: Option) -> Result { let db = &self.state.database; diff --git a/src/database/content_folder.rs b/src/database/content_folder.rs new file mode 100644 index 0000000..422bb33 --- /dev/null +++ b/src/database/content_folder.rs @@ -0,0 +1,180 @@ +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::*; +use snafu::prelude::*; + +use crate::database::category::{self, CategoryError, CategoryOperator}; +use crate::database::operation::{Operation, OperationId, OperationLog, OperationType, Table}; +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)] + pub path: String, + 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}`"))] + NameTaken { name: String }, + #[snafu(display("There is already a content folder in dir `{path}`"))] + PathTaken { path: String }, + #[snafu(display("The Content Folder (Path: {path}) does not exist"))] + NotFound { path: 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 }, +} + +#[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 } + } + + /// 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 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 }), + } + } + + /// Find one Content Folder by ID + /// + /// Should not fail, unless SQLite was corrupted for some reason. + 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(), + }), + } + } + + /// Create a new content folder + /// + /// Fails if: + /// + /// - name or path 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, + user: Option, + ) -> Result { + // Check duplicates in same folder + let list = if let Some(parent_id) = f.parent_id { + self.list_child_folders(parent_id).await? + } else { + let category = CategoryOperator::new(self.state.clone(), None); + category + .list_folders(f.category_id) + .await + .context(CategorySnafu)? + }; + + if list.iter().any(|x| x.name == f.name) { + return Err(ContentFolderError::NameTaken { + name: f.name.clone(), + }); + } + + if list.iter().any(|x| x.path == f.path) { + return Err(ContentFolderError::PathTaken { + path: f.path.clone(), + }); + } + + let model = ActiveModel { + name: Set(f.name.clone()), + path: Set(f.path.clone()), + category_id: Set(f.category_id), + parent_id: Set(f.parent_id), + ..Default::default() + } + .save(&self.state.database) + .await + .context(DBSnafu)?; + + // Should not fail + let model = model.try_into_model().unwrap(); + + let operation_log = OperationLog { + user, + 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) + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs index 9f25b45..d795369 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,3 +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; diff --git a/src/database/operation.rs b/src/database/operation.rs index 549d35d..492f8c4 100644 --- a/src/database/operation.rs +++ b/src/database/operation.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; 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)] @@ -22,6 +23,7 @@ pub struct OperationId { #[derive(Clone, Debug, Display, Serialize, Deserialize)] pub enum Table { Category, + ContentFolder, } /// Operation applied to the database. @@ -31,6 +33,7 @@ pub enum Table { #[serde(untagged)] pub enum Operation { Category(CategoryForm), + ContentFolder(ContentFolderForm), } impl std::fmt::Display for Operation { diff --git a/src/lib.rs b/src/lib.rs index 00473d3..b00a1fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,11 @@ pub fn router(state: state::AppState) -> Router { .route("/categories/new", get(routes::category::new)) .route("/categories/{id}/delete", get(routes::category::delete)) .route("/folders/{category_id}", get(routes::category::show)) + .route( + "/folders/{category_name}/{*folder_path}", + get(routes::content_folder::show), + ) + .route("/folders", post(routes::content_folder::create)) .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 index 2cf8da4..327271f 100644 --- a/src/migration/m20251110_01_create_table_category.rs +++ b/src/migration/m20251110_01_create_table_category.rs @@ -27,7 +27,7 @@ impl MigrationTrait for Migration { } #[derive(DeriveIden)] -enum Category { +pub enum Category { Table, Id, Name, diff --git a/src/migration/m20251113_203047_add_content_folder.rs b/src/migration/m20251113_203047_add_content_folder.rs new file mode 100644 index 0000000..2d818a8 --- /dev/null +++ b/src/migration/m20251113_203047_add_content_folder.rs @@ -0,0 +1,57 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +use super::m20251110_01_create_table_category::Category; + +#[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(ContentFolder::Table) + .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()) + .foreign_key( + ForeignKey::create() + .name("fk-content-folder-parent_id") + .from(ContentFolder::ParentId, ContentFolder::ParentId) + .to(ContentFolder::Table, ContentFolder::Id), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ContentFolder::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +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 new file mode 100644 index 0000000..06bf1c9 --- /dev/null +++ b/src/migration/m20251113_203899_add_uniq_to_content_folder.rs @@ -0,0 +1,37 @@ +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 55574d2..ee2294a 100644 --- a/src/migration/mod.rs +++ b/src/migration/mod.rs @@ -1,12 +1,18 @@ 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)] + 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), + ] } } diff --git a/src/routes/category.rs b/src/routes/category.rs index b9f463a..37f6468 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use snafu::prelude::*; use crate::database::category::CategoryError; +use crate::database::content_folder; use crate::database::{category, category::CategoryOperator}; use crate::extractors::normalized_path::*; use crate::extractors::user::User; @@ -151,6 +152,8 @@ pub async fn index( pub struct CategoryShowTemplate { /// Global application state pub state: AppStateContext, + /// Categories found in database + pub content_folders: Vec, /// Logged-in user. pub user: Option, /// Category @@ -169,7 +172,15 @@ pub async fn show( .await .context(CategorySnafu)?; + // get all content folders in this category + let content_folders: Vec = + CategoryOperator::new(app_state.clone(), user.clone()) + .list_folders(category.id) + .await + .context(CategorySnafu)?; + Ok(CategoryShowTemplate { + content_folders, category, state: app_state_context, user, diff --git a/src/routes/content_folder.rs b/src/routes/content_folder.rs new file mode 100644 index 0000000..82f62cb --- /dev/null +++ b/src/routes/content_folder.rs @@ -0,0 +1,198 @@ +use askama::Template; +use askama_web::WebTemplate; +use axum::Form; +use axum::extract::{Path, State}; +use axum::response::{IntoResponse, Redirect}; +use axum_extra::extract::CookieJar; +use camino::Utf8PathBuf; +use serde::{Deserialize, Serialize}; +use snafu::prelude::*; + +use crate::database::category::CategoryOperator; +use crate::database::content_folder::ContentFolderOperator; +use crate::database::{category, content_folder}; +use crate::extractors::user::User; +use crate::state::flash_message::{OperationStatus, get_cookie}; +use crate::state::{AppState, AppStateContext, error::*}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ContentFolderForm { + pub name: String, + pub parent_id: Option, + pub path: String, + pub category_id: i32, +} + +#[derive(Template, WebTemplate)] +#[template(path = "content_folders/show.html")] +pub struct ContentFolderShowTemplate { + /// Global application state + pub state: AppStateContext, + /// current folder + pub current_content_folder: content_folder::Model, + /// Folders with parent_id set to current folder + pub sub_content_folders: Vec, + /// Logged-in user. + pub user: Option, + /// Category + pub category: category::Model, + /// BreadCrumb extract from path + pub breadcrumb_items: Vec, + /// Parent Folder if exist. If None, the parent is category + pub parent_folder: Option, + /// Operation status for UI confirmation (Cookie) + pub flash: Option, +} + +pub struct PathBreadcrumb { + pub name: String, + pub path: String, +} + +pub async fn show( + State(app_state): State, + user: Option, + Path((_category_name, folder_path)): Path<(String, String)>, + jar: CookieJar, +) -> Result<(CookieJar, ContentFolderShowTemplate), AppStateError> { + let app_state_context = app_state.context().await?; + + let content_folder_operator = ContentFolderOperator::new(app_state.clone(), user.clone()); + + // 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)?; + + // Get current categories + let category: category::Model = CategoryOperator::new(app_state.clone(), user.clone()) + .find_by_id(current_content_folder.category_id) + .await + .context(CategorySnafu)?; + + // create breadcrumb with ancestor of current folders + let mut content_folder_ancestors: Vec = Vec::new(); + // To get Current Parent Folder + let mut parent_folder: Option = None; + + content_folder_ancestors.push(PathBreadcrumb { + name: current_content_folder.name.clone(), + path: current_content_folder.path.clone(), + }); + + let mut current_id = current_content_folder.parent_id; + while let Some(id) = current_id { + let folder = content_folder_operator + .find_by_id(id) + .await + .context(ContentFolderSnafu)?; + + if parent_folder.is_none() { + parent_folder = Some(folder.clone()); + } + + content_folder_ancestors.push(PathBreadcrumb { + name: folder.name, + path: folder.path, + }); + + current_id = folder.parent_id; + } + + // Reverse the ancestor to create Breadrumb + content_folder_ancestors.reverse(); + + let (jar, operation_status) = get_cookie(jar); + + Ok(( + jar, + ContentFolderShowTemplate { + parent_folder, + breadcrumb_items: content_folder_ancestors, + sub_content_folders, + current_content_folder, + category, + state: app_state_context, + user, + flash: operation_status, + }, + )) +} + +pub async fn create( + State(app_state): State, + user: Option, + jar: CookieJar, + Form(mut form): Form, +) -> Result { + // let app_state_context = app_state.context().await?; + let content_folder = ContentFolderOperator::new(app_state.clone(), user.clone()); + + // build path with Parent folder path (or category path if parent is None) + folder.name + let parent_path = if let Some(parent_id) = form.parent_id { + let parent_folder = content_folder + .find_by_id(parent_id) + .await + .context(ContentFolderSnafu)?; + Utf8PathBuf::from(parent_folder.path) + } else { + Utf8PathBuf::new() + }; + + // Get folder category + let category: category::Model = CategoryOperator::new(app_state.clone(), user.clone()) + .find_by_id(form.category_id) + .await + .context(CategorySnafu)?; + + // If name contains "/" returns an error + if form.name.contains("/") { + let operation_status = OperationStatus { + success: false, + message: format!( + "Failed to create Folder, {} is not valid (it contains '/')", + form.name + ), + }; + let jar = operation_status.set_cookie(jar); + + let uri = format!("/folders/{}{}", category.name, parent_path.into_string()); + + return Ok((jar, Redirect::to(uri.as_str()).into_response())); + } + + // build final path with parent_path and path of form + form.path = format!("{}/{}", parent_path, form.name); + + let created = content_folder.create(&form, user.clone()).await; + + match created { + Ok(created) => { + tokio::fs::create_dir_all(format!("{}/{}", category.path, created.path.clone())) + .await + .context(IOSnafu)?; + + let operation_status = OperationStatus { + success: true, + message: format!( + "The folder {} has been successfully created (ID: {})", + created.name, created.id + ), + }; + + let jar = operation_status.set_cookie(jar); + let uri = format!("/folders/{}{}", category.name, created.path); + + Ok((jar, Redirect::to(uri.as_str()).into_response())) + } + Err(_error) => Ok((jar, Redirect::to("/").into_response())), + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 75462de..34f239e 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,4 +1,5 @@ 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 b84254f..21d58a3 100644 --- a/src/state/error.rs +++ b/src/state/error.rs @@ -6,6 +6,7 @@ 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; use crate::state::logger::LoggerError; @@ -31,6 +32,10 @@ pub enum AppStateError { Category { source: CategoryError }, #[snafu(display("Error during migration"))] Migration { source: MigrationError }, + #[snafu(display("Content folder error"))] + ContentFolder { source: ContentFolderError }, + #[snafu(display("IO error"))] + IO { source: std::io::Error }, } impl AppStateError { diff --git a/templates/categories/show.html b/templates/categories/show.html index 07db9fa..2ed7800 100644 --- a/templates/categories/show.html +++ b/templates/categories/show.html @@ -25,4 +25,56 @@ {% endblock %} {% block system_list %} + {% if content_folders.is_empty() %} + {% include "shared/empty_state.html" %} + {% endif %} + + {% for folder in content_folders %} +
  • + +
    +
    +
    + +
    + {{ folder.name }} +
    +
    +
    +
    +

    + +

    +
    +
    +
    +
  • + {% endfor %} +{% endblock %} + + +{% block actions_buttons %} + {% include "content_folders/dropdown_actions.html" %} +{% endblock %} + +{% block content_folder_form %} +
    +
    + + +
    + + + + +
    + +
    +
    {% endblock %} diff --git a/templates/content_folders/dropdown_actions.html b/templates/content_folders/dropdown_actions.html new file mode 100644 index 0000000..acc76b6 --- /dev/null +++ b/templates/content_folders/dropdown_actions.html @@ -0,0 +1,10 @@ + diff --git a/templates/content_folders/show.html b/templates/content_folders/show.html new file mode 100644 index 0000000..0441874 --- /dev/null +++ b/templates/content_folders/show.html @@ -0,0 +1,106 @@ +{% extends "layouts/file_system_base.html" %} + +{% block breadcrumb %} + + + + + {% for i in (0..breadcrumb_items.len()) %} + {% let breadcrumb_item = breadcrumb_items[i] %} + + + {% endfor %} +{% endblock %} + +{% block folder_title %} + {{ current_content_folder.name }} +{% endblock%} + +{% block additional_buttons %} + {% if let Some(p) = parent_folder %} + + Go up + + {% else %} + + Go up + + {% endif %} +{% endblock %} + +{% block alert_message %} + {% include "shared/alert_operation_status.html" %} +{% endblock %} + + +{% block system_list %} + {% if sub_content_folders.is_empty() %} + {% include "shared/empty_state.html" %} + {% endif %} + + {% for folder in sub_content_folders %} +
  • + +
    +
    +
    + +
    + {{ folder.name }} +
    +
    +
    +
    +

    + +

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