9 Commits

Author SHA1 Message Date
skepsismusic
9b9cb1f0b4 Fix cargo fmt formatting in projects.rs and tasks.rs
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-16 14:11:00 +01:00
skepsismusic
bbffc0e6bb Fix Linux build: enable ksni feature for tray-item and add libdbus-1-dev to CI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 14:05:51 +01:00
skepsismusic
7c91e1775a Update graphics for v0.2.0: roadmap, architecture, and what's-new feature highlight
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 13:57:19 +01:00
skepsismusic
781ea28097 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>
2026-02-16 13:48:54 +01:00
skepsismusic
b150a243fd update roadmap and readme 2026-02-07 17:38:34 +01:00
skepsismusic
df27a27a2d updated roadmap 2026-02-06 15:05:42 +01:00
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
58 changed files with 2225 additions and 352 deletions

11
.ferrite/state.json Normal file
View File

@@ -0,0 +1,11 @@
{
"recent_files": [
"G:\\DEV\\proman2k\\README.md",
"G:\\DEV\\proman2k\\assets\\test.md"
],
"expanded_paths": [
"G:\\DEV\\proman2k"
],
"file_tree_width": 250.0,
"show_file_tree": true
}

View File

@@ -0,0 +1,20 @@
{
"name": "Workspace",
"tabs": [
{
"name": "Tab 1",
"layout": {
"Terminal": 1
},
"terminals": {
"1": {
"shell": "Default",
"cwd": "G:\\DEV\\proman2k",
"title": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
}
}
}
],
"floating_windows": [],
"active_tab_index": 0
}

View File

@@ -13,6 +13,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable

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 libdbus-1-dev pkg-config
- 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:

3
.gitignore vendored
View File

