Release v0.2.0: Task comments, recurring calendar, system tray, app branding
New features: - Task comments with date-stamped entries and last-comment summary - Recurring tasks expanded on calendar (daily/weekly/monthly/yearly) - System tray mode replacing CMD window (Windows/macOS/Linux) - Ironpad logo as exe icon, tray icon, favicon, and header logo Technical changes: - Backend restructured for dual-mode: dev (API-only) / prod (tray + server) - tray-item crate for cross-platform tray, winresource for icon embedding - Calendar view refactored with CalendarEntry interface for recurring merging - Added CHANGELOG.md, build-local.ps1, version bumped to 0.2.0 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
628
backend/Cargo.lock
generated
628
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ironpad"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -46,4 +46,15 @@ uuid = { version = "1.0", features = ["v4"] }
|
||||
|
||||
# Utilities
|
||||
lazy_static = "1.4"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
# System tray (production mode)
|
||||
tray-item = "0.10"
|
||||
|
||||
# Windows icon loading (for tray icon)
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.52", features = ["Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader"] }
|
||||
|
||||
# Build dependencies (Windows icon embedding)
|
||||
[target.'cfg(target_os = "windows")'.build-dependencies]
|
||||
winresource = "0.1"
|
||||
BIN
backend/assets/icon-32x32.png
Normal file
BIN
backend/assets/icon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
backend/assets/ironpad.ico
Normal file
BIN
backend/assets/ironpad.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
10
backend/build.rs
Normal file
10
backend/build.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
fn main() {
|
||||
// On Windows, embed the application icon into the .exe
|
||||
// This sets both the Explorer icon and makes it available as a resource
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
res.set_icon("assets/ironpad.ico");
|
||||
res.compile().expect("Failed to compile Windows resources");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Hide console window on Windows in release builds (production mode)
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -26,17 +29,29 @@ async fn find_available_port() -> (TcpListener, u16) {
|
||||
panic!("No available ports in range 3000–3010");
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Logging
|
||||
fn main() {
|
||||
tracing_subscriber::fmt().init();
|
||||
|
||||
// Resolve data directory (production vs development mode)
|
||||
config::init_data_dir();
|
||||
|
||||
// Find port and bind (listener kept alive to avoid race condition)
|
||||
if config::is_production() {
|
||||
run_with_tray();
|
||||
} else {
|
||||
// Development mode: normal tokio runtime, no tray
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
rt.block_on(run_server(None));
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the Axum server. In tray mode, sends the bound port through `port_tx`
|
||||
/// before entering the serve loop.
|
||||
async fn run_server(port_tx: Option<std::sync::mpsc::Sender<u16>>) {
|
||||
let (listener, port) = find_available_port().await;
|
||||
|
||||
// Notify tray thread of the bound port
|
||||
if let Some(tx) = port_tx {
|
||||
let _ = tx.send(port);
|
||||
}
|
||||
|
||||
// WebSocket state (shared across handlers)
|
||||
let ws_state = Arc::new(websocket::WsState::new());
|
||||
|
||||
@@ -94,7 +109,6 @@ async fn main() {
|
||||
.layer(cors);
|
||||
|
||||
// Check for embedded frontend (production mode)
|
||||
// Resolve relative to the executable's directory, not the working directory
|
||||
let has_frontend = config::is_production();
|
||||
|
||||
if has_frontend {
|
||||
@@ -116,23 +130,110 @@ async fn main() {
|
||||
}
|
||||
|
||||
// Start server
|
||||
info!("🚀 Ironpad running on http://localhost:{port}");
|
||||
|
||||
// Auto-open browser in production mode
|
||||
if has_frontend {
|
||||
let url = format!("http://localhost:{}", port);
|
||||
tokio::spawn(async move {
|
||||
// Small delay to ensure server is ready
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
if let Err(e) = webbrowser::open(&url) {
|
||||
tracing::warn!(
|
||||
"Failed to open browser: {}. Open http://localhost:{} manually.",
|
||||
e,
|
||||
port
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
info!("Ironpad running on http://localhost:{port}");
|
||||
|
||||
axum::serve(listener, app).await.expect("Server failed");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System tray (production mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a platform-appropriate tray icon.
|
||||
///
|
||||
/// On Windows the Ironpad icon is embedded in the .exe via winresource (build.rs).
|
||||
/// We load it with LoadIconW using the resource ID assigned by winresource.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn tray_icon() -> tray_item::IconSource {
|
||||
let hicon = unsafe {
|
||||
// winresource embeds the icon at resource ID 1.
|
||||
// GetModuleHandleW(null) = current exe, MAKEINTRESOURCE(1) = 1 as PCWSTR.
|
||||
let hinstance =
|
||||
windows_sys::Win32::System::LibraryLoader::GetModuleHandleW(std::ptr::null());
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::LoadIconW(hinstance, 1 as *const u16)
|
||||
};
|
||||
tray_item::IconSource::RawIcon(hicon)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn tray_icon() -> tray_item::IconSource {
|
||||
tray_item::IconSource::Resource("")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn tray_icon() -> tray_item::IconSource {
|
||||
tray_item::IconSource::Resource("application-x-executable")
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
fn tray_icon() -> tray_item::IconSource {
|
||||
tray_item::IconSource::Resource("")
|
||||
}
|
||||
|
||||
/// Production mode: run the server on a background thread, tray on main thread.
|
||||
/// The main thread drives the tray event loop (required on macOS; safe everywhere).
|
||||
fn run_with_tray() {
|
||||
use std::sync::mpsc;
|
||||
|
||||
enum TrayMessage {
|
||||
OpenBrowser,
|
||||
Quit,
|
||||
}
|
||||
|
||||
// Channel to receive the dynamically-bound port from the server thread
|
||||
let (port_tx, port_rx) = mpsc::channel::<u16>();
|
||||
|
||||
// Start the Axum server on a background thread with its own tokio runtime
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
rt.block_on(run_server(Some(port_tx)));
|
||||
});
|
||||
|
||||
// Wait for the server to report its port
|
||||
let port = port_rx.recv().expect("Server failed to start");
|
||||
let url = format!("http://localhost:{}", port);
|
||||
|
||||
// Auto-open browser after a short delay (non-blocking)
|
||||
let url_for_open = url.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
let _ = webbrowser::open(&url_for_open);
|
||||
});
|
||||
|
||||
// Set up system tray icon and menu
|
||||
let (tx, rx) = mpsc::sync_channel::<TrayMessage>(2);
|
||||
|
||||
let mut tray = match tray_item::TrayItem::new("Ironpad", tray_icon()) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create system tray: {}. Running headless.", e);
|
||||
// Keep the process alive so the server thread continues
|
||||
loop {
|
||||
std::thread::park();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let tx_open = tx.clone();
|
||||
let _ = tray.add_menu_item("Open in Browser", move || {
|
||||
let _ = tx_open.send(TrayMessage::OpenBrowser);
|
||||
});
|
||||
|
||||
let tx_quit = tx;
|
||||
let _ = tray.add_menu_item("Quit", move || {
|
||||
let _ = tx_quit.send(TrayMessage::Quit);
|
||||
});
|
||||
|
||||
// Main-thread event loop — processes tray menu actions
|
||||
for msg in rx {
|
||||
match msg {
|
||||
TrayMessage::OpenBrowser => {
|
||||
let _ = webbrowser::open(&url);
|
||||
}
|
||||
TrayMessage::Quit => {
|
||||
info!("Quit requested from system tray");
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ use std::fs;
|
||||
|
||||
use crate::config;
|
||||
use crate::routes::tasks::{
|
||||
create_task_handler, delete_task_handler, get_task_handler, list_project_tasks_handler,
|
||||
toggle_task_handler, update_task_content_handler, update_task_meta_handler, CreateTaskRequest,
|
||||
add_comment_handler, create_task_handler, delete_comment_handler, delete_task_handler,
|
||||
get_task_handler, list_project_tasks_handler, toggle_task_handler,
|
||||
update_task_content_handler, update_task_meta_handler, AddCommentRequest, CreateTaskRequest,
|
||||
UpdateTaskMetaRequest,
|
||||
};
|
||||
use crate::services::filesystem;
|
||||
@@ -91,6 +92,14 @@ pub fn router() -> Router {
|
||||
)
|
||||
.route("/{id}/tasks/{task_id}/toggle", put(toggle_project_task))
|
||||
.route("/{id}/tasks/{task_id}/meta", put(update_project_task_meta))
|
||||
.route(
|
||||
"/{id}/tasks/{task_id}/comments",
|
||||
axum::routing::post(add_project_task_comment),
|
||||
)
|
||||
.route(
|
||||
"/{id}/tasks/{task_id}/comments/{comment_index}",
|
||||
axum::routing::delete(delete_project_task_comment),
|
||||
)
|
||||
// Note routes
|
||||
.route(
|
||||
"/{id}/notes",
|
||||
@@ -143,6 +152,19 @@ async fn delete_project_task(Path((id, task_id)): Path<(String, String)>) -> imp
|
||||
delete_task_handler(id, task_id).await
|
||||
}
|
||||
|
||||
async fn add_project_task_comment(
|
||||
Path((id, task_id)): Path<(String, String)>,
|
||||
Json(payload): Json<AddCommentRequest>,
|
||||
) -> impl IntoResponse {
|
||||
add_comment_handler(id, task_id, payload).await
|
||||
}
|
||||
|
||||
async fn delete_project_task_comment(
|
||||
Path((id, task_id, comment_index)): Path<(String, String, usize)>,
|
||||
) -> impl IntoResponse {
|
||||
delete_comment_handler(id, task_id, comment_index).await
|
||||
}
|
||||
|
||||
async fn list_projects() -> impl IntoResponse {
|
||||
match list_projects_impl() {
|
||||
Ok(projects) => Json(projects).into_response(),
|
||||
|
||||
@@ -7,6 +7,13 @@ use crate::config;
|
||||
use crate::services::filesystem;
|
||||
use crate::services::frontmatter;
|
||||
|
||||
/// A single comment entry on a task
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Comment {
|
||||
pub date: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Task summary for list views
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Task {
|
||||
@@ -25,6 +32,7 @@ pub struct Task {
|
||||
pub path: String,
|
||||
pub created: String,
|
||||
pub updated: String,
|
||||
pub last_comment: Option<String>,
|
||||
}
|
||||
|
||||
/// Task with full content for detail view
|
||||
@@ -46,6 +54,7 @@ pub struct TaskWithContent {
|
||||
pub created: String,
|
||||
pub updated: String,
|
||||
pub content: String,
|
||||
pub comments: Vec<Comment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -67,6 +76,11 @@ pub struct UpdateTaskMetaRequest {
|
||||
pub recurrence_interval: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddCommentRequest {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new().route("/", get(list_all_tasks_handler))
|
||||
}
|
||||
@@ -178,6 +192,43 @@ pub async fn delete_task_handler(project_id: String, task_id: String) -> impl In
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a comment to a task
|
||||
pub async fn add_comment_handler(
|
||||
project_id: String,
|
||||
task_id: String,
|
||||
payload: AddCommentRequest,
|
||||
) -> impl IntoResponse {
|
||||
match add_comment_impl(&project_id, &task_id, &payload.text) {
|
||||
Ok(task) => (StatusCode::CREATED, 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 add comment: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a comment from a task by index
|
||||
pub async fn delete_comment_handler(
|
||||
project_id: String,
|
||||
task_id: String,
|
||||
comment_index: usize,
|
||||
) -> impl IntoResponse {
|
||||
match delete_comment_impl(&project_id, &task_id, comment_index) {
|
||||
Ok(task) => Json(task).into_response(),
|
||||
Err(err) if err.contains("not found") => (StatusCode::NOT_FOUND, err).into_response(),
|
||||
Err(err) if err.contains("out of range") => {
|
||||
(StatusCode::BAD_REQUEST, err).into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to delete comment: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Implementation Functions ============
|
||||
|
||||
fn get_tasks_dir(project_id: &str) -> std::path::PathBuf {
|
||||
@@ -233,6 +284,29 @@ fn list_project_tasks_impl(project_id: &str) -> Result<Vec<Task>, String> {
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
/// Parse comments from frontmatter YAML sequence.
|
||||
fn parse_comments(fm: &serde_yaml::Mapping) -> Vec<Comment> {
|
||||
fm.get(&serde_yaml::Value::from("comments"))
|
||||
.and_then(|v| v.as_sequence())
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
.filter_map(|item| {
|
||||
let map = item.as_mapping()?;
|
||||
let date = map
|
||||
.get(&serde_yaml::Value::from("date"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)?;
|
||||
let text = map
|
||||
.get(&serde_yaml::Value::from("text"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)?;
|
||||
Some(Comment { date, text })
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -242,6 +316,9 @@ fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &st
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let comments = parse_comments(fm);
|
||||
let last_comment = comments.last().map(|c| c.text.clone());
|
||||
|
||||
Task {
|
||||
id: frontmatter::get_str_or(fm, "id", &filename),
|
||||
title: frontmatter::get_str_or(fm, "title", "Untitled"),
|
||||
@@ -258,6 +335,7 @@ fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &st
|
||||
path: format!("projects/{}/tasks/{}.md", project_id, filename),
|
||||
created: frontmatter::get_str_or(fm, "created", ""),
|
||||
updated: frontmatter::get_str_or(fm, "updated", ""),
|
||||
last_comment,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +433,7 @@ fn create_task_impl(
|
||||
created: now_str.clone(),
|
||||
updated: now_str,
|
||||
content: body,
|
||||
comments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -407,6 +486,7 @@ fn parse_task_with_content(
|
||||
project_id: &str,
|
||||
) -> Result<TaskWithContent, String> {
|
||||
let task = extract_task_fields(fm, path, project_id);
|
||||
let comments = parse_comments(fm);
|
||||
Ok(TaskWithContent {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
@@ -424,6 +504,7 @@ fn parse_task_with_content(
|
||||
created: task.created,
|
||||
updated: task.updated,
|
||||
content: body.to_string(),
|
||||
comments,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -683,6 +764,7 @@ fn create_recurring_task_impl(
|
||||
created: now_str.clone(),
|
||||
updated: now_str,
|
||||
content: body,
|
||||
comments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -776,6 +858,102 @@ fn update_task_meta_impl(
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
/// Serialize a Vec<Comment> into a YAML sequence Value.
|
||||
fn comments_to_yaml(comments: &[Comment]) -> serde_yaml::Value {
|
||||
let seq: Vec<serde_yaml::Value> = comments
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut map = serde_yaml::Mapping::new();
|
||||
map.insert(
|
||||
serde_yaml::Value::from("date"),
|
||||
serde_yaml::Value::from(c.date.as_str()),
|
||||
);
|
||||
map.insert(
|
||||
serde_yaml::Value::from("text"),
|
||||
serde_yaml::Value::from(c.text.as_str()),
|
||||
);
|
||||
serde_yaml::Value::Mapping(map)
|
||||
})
|
||||
.collect();
|
||||
serde_yaml::Value::Sequence(seq)
|
||||
}
|
||||
|
||||
fn add_comment_impl(
|
||||
project_id: &str,
|
||||
task_id: &str,
|
||||
text: &str,
|
||||
) -> Result<TaskWithContent, String> {
|
||||
let task_path = find_task_path(project_id, task_id)?;
|
||||
|
||||
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
|
||||
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
|
||||
|
||||
// Parse existing comments and append the new one
|
||||
let mut comments = parse_comments(&fm);
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
comments.push(Comment {
|
||||
date: now.clone(),
|
||||
text: text.to_string(),
|
||||
});
|
||||
|
||||
// Write comments back to frontmatter
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("comments"),
|
||||
comments_to_yaml(&comments),
|
||||
);
|
||||
|
||||
// Update timestamp
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("updated"),
|
||||
serde_yaml::Value::from(now),
|
||||
);
|
||||
|
||||
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
|
||||
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
|
||||
|
||||
parse_task_with_content(&fm, &body, &task_path, project_id)
|
||||
}
|
||||
|
||||
fn delete_comment_impl(
|
||||
project_id: &str,
|
||||
task_id: &str,
|
||||
comment_index: usize,
|
||||
) -> Result<TaskWithContent, String> {
|
||||
let task_path = find_task_path(project_id, task_id)?;
|
||||
|
||||
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
|
||||
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
|
||||
|
||||
let mut comments = parse_comments(&fm);
|
||||
if comment_index >= comments.len() {
|
||||
return Err("Comment index out of range".to_string());
|
||||
}
|
||||
|
||||
comments.remove(comment_index);
|
||||
|
||||
// Write comments back (or remove key if empty)
|
||||
if comments.is_empty() {
|
||||
fm.remove(&serde_yaml::Value::from("comments"));
|
||||
} else {
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("comments"),
|
||||
comments_to_yaml(&comments),
|
||||
);
|
||||
}
|
||||
|
||||
// Update timestamp
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("updated"),
|
||||
serde_yaml::Value::from(now),
|
||||
);
|
||||
|
||||
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
|
||||
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
|
||||
|
||||
parse_task_with_content(&fm, &body, &task_path, project_id)
|
||||
}
|
||||
|
||||
fn delete_task_impl(project_id: &str, task_id: &str) -> Result<(), String> {
|
||||
let task_path = find_task_path(project_id, task_id)?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user