use axum::{ extract::Path, http::StatusCode, response::IntoResponse, routing::{get, put}, Json, Router, }; use serde::{Deserialize, Serialize}; use std::fs; use crate::config; use crate::routes::tasks::{ add_comment_handler, create_task_handler, delete_comment_handler, delete_task_handler, get_task_handler, list_project_tasks_handler, toggle_task_handler, update_task_content_handler, update_task_meta_handler, AddCommentRequest, CreateTaskRequest, UpdateTaskMetaRequest, }; use crate::services::filesystem; use crate::services::frontmatter; #[derive(Debug, Serialize)] pub struct Project { pub id: String, pub name: String, pub path: String, pub created: String, } #[derive(Debug, Serialize)] pub struct ProjectWithContent { pub id: String, pub name: String, pub path: String, pub created: String, pub content: String, } #[derive(Debug, Deserialize)] pub struct UpdateProjectContentRequest { pub content: String, } #[derive(Debug, Deserialize)] pub struct CreateProjectRequest { pub name: String, } #[derive(Debug, Serialize)] pub struct ProjectNote { pub id: String, pub title: String, pub path: String, pub project_id: String, pub created: String, pub updated: String, } #[derive(Debug, Serialize)] pub struct ProjectNoteWithContent { pub id: String, pub title: String, pub path: String, pub project_id: String, pub created: String, pub updated: String, pub content: String, } #[derive(Debug, Deserialize)] pub struct CreateNoteRequest { pub title: Option, } pub fn router() -> Router { Router::new() .route("/", get(list_projects).post(create_project)) .route("/{id}", get(get_project)) .route( "/{id}/content", get(get_project_content).put(update_project_content), ) // Task routes (file-based) .route( "/{id}/tasks", get(get_project_tasks).post(create_project_task), ) .route( "/{id}/tasks/{task_id}", get(get_project_task) .put(update_project_task) .delete(delete_project_task), ) .route("/{id}/tasks/{task_id}/toggle", put(toggle_project_task)) .route("/{id}/tasks/{task_id}/meta", put(update_project_task_meta)) .route( "/{id}/tasks/{task_id}/comments", axum::routing::post(add_project_task_comment), ) .route( "/{id}/tasks/{task_id}/comments/{comment_index}", axum::routing::delete(delete_project_task_comment), ) // Note routes .route( "/{id}/notes", get(list_project_notes).post(create_project_note), ) .route( "/{id}/notes/{note_id}", get(get_project_note) .put(update_project_note) .delete(delete_project_note), ) } // ============ Task Handlers ============ async fn get_project_tasks(Path(id): Path) -> impl IntoResponse { list_project_tasks_handler(id).await } async fn create_project_task( Path(id): Path, Json(payload): Json, ) -> impl IntoResponse { create_task_handler(id, payload).await } async fn get_project_task(Path((id, task_id)): Path<(String, String)>) -> impl IntoResponse { get_task_handler(id, task_id).await } async fn update_project_task( Path((id, task_id)): Path<(String, String)>, body: String, ) -> impl IntoResponse { update_task_content_handler(id, task_id, body).await } async fn toggle_project_task(Path((id, task_id)): Path<(String, String)>) -> impl IntoResponse { toggle_task_handler(id, task_id).await } async fn update_project_task_meta( Path((id, task_id)): Path<(String, String)>, Json(payload): Json, ) -> impl IntoResponse { update_task_meta_handler(id, task_id, payload).await } async fn delete_project_task(Path((id, task_id)): Path<(String, String)>) -> impl IntoResponse { delete_task_handler(id, task_id).await } async fn add_project_task_comment( Path((id, task_id)): Path<(String, String)>, Json(payload): Json, ) -> impl IntoResponse { add_comment_handler(id, task_id, payload).await } async fn delete_project_task_comment( Path((id, task_id, comment_index)): Path<(String, String, usize)>, ) -> impl IntoResponse { delete_comment_handler(id, task_id, comment_index).await } async fn list_projects() -> impl IntoResponse { match list_projects_impl() { Ok(projects) => Json(projects).into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to list projects: {}", err), ) .into_response(), } } fn list_projects_impl() -> Result, String> { let projects_dir = config::data_dir().join("projects"); if !projects_dir.exists() { return Ok(Vec::new()); } let mut projects = Vec::new(); for entry in fs::read_dir(&projects_dir).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); if !path.is_dir() { continue; } let index_path = path.join("index.md"); if !index_path.exists() { continue; } let content = fs::read_to_string(&index_path).map_err(|e| e.to_string())?; let (fm, _, _) = frontmatter::parse_frontmatter(&content); let id = path .file_name() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); let name = fm .get(&serde_yaml::Value::from("title")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_else(|| id.clone()); let created = fm .get(&serde_yaml::Value::from("created")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); projects.push(Project { id: id.clone(), name, path: format!("projects/{}", id), created, }); } Ok(projects) } async fn get_project(Path(id): Path) -> impl IntoResponse { let projects_dir = config::data_dir().join("projects").join(&id); let index_path = projects_dir.join("index.md"); if !index_path.exists() { return (StatusCode::NOT_FOUND, "Project not found").into_response(); } match fs::read_to_string(&index_path) { Ok(content) => { let (fm, _, _) = frontmatter::parse_frontmatter(&content); let name = fm .get(&serde_yaml::Value::from("title")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_else(|| id.clone()); let created = fm .get(&serde_yaml::Value::from("created")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); Json(Project { id: id.clone(), name, path: format!("projects/{}", id), created, }) .into_response() } Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read project: {}", err), ) .into_response(), } } async fn create_project(Json(payload): Json) -> impl IntoResponse { match create_project_impl(&payload.name) { Ok(project) => (StatusCode::CREATED, Json(project)).into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create project: {}", err), ) .into_response(), } } fn create_project_impl(name: &str) -> Result { use chrono::Utc; // Create slug from name let slug = name .to_lowercase() .chars() .map(|c| if c.is_alphanumeric() { c } else { '-' }) .collect::() .trim_matches('-') .to_string(); if slug.is_empty() { return Err("Invalid project name".to_string()); } let projects_dir = config::data_dir().join("projects"); let project_dir = projects_dir.join(&slug); if project_dir.exists() { return Err("Project already exists".to_string()); } // Create directories fs::create_dir_all(&project_dir).map_err(|e| e.to_string())?; fs::create_dir_all(project_dir.join("assets")).map_err(|e| e.to_string())?; // Create index.md let index_path = project_dir.join("index.md"); let now = Utc::now().to_rfc3339(); let mut fm = serde_yaml::Mapping::new(); fm.insert( serde_yaml::Value::from("id"), serde_yaml::Value::from(format!("{}-index", slug)), ); fm.insert( serde_yaml::Value::from("type"), serde_yaml::Value::from("project"), ); fm.insert( serde_yaml::Value::from("title"), serde_yaml::Value::from(name), ); fm.insert( serde_yaml::Value::from("created"), serde_yaml::Value::from(now.clone()), ); fm.insert( serde_yaml::Value::from("updated"), serde_yaml::Value::from(now.clone()), ); let content = frontmatter::serialize_frontmatter(&fm, &format!("# {}\n\n", name))?; filesystem::atomic_write(&index_path, content.as_bytes())?; // Also create notes directory for project-scoped notes fs::create_dir_all(project_dir.join("notes")).map_err(|e| e.to_string())?; // Create tasks directory for file-based tasks fs::create_dir_all(project_dir.join("tasks")).map_err(|e| e.to_string())?; Ok(Project { id: slug.clone(), name: name.to_string(), path: format!("projects/{}", slug), created: now, }) } async fn get_project_content(Path(id): Path) -> impl IntoResponse { let index_path = config::data_dir() .join("projects") .join(&id) .join("index.md"); if !index_path.exists() { return (StatusCode::NOT_FOUND, "Project not found").into_response(); } match fs::read_to_string(&index_path) { Ok(content) => { let (fm, body, _) = frontmatter::parse_frontmatter(&content); let name = fm .get(&serde_yaml::Value::from("title")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_else(|| id.clone()); let created = fm .get(&serde_yaml::Value::from("created")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); Json(ProjectWithContent { id: id.clone(), name, path: format!("projects/{}", id), created, content: body, }) .into_response() } Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read project: {}", err), ) .into_response(), } } async fn update_project_content(Path(id): Path, body: String) -> impl IntoResponse { let index_path = config::data_dir() .join("projects") .join(&id) .join("index.md"); if !index_path.exists() { return (StatusCode::NOT_FOUND, "Project not found").into_response(); } // Read existing file to get frontmatter let existing = match fs::read_to_string(&index_path) { Ok(content) => content, Err(err) => { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read project: {}", err), ) .into_response(); } }; let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing); // Update the timestamp let now = chrono::Utc::now().to_rfc3339(); fm.insert( serde_yaml::Value::from("updated"), serde_yaml::Value::from(now), ); // Serialize with new content let new_content = match frontmatter::serialize_frontmatter(&fm, &body) { Ok(c) => c, Err(err) => { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize: {}", err), ) .into_response(); } }; // Write back (atomic to prevent corruption) if let Err(err) = filesystem::atomic_write(&index_path, new_content.as_bytes()) { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to write file: {}", err), ) .into_response(); } let name = fm .get(&serde_yaml::Value::from("title")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_else(|| id.clone()); let created = fm .get(&serde_yaml::Value::from("created")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); Json(ProjectWithContent { id: id.clone(), name, path: format!("projects/{}", id), created, content: body, }) .into_response() } // ============ Project Notes Handlers ============ async fn list_project_notes(Path(project_id): Path) -> impl IntoResponse { let notes_dir = config::data_dir() .join("projects") .join(&project_id) .join("notes"); // Create notes directory if it doesn't exist if !notes_dir.exists() { if let Err(e) = fs::create_dir_all(¬es_dir) { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create notes directory: {}", e), ) .into_response(); } } let mut notes = Vec::new(); let entries = match fs::read_dir(¬es_dir) { Ok(e) => e, Err(err) => { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read notes directory: {}", err), ) .into_response(); } }; for entry in entries { let entry = match entry { Ok(e) => e, Err(_) => continue, }; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("md") { continue; } let content = match fs::read_to_string(&path) { Ok(c) => c, Err(_) => continue, }; let (fm, _, _) = frontmatter::parse_frontmatter(&content); let filename = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); let id = fm .get(&serde_yaml::Value::from("id")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_else(|| filename.clone()); let title = fm .get(&serde_yaml::Value::from("title")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_else(|| filename.clone()); let created = fm .get(&serde_yaml::Value::from("created")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); let updated = fm .get(&serde_yaml::Value::from("updated")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); notes.push(ProjectNote { id, title, path: format!("projects/{}/notes/{}.md", project_id, filename), project_id: project_id.clone(), created, updated, }); } // Sort by updated date descending // Sort by created date (stable ordering - won't change when note is viewed/edited) notes.sort_by(|a, b| b.created.cmp(&a.created)); Json(notes).into_response() } async fn create_project_note( Path(project_id): Path, Json(payload): Json, ) -> impl IntoResponse { use chrono::Utc; let notes_dir = config::data_dir() .join("projects") .join(&project_id) .join("notes"); // Create notes directory if it doesn't exist if let Err(e) = fs::create_dir_all(¬es_dir) { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create notes directory: {}", e), ) .into_response(); } // Generate filename from timestamp let now = Utc::now(); let filename = now.format("%Y%m%d-%H%M%S").to_string(); let note_path = notes_dir.join(format!("{}.md", filename)); let title = payload.title.unwrap_or_else(|| "Untitled".to_string()); let now_str = now.to_rfc3339(); let mut fm = serde_yaml::Mapping::new(); fm.insert( serde_yaml::Value::from("id"), serde_yaml::Value::from(format!("{}-{}", project_id, filename)), ); fm.insert( serde_yaml::Value::from("type"), serde_yaml::Value::from("note"), ); fm.insert( serde_yaml::Value::from("title"), serde_yaml::Value::from(title.clone()), ); fm.insert( serde_yaml::Value::from("project_id"), serde_yaml::Value::from(project_id.clone()), ); fm.insert( serde_yaml::Value::from("created"), serde_yaml::Value::from(now_str.clone()), ); fm.insert( serde_yaml::Value::from("updated"), serde_yaml::Value::from(now_str.clone()), ); let body = format!("# {}\n\n", title); let content = match frontmatter::serialize_frontmatter(&fm, &body) { Ok(c) => c, Err(err) => { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize frontmatter: {}", err), ) .into_response(); } }; if let Err(err) = filesystem::atomic_write(¬e_path, content.as_bytes()) { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to write note file: {}", err), ) .into_response(); } ( StatusCode::CREATED, Json(ProjectNoteWithContent { id: format!("{}-{}", project_id, filename), title, path: format!("projects/{}/notes/{}.md", project_id, filename), project_id, created: now_str.clone(), updated: now_str, content: body, }), ) .into_response() } async fn get_project_note( Path((project_id, note_id)): Path<(String, String)>, ) -> impl IntoResponse { let notes_dir = config::data_dir() .join("projects") .join(&project_id) .join("notes"); // Try to find the note by ID (which might be the filename) let note_path = notes_dir.join(format!("{}.md", note_id)); if !note_path.exists() { // Try to find by searching all notes for matching ID if let Ok(entries) = fs::read_dir(¬es_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("md") { continue; } if let Ok(content) = fs::read_to_string(&path) { let (fm, body, _) = frontmatter::parse_frontmatter(&content); let file_id = fm .get(&serde_yaml::Value::from("id")) .and_then(|v| v.as_str()) .map(String::from); if file_id.as_deref() == Some(¬e_id) { let title = fm .get(&serde_yaml::Value::from("title")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); let created = fm .get(&serde_yaml::Value::from("created")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); let updated = fm .get(&serde_yaml::Value::from("updated")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); let filename = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); return Json(ProjectNoteWithContent { id: note_id, title, path: format!("projects/{}/notes/{}.md", project_id, filename), project_id, created, updated, content: body, }) .into_response(); } } } } return (StatusCode::NOT_FOUND, "Note not found").into_response(); } let content = match fs::read_to_string(¬e_path) { Ok(c) => c, Err(err) => { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read note: {}", err), ) .into_response(); } }; let (fm, body, _) = frontmatter::parse_frontmatter(&content); let id = fm .get(&serde_yaml::Value::from("id")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_else(|| note_id.clone()); let title = fm .get(&serde_yaml::Value::from("title")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); let created = fm .get(&serde_yaml::Value::from("created")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); let updated = fm .get(&serde_yaml::Value::from("updated")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); Json(ProjectNoteWithContent { id, title, path: format!("projects/{}/notes/{}.md", project_id, note_id), project_id, created, updated, content: body, }) .into_response() } async fn update_project_note( Path((project_id, note_id)): Path<(String, String)>, body: String, ) -> impl IntoResponse { let notes_dir = config::data_dir() .join("projects") .join(&project_id) .join("notes"); let note_path = notes_dir.join(format!("{}.md", note_id)); if !note_path.exists() { return (StatusCode::NOT_FOUND, "Note not found").into_response(); } // Read existing content for frontmatter let existing = match fs::read_to_string(¬e_path) { Ok(c) => c, Err(err) => { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read note: {}", err), ) .into_response(); } }; let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing); // Update timestamp let now = chrono::Utc::now().to_rfc3339(); fm.insert( serde_yaml::Value::from("updated"), serde_yaml::Value::from(now.clone()), ); // Serialize with new content let new_content = match frontmatter::serialize_frontmatter(&fm, &body) { Ok(c) => c, Err(err) => { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize: {}", err), ) .into_response(); } }; if let Err(err) = filesystem::atomic_write(¬e_path, new_content.as_bytes()) { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to write file: {}", err), ) .into_response(); } let id = fm .get(&serde_yaml::Value::from("id")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_else(|| note_id.clone()); let title = fm .get(&serde_yaml::Value::from("title")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); let created = fm .get(&serde_yaml::Value::from("created")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_default(); Json(ProjectNoteWithContent { id, title, path: format!("projects/{}/notes/{}.md", project_id, note_id), project_id, created, updated: now, content: body, }) .into_response() } async fn delete_project_note( Path((project_id, note_id)): Path<(String, String)>, ) -> impl IntoResponse { let notes_dir = config::data_dir() .join("projects") .join(&project_id) .join("notes"); let note_path = notes_dir.join(format!("{}.md", note_id)); if !note_path.exists() { return (StatusCode::NOT_FOUND, "Note not found").into_response(); } // Move to archive instead of deleting let archive_dir = config::data_dir().join("archive"); if let Err(e) = fs::create_dir_all(&archive_dir) { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create archive directory: {}", e), ) .into_response(); } let archive_path = archive_dir.join(format!("{}-{}.md", project_id, note_id)); if let Err(err) = fs::rename(¬e_path, &archive_path) { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to archive note: {}", err), ) .into_response(); } StatusCode::NO_CONTENT.into_response() }