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:
265
backend/src/routes/assets.rs
Normal file
265
backend/src/routes/assets.rs
Normal 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
319
backend/src/routes/daily.rs
Normal 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(¬e_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(¬e_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(¬e_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(¬e_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
184
backend/src/routes/git.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
7
backend/src/routes/mod.rs
Normal file
7
backend/src/routes/mod.rs
Normal 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;
|
||||
82
backend/src/routes/notes.rs
Normal file
82
backend/src/routes/notes.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
860
backend/src/routes/projects.rs
Normal file
860
backend/src/routes/projects.rs
Normal 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(¬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<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(¬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()
|
||||
}
|
||||
30
backend/src/routes/search.rs
Normal file
30
backend/src/routes/search.rs
Normal 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(¶ms.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
835
backend/src/routes/tasks.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user