@@ -41,5 +41,8 @@ data/projects/*/
# === Stray root lock file (frontend/package-lock.json is kept for CI) === # === Stray root lock file (frontend/package-lock.json is kept for CI) ===
/package-lock.json /package-lock.json
# === Local build output ===
release/
# === Generated images (article assets, not source) === # === Generated images (article assets, not source) ===
/assets/ /assets/

39
CHANGELOG.md Normal file
View File

@@ -0,0 +1,39 @@
# Changelog
All notable changes to Ironpad are documented here.
## [0.2.0] - 2026-02-16
### Added
- **Task comments** -- date-stamped comment entries per task, stored as YAML in frontmatter. Last comment shown as summary in task list and dashboard cards. Add/delete via API and UI.
- **Recurring tasks on calendar** -- tasks with daily/weekly/monthly/yearly recurrence now appear on the calendar even without an explicit `due_date`. Occurrences are computed from the anchor date (`due_date` or `created`) and `recurrence_interval`. Recurring entries show with a dashed border and recurrence icon to distinguish from regular due-date tasks.
- **System tray mode** -- production binary runs in the system tray instead of a console window. Tray menu with "Open in Browser" and "Quit". No CMD window on Windows in release builds (`windows_subsystem = "windows"`). Server runs on a background thread with the tray event loop on the main thread for cross-platform safety.
- **App icon and branding** -- Ironpad logo embedded in the Windows executable (Explorer icon + tray icon) via `winresource`. Favicon and logo added to the web UI (browser tab + top bar header).
- **Local build script** -- `build-local.ps1` for building a testable release package locally.
### Changed
- Backend `main.rs` restructured for dual-mode operation: development mode runs the server directly (no tray), production mode runs server on background thread with tray on main thread.
- Calendar view refactored to use `CalendarEntry` interface that merges regular due-date tasks with computed recurring occurrences.
### Dependencies
- Added `tray-item = "0.10"` for cross-platform system tray support.
- Added `windows-sys = "0.52"` (Windows only) for loading the embedded icon resource.
- Added `winresource = "0.1"` (Windows build dependency) for embedding the icon in the .exe.
---
## [0.1.0] - 2025-12-01
### Added
- Initial release of Ironpad -- local-first, file-based project and knowledge management.
- **Backend**: Rust/Axum API server with dynamic port (3000-3010), WebSocket real-time sync, file watcher, Git auto-commit (60s batching), ripgrep search.
- **Frontend**: Vue 3 SPA with Milkdown WYSIWYG editor, dark/light theme, Pinia state management.
- **File-based tasks**: each task stored as a markdown file with YAML frontmatter (title, completed, section, priority, due_date, tags, subtasks, recurrence).
- **Split-panel task view**: task list with active/backlog/completed sections, detail editor with markdown, due date picker, tag system, subtasks, recurrence picker.
- **Calendar view**: month grid showing tasks by due date with color-coded urgency and daily note indicators.
- **Dashboard**: cross-project home page with active task summaries per project.
- **Daily notes**: date-based notes with templates.
- **Git panel**: commit history with diffs, working directory changes, push/fetch with ahead/behind indicators.
- **Project notes**: split-panel notes view per project.
- **Search**: Ctrl+K search panel with ripgrep-powered full-text search.
- Cross-platform builds (Windows, macOS, Linux) via GitHub Actions.

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 Ola Proeis Copyright (c) 2026 Ola Prøis
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,18 +1,22 @@
# Ironpad # Ironpad
![Ironpad Banner](docs/graphics/ironpad-banner.png)
**A local-first, file-based project & knowledge management system.** **A local-first, file-based project & knowledge management system.**
![Build](https://github.com/OlaProeis/ironPad/actions/workflows/release.yml/badge.svg) ![Build](https://github.com/OlaProeis/ironPad/actions/workflows/release.yml/badge.svg)
![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg) ![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey) ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)
![Rust](https://img.shields.io/badge/rust-1.70%2B-orange) ![Rust](https://img.shields.io/badge/rust-1.70%2B-orange)
![Version](https://img.shields.io/badge/version-0.1.0-green) ![Version](https://img.shields.io/badge/version-0.2.0-green)
Ironpad stores all your notes, projects, and tasks as plain Markdown files. No cloud services, no vendor lock-in -- your data stays on your machine in a format you can read and edit with any text editor. Every change is automatically versioned with Git. Ironpad stores all your notes, projects, and tasks as plain Markdown files. No cloud services, no vendor lock-in -- your data stays on your machine in a format you can read and edit with any text editor. Every change is automatically versioned with Git.
![Ironpad Screenshot](docs/screenshot.jpg) ![Ironpad Screenshot](docs/screenshot.jpg)
> **v0.1.0 -- Early Release.** This is the first public release. It's functional and we use it daily, but expect rough edges. Bug reports and feature requests are welcome via [Issues](https://github.com/OlaProeis/ironPad/issues). > **v0.2.0** -- Task comments, recurring tasks on calendar, system tray mode, and app branding. See [CHANGELOG.md](CHANGELOG.md) for details.
![What's New in v0.2.0](docs/graphics/whats-new-v020.png)
--- ---
@@ -22,14 +26,15 @@ Ironpad stores all your notes, projects, and tasks as plain Markdown files. No c
- **Local-first** -- Works fully offline, no internet required - **Local-first** -- Works fully offline, no internet required
- **Git integration** -- Automatic version control with 60-second commit batching, full diff viewer, push/fetch - **Git integration** -- Automatic version control with 60-second commit batching, full diff viewer, push/fetch
- **WYSIWYG editing** -- Milkdown editor with real-time markdown rendering and formatting toolbar - **WYSIWYG editing** -- Milkdown editor with real-time markdown rendering and formatting toolbar
- **Project management** -- Organize tasks and notes by project with due dates, tags, subtasks, and recurrence - **Project management** -- Organize tasks and notes by project with due dates, tags, subtasks, recurrence, and comments
- **Calendar view** -- Month grid showing tasks by due date with color-coded urgency - **Calendar view** -- Month grid showing tasks by due date with color-coded urgency; recurring tasks automatically expanded across the month
- **Dashboard** -- Cross-project overview with active task summaries - **Dashboard** -- Cross-project overview with active task summaries
- **Daily notes** -- Quick capture with templates for daily journaling - **Daily notes** -- Quick capture with templates for daily journaling
- **Real-time sync** -- WebSocket-based live updates; edit in VS Code, see changes in the browser instantly - **Real-time sync** -- WebSocket-based live updates; edit in VS Code, see changes in the browser instantly
- **External editing** -- Full support for VS Code, Obsidian, Vim, or any text editor - **External editing** -- Full support for VS Code, Obsidian, Vim, or any text editor
- **Search** -- ripgrep-powered full-text search across all files (Ctrl+K) - **Search** -- ripgrep-powered full-text search across all files (Ctrl+K)
- **Dark theme** -- Beautiful dark UI by default with light mode toggle - **Dark theme** -- Beautiful dark UI by default with light mode toggle
- **System tray** -- Runs quietly in the system tray (Windows, macOS, Linux); no console window in release builds
- **Tiny footprint** -- 5 MB binary, ~20 MB RAM, sub-second startup - **Tiny footprint** -- 5 MB binary, ~20 MB RAM, sub-second startup
## Quick Start ## Quick Start
@@ -65,6 +70,8 @@ Open http://localhost:5173 in your browser.
## Tech Stack ## Tech Stack
![Tech Stack](docs/graphics/tech-stack.png)
| Component | Technology | | Component | Technology |
|-----------|------------| |-----------|------------|
| Backend | Rust, Axum 0.8, Tokio | | Backend | Rust, Axum 0.8, Tokio |
@@ -78,8 +85,13 @@ Open http://localhost:5173 in your browser.
## Roadmap ## Roadmap
![Roadmap](docs/graphics/roadmap.png)
Ironpad is under active development. Here's what's planned: Ironpad is under active development. Here's what's planned:
- [x] Task comments and activity summary
- [x] Recurring tasks on calendar (daily/weekly/monthly/yearly expansion)
- [x] System tray mode (Windows, macOS, Linux)
- [ ] UI polish and animations - [ ] UI polish and animations
- [ ] Tag extraction and filtering across projects - [ ] Tag extraction and filtering across projects
- [ ] Backlinks between notes - [ ] Backlinks between notes
@@ -87,13 +99,14 @@ Ironpad is under active development. Here's what's planned:
- [ ] Export to PDF / HTML - [ ] Export to PDF / HTML
- [ ] Custom themes - [ ] Custom themes
- [ ] Global hotkey (Ctrl+Shift+Space) - [ ] Global hotkey (Ctrl+Shift+Space)
- [ ] System tray mode
- [ ] Kanban board view for tasks - [ ] Kanban board view for tasks
See [CHECKLIST.md](docs/ai-workflow/CHECKLIST.md) for detailed implementation status. See [CHECKLIST.md](docs/ai-workflow/CHECKLIST.md) for detailed implementation status.
## Built With AI ## Built With AI
![AI Workflow](docs/graphics/ai-workflow.png)
This entire application was built using AI-assisted development -- an approach we call **Open Method**. We share not just the code, but the complete process: the PRD, task breakdowns, handover documents, and workflow artifacts. This entire application was built using AI-assisted development -- an approach we call **Open Method**. We share not just the code, but the complete process: the PRD, task breakdowns, handover documents, and workflow artifacts.
Read about the method: Read about the method:
@@ -113,6 +126,8 @@ Read about the method:
## Documentation ## Documentation
![Architecture](docs/graphics/architecture.png)
| Document | Description | | Document | Description |
|----------|-------------| |----------|-------------|
| [docs/API.md](docs/API.md) | Complete REST API reference | | [docs/API.md](docs/API.md) | Complete REST API reference |

64
ROADMAP.md Normal file
View File

@@ -0,0 +1,64 @@
# Ironpad Roadmap
## Release 0.2.0 (Current)
### Features
#### 1. Task comments & activity summary
- **Comment section** per task with date-stamped entries
- Store comments as YAML sequence in task frontmatter
- **Last comment as summary** -- most recent comment shown in task list and dashboard cards
- Add/delete comments via API and UI, newest-first display with relative timestamps
#### 2. Recurring tasks on the calendar
- Tasks with daily/weekly recurrence now appear on the calendar (previously required explicit `due_date`)
- Recurring tasks expanded into the visible month grid (daily/weekly/monthly/yearly)
- Anchor date: `due_date` if set, otherwise `created`; respects `recurrence_interval`
- Recurring occurrences shown with dashed border and recurrence icon
#### 3. System tray mode
- System tray icon replaces CMD window (Windows, macOS, Linux)
- Tray menu: **Open in Browser** | **Quit**
- No console window on Windows in release builds
- Server runs on background thread; tray event loop on main thread (cross-platform safe)
#### 4. App branding
- Ironpad logo as system tray icon and Windows exe icon
- Favicon and logo in the web UI (browser tab + header)
---
## Suggested features (future releases)
Ideas that fit the current architecture and local-first design:
### High fit (0.3.x)
- **Calendar drag-and-drop** -- reschedule tasks by dragging onto a new date
- **Week / day calendar views** -- alternative to month view for denser task planning
- **Sort task list by due date / priority** -- alongside current created-date sorting
- **Overdue indicator** -- clearer overdue badge or count in sidebar and dashboard
### Medium fit (0.4.x)
- **Quick-add task** -- global or dashboard shortcut to create a task without opening a project
- **Bulk actions** -- complete multiple tasks, move section, add/remove tags in one go
- **Task templates** -- create tasks from predefined templates (e.g. "Meeting prep", "Review")
- **Tag extraction and cross-project filtering** -- surface and filter by tags across all projects
### Longer term
- UI polish and subtle animations
- Responsive sidebar / mobile-friendly layout
- Global hotkey (e.g. Ctrl+Shift+Space)
- Backlinks between notes
- Graph view of note connections
- Export to PDF / HTML
- Custom themes
- Kanban board view
---
## Version history
| Version | Status | Date | Notes |
|---------|----------|------------|----------------------------------------------------------|
| 0.1.0 | Released | 2025-12-01 | First public release, core features in place |
| 0.2.0 | Current | 2026-02-16 | Comments, recurring calendar, system tray, app branding |

View File

@@ -67,7 +67,7 @@ ironpad/
## Implemented Features ## Implemented Features
### Backend ### Backend
- API-only server (no frontend serving, no browser auto-open) - **Dual-mode server**: API-only in development; frontend-serving + system tray in production
- Dynamic port (3000-3010) - Dynamic port (3000-3010)
- Notes CRUD with atomic writes - Notes CRUD with atomic writes
- Frontmatter auto-management - Frontmatter auto-management
@@ -79,12 +79,13 @@ ironpad/
- Git remote info (ahead/behind tracking), fetch support - Git remote info (ahead/behind tracking), fetch support
- Projects API with notes management - Projects API with notes management
- **File-based Tasks API** — each task is a markdown file with frontmatter - **File-based Tasks API** — each task is a markdown file with frontmatter
- Fields: id, title, completed, section, priority, due_date, is_active, tags, parent_id, recurrence, recurrence_interval - Fields: id, title, completed, section, priority, due_date, is_active, tags, parent_id, recurrence, recurrence_interval, comments
- Rich text descriptions with markdown support - Rich text descriptions with markdown support
- Sorted by created date (stable ordering) - Sorted by created date (stable ordering)
- **Subtasks** — tasks with `parent_id` link to a parent task - **Subtasks** — tasks with `parent_id` link to a parent task
- **Tags** — YAML sequence in frontmatter, per-task labels for filtering - **Tags** — YAML sequence in frontmatter, per-task labels for filtering
- **Recurring tasks** — when completing a recurring task, auto-creates next instance with advanced due date - **Recurring tasks** — when completing a recurring task, auto-creates next instance with advanced due date
- **Comments** — date-stamped comment entries stored as YAML sequence in frontmatter; last comment shown as summary in list/dashboard
- Daily notes API (`/api/daily`, `/api/daily/today`, `/api/daily/:date`) - Daily notes API (`/api/daily`, `/api/daily/today`, `/api/daily/:date`)
- Assets API (upload + serve) - Assets API (upload + serve)
@@ -113,9 +114,14 @@ ironpad/
- **Tag filter bar** — click tags to filter task list - **Tag filter bar** — click tags to filter task list
- **Subtasks** — expandable subtasks under parent tasks, add subtask inline - **Subtasks** — expandable subtasks under parent tasks, add subtask inline
- **Recurrence picker** — set daily/weekly/monthly/yearly recurrence - **Recurrence picker** — set daily/weekly/monthly/yearly recurrence
- **Task comments** — date-stamped comments section in task detail, newest first, add/delete
- **Last comment summary** — most recent comment shown in task list and dashboard cards
- Inline title editing (double-click) - Inline title editing (double-click)
- **Calendar view** — month grid showing tasks by due date - **Calendar view** — month grid showing tasks by due date + recurring task expansion
- Tasks with due dates plotted on calendar cells - Tasks with due dates plotted on calendar cells
- **Recurring tasks expanded** — daily/weekly/monthly/yearly tasks shown on computed occurrences
- Recurring occurrences use anchor date (`due_date` or `created`), respect `recurrence_interval`
- Recurring entries shown with dashed border and ↻ icon to distinguish from regular tasks
- Daily notes shown as blue dots - Daily notes shown as blue dots
- Color-coded urgency (overdue, today, soon) - Color-coded urgency (overdue, today, soon)
- Month navigation + Today button - Month navigation + Today button
@@ -150,6 +156,8 @@ GET/POST /api/projects/:id/tasks
GET/PUT/DEL /api/projects/:id/tasks/:task_id GET/PUT/DEL /api/projects/:id/tasks/:task_id
PUT /api/projects/:id/tasks/:task_id/toggle PUT /api/projects/:id/tasks/:task_id/toggle
PUT /api/projects/:id/tasks/:task_id/meta PUT /api/projects/:id/tasks/:task_id/meta
POST /api/projects/:id/tasks/:task_id/comments
DELETE /api/projects/:id/tasks/:task_id/comments/:index
GET /api/tasks # All tasks across projects GET /api/tasks # All tasks across projects
GET /api/daily GET /api/daily
@@ -224,12 +232,25 @@ WS /ws
--- ---
## Not Yet Implemented (Phase 6+) ## Implemented in Phase 6
- **Recurring tasks on calendar** — frontend expands daily/weekly/monthly/yearly tasks into the visible month grid
- Anchor date: `due_date` if set, otherwise `created`; respects `recurrence_interval`
- Deduplicates against regular due-date entries; visual indicator (dashed border, ↻ icon)
- **System tray mode** — production binary runs in system tray (Windows, macOS, Linux)
- `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]` hides console on Windows
- Server on background thread, tray event loop on main thread (cross-platform safe)
- Tray menu: "Open in Browser" / "Quit"
- Uses `tray-item` crate with platform-specific icon loading (`windows-sys` on Windows)
- Development mode unchanged (no tray, API-only)
---
## Not Yet Implemented (Phase 7+)
- UI polish and animations - UI polish and animations
- Responsive sidebar - Responsive sidebar
- Global hotkey (Ctrl+Shift+Space) - Global hotkey (Ctrl+Shift+Space)
- System tray mode
- Backlinks between notes - Backlinks between notes
- Graph view - Graph view
- Export (PDF / HTML) - Export (PDF / HTML)
@@ -262,12 +283,14 @@ WS /ws
| Project note ID | `{slug}-index` format | | Project note ID | `{slug}-index` format |
| Task storage | Individual .md files in `tasks/` folder | | Task storage | Individual .md files in `tasks/` folder |
| List sorting | By created date (stable, not affected by edits) | | List sorting | By created date (stable, not affected by edits) |
| Backend mode | API-only (no frontend serving) | | Backend mode | API-only (dev); frontend-serving + system tray (production) |
| Theme | Dark by default, toggle to light, persists to localStorage | | Theme | Dark by default, toggle to light, persists to localStorage |
| Tags | YAML sequence in frontmatter, project-scoped filtering | | Tags | YAML sequence in frontmatter, project-scoped filtering |
| Subtasks | Separate task files with `parent_id` field linking to parent | | Subtasks | Separate task files with `parent_id` field linking to parent |
| Recurring tasks | On completion, backend auto-creates next instance with advanced due date | | Recurring tasks | On completion, backend auto-creates next instance with advanced due date |
| Calendar | Pure frontend month grid, tasks filtered by `due_date` presence | | Calendar | Pure frontend month grid, tasks by `due_date` + recurring expansion |
| System tray | `tray-item` crate; main thread tray, background thread server; `windows-sys` for icon on Windows |
| Task comments | YAML sequence in frontmatter, date-stamped, last comment as list/dashboard summary |
| Dashboard | Home route `/`, loads all projects + all tasks for cross-project summary | | Dashboard | Home route `/`, loads all projects + all tasks for cross-project summary |
--- ---

638
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ironpad" name = "ironpad"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
@@ -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
@@ -47,3 +47,18 @@ uuid = { version = "1.0", features = ["v4"] }
# Utilities # Utilities
lazy_static = "1.4" 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"
# Linux: tray-item needs ksni feature (pure-Rust D-Bus StatusNotifierItem)
[target.'cfg(target_os = "linux")'.dependencies]
tray-item = { version = "0.10", features = ["ksni"] }
# 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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
backend/assets/ironpad.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

10
backend/build.rs Normal file
View 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");
}
}

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,7 @@
// Hide console window on Windows in release builds (production mode)
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
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};
@@ -27,17 +29,29 @@ async fn find_available_port() -> (TcpListener, u16) {
panic!("No available ports in range 30003010"); panic!("No available ports in range 30003010");
} }
#[tokio::main] fn main() {
async fn main() {
// Logging
tracing_subscriber::fmt().init(); tracing_subscriber::fmt().init();
// Resolve data directory (production vs development mode)
config::init_data_dir(); 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; 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) // WebSocket state (shared across handlers)
let ws_state = Arc::new(websocket::WsState::new()); let ws_state = Arc::new(websocket::WsState::new());
@@ -95,14 +109,18 @@ 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"); let has_frontend = config::is_production();
let has_frontend = static_dir.join("index.html").exists();
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
@@ -112,19 +130,110 @@ async fn main() {
} }
// Start server // Start server
info!("🚀 Ironpad running on http://localhost:{port}"); 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);
}
});
}
axum::serve(listener, app).await.expect("Server failed"); 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);
}
}
}
}

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
@@ -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),
@@ -206,8 +211,7 @@ 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,9 +242,7 @@ 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())
.unwrap_or_else(|| {
format!( format!(
"# {}\n\n## Today's Focus\n\n- \n\n## Notes\n\n\n\n## Tasks\n\n- [ ] \n", "# {}\n\n## Today's Focus\n\n- \n\n## Notes\n\n\n\n## Tasks\n\n- [ ] \n",
display_date display_date
@@ -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

@@ -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, add_comment_handler, create_task_handler, delete_comment_handler, delete_task_handler,
list_project_tasks_handler, create_task_handler, get_task_handler, get_task_handler, list_project_tasks_handler, toggle_task_handler, update_task_content_handler,
update_task_content_handler, toggle_task_handler, update_task_meta_handler, update_task_meta_handler, AddCommentRequest, CreateTaskRequest, 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,42 @@ 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))
.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 // 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 ============
@@ -125,6 +151,19 @@ async fn delete_project_task(Path((id, task_id)): Path<(String, String)>) -> imp
delete_task_handler(id, task_id).await 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 { async fn list_projects() -> impl IntoResponse {
match list_projects_impl() { match list_projects_impl() {
Ok(projects) => Json(projects).into_response(), Ok(projects) => Json(projects).into_response(),
@@ -355,10 +394,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 +654,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)

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,17 +1,19 @@
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;
/// 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 /// Task summary for list views
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct Task { pub struct Task {
@@ -30,6 +32,7 @@ pub struct Task {
pub path: String, pub path: String,
pub created: String, pub created: String,
pub updated: String, pub updated: String,
pub last_comment: Option<String>,
} }
/// Task with full content for detail view /// Task with full content for detail view
@@ -51,6 +54,7 @@ pub struct TaskWithContent {
pub created: String, pub created: String,
pub updated: String, pub updated: String,
pub content: String, pub content: String,
pub comments: Vec<Comment>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -72,9 +76,13 @@ pub struct UpdateTaskMetaRequest {
pub recurrence_interval: Option<u32>, pub recurrence_interval: Option<u32>,
} }
#[derive(Debug, Deserialize)]
pub struct AddCommentRequest {
pub text: String,
}
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 +104,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 +123,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 +140,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 +153,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 +170,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 +183,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),
@@ -189,6 +192,41 @@ 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 ============ // ============ Implementation Functions ============
fn get_tasks_dir(project_id: &str) -> std::path::PathBuf { fn get_tasks_dir(project_id: &str) -> std::path::PathBuf {
@@ -244,6 +282,29 @@ fn list_project_tasks_impl(project_id: &str) -> Result<Vec<Task>, String> {
Ok(tasks) 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. /// Shared helper: extract common task fields from frontmatter.
/// Eliminates duplication between parse_task_file and parse_task_with_content. /// 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 { fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &str) -> Task {
@@ -253,6 +314,9 @@ fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &st
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let comments = parse_comments(fm);
let last_comment = comments.last().map(|c| c.text.clone());
Task { Task {
id: frontmatter::get_str_or(fm, "id", &filename), id: frontmatter::get_str_or(fm, "id", &filename),
title: frontmatter::get_str_or(fm, "title", "Untitled"), title: frontmatter::get_str_or(fm, "title", "Untitled"),
@@ -269,6 +333,7 @@ fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &st
path: format!("projects/{}/tasks/{}.md", project_id, filename), path: format!("projects/{}/tasks/{}.md", project_id, filename),
created: frontmatter::get_str_or(fm, "created", ""), created: frontmatter::get_str_or(fm, "created", ""),
updated: frontmatter::get_str_or(fm, "updated", ""), updated: frontmatter::get_str_or(fm, "updated", ""),
last_comment,
} }
} }
@@ -366,6 +431,7 @@ fn create_task_impl(
created: now_str.clone(), created: now_str.clone(),
updated: now_str, updated: now_str,
content: body, content: body,
comments: Vec::new(),
}) })
} }
@@ -418,6 +484,7 @@ fn parse_task_with_content(
project_id: &str, project_id: &str,
) -> Result<TaskWithContent, String> { ) -> Result<TaskWithContent, String> {
let task = extract_task_fields(fm, path, project_id); let task = extract_task_fields(fm, path, project_id);
let comments = parse_comments(fm);
Ok(TaskWithContent { Ok(TaskWithContent {
id: task.id, id: task.id,
title: task.title, title: task.title,
@@ -435,6 +502,7 @@ fn parse_task_with_content(
created: task.created, created: task.created,
updated: task.updated, updated: task.updated,
content: body.to_string(), content: body.to_string(),
comments,
}) })
} }
@@ -483,11 +551,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 +618,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(
&fs::read_to_string(&task_path).unwrap(),
&task_path,
project_id,
)
.ok_or_else(|| "Failed to parse updated task".to_string())?; .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 +672,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)?;
@@ -645,6 +762,7 @@ fn create_recurring_task_impl(
created: now_str.clone(), created: now_str.clone(),
updated: now_str, updated: now_str,
content: body, content: body,
comments: Vec::new(),
}) })
} }
@@ -728,12 +846,112 @@ 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(
&fs::read_to_string(&task_path).unwrap(),
&task_path,
project_id,
)
.ok_or_else(|| "Failed to parse updated task".to_string())?; .ok_or_else(|| "Failed to parse updated task".to_string())?;
Ok(task) 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> { fn delete_task_impl(project_id: &str, task_id: &str) -> Result<(), String> {
let task_path = find_task_path(project_id, task_id)?; let task_path = find_task_path(project_id, task_id)?;

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,7 +310,9 @@ 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());
} }
@@ -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,8 +417,7 @@ 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());
@@ -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
@@ -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

@@ -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
);
} }
} }
@@ -48,8 +51,10 @@ fn search_with_ripgrep(query: &str) -> Result<Vec<SearchResult>, String> {
.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,12 +93,12 @@ 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 {

View File

@@ -28,7 +28,10 @@ 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
@@ -36,7 +39,8 @@ pub async fn start_watcher(ws_state: Arc<WsState>) -> Result<(), String> {
{ {
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))?;
} }
@@ -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! {
@@ -154,7 +158,10 @@ fn normalize_path(path: &Path) -> String {
// 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",

40
build-local.ps1 Normal file
View File

@@ -0,0 +1,40 @@
# build-local.ps1 — Build a local release package for testing
# Creates: release/ironpad.exe + release/static/
# Usage: .\build-local.ps1
$ErrorActionPreference = "Stop"
$root = $PSScriptRoot
Write-Host "`n=== Ironpad Local Build ===" -ForegroundColor Cyan
# 1. Build frontend
Write-Host "`n[1/4] Building frontend..." -ForegroundColor Yellow
Push-Location "$root\frontend"
npm run build
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "Frontend build failed" }
Pop-Location
# 2. Build backend (release)
Write-Host "`n[2/4] Building backend (release)..." -ForegroundColor Yellow
Push-Location "$root\backend"
cargo build --release
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "Backend build failed" }
Pop-Location
# 3. Assemble release folder
Write-Host "`n[3/4] Assembling release folder..." -ForegroundColor Yellow
$releaseDir = "$root\release"
if (Test-Path $releaseDir) { Remove-Item -Recurse -Force $releaseDir }
New-Item -ItemType Directory -Path $releaseDir | Out-Null
New-Item -ItemType Directory -Path "$releaseDir\static" | Out-Null
Copy-Item "$root\backend\target\release\ironpad.exe" "$releaseDir\ironpad.exe"
Copy-Item "$root\frontend\dist\*" "$releaseDir\static\" -Recurse
# 4. Done
$size = [math]::Round((Get-Item "$releaseDir\ironpad.exe").Length / 1MB, 1)
Write-Host "`n[4/4] Done!" -ForegroundColor Green
Write-Host " Binary: $releaseDir\ironpad.exe ($size MB)"
Write-Host " Static: $releaseDir\static\"
Write-Host "`nTo run: cd release && .\ironpad.exe" -ForegroundColor Cyan
Write-Host "(Tray icon will appear - right-click for menu)`n"

View File

@@ -267,7 +267,12 @@ GET /api/projects/:id/tasks
"priority": "high", "priority": "high",
"due_date": "2026-02-10", "due_date": "2026-02-10",
"is_active": true, "is_active": true,
"content": "## Requirements\n\n- Item 1\n- Item 2", "tags": ["backend", "api"],
"parent_id": null,
"recurrence": null,
"recurrence_interval": null,
"project_id": "ferrite",
"last_comment": "API endpoint done, moving to frontend",
"path": "projects/ferrite/tasks/task-20260205-123456.md", "path": "projects/ferrite/tasks/task-20260205-123456.md",
"created": "2026-02-05T12:34:56Z", "created": "2026-02-05T12:34:56Z",
"updated": "2026-02-05T12:34:56Z" "updated": "2026-02-05T12:34:56Z"
@@ -338,6 +343,72 @@ PUT /api/projects/:id/tasks/:taskId/toggle
DELETE /api/projects/:id/tasks/:taskId DELETE /api/projects/:id/tasks/:taskId
``` ```
### Add Comment
```http
POST /api/projects/:id/tasks/:taskId/comments
Content-Type: application/json
{
"text": "Started work on this API integration is in progress."
}
```
**Response:** `201 Created`
```json
{
"id": "task-20260216-120000",
"title": "Implement feature X",
"completed": false,
"section": "Active",
"is_active": true,
"comments": [
{
"date": "2026-02-16T10:30:00+00:00",
"text": "Created initial spec"
},
{
"date": "2026-02-16T12:00:00+00:00",
"text": "Started work on this — API integration is in progress."
}
],
"content": "## Requirements\n\n- Item 1\n- Item 2",
"...": "other task fields"
}
```
Comments are stored as a YAML sequence in the task's frontmatter. The response returns the full `TaskWithContent` object with all comments.
### Delete Comment
```http
DELETE /api/projects/:id/tasks/:taskId/comments/:commentIndex
```
Removes the comment at the given zero-based index.
**Response:**
```json
{
"id": "task-20260216-120000",
"comments": [],
"...": "full TaskWithContent"
}
```
### Comment in List Views
When listing tasks (`GET /api/projects/:id/tasks` or `GET /api/tasks`), each task includes a `last_comment` field with the text of the most recent comment (or `null` if no comments exist). This enables showing a quick status summary without loading the full task.
```json
{
"id": "task-20260216-120000",
"title": "Implement feature X",
"last_comment": "Started work on this — API integration is in progress.",
"...": "other task fields"
}
```
--- ---
## All Tasks ## All Tasks

View File

@@ -320,6 +320,11 @@ interface Project {
### Task ### Task
```typescript ```typescript
interface Comment {
date: string // ISO 8601 timestamp
text: string // Comment body
}
interface Task { interface Task {
id: string // e.g., "task-20260205-123456" id: string // e.g., "task-20260205-123456"
title: string title: string
@@ -328,13 +333,52 @@ interface Task {
priority?: string priority?: string
due_date?: string due_date?: string
is_active: boolean is_active: boolean
content: string // Markdown description tags: string[]
parent_id?: string // Links subtask to parent
recurrence?: string // "daily" | "weekly" | "monthly" | "yearly"
recurrence_interval?: number
last_comment?: string // Most recent comment text (list views)
path: string path: string
created: string created: string
updated: string updated: string
} }
interface TaskWithContent extends Task {
content: string // Markdown description
comments: Comment[] // Full comment history
}
``` ```
#### Task File Format
```markdown
---
id: ferrite-task-20260216-120000
type: task
title: Implement feature X
completed: false
section: Active
priority: normal
is_active: true
tags:
- backend
- api
comments:
- date: "2026-02-16T10:30:00+00:00"
text: Started initial research
- date: "2026-02-16T14:00:00+00:00"
text: API endpoint done, moving to frontend
created: "2026-02-16T12:00:00+00:00"
updated: "2026-02-16T14:00:00+00:00"
---
# Implement feature X
Detailed description in markdown...
```
Comments are stored as a YAML sequence directly in frontmatter, keeping everything in a single file. The `last_comment` field in list views is derived at read time from the last entry in the sequence.
## API Design ## API Design
### REST Conventions ### REST Conventions

View File

@@ -0,0 +1,8 @@
{
"recent_files": [],
"expanded_paths": [
"G:\\DEV\\proman2k\\docs\\ai-workflow"
],
"file_tree_width": 250.0,
"show_file_tree": true
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

BIN
docs/graphics/roadmap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

View File

@@ -0,0 +1,108 @@
# System Tray Implementation (v0.2.0) ✅
**Status:** Implemented
**Goal:** Replace the CMD window with a system tray icon. Users interact via tray menu: "Open in Browser" or "Quit". No console window on Windows.
---
## Overview
- **Scope:** Single codebase, cross-platform. CI/CD unchanged — same build pipeline produces one binary per OS.
- **Crate:** `tray-item = "0.10"` (cross-platform tray icon with simple API)
- **Windows icon:** `windows-sys = "0.52"` for loading the standard application icon via `LoadIconW`
---
## What Was Implemented
### 1. Dependencies (`backend/Cargo.toml`)
```toml
# System tray (production mode)
tray-item = "0.10"
# Windows system icon loading (for tray icon)
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_UI_WindowsAndMessaging"] }
```
### 2. Windows: Hide Console Window (`backend/src/main.rs`)
```rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
```
- **Debug builds (`cargo run`):** Console remains for logs.
- **Release builds (`cargo build --release`):** No CMD window on Windows.
### 3. Restructured `main()` for Dual-Mode Operation
```
main()
├── Development mode (no static/index.html next to exe)
│ └── Normal tokio runtime on main thread, API-only, no tray
└── Production mode (static/index.html exists)
└── run_with_tray()
├── Background thread: tokio runtime + Axum server
├── mpsc channel: server sends bound port back to main thread
├── Auto-open browser (400ms delay)
└── Main thread: tray icon + event loop
```
### 4. Tray Icon (Platform-Specific)
| Platform | Icon Source |
|----------|------------|
| Windows | `IDI_APPLICATION` via `LoadIconW` (standard system app icon) |
| macOS | `IconSource::Resource("")` (default icon) |
| Linux | `IconSource::Resource("application-x-executable")` (icon theme) |
### 5. Tray Menu
- **"Open in Browser"** — calls `webbrowser::open()` with `http://localhost:{port}`
- **"Quit"** — calls `std::process::exit(0)` to shut down server and exit
### 6. Threading Model
The tray event loop runs on the **main thread** (required on macOS, safe on all platforms). The Axum server runs on a **background thread** with its own `tokio::runtime::Runtime`. Port discovery uses `std::sync::mpsc::channel`.
---
## Behaviour Summary
| Before (v0.1.0) | After (v0.2.0) |
|------------------------|------------------------------------|
| CMD window visible | No console window (Windows release)|
| Browser opens on start | Browser opens on start + via tray |
| Quit via Ctrl+C | Quit via tray menu or Ctrl+C (dev) |
---
## Testing Checklist
- [x] Backend compiles with new dependencies (`cargo check`)
- [ ] Windows: No CMD window when running release binary
- [ ] Windows: Tray icon appears; "Open in Browser" opens correct URL
- [ ] Windows: "Quit" exits cleanly
- [ ] macOS: Tray icon in menu bar; menu works
- [ ] Linux: Tray icon in system tray; menu works
- [ ] Development mode (`cargo run`): Behaviour unchanged (no tray, API-only)
---
## Icon Asset
Currently using platform default icons (Windows system app icon, macOS default, Linux icon theme). To use a custom branded icon:
1. Create a 16×16 or 32×32 PNG/ICO
2. On Windows: embed via `.rc` resource file and `build.rs`, use `IconSource::Resource("icon-name")`
3. On macOS/Linux: use `IconSource::Data { width, height, data }` with raw RGBA bytes
---
## References
- [tray-item crate](https://crates.io/crates/tray-item)
- [tray-icon crate](https://crates.io/crates/tray-icon) (alternative, heavier)
- `#![windows_subsystem = "windows"]` — [Rust conditional compilation](https://doc.rust-lang.org/reference/conditional-compilation.html#windows_subsystem)

View File

@@ -6,6 +6,8 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="apple-touch-icon" href="/logo-180.png" />
<title>Ironpad</title> <title>Ironpad</title>
<script> <script>
// Apply saved theme immediately to prevent flash // Apply saved theme immediately to prevent flash

View File

@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
frontend/public/logo-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -15,7 +15,8 @@ import type {
CommitDetail, CommitDetail,
DiffInfo, DiffInfo,
RemoteInfo, RemoteInfo,
DailyNote DailyNote,
Comment
} from '../types' } from '../types'
const API_BASE = '/api' const API_BASE = '/api'
@@ -150,6 +151,20 @@ export const tasksApi = {
delete: (projectId: string, taskId: string) => delete: (projectId: string, taskId: string) =>
request<void>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, { request<void>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, {
method: 'DELETE' method: 'DELETE'
}),
// Add a comment to a task
addComment: (projectId: string, taskId: string, text: string) =>
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
}),
// Delete a comment from a task by index
deleteComment: (projectId: string, taskId: string, commentIndex: number) =>
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/comments/${commentIndex}`, {
method: 'DELETE'
}) })
} }

View File

@@ -65,7 +65,10 @@ function goHome() {
<template> <template>
<header class="topbar"> <header class="topbar">
<div class="topbar-left"> <div class="topbar-left">
<h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">Ironpad</h1> <h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">
<img src="/logo-32.png" alt="" class="app-logo" />
Ironpad
</h1>
<div class="project-selector"> <div class="project-selector">
<button class="project-button" @click="toggleDropdown"> <button class="project-button" @click="toggleDropdown">
@@ -175,6 +178,15 @@ function goHome() {
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
color: var(--color-text); color: var(--color-text);
display: flex;
align-items: center;
gap: 8px;
}
.app-logo {
width: 22px;
height: 22px;
object-fit: contain;
} }
.project-selector { .project-selector {

View File

@@ -181,6 +181,42 @@ export const useTasksStore = defineStore('tasks', () => {
} }
} }
async function addComment(projectId: string, taskId: string, text: string) {
try {
error.value = null
const task = await tasksApi.addComment(projectId, taskId, text)
selectedTask.value = task
// Refresh task list so last_comment updates
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to add comment: ${err}`
throw err
}
}
async function deleteComment(projectId: string, taskId: string, commentIndex: number) {
try {
error.value = null
const task = await tasksApi.deleteComment(projectId, taskId, commentIndex)
selectedTask.value = task
// Refresh task list so last_comment updates
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to delete comment: ${err}`
throw err
}
}
function selectTask(task: Task | null) { function selectTask(task: Task | null) {
if (task && currentProjectId.value) { if (task && currentProjectId.value) {
loadTask(currentProjectId.value, task.id) loadTask(currentProjectId.value, task.id)
@@ -227,6 +263,8 @@ export const useTasksStore = defineStore('tasks', () => {
toggleTask, toggleTask,
updateTaskMeta, updateTaskMeta,
deleteTask, deleteTask,
addComment,
deleteComment,
selectTask, selectTask,
clearSelectedTask, clearSelectedTask,
clearProjectTasks, clearProjectTasks,

View File

@@ -40,6 +40,11 @@ export interface ProjectNoteWithContent extends ProjectNote {
content: string content: string
} }
export interface Comment {
date: string
text: string
}
export interface Task { export interface Task {
id: string id: string
title: string title: string
@@ -56,10 +61,12 @@ export interface Task {
path: string path: string
created: string created: string
updated: string updated: string
last_comment?: string
} }
export interface TaskWithContent extends Task { export interface TaskWithContent extends Task {
content: string content: string
comments: Comment[]
} }
export interface SearchResult { export interface SearchResult {

View File

@@ -93,21 +93,144 @@ function formatDate(y: number, m: number, d: number): string {
return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}` return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
} }
// Tasks grouped by due date // Calendar entry: a task plus whether it's a recurring occurrence (vs a real due_date)
interface CalendarEntry {
task: Task
isRecurring: boolean
}
// Tasks grouped by due date (regular, non-recurring placements)
const tasksByDate = computed(() => { const tasksByDate = computed(() => {
const map = new Map<string, Task[]>() const map = new Map<string, CalendarEntry[]>()
for (const task of tasksStore.allTasks) { for (const task of tasksStore.allTasks) {
if (task.due_date && !task.completed) { if (task.due_date && !task.completed) {
const existing = map.get(task.due_date) || [] const existing = map.get(task.due_date) || []
existing.push(task) existing.push({ task, isRecurring: false })
map.set(task.due_date, existing) map.set(task.due_date, existing)
} }
} }
return map return map
}) })
function getTasksForDate(dateStr: string): Task[] { // Expand recurring tasks into the visible calendar range
return tasksByDate.value.get(dateStr) || [] const recurringByDate = computed(() => {
const map = new Map<string, CalendarEntry[]>()
const days = calendarDays.value
if (days.length === 0) return map
const rangeStart = days[0].date
const rangeEnd = days[days.length - 1].date
for (const task of tasksStore.allTasks) {
if (task.completed || !task.recurrence) continue
const interval = task.recurrence_interval || 1
const anchorStr = task.due_date || task.created?.split('T')[0]
if (!anchorStr) continue
const anchor = new Date(anchorStr + 'T00:00:00')
const start = new Date(rangeStart + 'T00:00:00')
const end = new Date(rangeEnd + 'T00:00:00')
const occurrences: string[] = []
switch (task.recurrence) {
case 'daily': {
let cur = new Date(anchor)
// Advance to range start (skip past occurrences efficiently)
if (cur < start) {
const daysBehind = Math.floor((start.getTime() - cur.getTime()) / 86400000)
const skipCycles = Math.floor(daysBehind / interval) * interval
cur.setDate(cur.getDate() + skipCycles)
}
while (cur <= end && occurrences.length < 60) {
if (cur >= start) {
occurrences.push(dateFromObj(cur))
}
cur.setDate(cur.getDate() + interval)
}
break
}
case 'weekly': {
const targetDow = anchor.getDay()
let cur = new Date(start)
// Find first matching weekday in range
while (cur.getDay() !== targetDow && cur <= end) {
cur.setDate(cur.getDate() + 1)
}
while (cur <= end && occurrences.length < 10) {
// Check interval alignment (weeks since anchor)
const msDiff = cur.getTime() - anchor.getTime()
const weeksDiff = Math.round(msDiff / (7 * 86400000))
if (weeksDiff >= 0 && weeksDiff % interval === 0) {
occurrences.push(dateFromObj(cur))
}
cur.setDate(cur.getDate() + 7)
}
break
}
case 'monthly': {
const targetDay = anchor.getDate()
// Check months visible in the calendar range (usually 3: prev, current, next)
for (let mOffset = -1; mOffset <= 1; mOffset++) {
let y = currentYear.value
let m = currentMonth.value + mOffset
if (m < 0) { m = 11; y-- }
if (m > 11) { m = 0; y++ }
const daysInM = new Date(y, m + 1, 0).getDate()
const day = Math.min(targetDay, daysInM)
const dateStr = formatDate(y, m, day)
if (dateStr >= rangeStart && dateStr <= rangeEnd) {
const monthsDiff = (y - anchor.getFullYear()) * 12 + (m - anchor.getMonth())
if (monthsDiff >= 0 && monthsDiff % interval === 0) {
occurrences.push(dateStr)
}
}
}
break
}
case 'yearly': {
const tgtMonth = anchor.getMonth()
const tgtDay = anchor.getDate()
const y = currentYear.value
const dateStr = formatDate(y, tgtMonth, tgtDay)
if (dateStr >= rangeStart && dateStr <= rangeEnd) {
const yearsDiff = y - anchor.getFullYear()
if (yearsDiff >= 0 && yearsDiff % interval === 0) {
occurrences.push(dateStr)
}
}
break
}
}
for (const dateStr of occurrences) {
// Skip if the task already appears on this date via its due_date
if (task.due_date === dateStr) continue
const existing = map.get(dateStr) || []
existing.push({ task, isRecurring: true })
map.set(dateStr, existing)
}
}
return map
})
function dateFromObj(d: Date): string {
return formatDate(d.getFullYear(), d.getMonth(), d.getDate())
}
// Merge regular due-date tasks and recurring occurrences for a given date
function getEntriesForDate(dateStr: string): CalendarEntry[] {
const regular = tasksByDate.value.get(dateStr) || []
const recurring = recurringByDate.value.get(dateStr) || []
return [...regular, ...recurring]
}
// Convenience: check if a date has any tasks (for cell styling)
function hasTasksOnDate(dateStr: string): boolean {
return getEntriesForDate(dateStr).length > 0
} }
function hasDailyNote(dateStr: string): boolean { function hasDailyNote(dateStr: string): boolean {
@@ -211,7 +334,7 @@ onMounted(async () => {
:class="['calendar-cell', { :class="['calendar-cell', {
'other-month': !day.isCurrentMonth, 'other-month': !day.isCurrentMonth,
'is-today': day.isToday, 'is-today': day.isToday,
'has-tasks': getTasksForDate(day.date).length > 0, 'has-tasks': hasTasksOnDate(day.date),
}]" }]"
> >
<div class="cell-header" @click="clickDate(day.date)"> <div class="cell-header" @click="clickDate(day.date)">
@@ -220,20 +343,21 @@ onMounted(async () => {
</div> </div>
<div class="cell-tasks"> <div class="cell-tasks">
<div <div
v-for="task in getTasksForDate(day.date).slice(0, 3)" v-for="entry in getEntriesForDate(day.date).slice(0, 3)"
:key="task.id" :key="`${entry.task.id}-${entry.isRecurring ? 'r' : 'd'}`"
:class="['cell-task', formatDueClass(day.date)]" :class="['cell-task', formatDueClass(day.date), { recurring: entry.isRecurring }]"
@click.stop="clickTask(task)" @click.stop="clickTask(entry.task)"
:title="`${projectName(task.project_id)}: ${task.title}`" :title="`${projectName(entry.task.project_id)}: ${entry.task.title}${entry.isRecurring ? ' (recurring)' : ''}`"
> >
{{ task.title }} <span v-if="entry.isRecurring" class="recurring-icon" title="Recurring">&#x21bb;</span>
{{ entry.task.title }}
</div> </div>
<div <div
v-if="getTasksForDate(day.date).length > 3" v-if="getEntriesForDate(day.date).length > 3"
class="cell-more" class="cell-more"
@click="clickDate(day.date)" @click="clickDate(day.date)"
> >
+{{ getTasksForDate(day.date).length - 3 }} more +{{ getEntriesForDate(day.date).length - 3 }} more
</div> </div>
</div> </div>
</div> </div>
@@ -413,6 +537,17 @@ onMounted(async () => {
border-left-color: var(--color-warning); border-left-color: var(--color-warning);
} }
.cell-task.recurring {
border-left-style: dashed;
opacity: 0.85;
}
.recurring-icon {
font-size: 10px;
margin-right: 2px;
opacity: 0.7;
}
.cell-more { .cell-more {
font-size: 10px; font-size: 10px;
color: var(--color-text-secondary); color: var(--color-text-secondary);

View File

@@ -140,6 +140,8 @@ onMounted(async () => {
@click="goToTask(project.id, task)" @click="goToTask(project.id, task)"
> >
<span class="task-checkbox">&#9744;</span> <span class="task-checkbox">&#9744;</span>
<div class="card-task-info">
<div class="card-task-row">
<span class="task-title">{{ task.title }}</span> <span class="task-title">{{ task.title }}</span>
<div class="task-meta"> <div class="task-meta">
<span <span
@@ -153,6 +155,11 @@ onMounted(async () => {
>{{ formatDueDate(task.due_date)?.text }}</span> >{{ formatDueDate(task.due_date)?.text }}</span>
</div> </div>
</div> </div>
<div v-if="task.last_comment" class="card-task-comment">
{{ task.last_comment }}
</div>
</div>
</div>
<div <div
v-if="project.activeTasks.length > 5" v-if="project.activeTasks.length > 5"
class="card-task-more" class="card-task-more"
@@ -304,6 +311,20 @@ onMounted(async () => {
font-size: 14px; font-size: 14px;
} }
.card-task-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.card-task-row {
display: flex;
align-items: center;
gap: 8px;
}
.card-task-item .task-title { .card-task-item .task-title {
flex: 1; flex: 1;
font-size: 13px; font-size: 13px;
@@ -313,6 +334,16 @@ onMounted(async () => {
min-width: 0; min-width: 0;
} }
.card-task-comment {
font-size: 11px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-style: italic;
opacity: 0.75;
}
.task-meta { .task-meta {
display: flex; display: flex;
gap: 4px; gap: 4px;

View File

@@ -338,6 +338,59 @@ async function addSubtask() {
} }
} }
// ============ Comment Management ============
const newCommentText = ref('')
const showCommentForm = ref(false)
function openCommentForm() {
showCommentForm.value = true
newCommentText.value = ''
}
function closeCommentForm() {
showCommentForm.value = false
newCommentText.value = ''
}
async function addComment() {
if (!newCommentText.value.trim() || !tasksStore.selectedTask) return
try {
await tasksStore.addComment(projectId.value, tasksStore.selectedTask.id, newCommentText.value.trim())
closeCommentForm()
} catch {
// Error handled in store
}
}
async function deleteComment(index: number) {
if (!tasksStore.selectedTask) return
try {
await tasksStore.deleteComment(projectId.value, tasksStore.selectedTask.id, index)
} catch {
// Error handled in store
}
}
function formatCommentDate(dateStr: string): string {
try {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
} catch {
return dateStr
}
}
// ============ Due Date Management ============ // ============ Due Date Management ============
async function setDueDate(date: string) { async function setDueDate(date: string) {
@@ -650,6 +703,9 @@ onUnmounted(() => {
{{ formatDueDate(task.due_date)?.text }} {{ formatDueDate(task.due_date)?.text }}
</span> </span>
</div> </div>
<div v-if="task.last_comment" class="task-last-comment">
{{ task.last_comment }}
</div>
</div> </div>
</template> </template>
<button <button
@@ -718,6 +774,9 @@ onUnmounted(() => {
{{ formatDueDate(task.due_date)?.text }} {{ formatDueDate(task.due_date)?.text }}
</span> </span>
</div> </div>
<div v-if="task.last_comment" class="task-last-comment">
{{ task.last_comment }}
</div>
</div> </div>
</template> </template>
<button <button
@@ -921,6 +980,49 @@ onUnmounted(() => {
<button class="tag-add-btn" @click="openSubtaskInput">+ Add subtask</button> <button class="tag-add-btn" @click="openSubtaskInput">+ Add subtask</button>
</div> </div>
<!-- Comments Section -->
<div class="comments-panel">
<div class="comments-header">
<span class="comments-label">Comments ({{ tasksStore.selectedTask.comments?.length || 0 }})</span>
<button v-if="!showCommentForm" class="tag-add-btn" @click="openCommentForm">+ Comment</button>
</div>
<!-- Add comment form -->
<div v-if="showCommentForm" class="comment-form">
<textarea
v-model="newCommentText"
class="comment-input"
placeholder="Add a comment or status update..."
rows="2"
@keydown.ctrl.enter="addComment"
@keyup.escape="closeCommentForm"
autofocus
></textarea>
<div class="comment-form-actions">
<button class="primary small" @click="addComment" :disabled="!newCommentText.trim()">Add Comment</button>
<button class="small" @click="closeCommentForm">Cancel</button>
<span class="comment-hint">Ctrl+Enter to submit</span>
</div>
</div>
<!-- Comment list (newest first) -->
<div v-if="tasksStore.selectedTask.comments?.length > 0" class="comment-list">
<div
v-for="(comment, index) in [...tasksStore.selectedTask.comments].reverse()"
:key="index"
class="comment-item"
>
<div class="comment-meta">
<span class="comment-date">{{ formatCommentDate(comment.date) }}</span>
<button
class="comment-delete"
@click="deleteComment(tasksStore.selectedTask.comments.length - 1 - index)"
title="Delete comment"
>&times;</button>
</div>
<div class="comment-text">{{ comment.text }}</div>
</div>
</div>
</div>
<div class="editor-content"> <div class="editor-content">
<div class="editor-pane"> <div class="editor-pane">
<MilkdownEditor <MilkdownEditor
@@ -1507,6 +1609,22 @@ button.small {
color: white; color: white;
} }
/* Last comment preview in task list */
.task-last-comment {
font-size: 11px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
opacity: 0.75;
font-style: italic;
}
.task-item.selected .task-last-comment {
color: rgba(255, 255, 255, 0.7);
}
/* Tag Editor Bar (detail panel) */ /* Tag Editor Bar (detail panel) */
.tag-editor-bar { .tag-editor-bar {
display: flex; display: flex;
@@ -1601,6 +1719,118 @@ button.small {
background: var(--color-bg-hover); background: var(--color-bg-hover);
} }
/* Comments Panel */
.comments-panel {
border-bottom: 1px solid var(--color-border);
padding: 8px 16px;
flex-shrink: 0;
max-height: 280px;
overflow-y: auto;
}
.comments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.comments-label {
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.comment-form {
margin-bottom: 8px;
}
.comment-input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
font-family: inherit;
outline: none;
resize: vertical;
min-height: 48px;
box-sizing: border-box;
}
.comment-input:focus {
border-color: var(--color-primary);
}
.comment-form-actions {
display: flex;
gap: 8px;
align-items: center;
margin-top: 6px;
}
.comment-hint {
font-size: 11px;
color: var(--color-text-secondary);
margin-left: auto;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.comment-item {
padding: 8px 10px;
border-radius: 6px;
background: var(--color-bg);
border: 1px solid var(--color-border);
}
.comment-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.comment-date {
font-size: 11px;
color: var(--color-text-secondary);
}
.comment-delete {
padding: 0 4px;
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 14px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s;
}
.comment-item:hover .comment-delete {
opacity: 1;
}
.comment-delete:hover {
color: var(--color-danger);
}
.comment-text {
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* Error Banner */ /* Error Banner */
.error-banner { .error-banner {
position: absolute; position: absolute;