3 Commits

Author SHA1 Message Date
skepsismusic
f5bdf461ba Fix production mode: resolve static/data paths relative to exe location, include static/ in release packages
Some checks failed
Release / Build Linux (x86_64) (push) Has been cancelled
Release / Build macOS (x86_64) (push) Has been cancelled
Release / Build Windows (x86_64) (push) Has been cancelled
Release / Create Release (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 00:40:45 +01:00
skepsismusic
cd1cb27d49 Fix macOS build: vendor OpenSSL, improve release asset names
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 00:30:27 +01:00
skepsismusic
10d7cfa739 Run cargo fmt on all Rust source files
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 00:22:14 +01:00
21 changed files with 357 additions and 223 deletions

View File

@@ -10,21 +10,27 @@ permissions:
jobs: jobs:
build: build:
name: Build ${{ matrix.target }} name: Build ${{ matrix.name }}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- target: x86_64-unknown-linux-gnu - name: Linux (x86_64)
target: x86_64-unknown-linux-gnu
os: ubuntu-latest os: ubuntu-latest
archive: tar.gz archive: tar.gz
- target: x86_64-apple-darwin asset_name: ironpad-linux-x86_64
- name: macOS (x86_64)
target: x86_64-apple-darwin
os: macos-latest os: macos-latest
archive: tar.gz archive: tar.gz
- target: x86_64-pc-windows-msvc asset_name: ironpad-macos-x86_64
- name: Windows (x86_64)
target: x86_64-pc-windows-msvc
os: windows-latest os: windows-latest
archive: zip archive: zip
asset_name: ironpad-windows-x86_64
steps: steps:
- name: Checkout - name: Checkout
@@ -35,6 +41,10 @@ jobs:
with: with:
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y cmake
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@@ -60,10 +70,10 @@ jobs:
if: matrix.archive == 'tar.gz' if: matrix.archive == 'tar.gz'
shell: bash shell: bash
run: | run: |
BINARY_NAME="ironpad" RELEASE_DIR="${{ matrix.asset_name }}-${{ github.ref_name }}"
RELEASE_DIR="ironpad-${{ github.ref_name }}-${{ matrix.target }}"
mkdir -p "$RELEASE_DIR" mkdir -p "$RELEASE_DIR"
cp "backend/target/${{ matrix.target }}/release/$BINARY_NAME" "$RELEASE_DIR/" cp "backend/target/${{ matrix.target }}/release/ironpad" "$RELEASE_DIR/"
cp -r backend/static "$RELEASE_DIR/static"
cp README.md LICENSE "$RELEASE_DIR/" cp README.md LICENSE "$RELEASE_DIR/"
tar czf "$RELEASE_DIR.tar.gz" "$RELEASE_DIR" tar czf "$RELEASE_DIR.tar.gz" "$RELEASE_DIR"
echo "ASSET=$RELEASE_DIR.tar.gz" >> $GITHUB_ENV echo "ASSET=$RELEASE_DIR.tar.gz" >> $GITHUB_ENV
@@ -72,10 +82,10 @@ jobs:
if: matrix.archive == 'zip' if: matrix.archive == 'zip'
shell: bash shell: bash
run: | run: |
BINARY_NAME="ironpad.exe" RELEASE_DIR="${{ matrix.asset_name }}-${{ github.ref_name }}"
RELEASE_DIR="ironpad-${{ github.ref_name }}-${{ matrix.target }}"
mkdir -p "$RELEASE_DIR" mkdir -p "$RELEASE_DIR"
cp "backend/target/${{ matrix.target }}/release/$BINARY_NAME" "$RELEASE_DIR/" cp "backend/target/${{ matrix.target }}/release/ironpad.exe" "$RELEASE_DIR/"
cp -r backend/static "$RELEASE_DIR/static"
cp README.md LICENSE "$RELEASE_DIR/" cp README.md LICENSE "$RELEASE_DIR/"
7z a "$RELEASE_DIR.zip" "$RELEASE_DIR" 7z a "$RELEASE_DIR.zip" "$RELEASE_DIR"
echo "ASSET=$RELEASE_DIR.zip" >> $GITHUB_ENV echo "ASSET=$RELEASE_DIR.zip" >> $GITHUB_ENV
@@ -83,7 +93,7 @@ jobs:
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ironpad-${{ matrix.target }} name: ${{ matrix.asset_name }}
path: ${{ env.ASSET }} path: ${{ env.ASSET }}
release: release:
@@ -99,6 +109,23 @@ jobs:
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
name: Ironpad ${{ github.ref_name }}
body: |
## Downloads
| Platform | File |
|----------|------|
| Windows (x86_64) | `ironpad-windows-x86_64-${{ github.ref_name }}.zip` |
| macOS (x86_64) | `ironpad-macos-x86_64-${{ github.ref_name }}.tar.gz` |
| Linux (x86_64) | `ironpad-linux-x86_64-${{ github.ref_name }}.tar.gz` |
**Linux:** Extract the tar.gz and run `./ironpad`. Works on any distro -- it's a standalone binary with no dependencies.
**macOS:** Extract and run. You may need to allow it in System Settings > Privacy & Security on first launch.
**Windows:** Extract the zip and run `ironpad.exe`.
---
files: artifacts/**/* files: artifacts/**/*
generate_release_notes: true generate_release_notes: true
env: env:

10
backend/Cargo.lock generated
View File

@@ -1141,6 +1141,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-src"
version = "300.5.5+3.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.111" version = "0.9.111"
@@ -1149,6 +1158,7 @@ checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
"openssl-src",
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
] ]

View File

@@ -21,8 +21,8 @@ serde_yaml = "0.9"
# Markdown parsing (CommonMark) # Markdown parsing (CommonMark)
markdown = "1.0.0-alpha.22" markdown = "1.0.0-alpha.22"
# Git operations # Git operations (vendored-openssl for cross-platform CI builds)
git2 = "0.19" git2 = { version = "0.19", features = ["vendored-openssl"] }
# File system watching # File system watching

View File

