Initial release: Ironpad v0.1.0 - Local-first, file-based project and knowledge management system. Rust backend, Vue 3 frontend, Milkdown editor, Git integration, cross-platform builds. Built with AI using Open Method.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
skepsismusic
2026-02-06 00:13:31 +01:00
commit ebe3e2aa8f
97 changed files with 25033 additions and 0 deletions

View File

@@ -0,0 +1,265 @@
use axum::{
body::Body,
extract::{Multipart, Path, Query},
http::{header, StatusCode},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::Path as StdPath;
use tokio_util::io::ReaderStream;
use crate::config;
const MAX_FILE_SIZE: usize = 10 * 1024 * 1024; // 10MB
#[derive(Debug, Deserialize)]
pub struct UploadQuery {
pub project: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UploadResponse {
pub url: String,
pub filename: String,
pub size: usize,
}
pub fn router() -> Router {
Router::new()
.route("/upload", post(upload_asset))
.route("/{project}/{filename}", get(get_asset))
}
async fn upload_asset(
Query(query): Query<UploadQuery>,
mut multipart: Multipart,
) -> impl IntoResponse {
// Determine target directory
let assets_dir = if let Some(project_id) = &query.project {
config::data_dir()
.join("projects")
.join(project_id)
.join("assets")
} else {
config::data_dir().join("notes").join("assets")
};
// Create assets directory if it doesn't exist
if !assets_dir.exists() {
if let Err(e) = fs::create_dir_all(&assets_dir) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create assets directory: {}", e),
)
.into_response();
}
}
// Process uploaded file
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("file").to_string();
if name != "file" {
continue;
}
let original_filename = field
.file_name()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("upload_{}", chrono::Utc::now().timestamp()));
// Validate file type (images only for now)
let content_type = field
.content_type()
.map(|s| s.to_string())
.unwrap_or_default();
if !is_allowed_content_type(&content_type) {
return (
StatusCode::BAD_REQUEST,
format!("Unsupported file type: {}. Only images are allowed.", content_type),
)
.into_response();
}
// Read file data
let data = match field.bytes().await {
Ok(bytes) => bytes,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
format!("Failed to read file data: {}", e),
)
.into_response();
}
};
// Check file size
if data.len() > MAX_FILE_SIZE {
return (
StatusCode::BAD_REQUEST,
format!("File too large. Maximum size is {} MB.", MAX_FILE_SIZE / 1024 / 1024),
)
.into_response();
}
// Generate unique filename if needed
let filename = generate_unique_filename(&assets_dir, &original_filename);
let file_path = assets_dir.join(&filename);
// Write file
let mut file = match fs::File::create(&file_path) {
Ok(f) => f,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create file: {}", e),
)
.into_response();
}
};
if let Err(e) = file.write_all(&data) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to write file: {}", e),
)
.into_response();
}
// Build response URL
let project_part = query.project.as_deref().unwrap_or("notes");
let url = format!("/api/assets/{}/{}", project_part, filename);
return (
StatusCode::CREATED,
Json(UploadResponse {
url,
filename,
size: data.len(),
}),
)
.into_response();
}
(StatusCode::BAD_REQUEST, "No file provided").into_response()
}
/// Validate that a path component doesn't contain directory traversal
fn validate_path_component(component: &str) -> Result<(), String> {
if component.contains("..") || component.contains('/') || component.contains('\\') || component.is_empty() {
return Err("Invalid path component".to_string());
}
Ok(())
}
async fn get_asset(Path((project, filename)): Path<(String, String)>) -> impl IntoResponse {
// Validate path components to prevent directory traversal
if validate_path_component(&project).is_err() || validate_path_component(&filename).is_err() {
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
}
// Determine file path
let file_path = if project == "notes" {
config::data_dir()
.join("notes")
.join("assets")
.join(&filename)
} else {
config::data_dir()
.join("projects")
.join(&project)
.join("assets")
.join(&filename)
};
// Check if file exists
if !file_path.exists() {
return (StatusCode::NOT_FOUND, "Asset not found").into_response();
}
// Read file
let file = match tokio::fs::File::open(&file_path).await {
Ok(f) => f,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to open file: {}", e),
)
.into_response();
}
};
// Determine content type
let content_type = get_content_type(&filename);
// Stream file response
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
(
StatusCode::OK,
[(header::CONTENT_TYPE, content_type)],
body,
)
.into_response()
}
fn is_allowed_content_type(content_type: &str) -> bool {
matches!(
content_type,
"image/jpeg"
| "image/png"
| "image/gif"
| "image/webp"
| "image/svg+xml"
| "application/pdf"
)
}
fn get_content_type(filename: &str) -> &'static str {
let ext = filename
.rsplit('.')
.next()
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
}
fn generate_unique_filename(dir: &StdPath, original: &str) -> String {
// Extract name and extension
let (name, ext) = if let Some(dot_idx) = original.rfind('.') {
(&original[..dot_idx], &original[dot_idx..])
} else {
(original, "")
};
// Sanitize filename
let sanitized_name: String = name
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
let base_filename = format!("{}{}", sanitized_name, ext);
let target_path = dir.join(&base_filename);
// If file doesn't exist, use original name
if !target_path.exists() {
return base_filename;
}
// Otherwise, add timestamp
let timestamp = chrono::Utc::now().timestamp_millis();
format!("{}_{}{}", sanitized_name, timestamp, ext)
}

