Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5299190973 | |||
|
|
f1f106c948 | ||
|
|
2a55d5ebec | ||
|
|
adefed7c74 | ||
|
|
d39bbd1801 | ||
|
|
0b5816621b | ||
|
|
97508e4f0a | ||
|
|
d98181bed9 | ||
|
|
10ead43260 | ||
|
|
9b9cb1f0b4 | ||
|
|
bbffc0e6bb | ||
|
|
7c91e1775a | ||
|
|
781ea28097 | ||
|
|
b150a243fd | ||
|
|
df27a27a2d |
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.ferrite
|
||||||
|
|
||||||
|
backend/target
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
|
||||||
|
data
|
||||||
|
docs
|
||||||
|
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
11
.ferrite/state.json
Normal 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
|
||||||
|
}
|
||||||
20
.ferrite/terminal-layout.json
Normal 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
|
||||||
|
}
|
||||||
4
.github/workflows/ci.yml
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install system dependencies (Linux)
|
- name: Install system dependencies (Linux)
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
run: sudo apt-get update && sudo apt-get install -y cmake
|
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
|
||||||
|
|||||||
4
.gitignore
vendored
@@ -37,9 +37,13 @@ data/notes/assets/*
|
|||||||
!data/notes/assets/.gitkeep
|
!data/notes/assets/.gitkeep
|
||||||
data/projects/*/
|
data/projects/*/
|
||||||
!data/projects/.gitkeep
|
!data/projects/.gitkeep
|
||||||
|
Codex Session.txt
|
||||||
|
|
||||||
# === 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
@@ -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.
|
||||||
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
FROM node:20-bookworm-slim AS frontend-builder
|
||||||
|
WORKDIR /src/frontend
|
||||||
|
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM rust:1.85-bookworm AS backend-builder
|
||||||
|
WORKDIR /src/backend
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends pkg-config libdbus-1-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/ ./
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates ripgrep libdbus-1-3 git openssh-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=backend-builder /src/backend/target/release/ironpad /app/ironpad
|
||||||
|
COPY --from=frontend-builder /src/frontend/dist /app/static
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
ENV IRONPAD_HOST=0.0.0.0
|
||||||
|
ENV IRONPAD_PORT=3000
|
||||||
|
ENV IRONPAD_DISABLE_TRAY=1
|
||||||
|
ENV IRONPAD_DATA_DIR=/app/data
|
||||||
|
ENV RUST_LOG=info
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
|
CMD ["./ironpad"]
|
||||||
2
LICENSE
@@ -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
|
||||||
|
|||||||
83
README.md
@@ -1,18 +1,22 @@
|
|||||||
# Ironpad
|
# Ironpad
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
**A local-first, file-based project & knowledge management system.**
|
**A local-first, file-based project & knowledge management system.**
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
> **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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -63,8 +68,57 @@ npm run dev
|
|||||||
|
|
||||||
Open http://localhost:5173 in your browser.
|
Open http://localhost:5173 in your browser.
|
||||||
|
|
||||||
|
### Option 3: Run with Docker (Centralized Server)
|
||||||
|
|
||||||
|
This runs Ironpad as a single container that serves both API and frontend on port `3000`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and start in the background
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
- `http://localhost:3000` from the same machine, or
|
||||||
|
- `http://<your-server-ip>:3000` from another device on your network.
|
||||||
|
|
||||||
|
Data persists in `./data` on the host via the compose volume mapping.
|
||||||
|
|
||||||
|
To stop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker + Private Git Remote Sync
|
||||||
|
|
||||||
|
Ironpad can automatically sync the `data/` git repo with a private remote over SSH.
|
||||||
|
|
||||||
|
1. Put your SSH key files on the host (example: `./deploy/ssh/id_ed25519` and `./deploy/ssh/id_ed25519.pub`).
|
||||||
|
2. Uncomment the SSH volume mount and git env vars in `docker-compose.yml`.
|
||||||
|
3. Set:
|
||||||
|
- `IRONPAD_GIT_REMOTE_URL` (example: `git@github.com:your-org/ironpad-data.git`)
|
||||||
|
- `IRONPAD_GIT_SSH_PRIVATE_KEY` (path inside container)
|
||||||
|
- `IRONPAD_GIT_SSH_KNOWN_HOSTS` (optional; defaults to `/root/.ssh/known_hosts`)
|
||||||
|
- `IRONPAD_GIT_SYNC_INTERVAL_SECS` (example: `300`)
|
||||||
|
4. Recreate the stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Sync behavior:
|
||||||
|
- Every cycle: `fetch -> safe fast-forward if possible -> push`
|
||||||
|
- If local and remote diverge, auto fast-forward is skipped and a warning is logged.
|
||||||
|
- If libgit2 SSH auth fails, Ironpad can fall back to `git` CLI (controlled by `IRONPAD_GIT_USE_CLI_FALLBACK`, default `true`).
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
| Component | Technology |
|
| Component | Technology |
|
||||||
|-----------|------------|
|
|-----------|------------|
|
||||||
| Backend | Rust, Axum 0.8, Tokio |
|
| Backend | Rust, Axum 0.8, Tokio |
|
||||||
@@ -78,8 +132,13 @@ Open http://localhost:5173 in your browser.
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
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 +146,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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
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:
|
||||||
@@ -108,11 +168,24 @@ Read about the method:
|
|||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| Data directory | `data/` next to executable | Override with `IRONPAD_DATA_DIR` env var |
|
| Data directory | `data/` next to executable | Override with `IRONPAD_DATA_DIR` env var |
|
||||||
| Backend port | 3000 (auto-increments to 3010) | Dynamic port selection |
|
| Backend port | 3000 (auto-increments to 3010) | Dynamic port selection |
|
||||||
|
| Backend host | `127.0.0.1` | Override with `IRONPAD_HOST` (use `0.0.0.0` for Docker/server access) |
|
||||||
|
| Fixed port | disabled | Set `IRONPAD_PORT` to force a specific port |
|
||||||
|
| Disable tray mode | `false` | Set `IRONPAD_DISABLE_TRAY=1` to run headless in production static mode |
|
||||||
| Auto-commit | Every 60 seconds | Git commits when changes exist |
|
| Auto-commit | Every 60 seconds | Git commits when changes exist |
|
||||||
|
| Git remote URL | not set | `IRONPAD_GIT_REMOTE_URL` creates/updates `origin` |
|
||||||
|
| Git sync interval | `0` (disabled) | Set `IRONPAD_GIT_SYNC_INTERVAL_SECS` to enable scheduled sync |
|
||||||
|
| Git SSH private key | not set | `IRONPAD_GIT_SSH_PRIVATE_KEY` path to private key in container |
|
||||||
|
| Git SSH public key | not set | Optional `IRONPAD_GIT_SSH_PUBLIC_KEY` path |
|
||||||
|
| Git known_hosts path | `/root/.ssh/known_hosts` | Override with `IRONPAD_GIT_SSH_KNOWN_HOSTS` |
|
||||||
|
| Git SSH username | `git` | Override with `IRONPAD_GIT_SSH_USERNAME` if needed |
|
||||||
|
| Git SSH passphrase | not set | Optional `IRONPAD_GIT_SSH_PASSPHRASE` |
|
||||||
|
| Git CLI fallback | `true` | `IRONPAD_GIT_USE_CLI_FALLBACK` for fetch/push auth fallback |
|
||||||
| Auto-save | 1 second debounce | Frontend saves after typing stops |
|
| Auto-save | 1 second debounce | Frontend saves after typing stops |
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
| 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
@@ -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 |
|
||||||
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
1
backend/.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
/release
|
||||||
791
backend/Cargo.lock
generated
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ironpad"
|
name = "ironpad"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -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"
|
||||||
BIN
backend/assets/icon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
backend/assets/ironpad.ico
Normal file
|
After Width: | Height: | Size: 40 KiB |
10
backend/build.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
fn main() {
|
||||||
|
// On Windows, embed the application icon into the .exe
|
||||||
|
// This sets both the Explorer icon and makes it available as a resource
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let mut res = winresource::WindowsResource::new();
|
||||||
|
res.set_icon("assets/ironpad.ico");
|
||||||
|
res.compile().expect("Failed to compile Windows resources");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
use std::net::SocketAddr;
|
// Hide console window on Windows in release builds (production mode)
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{routing::get, Router};
|
use axum::{routing::get, Router};
|
||||||
@@ -17,26 +20,74 @@ mod websocket;
|
|||||||
/// Find an available port and return the bound listener.
|
/// Find an available port and return the bound listener.
|
||||||
/// Avoids TOCTOU race by keeping the listener alive.
|
/// Avoids TOCTOU race by keeping the listener alive.
|
||||||
async fn find_available_port() -> (TcpListener, u16) {
|
async fn find_available_port() -> (TcpListener, u16) {
|
||||||
|
let host = std::env::var("IRONPAD_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||||
|
let bind_ip = match host.parse::<IpAddr>() {
|
||||||
|
Ok(ip) => ip,
|
||||||
|
Err(_) => {
|
||||||
|
warn!("Invalid IRONPAD_HOST '{}', falling back to 127.0.0.1", host);
|
||||||
|
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(port_str) = std::env::var("IRONPAD_PORT") {
|
||||||
|
match port_str.parse::<u16>() {
|
||||||
|
Ok(port) => {
|
||||||
|
let addr = SocketAddr::new(bind_ip, port);
|
||||||
|
let listener = TcpListener::bind(addr)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("Failed to bind to {addr}: {e}"));
|
||||||
|
return (listener, port);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warn!("Invalid IRONPAD_PORT '{}', falling back to 3000-3010", port_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for port in 3000..=3010 {
|
for port in 3000..=3010 {
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
let addr = SocketAddr::new(bind_ip, port);
|
||||||
if let Ok(listener) = TcpListener::bind(addr).await {
|
if let Ok(listener) = TcpListener::bind(addr).await {
|
||||||
return (listener, port);
|
return (listener, port);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
panic!("No available ports in range 3000–3010");
|
panic!("No available ports in range 3000-3010");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
fn env_flag(name: &str) -> bool {
|
||||||
async fn main() {
|
std::env::var(name)
|
||||||
// Logging
|
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||||
tracing_subscriber::fmt().init();
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve data directory (production vs development mode)
|
fn main() {
|
||||||
|
tracing_subscriber::fmt().init();
|
||||||
config::init_data_dir();
|
config::init_data_dir();
|
||||||
|
|
||||||
// Find port and bind (listener kept alive to avoid race condition)
|
let production = config::is_production();
|
||||||
|
let disable_tray = env_flag("IRONPAD_DISABLE_TRAY");
|
||||||
|
|
||||||
|
if production && !disable_tray {
|
||||||
|
run_with_tray();
|
||||||
|
} else {
|
||||||
|
if production && disable_tray {
|
||||||
|
info!("Production static mode detected; running headless (IRONPAD_DISABLE_TRAY=1)");
|
||||||
|
}
|
||||||
|
// 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());
|
||||||
|
|
||||||
@@ -51,8 +102,14 @@ async fn main() {
|
|||||||
warn!("Git init skipped: {}", e);
|
warn!("Git init skipped: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure git remote from env (if provided)
|
||||||
|
if let Err(e) = services::git::configure_remote_from_env() {
|
||||||
|
warn!("Git remote setup skipped: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
// Start auto-commit background task (tries to commit every 60s)
|
// Start auto-commit background task (tries to commit every 60s)
|
||||||
services::git::start_auto_commit();
|
services::git::start_auto_commit();
|
||||||
|
services::git::start_auto_sync();
|
||||||
|
|
||||||
// CORS layer (permissive for local-only app)
|
// CORS layer (permissive for local-only app)
|
||||||
let cors = CorsLayer::permissive();
|
let cors = CorsLayer::permissive();
|
||||||
@@ -94,7 +151,6 @@ async fn main() {
|
|||||||
.layer(cors);
|
.layer(cors);
|
||||||
|
|
||||||
// Check for embedded frontend (production mode)
|
// Check for embedded frontend (production mode)
|
||||||
// Resolve relative to the executable's directory, not the working directory
|
|
||||||
let has_frontend = config::is_production();
|
let has_frontend = config::is_production();
|
||||||
|
|
||||||
if has_frontend {
|
if has_frontend {
|
||||||
@@ -116,23 +172,114 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
info!("🚀 Ironpad running on http://localhost:{port}");
|
let bound_addr = listener
|
||||||
|
.local_addr()
|
||||||
// Auto-open browser in production mode
|
.map(|a| a.to_string())
|
||||||
if has_frontend {
|
.unwrap_or_else(|_| format!("127.0.0.1:{port}"));
|
||||||
let url = format!("http://localhost:{}", port);
|
info!("Ironpad running on http://{bound_addr}");
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ use std::fs;
|
|||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::routes::tasks::{
|
use crate::routes::tasks::{
|
||||||
create_task_handler, delete_task_handler, get_task_handler, list_project_tasks_handler,
|
add_comment_handler, create_task_handler, delete_comment_handler, delete_task_handler,
|
||||||
toggle_task_handler, update_task_content_handler, update_task_meta_handler, CreateTaskRequest,
|
get_task_handler, list_project_tasks_handler, toggle_task_handler, update_task_content_handler,
|
||||||
UpdateTaskMetaRequest,
|
update_task_meta_handler, AddCommentRequest, CreateTaskRequest, UpdateTaskMetaRequest,
|
||||||
};
|
};
|
||||||
use crate::services::filesystem;
|
use crate::services::filesystem;
|
||||||
use crate::services::frontmatter;
|
use crate::services::frontmatter;
|
||||||
@@ -91,6 +91,14 @@ pub fn router() -> Router {
|
|||||||
)
|
)
|
||||||
.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(
|
.route(
|
||||||
"/{id}/notes",
|
"/{id}/notes",
|
||||||
@@ -143,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(),
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ use crate::config;
|
|||||||
use crate::services::filesystem;
|
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 {
|
||||||
@@ -25,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
|
||||||
@@ -46,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)]
|
||||||
@@ -67,6 +76,11 @@ 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().route("/", get(list_all_tasks_handler))
|
Router::new().route("/", get(list_all_tasks_handler))
|
||||||
}
|
}
|
||||||
@@ -178,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 {
|
||||||
@@ -233,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 {
|
||||||
@@ -242,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"),
|
||||||
@@ -258,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,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,
|
||||||
@@ -424,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,6 +856,102 @@ fn update_task_meta_impl(
|
|||||||
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)?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -96,6 +98,171 @@ pub struct RemoteInfo {
|
|||||||
/// The background task simply tries to commit every interval;
|
/// The background task simply tries to commit every interval;
|
||||||
/// commit_all() already handles "no changes" gracefully.
|
/// commit_all() already handles "no changes" gracefully.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct GitAuthConfig {
|
||||||
|
username: String,
|
||||||
|
private_key: Option<PathBuf>,
|
||||||
|
public_key: Option<PathBuf>,
|
||||||
|
passphrase: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_auth_config() -> GitAuthConfig {
|
||||||
|
let username = std::env::var("IRONPAD_GIT_SSH_USERNAME").unwrap_or_else(|_| "git".to_string());
|
||||||
|
let private_key = std::env::var("IRONPAD_GIT_SSH_PRIVATE_KEY")
|
||||||
|
.ok()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.filter(|p| p.exists());
|
||||||
|
let public_key = std::env::var("IRONPAD_GIT_SSH_PUBLIC_KEY")
|
||||||
|
.ok()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.filter(|p| p.exists());
|
||||||
|
let passphrase = std::env::var("IRONPAD_GIT_SSH_PASSPHRASE")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| {
|
||||||
|
let trimmed = s.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
GitAuthConfig {
|
||||||
|
username,
|
||||||
|
private_key,
|
||||||
|
public_key,
|
||||||
|
passphrase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_callbacks() -> git2::RemoteCallbacks<'static> {
|
||||||
|
let auth = git_auth_config();
|
||||||
|
let mut callbacks = git2::RemoteCallbacks::new();
|
||||||
|
|
||||||
|
callbacks.credentials(move |_url, username_from_url, _allowed_types| {
|
||||||
|
// Always prefer configured username (env-driven).
|
||||||
|
// Some remotes embed a username in URL; that can cause auth mismatches.
|
||||||
|
let username = auth.username.as_str();
|
||||||
|
if let Some(url_user) = username_from_url {
|
||||||
|
if url_user != username {
|
||||||
|
tracing::warn!(
|
||||||
|
"Remote URL username '{}' differs from IRONPAD_GIT_SSH_USERNAME '{}'; using env value",
|
||||||
|
url_user,
|
||||||
|
username
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(private_key) = auth.private_key.as_deref() {
|
||||||
|
let public_key: Option<&Path> = auth.public_key.as_deref();
|
||||||
|
let passphrase = auth.passphrase.as_deref();
|
||||||
|
|
||||||
|
// First try with configured public key path (if provided),
|
||||||
|
// then retry without public key file to avoid mismatch issues.
|
||||||
|
if let Some(pub_key_path) = public_key {
|
||||||
|
match git2::Cred::ssh_key(username, Some(pub_key_path), private_key, passphrase) {
|
||||||
|
Ok(cred) => return Ok(cred),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"SSH key auth with explicit public key failed for user '{}', private '{}', public '{}': {}",
|
||||||
|
username,
|
||||||
|
private_key.display(),
|
||||||
|
pub_key_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match git2::Cred::ssh_key(username, None, private_key, passphrase) {
|
||||||
|
Ok(cred) => return Ok(cred),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"SSH key auth from private key failed for user '{}', key '{}': {}",
|
||||||
|
username,
|
||||||
|
private_key.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"IRONPAD_GIT_SSH_PRIVATE_KEY not set or file missing; falling back to SSH agent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
git2::Cred::ssh_key_from_agent(username)
|
||||||
|
});
|
||||||
|
|
||||||
|
callbacks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn use_git_cli_fallback() -> bool {
|
||||||
|
std::env::var("IRONPAD_GIT_USE_CLI_FALLBACK")
|
||||||
|
.map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off"))
|
||||||
|
.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn known_hosts_path() -> Option<PathBuf> {
|
||||||
|
if let Ok(path) = std::env::var("IRONPAD_GIT_SSH_KNOWN_HOSTS") {
|
||||||
|
let p = PathBuf::from(path);
|
||||||
|
if p.exists() {
|
||||||
|
return Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let default = PathBuf::from("/root/.ssh/known_hosts");
|
||||||
|
if default.exists() {
|
||||||
|
Some(default)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_ssh_command(auth: &GitAuthConfig) -> Option<String> {
|
||||||
|
let private_key = auth.private_key.as_ref()?;
|
||||||
|
|
||||||
|
let mut cmd = format!(
|
||||||
|
"ssh -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes",
|
||||||
|
private_key.display()
|
||||||
|
);
|
||||||
|
if let Some(known_hosts) = known_hosts_path() {
|
||||||
|
cmd.push_str(&format!(" -o UserKnownHostsFile={}", known_hosts.display()));
|
||||||
|
}
|
||||||
|
Some(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_git_cli(args: &[&str]) -> Result<(), String> {
|
||||||
|
let auth = git_auth_config();
|
||||||
|
let data_path = config::data_dir();
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.args(args)
|
||||||
|
.current_dir(data_path)
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0");
|
||||||
|
|
||||||
|
if let Some(ssh_cmd) = git_ssh_command(&auth) {
|
||||||
|
cmd.env("GIT_SSH_COMMAND", ssh_cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run git CLI: {}", e))?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let msg = if !stderr.is_empty() { stderr } else { stdout };
|
||||||
|
Err(format!(
|
||||||
|
"git {} failed (exit {}): {}",
|
||||||
|
args.join(" "),
|
||||||
|
output.status.code().unwrap_or(-1),
|
||||||
|
msg
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get repository status
|
/// Get repository status
|
||||||
pub fn get_status() -> Result<RepoStatus, String> {
|
pub fn get_status() -> Result<RepoStatus, String> {
|
||||||
let data_path = config::data_dir();
|
let data_path = config::data_dir();
|
||||||
@@ -317,27 +484,33 @@ pub fn push_to_remote() -> Result<(), String> {
|
|||||||
return Err("No remote URL configured".to_string());
|
return Err("No remote URL configured".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create callbacks for authentication
|
|
||||||
let mut callbacks = git2::RemoteCallbacks::new();
|
|
||||||
|
|
||||||
// Try to use credential helper from git config
|
|
||||||
callbacks.credentials(|_url, username_from_url, _allowed_types| {
|
|
||||||
// Try SSH agent first
|
|
||||||
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up push options
|
// Set up push options
|
||||||
let mut push_options = git2::PushOptions::new();
|
let mut push_options = git2::PushOptions::new();
|
||||||
push_options.remote_callbacks(callbacks);
|
push_options.remote_callbacks(remote_callbacks());
|
||||||
|
|
||||||
// Push the current branch
|
// Push the current branch
|
||||||
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
|
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
|
||||||
remote
|
match remote.push(&[&refspec], Some(&mut push_options)) {
|
||||||
.push(&[&refspec], Some(&mut push_options))
|
Ok(_) => {
|
||||||
.map_err(|e| format!("Push failed: {}. Make sure SSH keys are configured.", e))?;
|
|
||||||
|
|
||||||
tracing::info!("Successfully pushed to origin/{}", branch_name);
|
tracing::info!("Successfully pushed to origin/{}", branch_name);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let err_text = e.to_string();
|
||||||
|
if !use_git_cli_fallback() {
|
||||||
|
return Err(format!(
|
||||||
|
"Push failed: {}. Make sure SSH keys are configured.",
|
||||||
|
err_text
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tracing::warn!("libgit2 push failed, trying git CLI fallback: {}", err_text);
|
||||||
|
run_git_cli(&["push", "origin", branch_name]).map_err(|cli_err| {
|
||||||
|
format!("Push failed: {} (libgit2) / {} (git CLI)", err_text, cli_err)
|
||||||
|
})?;
|
||||||
|
tracing::info!("Successfully pushed to origin/{} (git CLI fallback)", branch_name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if remote is configured
|
/// Check if remote is configured
|
||||||
@@ -351,6 +524,30 @@ pub fn has_remote() -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configure `origin` from IRONPAD_GIT_REMOTE_URL.
|
||||||
|
/// If `origin` exists, updates its URL. Otherwise creates it.
|
||||||
|
pub fn configure_remote_from_env() -> Result<(), String> {
|
||||||
|
let remote_url = match std::env::var("IRONPAD_GIT_REMOTE_URL") {
|
||||||
|
Ok(url) if !url.trim().is_empty() => url,
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let data_path = config::data_dir();
|
||||||
|
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
|
||||||
|
|
||||||
|
if repo.find_remote("origin").is_ok() {
|
||||||
|
repo.remote_set_url("origin", &remote_url)
|
||||||
|
.map_err(|e| format!("Failed to update origin URL: {}", e))?;
|
||||||
|
tracing::info!("Updated git remote origin from IRONPAD_GIT_REMOTE_URL");
|
||||||
|
} else {
|
||||||
|
repo.remote("origin", &remote_url)
|
||||||
|
.map_err(|e| format!("Failed to create origin remote: {}", e))?;
|
||||||
|
tracing::info!("Configured git remote origin from IRONPAD_GIT_REMOTE_URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Start auto-commit background task.
|
/// Start auto-commit background task.
|
||||||
/// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully.
|
/// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully.
|
||||||
pub fn start_auto_commit() {
|
pub fn start_auto_commit() {
|
||||||
@@ -374,6 +571,111 @@ pub fn start_auto_commit() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fast_forward_to_upstream(repo: &Repository, branch_name: &str) -> Result<(), String> {
|
||||||
|
let local_branch = repo
|
||||||
|
.find_branch(branch_name, git2::BranchType::Local)
|
||||||
|
.map_err(|e| format!("Local branch not found: {}", e))?;
|
||||||
|
let upstream_branch = match local_branch.upstream() {
|
||||||
|
Ok(upstream) => upstream,
|
||||||
|
Err(_) => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let local_oid = match local_branch.get().target() {
|
||||||
|
Some(oid) => oid,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
let upstream_oid = match upstream_branch.get().target() {
|
||||||
|
Some(oid) => oid,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (ahead, behind) = repo
|
||||||
|
.graph_ahead_behind(local_oid, upstream_oid)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if behind == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ahead > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
"Remote is ahead by {} and local is ahead by {}; skipping auto fast-forward",
|
||||||
|
behind,
|
||||||
|
ahead
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let refname = format!("refs/heads/{}", branch_name);
|
||||||
|
repo.reference(&refname, upstream_oid, true, "ironpad auto fast-forward")
|
||||||
|
.map_err(|e| format!("Failed to move local branch ref: {}", e))?;
|
||||||
|
repo.set_head(&refname)
|
||||||
|
.map_err(|e| format!("Failed to set HEAD: {}", e))?;
|
||||||
|
|
||||||
|
let mut checkout = git2::build::CheckoutBuilder::new();
|
||||||
|
checkout.force();
|
||||||
|
repo.checkout_head(Some(&mut checkout))
|
||||||
|
.map_err(|e| format!("Failed to checkout fast-forwarded HEAD: {}", e))?;
|
||||||
|
|
||||||
|
tracing::info!("Fast-forwarded local branch {} by {} commit(s)", branch_name, behind);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_once() -> Result<(), String> {
|
||||||
|
configure_remote_from_env()?;
|
||||||
|
if !has_remote() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_from_remote()?;
|
||||||
|
|
||||||
|
// Try to fast-forward local branch to upstream when safe (no divergence).
|
||||||
|
let data_path = config::data_dir();
|
||||||
|
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
|
||||||
|
if let Ok(head) = repo.head() {
|
||||||
|
if let Some(branch_name) = head.shorthand() {
|
||||||
|
let _ = fast_forward_to_upstream(&repo, branch_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push local commits (if any) after fetching.
|
||||||
|
if let Err(e) = push_to_remote() {
|
||||||
|
if !(e.contains("non-fast-forward") || e.contains("rejected")) {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
tracing::warn!("Auto-sync push skipped: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start periodic remote sync.
|
||||||
|
/// Controlled via env vars:
|
||||||
|
/// - IRONPAD_GIT_SYNC_INTERVAL_SECS: sync interval in seconds (default 0 = disabled)
|
||||||
|
/// - IRONPAD_GIT_REMOTE_URL: optional remote URL used to create/update `origin`
|
||||||
|
pub fn start_auto_sync() {
|
||||||
|
let interval_secs = std::env::var("IRONPAD_GIT_SYNC_INTERVAL_SECS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if interval_secs == 0 {
|
||||||
|
tracing::info!("Git auto-sync disabled (set IRONPAD_GIT_SYNC_INTERVAL_SECS > 0 to enable)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = interval(Duration::from_secs(interval_secs));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
if let Err(e) = sync_once() {
|
||||||
|
tracing::warn!("Git auto-sync failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Get commit history (most recent first)
|
/// Get commit history (most recent first)
|
||||||
pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
|
pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
|
||||||
let data_path = config::data_dir();
|
let data_path = config::data_dir();
|
||||||
@@ -630,18 +932,20 @@ pub fn fetch_from_remote() -> Result<(), String> {
|
|||||||
.find_remote("origin")
|
.find_remote("origin")
|
||||||
.map_err(|e| format!("Remote 'origin' not found: {}", e))?;
|
.map_err(|e| format!("Remote 'origin' not found: {}", e))?;
|
||||||
|
|
||||||
// Create callbacks for authentication
|
|
||||||
let mut callbacks = git2::RemoteCallbacks::new();
|
|
||||||
callbacks.credentials(|_url, username_from_url, _allowed_types| {
|
|
||||||
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut fetch_options = git2::FetchOptions::new();
|
let mut fetch_options = git2::FetchOptions::new();
|
||||||
fetch_options.remote_callbacks(callbacks);
|
fetch_options.remote_callbacks(remote_callbacks());
|
||||||
|
|
||||||
remote
|
match remote.fetch(&[] as &[&str], Some(&mut fetch_options), None) {
|
||||||
.fetch(&[] as &[&str], Some(&mut fetch_options), None)
|
Ok(_) => Ok(()),
|
||||||
.map_err(|e| format!("Fetch failed: {}", e))?;
|
Err(e) => {
|
||||||
|
let err_text = e.to_string();
|
||||||
Ok(())
|
if !use_git_cli_fallback() {
|
||||||
|
return Err(format!("Fetch failed: {}", err_text));
|
||||||
|
}
|
||||||
|
tracing::warn!("libgit2 fetch failed, trying git CLI fallback: {}", err_text);
|
||||||
|
run_git_cli(&["fetch", "origin", "--prune"]).map_err(|cli_err| {
|
||||||
|
format!("Fetch failed: {} (libgit2) / {} (git CLI)", err_text, cli_err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
build-local.ps1
Normal 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"
|
||||||
0
deploy/ssh/.gitkeep
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
ironpad:
|
||||||
|
container_name: ironpad
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
|
environment:
|
||||||
|
IRONPAD_HOST: "0.0.0.0"
|
||||||
|
IRONPAD_PORT: "3000"
|
||||||
|
IRONPAD_DISABLE_TRAY: "1"
|
||||||
|
IRONPAD_DATA_DIR: "/app/data"
|
||||||
|
RUST_LOG: "info"
|
||||||
|
# Git sync (optional)
|
||||||
|
# IRONPAD_GIT_REMOTE_URL: "git@github.com:your-org/your-private-repo.git"
|
||||||
|
# IRONPAD_GIT_SSH_USERNAME: "git"
|
||||||
|
# IRONPAD_GIT_SSH_PRIVATE_KEY: "/run/secrets/ironpad_ssh/id_ed25519"
|
||||||
|
# IRONPAD_GIT_SSH_PUBLIC_KEY: "/run/secrets/ironpad_ssh/id_ed25519.pub"
|
||||||
|
# IRONPAD_GIT_SSH_KNOWN_HOSTS: "/run/secrets/ironpad_ssh/known_hosts"
|
||||||
|
# IRONPAD_GIT_SSH_PASSPHRASE: ""
|
||||||
|
# IRONPAD_GIT_SYNC_INTERVAL_SECS: "300"
|
||||||
|
# IRONPAD_GIT_USE_CLI_FALLBACK: "true"
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
# Mount SSH key material read-only for private remote auth
|
||||||
|
# - ./deploy/ssh:/run/secrets/ironpad_ssh:ro
|
||||||
73
docs/API.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
8
docs/ai-workflow/.ferrite/state.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"recent_files": [],
|
||||||
|
"expanded_paths": [
|
||||||
|
"G:\\DEV\\proman2k\\docs\\ai-workflow"
|
||||||
|
],
|
||||||
|
"file_tree_width": 250.0,
|
||||||
|
"show_file_tree": true
|
||||||
|
}
|
||||||
BIN
docs/graphics/ai-workflow.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
docs/graphics/architecture.png
Normal file
|
After Width: | Height: | Size: 4.1 MiB |
BIN
docs/graphics/context-comparison.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
docs/graphics/electron-comparison.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
docs/graphics/ironpad-banner.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
docs/graphics/roadmap.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
docs/graphics/tech-stack.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
docs/graphics/whats-new-v020.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
108
docs/system-tray-implementation.md
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
After Width: | Height: | Size: 5.8 KiB |
BIN
frontend/public/logo-180.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/logo-32.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">↻</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);
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ onMounted(async () => {
|
|||||||
@click="goToTask(project.id, task)"
|
@click="goToTask(project.id, task)"
|
||||||
>
|
>
|
||||||
<span class="task-checkbox">☐</span>
|
<span class="task-checkbox">☐</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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
>×</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;
|
||||||
|
|||||||