@@ -5,19 +5,39 @@ use std::sync::OnceLock;
/// Priority: IRONPAD_DATA_DIR env var > auto-detect (production vs development). /// Priority: IRONPAD_DATA_DIR env var > auto-detect (production vs development).
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new(); static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Directory where the executable lives.
/// Used to resolve `static/` and `data/` in production mode.
static EXE_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Get the directory containing the executable.
/// Falls back to "." if detection fails.
pub fn exe_dir() -> &'static Path {
EXE_DIR.get_or_init(|| {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.unwrap_or_else(|| PathBuf::from("."))
})
}
/// Check if we're in production mode (static/index.html exists next to the binary).
pub fn is_production() -> bool {
exe_dir().join("static").join("index.html").exists()
}
/// Initialize the data directory path. Call once at startup. /// Initialize the data directory path. Call once at startup.
/// ///
/// Resolution order: /// Resolution order:
/// 1. `IRONPAD_DATA_DIR` environment variable (if set) /// 1. `IRONPAD_DATA_DIR` environment variable (if set)
/// 2. `./data` if `static/index.html` exists (production mode) /// 2. `{exe_dir}/data` if `{exe_dir}/static/index.html` exists (production mode)
/// 3. `../data` (development mode, binary runs from backend/) /// 3. `../data` (development mode, binary runs from backend/)
pub fn init_data_dir() { pub fn init_data_dir() {
let path = if let Ok(custom) = std::env::var("IRONPAD_DATA_DIR") { let path = if let Ok(custom) = std::env::var("IRONPAD_DATA_DIR") {
tracing::info!("Using custom data directory from IRONPAD_DATA_DIR"); tracing::info!("Using custom data directory from IRONPAD_DATA_DIR");
PathBuf::from(custom) PathBuf::from(custom)
} else if Path::new("static/index.html").exists() { } else if is_production() {
// Production mode: data/ is next to the binary // Production mode: data/ is next to the binary
PathBuf::from("data") exe_dir().join("data")
} else { } else {
// Development mode: binary runs from backend/, data/ is one level up // Development mode: binary runs from backend/, data/ is one level up
PathBuf::from("../data") PathBuf::from("../data")
@@ -31,10 +51,14 @@ pub fn init_data_dir() {
} }
tracing::info!("Data directory: {}", path.display()); tracing::info!("Data directory: {}", path.display());
DATA_DIR.set(path).expect("Data directory already initialized"); DATA_DIR
.set(path)
.expect("Data directory already initialized");
} }
/// Get the resolved data directory path. /// Get the resolved data directory path.
pub fn data_dir() -> &'static Path { pub fn data_dir() -> &'static Path {
DATA_DIR.get().expect("Data directory not initialized. Call config::init_data_dir() first.") DATA_DIR
.get()
.expect("Data directory not initialized. Call config::init_data_dir() first.")
} }

View File

@@ -1,5 +1,4 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use axum::{routing::get, Router}; use axum::{routing::get, Router};
@@ -95,14 +94,19 @@ async fn main() {
.layer(cors); .layer(cors);
// Check for embedded frontend (production mode) // Check for embedded frontend (production mode)
let static_dir = Path::new("static"); // Resolve relative to the executable's directory, not the working directory
let has_frontend = static_dir.join("index.html").exists(); let has_frontend = config::is_production();
if has_frontend { if has_frontend {
// Production mode: serve frontend from static/ and use SPA fallback // Production mode: serve frontend from static/ next to the exe
info!("Production mode: serving frontend from static/"); let static_path = config::exe_dir().join("static");
let serve_dir = ServeDir::new("static") let index_path = static_path.join("index.html");
.fallback(tower_http::services::ServeFile::new("static/index.html")); info!(
"Production mode: serving frontend from {}",
static_path.display()
);
let serve_dir =
ServeDir::new(&static_path).fallback(tower_http::services::ServeFile::new(index_path));
app = app.fallback_service(serve_dir); app = app.fallback_service(serve_dir);
} else { } else {
// Development mode: API-only // Development mode: API-only
@@ -121,7 +125,11 @@ async fn main() {
// Small delay to ensure server is ready // Small delay to ensure server is ready
tokio::time::sleep(std::time::Duration::from_millis(300)).await; tokio::time::sleep(std::time::Duration::from_millis(300)).await;
if let Err(e) = webbrowser::open(&url) { if let Err(e) = webbrowser::open(&url) {
tracing::warn!("Failed to open browser: {}. Open http://localhost:{} manually.", e, port); tracing::warn!(
"Failed to open browser: {}. Open http://localhost:{} manually.",
e,
port
);
} }
}); });
} }

View File

@@ -1,3 +1,3 @@
pub mod note; pub mod note;
pub mod project; pub mod project;
pub mod task; pub mod task;

View File