319
backend/src/routes/daily.rs Normal file
View File

@@ -0,0 +1,319 @@
use axum::{
body::Bytes,
extract::Path,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use chrono::{NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use crate::services::filesystem;
use crate::config;
use crate::services::frontmatter;
#[derive(Debug, Serialize)]
pub struct DailyNote {
pub id: String,
pub date: String,
pub path: String,
pub content: String,
pub frontmatter: serde_yaml::Mapping,
}
#[derive(Debug, Serialize)]
pub struct DailyNoteSummary {
pub id: String,
pub date: String,
pub path: String,
pub title: String,
}
pub fn router() -> Router {
Router::new()
.route("/", get(list_daily_notes))
.route("/today", get(get_or_create_today))
.route("/{date}", get(get_daily_note).post(create_daily_note).put(update_daily_note))
}
/// List all daily notes
async fn list_daily_notes() -> impl IntoResponse {
match list_daily_notes_impl() {
Ok(notes) => Json(notes).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list daily notes: {}", err),
)
.into_response(),
}
}
fn list_daily_notes_impl() -> Result<Vec<DailyNoteSummary>, String> {
let daily_dir = config::data_dir().join("daily");
// Create directory if it doesn't exist
if !daily_dir.exists() {
fs::create_dir_all(&daily_dir).map_err(|e| e.to_string())?;
return Ok(Vec::new());
}
let mut notes = Vec::new();
for entry in fs::read_dir(&daily_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
// Validate date format
if NaiveDate::parse_from_str(filename, "%Y-%m-%d").is_err() {
continue;
}
let content = fs::read_to_string(&path).map_err(|e| e.to_string())?;
let (fm, _, _) = frontmatter::parse_frontmatter(&content);
let title = fm
.get(&serde_yaml::Value::from("title"))
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| filename.to_string());
notes.push(DailyNoteSummary {
id: format!("daily-{}", filename),
date: filename.to_string(),
path: format!("daily/{}.md", filename),
title,
});
}
// Sort by date descending
notes.sort_by(|a, b| b.date.cmp(&a.date));
Ok(notes)
}
/// Get or create today's daily note
async fn get_or_create_today() -> impl IntoResponse {
let today = Utc::now().format("%Y-%m-%d").to_string();
match get_daily_note_impl(&today) {
Ok(note) => Json(note).into_response(),
Err(_) => {
// Note doesn't exist, create it with default template
match create_daily_note_impl(&today, None) {
Ok(note) => (StatusCode::CREATED, Json(note)).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create today's note: {}", err),
)
.into_response(),
}
}
}
}
/// Get a daily note by date
async fn get_daily_note(Path(date): Path<String>) -> impl IntoResponse {
// Validate date format
if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response();
}
match get_daily_note_impl(&date) {
Ok(note) => Json(note).into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get daily note: {}", err),
)
.into_response(),
}
}
fn get_daily_note_impl(date: &str) -> Result<DailyNote, String> {
let daily_dir = config::data_dir().join("daily");
let note_path = daily_dir.join(format!("{}.md", date));
if !note_path.exists() {
return Err(format!("Daily note not found: {}", date));
}
let content = fs::read_to_string(&note_path).map_err(|e| e.to_string())?;
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
Ok(DailyNote {
id: format!("daily-{}", date),
date: date.to_string(),
path: format!("daily/{}.md", date),
content: body,
frontmatter: fm,
})
}
#[derive(Debug, Deserialize)]
pub struct CreateDailyNoteRequest {
pub content: Option<String>,
}
/// Create a daily note (optionally with content)
async fn create_daily_note(
Path(date): Path<String>,
body: Option<Json<CreateDailyNoteRequest>>,
) -> impl IntoResponse {
// Validate date format
if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response();
}
let content = body.and_then(|b| b.content.clone());
match create_daily_note_impl(&date, content.as_deref()) {
Ok(note) => (StatusCode::CREATED, Json(note)).into_response(),
Err(err) if err.contains("already exists") => {
(StatusCode::CONFLICT, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create daily note: {}", err),
)
.into_response(),
}
}
fn create_daily_note_impl(date: &str, initial_content: Option<&str>) -> Result<DailyNote, String> {
let daily_dir = config::data_dir().join("daily");
// Create directory if it doesn't exist
if !daily_dir.exists() {
fs::create_dir_all(&daily_dir).map_err(|e| e.to_string())?;
}
let note_path = daily_dir.join(format!("{}.md", date));
if note_path.exists() {
return Err(format!("Daily note already exists: {}", date));
}
let now = Utc::now().to_rfc3339();
// Parse date for display
let parsed_date = NaiveDate::parse_from_str(date, "%Y-%m-%d")
.map_err(|e| e.to_string())?;
let display_date = parsed_date.format("%A, %B %d, %Y").to_string();
// Create frontmatter
let mut fm = serde_yaml::Mapping::new();
fm.insert(
serde_yaml::Value::from("id"),
serde_yaml::Value::from(format!("daily-{}", date)),
);
fm.insert(
serde_yaml::Value::from("type"),
serde_yaml::Value::from("daily"),
);
fm.insert(
serde_yaml::Value::from("title"),
serde_yaml::Value::from(display_date.clone()),
);
fm.insert(
serde_yaml::Value::from("date"),
serde_yaml::Value::from(date),
);
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),
);
// Use provided content or default template
let body = initial_content
.map(|c| c.to_string())
.unwrap_or_else(|| {
format!(
"# {}\n\n## Today's Focus\n\n- \n\n## Notes\n\n\n\n## Tasks\n\n- [ ] \n",
display_date
)
});
let content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&note_path, content.as_bytes())?;
Ok(DailyNote {
id: format!("daily-{}", date),
date: date.to_string(),
path: format!("daily/{}.md", date),
content: body,
frontmatter: fm,
})
}
/// Update a daily note's content
async fn update_daily_note(
Path(date): Path<String>,
body: Bytes,
) -> impl IntoResponse {
// Validate date format
if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response();
}
let content = String::from_utf8_lossy(&body).to_string();
match update_daily_note_impl(&date, &content) {
Ok(note) => Json(note).into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update daily note: {}", err),
)
.into_response(),
}
}
fn update_daily_note_impl(date: &str, new_content: &str) -> Result<DailyNote, String> {
let daily_dir = config::data_dir().join("daily");
let note_path = daily_dir.join(format!("{}.md", date));
if !note_path.exists() {
return Err(format!("Daily note not found: {}", date));
}
// Read existing file to preserve frontmatter
let existing_content = fs::read_to_string(&note_path).map_err(|e| e.to_string())?;
let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing_content);
// Update the 'updated' timestamp
let now = Utc::now().to_rfc3339();
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
// Serialize with updated frontmatter and new content (atomic write)
let file_content = frontmatter::serialize_frontmatter(&fm, new_content)?;
filesystem::atomic_write(&note_path, file_content.as_bytes())?;
Ok(DailyNote {
id: format!("daily-{}", date),
date: date.to_string(),
path: format!("daily/{}.md", date),
content: new_content.to_string(),
frontmatter: fm,
})
}