@@ -79,7 +79,10 @@ async fn upload_asset(
if !is_allowed_content_type(&content_type) { if !is_allowed_content_type(&content_type) {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
format!("Unsupported file type: {}. Only images are allowed.", content_type), format!(
"Unsupported file type: {}. Only images are allowed.",
content_type
),
) )
.into_response(); .into_response();
} }
@@ -100,7 +103,10 @@ async fn upload_asset(
if data.len() > MAX_FILE_SIZE { if data.len() > MAX_FILE_SIZE {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
format!("File too large. Maximum size is {} MB.", MAX_FILE_SIZE / 1024 / 1024), format!(
"File too large. Maximum size is {} MB.",
MAX_FILE_SIZE / 1024 / 1024
),
) )
.into_response(); .into_response();
} }
@@ -149,7 +155,11 @@ async fn upload_asset(
/// Validate that a path component doesn't contain directory traversal /// Validate that a path component doesn't contain directory traversal
fn validate_path_component(component: &str) -> Result<(), String> { fn validate_path_component(component: &str) -> Result<(), String> {
if component.contains("..") || component.contains('/') || component.contains('\\') || component.is_empty() { if component.contains("..")
|| component.contains('/')
|| component.contains('\\')
|| component.is_empty()
{
return Err("Invalid path component".to_string()); return Err("Invalid path component".to_string());
} }
Ok(()) Ok(())
@@ -199,12 +209,7 @@ async fn get_asset(Path((project, filename)): Path<(String, String)>) -> impl In
let stream = ReaderStream::new(file); let stream = ReaderStream::new(file);
let body = Body::from_stream(stream); let body = Body::from_stream(stream);
( (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], body).into_response()
StatusCode::OK,
[(header::CONTENT_TYPE, content_type)],
body,
)
.into_response()
} }
fn is_allowed_content_type(content_type: &str) -> bool { fn is_allowed_content_type(content_type: &str) -> bool {
@@ -220,11 +225,7 @@ fn is_allowed_content_type(content_type: &str) -> bool {
} }
fn get_content_type(filename: &str) -> &'static str { fn get_content_type(filename: &str) -> &'static str {
let ext = filename let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
.rsplit('.')
.next()
.unwrap_or("")
.to_lowercase();
match ext.as_str() { match ext.as_str() {
"jpg" | "jpeg" => "image/jpeg", "jpg" | "jpeg" => "image/jpeg",
@@ -248,7 +249,13 @@ fn generate_unique_filename(dir: &StdPath, original: &str) -> String {
// Sanitize filename // Sanitize filename
let sanitized_name: String = name let sanitized_name: String = name
.chars() .chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) .map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect(); .collect();
let base_filename = format!("{}{}", sanitized_name, ext); let base_filename = format!("{}{}", sanitized_name, ext);

View File

@@ -1,17 +1,13 @@
use axum::{ use axum::{
body::Bytes, body::Bytes, extract::Path, http::StatusCode, response::IntoResponse, routing::get, Json,
extract::Path, Router,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
}; };
use chrono::{NaiveDate, Utc}; use chrono::{NaiveDate, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use crate::services::filesystem;
use crate::config; use crate::config;
use crate::services::filesystem;
use crate::services::frontmatter; use crate::services::frontmatter;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -35,7 +31,12 @@ pub fn router() -> Router {
Router::new() Router::new()
.route("/", get(list_daily_notes)) .route("/", get(list_daily_notes))
.route("/today", get(get_or_create_today)) .route("/today", get(get_or_create_today))
.route("/{date}", get(get_daily_note).post(create_daily_note).put(update_daily_note)) .route(
"/{date}",
get(get_daily_note)
.post(create_daily_note)
.put(update_daily_note),
)
} }
/// List all daily notes /// List all daily notes
@@ -52,7 +53,7 @@ async fn list_daily_notes() -> impl IntoResponse {
fn list_daily_notes_impl() -> Result<Vec<DailyNoteSummary>, String> { fn list_daily_notes_impl() -> Result<Vec<DailyNoteSummary>, String> {
let daily_dir = config::data_dir().join("daily"); let daily_dir = config::data_dir().join("daily");
// Create directory if it doesn't exist // Create directory if it doesn't exist
if !daily_dir.exists() { if !daily_dir.exists() {
fs::create_dir_all(&daily_dir).map_err(|e| e.to_string())?; fs::create_dir_all(&daily_dir).map_err(|e| e.to_string())?;
@@ -70,7 +71,7 @@ fn list_daily_notes_impl() -> Result<Vec<DailyNoteSummary>, String> {
} }
let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
// Validate date format // Validate date format
if NaiveDate::parse_from_str(filename, "%Y-%m-%d").is_err() { if NaiveDate::parse_from_str(filename, "%Y-%m-%d").is_err() {
continue; continue;
@@ -102,7 +103,7 @@ fn list_daily_notes_impl() -> Result<Vec<DailyNoteSummary>, String> {
/// Get or create today's daily note /// Get or create today's daily note
async fn get_or_create_today() -> impl IntoResponse { async fn get_or_create_today() -> impl IntoResponse {
let today = Utc::now().format("%Y-%m-%d").to_string(); let today = Utc::now().format("%Y-%m-%d").to_string();
match get_daily_note_impl(&today) { match get_daily_note_impl(&today) {
Ok(note) => Json(note).into_response(), Ok(note) => Json(note).into_response(),
Err(_) => { Err(_) => {
@@ -123,14 +124,16 @@ async fn get_or_create_today() -> impl IntoResponse {
async fn get_daily_note(Path(date): Path<String>) -> impl IntoResponse { async fn get_daily_note(Path(date): Path<String>) -> impl IntoResponse {
// Validate date format // Validate date format
if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() { if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response(); return (
StatusCode::BAD_REQUEST,
"Invalid date format. Use YYYY-MM-DD",
)
.into_response();
} }
match get_daily_note_impl(&date) { match get_daily_note_impl(&date) {
Ok(note) => Json(note).into_response(), Ok(note) => Json(note).into_response(),
Err(err) if err.contains("not found") => { Err(err) if err.contains("not found") => (StatusCode::NOT_FOUND, err).into_response(),
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => ( Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get daily note: {}", err), format!("Failed to get daily note: {}", err),
@@ -171,16 +174,18 @@ async fn create_daily_note(
) -> impl IntoResponse { ) -> impl IntoResponse {
// Validate date format // Validate date format
if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() { if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response(); return (
StatusCode::BAD_REQUEST,
"Invalid date format. Use YYYY-MM-DD",
)
.into_response();
} }
let content = body.and_then(|b| b.content.clone()); let content = body.and_then(|b| b.content.clone());
match create_daily_note_impl(&date, content.as_deref()) { match create_daily_note_impl(&date, content.as_deref()) {
Ok(note) => (StatusCode::CREATED, Json(note)).into_response(), Ok(note) => (StatusCode::CREATED, Json(note)).into_response(),
Err(err) if err.contains("already exists") => { Err(err) if err.contains("already exists") => (StatusCode::CONFLICT, err).into_response(),
(StatusCode::CONFLICT, err).into_response()
}
Err(err) => ( Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create daily note: {}", err), format!("Failed to create daily note: {}", err),
@@ -191,7 +196,7 @@ async fn create_daily_note(
fn create_daily_note_impl(date: &str, initial_content: Option<&str>) -> Result<DailyNote, String> { fn create_daily_note_impl(date: &str, initial_content: Option<&str>) -> Result<DailyNote, String> {
let daily_dir = config::data_dir().join("daily"); let daily_dir = config::data_dir().join("daily");
// Create directory if it doesn't exist // Create directory if it doesn't exist
if !daily_dir.exists() { if !daily_dir.exists() {
fs::create_dir_all(&daily_dir).map_err(|e| e.to_string())?; fs::create_dir_all(&daily_dir).map_err(|e| e.to_string())?;
@@ -204,10 +209,9 @@ fn create_daily_note_impl(date: &str, initial_content: Option<&str>) -> Result<D
} }
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
// Parse date for display // Parse date for display
let parsed_date = NaiveDate::parse_from_str(date, "%Y-%m-%d") let parsed_date = NaiveDate::parse_from_str(date, "%Y-%m-%d").map_err(|e| e.to_string())?;
.map_err(|e| e.to_string())?;
let display_date = parsed_date.format("%A, %B %d, %Y").to_string(); let display_date = parsed_date.format("%A, %B %d, %Y").to_string();
// Create frontmatter // Create frontmatter
@@ -238,14 +242,12 @@ fn create_daily_note_impl(date: &str, initial_content: Option<&str>) -> Result<D
); );
// Use provided content or default template // Use provided content or default template
let body = initial_content let body = initial_content.map(|c| c.to_string()).unwrap_or_else(|| {
.map(|c| c.to_string()) format!(
.unwrap_or_else(|| { "# {}\n\n## Today's Focus\n\n- \n\n## Notes\n\n\n\n## Tasks\n\n- [ ] \n",
format!( display_date
"# {}\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)?; let content = frontmatter::serialize_frontmatter(&fm, &body)?;
@@ -261,22 +263,21 @@ fn create_daily_note_impl(date: &str, initial_content: Option<&str>) -> Result<D
} }
/// Update a daily note's content /// Update a daily note's content
async fn update_daily_note( async fn update_daily_note(Path(date): Path<String>, body: Bytes) -> impl IntoResponse {
Path(date): Path<String>,
body: Bytes,
) -> impl IntoResponse {
// Validate date format // Validate date format
if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() { if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response(); return (
StatusCode::BAD_REQUEST,
"Invalid date format. Use YYYY-MM-DD",
)
.into_response();
} }
let content = String::from_utf8_lossy(&body).to_string(); let content = String::from_utf8_lossy(&body).to_string();
match update_daily_note_impl(&date, &content) { match update_daily_note_impl(&date, &content) {
Ok(note) => Json(note).into_response(), Ok(note) => Json(note).into_response(),
Err(err) if err.contains("not found") => { Err(err) if err.contains("not found") => (StatusCode::NOT_FOUND, err).into_response(),
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => ( Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update daily note: {}", err), format!("Failed to update daily note: {}", err),

View File

@@ -4,4 +4,4 @@ pub mod git;
pub mod notes; pub mod notes;
pub mod projects; pub mod projects;
pub mod search; pub mod search;
pub mod tasks; pub mod tasks;

View File

@@ -1,17 +1,10 @@
use axum::{ use axum::{extract::Path, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
extract::Path,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use crate::models::note::{Note, NoteSummary}; use crate::models::note::{Note, NoteSummary};
use crate::services::filesystem; use crate::services::filesystem;
pub fn router() -> Router { pub fn router() -> Router {
Router::new() Router::new().route("/{id}", get(get_note).put(update_note).delete(delete_note))
.route("/{id}", get(get_note).put(update_note).delete(delete_note))
} }
pub async fn list_notes() -> impl IntoResponse { pub async fn list_notes() -> impl IntoResponse {
@@ -50,10 +43,7 @@ pub async fn create_note() -> impl IntoResponse {
} }
} }
async fn update_note( async fn update_note(Path(id): Path<String>, body: String) -> impl IntoResponse {
Path(id): Path<String>,
body: String,
) -> impl IntoResponse {
match filesystem::update_note(&id, &body) { match filesystem::update_note(&id, &body) {
Ok(note) => Json::<Note>(note).into_response(), Ok(note) => Json::<Note>(note).into_response(),
Err(err) if err.starts_with("Note not found") => { Err(err) if err.starts_with("Note not found") => {

View File

@@ -8,14 +8,13 @@ use axum::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use crate::config;
use crate::routes::tasks::{ use crate::routes::tasks::{
CreateTaskRequest, UpdateTaskMetaRequest, create_task_handler, delete_task_handler, get_task_handler, list_project_tasks_handler,
list_project_tasks_handler, create_task_handler, get_task_handler, toggle_task_handler, update_task_content_handler, update_task_meta_handler, CreateTaskRequest,
update_task_content_handler, toggle_task_handler, update_task_meta_handler, UpdateTaskMetaRequest,
delete_task_handler,
}; };
use crate::services::filesystem; use crate::services::filesystem;
use crate::config;
use crate::services::frontmatter; use crate::services::frontmatter;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -75,15 +74,34 @@ pub fn router() -> Router {
Router::new() Router::new()
.route("/", get(list_projects).post(create_project)) .route("/", get(list_projects).post(create_project))
.route("/{id}", get(get_project)) .route("/{id}", get(get_project))
.route("/{id}/content", get(get_project_content).put(update_project_content)) .route(
"/{id}/content",
get(get_project_content).put(update_project_content),
)
// Task routes (file-based) // Task routes (file-based)
.route("/{id}/tasks", get(get_project_tasks).post(create_project_task)) .route(
.route("/{id}/tasks/{task_id}", get(get_project_task).put(update_project_task).delete(delete_project_task)) "/{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}/toggle", put(toggle_project_task))
.route("/{id}/tasks/{task_id}/meta", put(update_project_task_meta)) .route("/{id}/tasks/{task_id}/meta", put(update_project_task_meta))
// Note routes // Note routes
.route("/{id}/notes", get(list_project_notes).post(create_project_note)) .route(
.route("/{id}/notes/{note_id}", get(get_project_note).put(update_project_note).delete(delete_project_note)) "/{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 ============ // ============ Task Handlers ============
@@ -355,10 +373,7 @@ async fn get_project_content(Path(id): Path<String>) -> impl IntoResponse {
} }
} }
async fn update_project_content( async fn update_project_content(Path(id): Path<String>, body: String) -> impl IntoResponse {
Path(id): Path<String>,
body: String,
) -> impl IntoResponse {
let index_path = config::data_dir() let index_path = config::data_dir()
.join("projects") .join("projects")
.join(&id) .join(&id)
@@ -618,7 +633,9 @@ async fn create_project_note(
.into_response() .into_response()
} }
async fn get_project_note(Path((project_id, note_id)): Path<(String, String)>) -> impl IntoResponse { async fn get_project_note(
Path((project_id, note_id)): Path<(String, String)>,
) -> impl IntoResponse {
let notes_dir = config::data_dir() let notes_dir = config::data_dir()
.join("projects") .join("projects")
.join(&project_id) .join(&project_id)
@@ -638,7 +655,7 @@ async fn get_project_note(Path((project_id, note_id)): Path<(String, String)>) -
if let Ok(content) = fs::read_to_string(&path) { if let Ok(content) = fs::read_to_string(&path) {
let (fm, body, _) = frontmatter::parse_frontmatter(&content); let (fm, body, _) = frontmatter::parse_frontmatter(&content);
let file_id = fm let file_id = fm
.get(&serde_yaml::Value::from("id")) .get(&serde_yaml::Value::from("id"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())

View File

@@ -1,10 +1,4 @@
use axum::{ use axum::{extract::Query, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
extract::Query,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::Deserialize; use serde::Deserialize;
use crate::services::search; use crate::services::search;

View File

@@ -1,15 +1,10 @@
use axum::{ use axum::{http::StatusCode, response::IntoResponse, routing::get, Json, Router};
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::Path as StdPath; use std::path::Path as StdPath;
use crate::services::filesystem;
use crate::config; use crate::config;
use crate::services::filesystem;
use crate::services::frontmatter; use crate::services::frontmatter;
/// Task summary for list views /// Task summary for list views
@@ -73,8 +68,7 @@ pub struct UpdateTaskMetaRequest {
} }
pub fn router() -> Router { pub fn router() -> Router {
Router::new() Router::new().route("/", get(list_all_tasks_handler))
.route("/", get(list_all_tasks_handler))
} }
// ============ Handler Functions (called from projects.rs) ============ // ============ Handler Functions (called from projects.rs) ============
@@ -96,7 +90,12 @@ pub async fn create_task_handler(
project_id: String, project_id: String,
payload: CreateTaskRequest, payload: CreateTaskRequest,
) -> impl IntoResponse { ) -> impl IntoResponse {
match create_task_impl(&project_id, &payload.title, payload.section.as_deref(), payload.parent_id.as_deref()) { 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(), Ok(task) => (StatusCode::CREATED, Json(task)).into_response(),
Err(err) => ( Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -110,9 +109,7 @@ pub async fn create_task_handler(
pub async fn get_task_handler(project_id: String, task_id: String) -> impl IntoResponse { pub async fn get_task_handler(project_id: String, task_id: String) -> impl IntoResponse {
match get_task_impl(&project_id, &task_id) { match get_task_impl(&project_id, &task_id) {
Ok(task) => Json(task).into_response(), Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => { Err(err) if err.contains("not found") => (StatusCode::NOT_FOUND, err).into_response(),
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => ( Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get task: {}", err), format!("Failed to get task: {}", err),
@@ -129,9 +126,7 @@ pub async fn update_task_content_handler(
) -> impl IntoResponse { ) -> impl IntoResponse {
match update_task_content_impl(&project_id, &task_id, &body) { match update_task_content_impl(&project_id, &task_id, &body) {
Ok(task) => Json(task).into_response(), Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => { Err(err) if err.contains("not found") => (StatusCode::NOT_FOUND, err).into_response(),
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => ( Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update task: {}", err), format!("Failed to update task: {}", err),
@@ -144,9 +139,7 @@ pub async fn update_task_content_handler(
pub async fn toggle_task_handler(project_id: String, task_id: String) -> impl IntoResponse { pub async fn toggle_task_handler(project_id: String, task_id: String) -> impl IntoResponse {
match toggle_task_impl(&project_id, &task_id) { match toggle_task_impl(&project_id, &task_id) {
Ok(task) => Json(task).into_response(), Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => { Err(err) if err.contains("not found") => (StatusCode::NOT_FOUND, err).into_response(),
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => ( Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to toggle task: {}", err), format!("Failed to toggle task: {}", err),
@@ -163,9 +156,7 @@ pub async fn update_task_meta_handler(
) -> impl IntoResponse { ) -> impl IntoResponse {
match update_task_meta_impl(&project_id, &task_id, payload) { match update_task_meta_impl(&project_id, &task_id, payload) {
Ok(task) => Json(task).into_response(), Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => { Err(err) if err.contains("not found") => (StatusCode::NOT_FOUND, err).into_response(),
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => ( Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update task metadata: {}", err), format!("Failed to update task metadata: {}", err),
@@ -178,9 +169,7 @@ pub async fn update_task_meta_handler(
pub async fn delete_task_handler(project_id: String, task_id: String) -> impl IntoResponse { pub async fn delete_task_handler(project_id: String, task_id: String) -> impl IntoResponse {
match delete_task_impl(&project_id, &task_id) { match delete_task_impl(&project_id, &task_id) {
Ok(()) => StatusCode::NO_CONTENT.into_response(), Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) if err.contains("not found") => { Err(err) if err.contains("not found") => (StatusCode::NOT_FOUND, err).into_response(),
(StatusCode::NOT_FOUND, err).into_response()
}
Err(err) => ( Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to delete task: {}", err), format!("Failed to delete task: {}", err),
@@ -483,11 +472,7 @@ fn toggle_task_impl(project_id: &str, task_id: &str) -> Result<Task, String> {
); );
// Update section based on completion status // Update section based on completion status
let new_section = if new_completed { let new_section = if new_completed { "Completed" } else { "Active" };
"Completed"
} else {
"Active"
};
fm.insert( fm.insert(
serde_yaml::Value::from("section"), serde_yaml::Value::from("section"),
serde_yaml::Value::from(new_section), serde_yaml::Value::from(new_section),
@@ -554,14 +539,22 @@ fn toggle_task_impl(project_id: &str, task_id: &str) -> Result<Task, String> {
} }
// Return updated task // Return updated task
let task = parse_task_file(&fs::read_to_string(&task_path).unwrap(), &task_path, project_id) let task = parse_task_file(
.ok_or_else(|| "Failed to parse updated task".to_string())?; &fs::read_to_string(&task_path).unwrap(),
&task_path,
project_id,
)
.ok_or_else(|| "Failed to parse updated task".to_string())?;
Ok(task) Ok(task)
} }
fn calculate_next_due_date(current_due: Option<&str>, recurrence: &str, interval: i64) -> Option<String> { fn calculate_next_due_date(
use chrono::{NaiveDate, Duration, Utc, Months}; current_due: Option<&str>,
recurrence: &str,
interval: i64,
) -> Option<String> {
use chrono::{Duration, Months, NaiveDate, Utc};
let base_date = if let Some(due_str) = current_due { 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()) NaiveDate::parse_from_str(due_str, "%Y-%m-%d").unwrap_or_else(|_| Utc::now().date_naive())
@@ -600,28 +593,73 @@ fn create_recurring_task_impl(
let id = format!("{}-{}", project_id, filename); let id = format!("{}-{}", project_id, filename);
let mut fm = serde_yaml::Mapping::new(); let mut fm = serde_yaml::Mapping::new();
fm.insert(serde_yaml::Value::from("id"), serde_yaml::Value::from(id.clone())); fm.insert(
fm.insert(serde_yaml::Value::from("type"), serde_yaml::Value::from("task")); serde_yaml::Value::from("id"),
fm.insert(serde_yaml::Value::from("title"), serde_yaml::Value::from(title)); serde_yaml::Value::from(id.clone()),
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(
fm.insert(serde_yaml::Value::from("priority"), serde_yaml::Value::from("normal")); serde_yaml::Value::from("type"),
fm.insert(serde_yaml::Value::from("is_active"), serde_yaml::Value::from(true)); serde_yaml::Value::from("task"),
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(
fm.insert(serde_yaml::Value::from("recurrence_interval"), serde_yaml::Value::from(interval as u64)); 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 { if let Some(due) = due_date {
fm.insert(serde_yaml::Value::from("due_date"), serde_yaml::Value::from(due)); fm.insert(
serde_yaml::Value::from("due_date"),
serde_yaml::Value::from(due),
);
} }
if !tags.is_empty() { if !tags.is_empty() {
let yaml_tags: Vec<serde_yaml::Value> = tags.iter().map(|t| serde_yaml::Value::from(t.as_str())).collect(); let yaml_tags: Vec<serde_yaml::Value> = tags
fm.insert(serde_yaml::Value::from("tags"), serde_yaml::Value::Sequence(yaml_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(
fm.insert(serde_yaml::Value::from("updated"), serde_yaml::Value::from(now_str.clone())); 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 body = format!("# {}\n\n", title);
let content = frontmatter::serialize_frontmatter(&fm, &body)?; let content = frontmatter::serialize_frontmatter(&fm, &body)?;
@@ -728,8 +766,12 @@ fn update_task_meta_impl(
filesystem::atomic_write(&task_path, new_content.as_bytes())?; filesystem::atomic_write(&task_path, new_content.as_bytes())?;
// Return updated task // Return updated task
let task = parse_task_file(&fs::read_to_string(&task_path).unwrap(), &task_path, project_id) let task = parse_task_file(
.ok_or_else(|| "Failed to parse updated task".to_string())?; &fs::read_to_string(&task_path).unwrap(),
&task_path,
project_id,
)
.ok_or_else(|| "Failed to parse updated task".to_string())?;
Ok(task) Ok(task)
} }

View File

@@ -60,7 +60,9 @@ fn is_note_file(path: &Path) -> bool {
} }
// data/projects/*/index.md // data/projects/*/index.md
if path_str.contains("projects") && path.file_name().and_then(|s| s.to_str()) == Some("index.md") { if path_str.contains("projects")
&& path.file_name().and_then(|s| s.to_str()) == Some("index.md")
{
return true; return true;
} }
@@ -123,7 +125,10 @@ pub fn normalize_path(path: &Path) -> String {
} else { } else {
&path_str &path_str
}; };
stripped.replace('\\', "/").trim_start_matches('/').to_string() stripped
.replace('\\', "/")
.trim_start_matches('/')
.to_string()
} }
/// Read a full note by deterministic ID. /// Read a full note by deterministic ID.
@@ -326,9 +331,7 @@ pub fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), String> {
let parent = path.parent().ok_or("Invalid path")?; let parent = path.parent().ok_or("Invalid path")?;
let temp_name = format!( let temp_name = format!(
".{}.tmp", ".{}.tmp",
path.file_name() path.file_name().and_then(|s| s.to_str()).unwrap_or("file")
.and_then(|s| s.to_str())
.unwrap_or("file")
); );
let temp_path = parent.join(temp_name); let temp_path = parent.join(temp_name);

View File

@@ -165,8 +165,14 @@ mod tests {
let (fm, body, has_fm) = parse_frontmatter(content); let (fm, body, has_fm) = parse_frontmatter(content);
assert!(has_fm); assert!(has_fm);
assert_eq!(fm.get(&Value::from("id")).unwrap().as_str().unwrap(), "test"); assert_eq!(
assert_eq!(fm.get(&Value::from("title")).unwrap().as_str().unwrap(), "Test Note"); fm.get(&Value::from("id")).unwrap().as_str().unwrap(),
"test"
);
assert_eq!(
fm.get(&Value::from("title")).unwrap().as_str().unwrap(),
"Test Note"
);
assert!(body.contains("Body content")); assert!(body.contains("Body content"));
} }

View File

@@ -161,8 +161,7 @@ pub fn get_status() -> Result<RepoStatus, String> {
Some(CommitInfo { Some(CommitInfo {
id: commit.id().to_string()[..8].to_string(), id: commit.id().to_string()[..8].to_string(),
message: commit.message()?.trim().to_string(), message: commit.message()?.trim().to_string(),
timestamp: chrono::DateTime::from_timestamp(commit.time().seconds(), 0)? timestamp: chrono::DateTime::from_timestamp(commit.time().seconds(), 0)?.to_rfc3339(),
.to_rfc3339(),
}) })
}); });
@@ -311,14 +310,16 @@ pub fn push_to_remote() -> Result<(), String> {
.map_err(|e| format!("Remote 'origin' not found: {}", e))?; .map_err(|e| format!("Remote 'origin' not found: {}", e))?;
// Check if remote URL is configured // Check if remote URL is configured
let remote_url = remote.url().ok_or_else(|| "No remote URL configured".to_string())?; let remote_url = remote
.url()
.ok_or_else(|| "No remote URL configured".to_string())?;
if remote_url.is_empty() { if remote_url.is_empty() {
return Err("No remote URL configured".to_string()); return Err("No remote URL configured".to_string());
} }
// Create callbacks for authentication // Create callbacks for authentication
let mut callbacks = git2::RemoteCallbacks::new(); let mut callbacks = git2::RemoteCallbacks::new();
// Try to use credential helper from git config // Try to use credential helper from git config
callbacks.credentials(|_url, username_from_url, _allowed_types| { callbacks.credentials(|_url, username_from_url, _allowed_types| {
// Try SSH agent first // Try SSH agent first
@@ -402,9 +403,7 @@ pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
let commit_tree = commit.tree().ok(); let commit_tree = commit.tree().ok();
if let (Some(pt), Some(ct)) = (parent_tree, commit_tree) { if let (Some(pt), Some(ct)) = (parent_tree, commit_tree) {
let diff = repo let diff = repo.diff_tree_to_tree(Some(&pt), Some(&ct), None).ok();
.diff_tree_to_tree(Some(&pt), Some(&ct), None)
.ok();
diff.map(|d| d.deltas().count()).unwrap_or(0) diff.map(|d| d.deltas().count()).unwrap_or(0)
} else { } else {
0 0
@@ -418,10 +417,9 @@ pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
.unwrap_or(0) .unwrap_or(0)
}; };
let timestamp = let timestamp = chrono::DateTime::from_timestamp(commit.time().seconds(), 0)
chrono::DateTime::from_timestamp(commit.time().seconds(), 0) .map(|dt| dt.to_rfc3339())
.map(|dt| dt.to_rfc3339()) .unwrap_or_else(|| "Unknown".to_string());
.unwrap_or_else(|| "Unknown".to_string());
commits.push(CommitDetail { commits.push(CommitDetail {
id: oid.to_string(), id: oid.to_string(),
@@ -449,10 +447,7 @@ pub fn get_working_diff() -> Result<DiffInfo, String> {
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?; let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
// Get HEAD tree (or empty tree if no commits) // Get HEAD tree (or empty tree if no commits)
let head_tree = repo let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
.head()
.ok()
.and_then(|h| h.peel_to_tree().ok());
// Diff against working directory // Diff against working directory
let diff = repo let diff = repo
@@ -495,7 +490,7 @@ fn parse_diff(diff: &git2::Diff) -> Result<DiffInfo, String> {
for delta_idx in 0..diff.deltas().count() { for delta_idx in 0..diff.deltas().count() {
let delta = diff.get_delta(delta_idx).ok_or("Missing delta")?; let delta = diff.get_delta(delta_idx).ok_or("Missing delta")?;
let path = delta let path = delta
.new_file() .new_file()
.path() .path()
@@ -609,10 +604,7 @@ pub fn get_remote_info() -> Result<Option<RemoteInfo>, String> {
let (ahead, behind) = if let Some(ref up) = upstream { let (ahead, behind) = if let Some(ref up) = upstream {
// Calculate ahead/behind // Calculate ahead/behind
let local_oid = head.target().unwrap_or_else(git2::Oid::zero); let local_oid = head.target().unwrap_or_else(git2::Oid::zero);
let upstream_oid = up let upstream_oid = up.get().target().unwrap_or_else(git2::Oid::zero);
.get()
.target()
.unwrap_or_else(git2::Oid::zero);
repo.graph_ahead_behind(local_oid, upstream_oid) repo.graph_ahead_behind(local_oid, upstream_oid)
.unwrap_or((0, 0)) .unwrap_or((0, 0))

View File

@@ -0,0 +1 @@

View File

@@ -3,4 +3,4 @@ pub mod frontmatter;
pub mod git; pub mod git;
pub mod locks; pub mod locks;
pub mod markdown; pub mod markdown;
pub mod search; pub mod search;

View File

@@ -33,7 +33,10 @@ pub fn search_notes(query: &str) -> Result<Vec<SearchResult>, String> {
match search_with_ripgrep(query) { match search_with_ripgrep(query) {
Ok(results) => return Ok(results), Ok(results) => return Ok(results),
Err(e) => { Err(e) => {
tracing::debug!("ripgrep not available, falling back to manual search: {}", e); tracing::debug!(
"ripgrep not available, falling back to manual search: {}",
e
);
} }
} }
@@ -46,10 +49,12 @@ fn search_with_ripgrep(query: &str) -> Result<Vec<SearchResult>, String> {
let data_dir_str = config::data_dir().to_string_lossy(); let data_dir_str = config::data_dir().to_string_lossy();
let output = Command::new("rg") let output = Command::new("rg")
.args([ .args([
"--json", // JSON output for parsing "--json", // JSON output for parsing
"--ignore-case", // Case insensitive "--ignore-case", // Case insensitive
"--type", "md", // Only markdown files "--type",
"--max-count", "5", // Max 5 matches per file "md", // Only markdown files
"--max-count",
"5", // Max 5 matches per file
query, query,
&data_dir_str, &data_dir_str,
]) ])
@@ -88,13 +93,13 @@ fn parse_ripgrep_output(output: &[u8]) -> Result<Vec<SearchResult>, String> {
let normalized_path = normalize_path(path_str); let normalized_path = normalize_path(path_str);
let title = extract_title_from_path(&normalized_path); let title = extract_title_from_path(&normalized_path);
let result = results_map.entry(normalized_path.clone()).or_insert_with(|| { let result = results_map
SearchResult { .entry(normalized_path.clone())
.or_insert_with(|| SearchResult {
path: normalized_path, path: normalized_path,
title, title,
matches: Vec::new(), matches: Vec::new(),
} });
});
result.matches.push(SearchMatch { result.matches.push(SearchMatch {
line_number, line_number,

View File

@@ -28,15 +28,19 @@ pub async fn start_watcher(ws_state: Arc<WsState>) -> Result<(), String> {
// Watch the data directory // Watch the data directory
let data_path = config::data_dir(); let data_path = config::data_dir();
if !data_path.exists() { if !data_path.exists() {
return Err(format!("Data directory does not exist: {}", data_path.display())); return Err(format!(
"Data directory does not exist: {}",
data_path.display()
));
} }
// We need to keep the debouncer alive, so we'll store it // We need to keep the debouncer alive, so we'll store it
let debouncer = Arc::new(tokio::sync::Mutex::new(debouncer)); let debouncer = Arc::new(tokio::sync::Mutex::new(debouncer));
{ {
let mut d = debouncer.lock().await; let mut d = debouncer.lock().await;
d.watcher().watch(data_path, RecursiveMode::Recursive) d.watcher()
.watch(data_path, RecursiveMode::Recursive)
.map_err(|e| format!("Failed to watch directory: {}", e))?; .map_err(|e| format!("Failed to watch directory: {}", e))?;
} }
@@ -47,7 +51,7 @@ pub async fn start_watcher(ws_state: Arc<WsState>) -> Result<(), String> {
tokio::spawn(async move { tokio::spawn(async move {
// Keep debouncer alive // Keep debouncer alive
let _debouncer = debouncer; let _debouncer = debouncer;
while let Some(events) = rx.recv().await { while let Some(events) = rx.recv().await {
for event in events { for event in events {
process_event(&event, &ws_state_clone); process_event(&event, &ws_state_clone);
@@ -58,9 +62,9 @@ pub async fn start_watcher(ws_state: Arc<WsState>) -> Result<(), String> {
Ok(()) Ok(())
} }
use std::collections::HashMap;
/// Track recent saves to avoid notifying about our own changes /// Track recent saves to avoid notifying about our own changes
use std::sync::Mutex; use std::sync::Mutex;
use std::collections::HashMap;
use std::time::Instant; use std::time::Instant;
lazy_static::lazy_static! { lazy_static::lazy_static! {
@@ -113,19 +117,19 @@ fn process_event(event: &DebouncedEvent, ws_state: &WsState) {
} }
let path_str = normalize_path(&paths[0]); let path_str = normalize_path(&paths[0]);
// Check if this was a recent save by us (within last 2 seconds) // Check if this was a recent save by us (within last 2 seconds)
if let Ok(mut saves) = RECENT_SAVES.lock() { if let Ok(mut saves) = RECENT_SAVES.lock() {
// Clean up old entries // Clean up old entries
saves.retain(|_, t| t.elapsed().as_secs() < 5); saves.retain(|_, t| t.elapsed().as_secs() < 5);
if let Some(saved_at) = saves.get(&path_str) { if let Some(saved_at) = saves.get(&path_str) {
if saved_at.elapsed().as_secs() < 2 { if saved_at.elapsed().as_secs() < 2 {
return; // Skip - this was our own save return; // Skip - this was our own save
} }
} }
} }
let msg = match &event.kind { let msg = match &event.kind {
EventKind::Create(_) => { EventKind::Create(_) => {
tracing::info!("External file created: {}", path_str); tracing::info!("External file created: {}", path_str);
@@ -150,12 +154,15 @@ fn process_event(event: &DebouncedEvent, ws_state: &WsState) {
/// Normalize path for client consumption /// Normalize path for client consumption
fn normalize_path(path: &Path) -> String { fn normalize_path(path: &Path) -> String {
let path_str = path.to_string_lossy(); let path_str = path.to_string_lossy();
// Find "data" in the path and strip everything before and including it // Find "data" in the path and strip everything before and including it
if let Some(idx) = path_str.find("data") { if let Some(idx) = path_str.find("data") {
let stripped = &path_str[idx + 5..]; // Skip "data" + separator let stripped = &path_str[idx + 5..]; // Skip "data" + separator
return stripped.replace('\\', "/").trim_start_matches('/').to_string(); return stripped
.replace('\\', "/")
.trim_start_matches('/')
.to_string();
} }
path_str.replace('\\', "/") path_str.replace('\\', "/")
} }

View File

@@ -147,11 +147,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<WsState>) {
if let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text) { if let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text) {
handle_client_message(&state_clone, &client_id_clone, client_msg).await; handle_client_message(&state_clone, &client_id_clone, client_msg).await;
} else { } else {
tracing::debug!( tracing::debug!("Unknown message from {}: {}", client_id_clone, text);
"Unknown message from {}: {}",
client_id_clone,
text
);
} }
} }
Message::Close(_) => break, Message::Close(_) => break,
@@ -195,7 +191,11 @@ async fn handle_client_message(state: &Arc<WsState>, client_id: &str, msg: Clien
} }
}; };
match state.lock_manager.acquire(&path, client_id, lock_type).await { match state
.lock_manager
.acquire(&path, client_id, lock_type)
.await
{
Ok(lock_info) => { Ok(lock_info) => {
let lock_type_str = match lock_info.lock_type { let lock_type_str = match lock_info.lock_type {
LockType::Editor => "editor", LockType::Editor => "editor",