184
backend/src/routes/git.rs Normal file
View File

@@ -0,0 +1,184 @@
use axum::{
extract::{Path, Query},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::services::git;
pub fn router() -> Router {
Router::new()
.route("/status", get(get_status))
.route("/commit", post(commit))
.route("/init", post(init_repo))
.route("/conflicts", get(get_conflicts))
.route("/push", post(push))
.route("/log", get(get_log))
.route("/diff", get(get_working_diff))
.route("/diff/{commit_id}", get(get_commit_diff))
.route("/remote", get(get_remote))
.route("/fetch", post(fetch))
}
async fn get_status() -> impl IntoResponse {
match git::get_status() {
Ok(status) => Json(status).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get git status: {}", err),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct CommitRequest {
message: Option<String>,
}
async fn commit(Json(payload): Json<CommitRequest>) -> impl IntoResponse {
match git::commit_all(payload.message.as_deref()) {
Ok(info) => (StatusCode::CREATED, Json(info)).into_response(),
Err(err) => (StatusCode::BAD_REQUEST, err).into_response(),
}
}
async fn init_repo() -> impl IntoResponse {
match git::init_repo() {
Ok(_) => StatusCode::OK.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to init repo: {}", err),
)
.into_response(),
}
}
async fn get_conflicts() -> impl IntoResponse {
match git::check_conflicts() {
Ok(conflicts) => Json(conflicts).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to check conflicts: {}", err),
)
.into_response(),
}
}
#[derive(Debug, Serialize)]
struct PushResponse {
success: bool,
message: String,
}
async fn push() -> impl IntoResponse {
// Check if remote is configured
if !git::has_remote() {
return (
StatusCode::BAD_REQUEST,
Json(PushResponse {
success: false,
message: "No remote repository configured. Add a remote with: git remote add origin <url>".to_string(),
}),
)
.into_response();
}
match git::push_to_remote() {
Ok(()) => (
StatusCode::OK,
Json(PushResponse {
success: true,
message: "Successfully pushed to remote".to_string(),
}),
)
.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(PushResponse {
success: false,
message: err,
}),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct LogQuery {
limit: Option<usize>,
}
async fn get_log(Query(query): Query<LogQuery>) -> impl IntoResponse {
match git::get_log(query.limit) {
Ok(commits) => Json(commits).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get git log: {}", err),
)
.into_response(),
}
}
async fn get_working_diff() -> impl IntoResponse {
match git::get_working_diff() {
Ok(diff) => Json(diff).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get diff: {}", err),
)
.into_response(),
}
}
async fn get_commit_diff(Path(commit_id): Path<String>) -> impl IntoResponse {
match git::get_commit_diff(&commit_id) {
Ok(diff) => Json(diff).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get commit diff: {}", err),
)
.into_response(),
}
}
async fn get_remote() -> impl IntoResponse {
match git::get_remote_info() {
Ok(info) => Json(info).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get remote info: {}", err),
)
.into_response(),
}
}
#[derive(Debug, Serialize)]
struct FetchResponse {
success: bool,
message: String,
}
async fn fetch() -> impl IntoResponse {
match git::fetch_from_remote() {
Ok(()) => (
StatusCode::OK,
Json(FetchResponse {
success: true,
message: "Successfully fetched from remote".to_string(),
}),
)
.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(FetchResponse {
success: false,
message: err,
}),
)
.into_response(),
}
}

View File

@@ -0,0 +1,7 @@
pub mod assets;
pub mod daily;
pub mod git;
pub mod notes;
pub mod projects;
pub mod search;
pub mod tasks;

View File

@@ -0,0 +1,82 @@
use axum::{
extract::Path,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use crate::models::note::{Note, NoteSummary};
use crate::services::filesystem;
pub fn router() -> Router {
Router::new()
.route("/{id}", get(get_note).put(update_note).delete(delete_note))
}
pub async fn list_notes() -> impl IntoResponse {
match filesystem::list_notes() {
Ok(notes) => Json::<Vec<NoteSummary>>(notes).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list notes: {}", err),
)
.into_response(),
}
}
async fn get_note(Path(id): Path<String>) -> impl IntoResponse {
match filesystem::read_note_by_id(&id) {
Ok(note) => Json::<Note>(note).into_response(),
Err(err) if err.starts_with("Note not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read note: {}", err),
)
.into_response(),
}
}
pub async fn create_note() -> impl IntoResponse {
match filesystem::create_note() {
Ok(note) => (StatusCode::CREATED, Json::<Note>(note)).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create note: {}", err),
)
.into_response(),
}
}
async fn update_note(
Path(id): Path<String>,
body: String,
) -> impl IntoResponse {
match filesystem::update_note(&id, &body) {
Ok(note) => Json::<Note>(note).into_response(),
Err(err) if err.starts_with("Note not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update note: {}", err),
)
.into_response(),
}
}
async fn delete_note(Path(id): Path<String>) -> impl IntoResponse {
match filesystem::archive_note(&id) {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(err) if err.starts_with("Note not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to archive note: {}", err),
)
.into_response(),
}
}

View File

@@ -0,0 +1,860 @@
use axum::{
extract::Path,
http::StatusCode,
response::IntoResponse,
routing::{get, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::fs;
use crate::routes::tasks::{
CreateTaskRequest, UpdateTaskMetaRequest,
list_project_tasks_handler, create_task_handler, get_task_handler,
update_task_content_handler, toggle_task_handler, update_task_meta_handler,
delete_task_handler,
};
use crate::services::filesystem;
use crate::config;
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<String>,
}
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))
// 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<String>) -> impl IntoResponse {
list_project_tasks_handler(id).await
}
async fn create_project_task(
Path(id): Path<String>,
Json(payload): Json<CreateTaskRequest>,
) -> 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<UpdateTaskMetaRequest>,
) -> 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 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<Vec<Project>, 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<String>) -> 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<CreateProjectRequest>) -> 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<Project, String> {
use chrono::Utc;
// Create slug from name
let slug = name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.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<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();
}
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<String>,
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<String>) -> 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(&notes_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(&notes_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<String>,
Json(payload): Json<CreateNoteRequest>,
) -> 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(&notes_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(&note_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(&notes_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(&note_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(&note_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(&note_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(&note_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(&note_path, &archive_path) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to archive note: {}", err),
)
.into_response();
}
StatusCode::NO_CONTENT.into_response()
}

View File

@@ -0,0 +1,30 @@
use axum::{
extract::Query,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::Deserialize;
use crate::services::search;
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
q: String,
}
pub fn router() -> Router {
Router::new().route("/", get(search_notes))
}
async fn search_notes(Query(params): Query<SearchQuery>) -> impl IntoResponse {
match search::search_notes(&params.q) {
Ok(results) => Json(results).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Search failed: {}", err),
)
.into_response(),
}
}

835
backend/src/routes/tasks.rs Normal file
View File

@@ -0,0 +1,835 @@
use axum::{
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path as StdPath;
use crate::services::filesystem;
use crate::config;
use crate::services::frontmatter;
/// Task summary for list views
#[derive(Debug, Clone, Serialize)]
pub struct Task {
pub id: String,
pub title: String,
pub completed: bool,
pub section: String,
pub priority: Option<String>,
pub due_date: Option<String>,
pub is_active: bool,
pub tags: Vec<String>,
pub parent_id: Option<String>,
pub recurrence: Option<String>,
pub recurrence_interval: Option<u32>,
pub project_id: String,
pub path: String,
pub created: String,
pub updated: String,
}
/// Task with full content for detail view
#[derive(Debug, Clone, Serialize)]
pub struct TaskWithContent {
pub id: String,
pub title: String,
pub completed: bool,
pub section: String,
pub priority: Option<String>,
pub due_date: Option<String>,
pub is_active: bool,
pub tags: Vec<String>,
pub parent_id: Option<String>,
pub recurrence: Option<String>,
pub recurrence_interval: Option<u32>,
pub project_id: String,
pub path: String,
pub created: String,
pub updated: String,
pub content: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateTaskRequest {
pub title: String,
pub section: Option<String>,
pub parent_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateTaskMetaRequest {
pub title: Option<String>,
pub section: Option<String>,
pub priority: Option<String>,
pub due_date: Option<String>,
pub is_active: Option<bool>,
pub tags: Option<Vec<String>>,
pub recurrence: Option<String>,
pub recurrence_interval: Option<u32>,
}
pub fn router() -> Router {
Router::new()
.route("/", get(list_all_tasks_handler))
}
// ============ Handler Functions (called from projects.rs) ============
/// List all tasks for a project
pub async fn list_project_tasks_handler(project_id: String) -> impl IntoResponse {
match list_project_tasks_impl(&project_id) {
Ok(tasks) => Json(tasks).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list tasks: {}", err),
)
.into_response(),
}
}
/// Create a new task
pub async fn create_task_handler(
project_id: String,
payload: CreateTaskRequest,
) -> impl IntoResponse {
match create_task_impl(&project_id, &payload.title, payload.section.as_deref(), payload.parent_id.as_deref()) {
Ok(task) => (StatusCode::CREATED, Json(task)).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create task: {}", err),
)
.into_response(),
}
}
/// Get a task with content
pub async fn get_task_handler(project_id: String, task_id: String) -> impl IntoResponse {
match get_task_impl(&project_id, &task_id) {
Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get task: {}", err),
)
.into_response(),
}
}
/// Update task content (markdown body)
pub async fn update_task_content_handler(
project_id: String,
task_id: String,
body: String,
) -> impl IntoResponse {
match update_task_content_impl(&project_id, &task_id, &body) {
Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update task: {}", err),
)
.into_response(),
}
}
/// Toggle task completion
pub async fn toggle_task_handler(project_id: String, task_id: String) -> impl IntoResponse {
match toggle_task_impl(&project_id, &task_id) {
Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to toggle task: {}", err),
)
.into_response(),
}
}
/// Update task metadata (title, section, priority)
pub async fn update_task_meta_handler(
project_id: String,
task_id: String,
payload: UpdateTaskMetaRequest,
) -> impl IntoResponse {
match update_task_meta_impl(&project_id, &task_id, payload) {
Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update task metadata: {}", err),
)
.into_response(),
}
}
/// Delete (archive) a task
pub async fn delete_task_handler(project_id: String, task_id: String) -> impl IntoResponse {
match delete_task_impl(&project_id, &task_id) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) if err.contains("not found") => {
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to delete task: {}", err),
)
.into_response(),
}
}
// ============ Implementation Functions ============
fn get_tasks_dir(project_id: &str) -> std::path::PathBuf {
config::data_dir()
.join("projects")
.join(project_id)
.join("tasks")
}
fn ensure_tasks_dir(project_id: &str) -> Result<std::path::PathBuf, String> {
let tasks_dir = get_tasks_dir(project_id);
if !tasks_dir.exists() {
fs::create_dir_all(&tasks_dir).map_err(|e| e.to_string())?;
}
Ok(tasks_dir)
}
fn list_project_tasks_impl(project_id: &str) -> Result<Vec<Task>, String> {
let tasks_dir = ensure_tasks_dir(project_id)?;
let mut tasks = Vec::new();
let entries = match fs::read_dir(&tasks_dir) {
Ok(e) => e,
Err(_) => return Ok(Vec::new()), // No tasks directory yet
};
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,
};
if let Some(task) = parse_task_file(&content, &path, project_id) {
tasks.push(task);
}
}
// Sort by updated date descending (most recent first)
// Sort by created date (stable ordering - won't change when task is viewed/edited)
tasks.sort_by(|a, b| b.created.cmp(&a.created));
Ok(tasks)
}
/// Shared helper: extract common task fields from frontmatter.
/// Eliminates duplication between parse_task_file and parse_task_with_content.
fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &str) -> Task {
let filename = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
Task {
id: frontmatter::get_str_or(fm, "id", &filename),
title: frontmatter::get_str_or(fm, "title", "Untitled"),
completed: frontmatter::get_bool_or(fm, "completed", false),
section: frontmatter::get_str_or(fm, "section", "Active"),
priority: frontmatter::get_str(fm, "priority"),
due_date: frontmatter::get_str(fm, "due_date"),
is_active: frontmatter::get_bool_or(fm, "is_active", true),
tags: frontmatter::get_string_seq(fm, "tags"),
parent_id: frontmatter::get_str(fm, "parent_id"),
recurrence: frontmatter::get_str(fm, "recurrence"),
recurrence_interval: frontmatter::get_u64(fm, "recurrence_interval").map(|v| v as u32),
project_id: project_id.to_string(),
path: format!("projects/{}/tasks/{}.md", project_id, filename),
created: frontmatter::get_str_or(fm, "created", ""),
updated: frontmatter::get_str_or(fm, "updated", ""),
}
}
fn parse_task_file(content: &str, path: &StdPath, project_id: &str) -> Option<Task> {
let (fm, _, _) = frontmatter::parse_frontmatter(content);
Some(extract_task_fields(&fm, path, project_id))
}
fn create_task_impl(
project_id: &str,
title: &str,
section: Option<&str>,
parent_id: Option<&str>,
) -> Result<TaskWithContent, String> {
use chrono::Utc;
let tasks_dir = ensure_tasks_dir(project_id)?;
// Generate filename from timestamp
let now = Utc::now();
let filename = format!("task-{}", now.format("%Y%m%d-%H%M%S"));
let task_path = tasks_dir.join(format!("{}.md", filename));
let section = section.unwrap_or("Active").to_string();
let now_str = now.to_rfc3339();
let id = format!("{}-{}", project_id, filename);
let mut fm = serde_yaml::Mapping::new();
fm.insert(
serde_yaml::Value::from("id"),
serde_yaml::Value::from(id.clone()),
);
fm.insert(
serde_yaml::Value::from("type"),
serde_yaml::Value::from("task"),
);
fm.insert(
serde_yaml::Value::from("title"),
serde_yaml::Value::from(title),
);
fm.insert(
serde_yaml::Value::from("completed"),
serde_yaml::Value::from(false),
);
fm.insert(
serde_yaml::Value::from("section"),
serde_yaml::Value::from(section.clone()),
);
fm.insert(
serde_yaml::Value::from("priority"),
serde_yaml::Value::from("normal"),
);
fm.insert(
serde_yaml::Value::from("is_active"),
serde_yaml::Value::from(true),
);
fm.insert(
serde_yaml::Value::from("project_id"),
serde_yaml::Value::from(project_id),
);
if let Some(pid) = parent_id {
fm.insert(
serde_yaml::Value::from("parent_id"),
serde_yaml::Value::from(pid),
);
}
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 = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, content.as_bytes())?;
Ok(TaskWithContent {
id,
title: title.to_string(),
completed: false,
section,
priority: Some("normal".to_string()),
due_date: None,
is_active: true,
tags: Vec::new(),
parent_id: parent_id.map(String::from),
recurrence: None,
recurrence_interval: None,
project_id: project_id.to_string(),
path: format!("projects/{}/tasks/{}.md", project_id, filename),
created: now_str.clone(),
updated: now_str,
content: body,
})
}
fn get_task_impl(project_id: &str, task_id: &str) -> Result<TaskWithContent, String> {
let tasks_dir = get_tasks_dir(project_id);
// Try direct filename match first
let task_path = tasks_dir.join(format!("{}.md", task_id));
if task_path.exists() {
return read_task_with_content(&task_path, project_id);
}
// Search by ID in frontmatter
if let Ok(entries) = fs::read_dir(&tasks_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(task_id) {
return parse_task_with_content(&fm, &body, &path, project_id);
}
}
}
}
Err("Task not found".to_string())
}
fn read_task_with_content(path: &StdPath, project_id: &str) -> Result<TaskWithContent, String> {
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
parse_task_with_content(&fm, &body, path, project_id)
}
fn parse_task_with_content(
fm: &serde_yaml::Mapping,
body: &str,
path: &StdPath,
project_id: &str,
) -> Result<TaskWithContent, String> {
let task = extract_task_fields(fm, path, project_id);
Ok(TaskWithContent {
id: task.id,
title: task.title,
completed: task.completed,
section: task.section,
priority: task.priority,
due_date: task.due_date,
is_active: task.is_active,
tags: task.tags,
parent_id: task.parent_id,
recurrence: task.recurrence,
recurrence_interval: task.recurrence_interval,
project_id: task.project_id,
path: task.path,
created: task.created,
updated: task.updated,
content: body.to_string(),
})
}
fn update_task_content_impl(
project_id: &str,
task_id: &str,
new_body: &str,
) -> Result<TaskWithContent, String> {
let task_path = find_task_path(project_id, task_id)?;
// Read existing content
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
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),
);
// Serialize with new content (atomic write to prevent corruption)
let new_content = frontmatter::serialize_frontmatter(&fm, new_body)?;
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
parse_task_with_content(&fm, new_body, &task_path, project_id)
}
fn toggle_task_impl(project_id: &str, task_id: &str) -> Result<Task, String> {
let task_path = find_task_path(project_id, task_id)?;
// Read existing content
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
// Toggle completed
let current_completed = fm
.get(&serde_yaml::Value::from("completed"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let new_completed = !current_completed;
fm.insert(
serde_yaml::Value::from("completed"),
serde_yaml::Value::from(new_completed),
);
// Update section based on completion status
let new_section = if new_completed {
"Completed"
} else {
"Active"
};
fm.insert(
serde_yaml::Value::from("section"),
serde_yaml::Value::from(new_section),
);
// Update timestamp
let now = chrono::Utc::now().to_rfc3339();
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
// Serialize and write (atomic to prevent corruption)
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
// If completing a recurring task, create the next instance
if new_completed {
let recurrence = fm
.get(&serde_yaml::Value::from("recurrence"))
.and_then(|v| v.as_str())
.map(String::from);
if let Some(rec) = recurrence {
let interval = fm
.get(&serde_yaml::Value::from("recurrence_interval"))
.and_then(|v| v.as_u64())
.unwrap_or(1) as i64;
let title = fm
.get(&serde_yaml::Value::from("title"))
.and_then(|v| v.as_str())
.unwrap_or("Untitled")
.to_string();
let due_date = fm
.get(&serde_yaml::Value::from("due_date"))
.and_then(|v| v.as_str())
.map(String::from);
let tags = fm
.get(&serde_yaml::Value::from("tags"))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
// Calculate next due date
let next_due = calculate_next_due_date(due_date.as_deref(), &rec, interval);
// Create the next recurring task
let _ = create_recurring_task_impl(
project_id,
&title,
next_due.as_deref(),
&rec,
interval as u32,
&tags,
);
}
}
// Return updated task
let task = parse_task_file(&fs::read_to_string(&task_path).unwrap(), &task_path, project_id)
.ok_or_else(|| "Failed to parse updated task".to_string())?;
Ok(task)
}
fn calculate_next_due_date(current_due: Option<&str>, recurrence: &str, interval: i64) -> Option<String> {
use chrono::{NaiveDate, Duration, Utc, Months};
let base_date = if let Some(due_str) = current_due {
NaiveDate::parse_from_str(due_str, "%Y-%m-%d").unwrap_or_else(|_| Utc::now().date_naive())
} else {
Utc::now().date_naive()
};
let next = match recurrence {
"daily" => Some(base_date + Duration::days(interval)),
"weekly" => Some(base_date + Duration::weeks(interval)),
"monthly" => base_date.checked_add_months(Months::new(interval as u32)),
"yearly" => base_date.checked_add_months(Months::new((interval * 12) as u32)),
_ => None,
};
next.map(|d| d.format("%Y-%m-%d").to_string())
}
fn create_recurring_task_impl(
project_id: &str,
title: &str,
due_date: Option<&str>,
recurrence: &str,
interval: u32,
tags: &[String],
) -> Result<TaskWithContent, String> {
use chrono::Utc;
let tasks_dir = ensure_tasks_dir(project_id)?;
let now = Utc::now();
// Add a small suffix to avoid filename collision with completed task
let filename = format!("task-{}-r", now.format("%Y%m%d-%H%M%S"));
let task_path = tasks_dir.join(format!("{}.md", filename));
let now_str = now.to_rfc3339();
let id = format!("{}-{}", project_id, filename);
let mut fm = serde_yaml::Mapping::new();
fm.insert(serde_yaml::Value::from("id"), serde_yaml::Value::from(id.clone()));
fm.insert(serde_yaml::Value::from("type"), serde_yaml::Value::from("task"));
fm.insert(serde_yaml::Value::from("title"), serde_yaml::Value::from(title));
fm.insert(serde_yaml::Value::from("completed"), serde_yaml::Value::from(false));
fm.insert(serde_yaml::Value::from("section"), serde_yaml::Value::from("Active"));
fm.insert(serde_yaml::Value::from("priority"), serde_yaml::Value::from("normal"));
fm.insert(serde_yaml::Value::from("is_active"), serde_yaml::Value::from(true));
fm.insert(serde_yaml::Value::from("project_id"), serde_yaml::Value::from(project_id));
fm.insert(serde_yaml::Value::from("recurrence"), serde_yaml::Value::from(recurrence));
fm.insert(serde_yaml::Value::from("recurrence_interval"), serde_yaml::Value::from(interval as u64));
if let Some(due) = due_date {
fm.insert(serde_yaml::Value::from("due_date"), serde_yaml::Value::from(due));
}
if !tags.is_empty() {
let yaml_tags: Vec<serde_yaml::Value> = tags.iter().map(|t| serde_yaml::Value::from(t.as_str())).collect();
fm.insert(serde_yaml::Value::from("tags"), serde_yaml::Value::Sequence(yaml_tags));
}
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 = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, content.as_bytes())?;
Ok(TaskWithContent {
id,
title: title.to_string(),
completed: false,
section: "Active".to_string(),
priority: Some("normal".to_string()),
due_date: due_date.map(String::from),
is_active: true,
tags: tags.to_vec(),
parent_id: None,
recurrence: Some(recurrence.to_string()),
recurrence_interval: Some(interval),
project_id: project_id.to_string(),
path: format!("projects/{}/tasks/{}.md", project_id, filename),
created: now_str.clone(),
updated: now_str,
content: body,
})
}
fn update_task_meta_impl(
project_id: &str,
task_id: &str,
meta: UpdateTaskMetaRequest,
) -> Result<Task, String> {
let task_path = find_task_path(project_id, task_id)?;
// Read existing content
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
// Update fields if provided
if let Some(title) = meta.title {
fm.insert(
serde_yaml::Value::from("title"),
serde_yaml::Value::from(title),
);
}
if let Some(section) = meta.section {
fm.insert(
serde_yaml::Value::from("section"),
serde_yaml::Value::from(section),
);
}
if let Some(priority) = meta.priority {
fm.insert(
serde_yaml::Value::from("priority"),
serde_yaml::Value::from(priority),
);
}
if let Some(due_date) = meta.due_date {
fm.insert(
serde_yaml::Value::from("due_date"),
serde_yaml::Value::from(due_date),
);
}
if let Some(is_active) = meta.is_active {
fm.insert(
serde_yaml::Value::from("is_active"),
serde_yaml::Value::from(is_active),
);
}
if let Some(tags) = meta.tags {
let yaml_tags: Vec<serde_yaml::Value> =
tags.into_iter().map(serde_yaml::Value::from).collect();
fm.insert(
serde_yaml::Value::from("tags"),
serde_yaml::Value::Sequence(yaml_tags),
);
}
if let Some(recurrence) = meta.recurrence {
if recurrence.is_empty() {
fm.remove(&serde_yaml::Value::from("recurrence"));
fm.remove(&serde_yaml::Value::from("recurrence_interval"));
} else {
fm.insert(
serde_yaml::Value::from("recurrence"),
serde_yaml::Value::from(recurrence),
);
}
}
if let Some(interval) = meta.recurrence_interval {
fm.insert(
serde_yaml::Value::from("recurrence_interval"),
serde_yaml::Value::from(interval as u64),
);
}
// Update timestamp
let now = chrono::Utc::now().to_rfc3339();
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
// Serialize and write (atomic to prevent corruption)
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
// Return updated task
let task = parse_task_file(&fs::read_to_string(&task_path).unwrap(), &task_path, project_id)
.ok_or_else(|| "Failed to parse updated task".to_string())?;
Ok(task)
}
fn delete_task_impl(project_id: &str, task_id: &str) -> Result<(), String> {
let task_path = find_task_path(project_id, task_id)?;
// Move to archive
let archive_dir = config::data_dir().join("archive");
fs::create_dir_all(&archive_dir).map_err(|e| e.to_string())?;
let filename = task_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("task.md");
let archive_path = archive_dir.join(format!("{}-{}", project_id, filename));
fs::rename(&task_path, &archive_path).map_err(|e| e.to_string())?;
Ok(())
}
fn find_task_path(project_id: &str, task_id: &str) -> Result<std::path::PathBuf, String> {
let tasks_dir = get_tasks_dir(project_id);
// Try direct filename match
let direct_path = tasks_dir.join(format!("{}.md", task_id));
if direct_path.exists() {
return Ok(direct_path);
}
// Search by ID in frontmatter
if let Ok(entries) = fs::read_dir(&tasks_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, _, _) = frontmatter::parse_frontmatter(&content);
let file_id = fm
.get(&serde_yaml::Value::from("id"))
.and_then(|v| v.as_str());
if file_id == Some(task_id) {
return Ok(path);
}
}
}
}
Err("Task not found".to_string())
}
// ============ Legacy/Global Task Listing ============
async fn list_all_tasks_handler() -> impl IntoResponse {
match list_all_tasks_impl() {
Ok(tasks) => Json(tasks).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list tasks: {}", err),
)
.into_response(),
}
}
fn list_all_tasks_impl() -> Result<Vec<Task>, String> {
let projects_dir = config::data_dir().join("projects");
if !projects_dir.exists() {
return Ok(Vec::new());
}
let mut all_tasks = 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 project_path = entry.path();
if !project_path.is_dir() {
continue;
}
let project_id = project_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if let Ok(tasks) = list_project_tasks_impl(&project_id) {
all_tasks.extend(tasks);
}
}
// Sort all tasks by updated date descending
// Sort by created date (stable ordering)
all_tasks.sort_by(|a, b| b.created.cmp(&a.created));
Ok(all_tasks)
}