feat: initialize Nocal terminal calendar
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/build/
|
||||||
|
/.cache/
|
||||||
|
compile_commands.json
|
||||||
|
calendar.ics
|
||||||
|
*.ics.lock
|
||||||
|
*.ics.bak
|
||||||
|
.*.ics.tmp.*
|
||||||
82
AGENTS.md
Normal file
82
AGENTS.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Nocal agent instructions
|
||||||
|
|
||||||
|
These instructions apply to the entire repository.
|
||||||
|
|
||||||
|
## Operating model
|
||||||
|
|
||||||
|
Use a frontier-capability model as the lead agent. The lead owns product and
|
||||||
|
technical judgment: it inspects the repository, defines the architecture,
|
||||||
|
creates the implementation plan, decomposes work, assigns non-overlapping file
|
||||||
|
ownership, reviews every delivered change, integrates the result, and performs
|
||||||
|
the final verification.
|
||||||
|
|
||||||
|
Delegate bounded coding tasks to cheaper/lower-cost agents whenever the
|
||||||
|
orchestration environment supports model selection. Coding agents should
|
||||||
|
implement clearly specified slices; they should not independently redefine the
|
||||||
|
architecture or expand product scope. Reserve the frontier model's context for
|
||||||
|
planning, difficult cross-cutting decisions, code review, debugging integration
|
||||||
|
failures, security/data-loss analysis, and final validation.
|
||||||
|
|
||||||
|
Delegation never transfers accountability. The lead must inspect the actual
|
||||||
|
diffs and rerun relevant checks; an agent's claim that code compiles or tests
|
||||||
|
pass is not sufficient evidence. Small, tightly coupled fixes may be completed
|
||||||
|
directly by the lead when delegation would create more coordination cost than
|
||||||
|
implementation value.
|
||||||
|
|
||||||
|
## Delegation protocol
|
||||||
|
|
||||||
|
Before dispatching implementation work, the lead must:
|
||||||
|
|
||||||
|
1. Read this file and inspect the current workspace, including uncommitted work.
|
||||||
|
2. State the intended behavior, interfaces, invariants, and failure semantics.
|
||||||
|
3. Give each agent exclusive ownership of an explicit file or directory set.
|
||||||
|
4. Identify shared API contracts up front and prevent concurrent edits to the
|
||||||
|
same files.
|
||||||
|
5. Keep build files, integration, roadmap decisions, and final review under the
|
||||||
|
lead unless explicitly delegated.
|
||||||
|
|
||||||
|
Each coding agent must:
|
||||||
|
|
||||||
|
- stay inside its assigned scope and preserve unrelated user changes;
|
||||||
|
- use C++20 and avoid new runtime dependencies unless the lead approves them;
|
||||||
|
- use `apply_patch` for source edits;
|
||||||
|
- add or update deterministic tests for its behavior;
|
||||||
|
- compile with strict warnings and report the exact checks run;
|
||||||
|
- document limitations, unhandled edge cases, and any shared-contract mismatch;
|
||||||
|
- never mark a task complete based only on visual inspection.
|
||||||
|
|
||||||
|
## Product and architecture constraints
|
||||||
|
|
||||||
|
- Nocal is a local-first, keyboard-driven terminal calendar for Linux and
|
||||||
|
Hyprland.
|
||||||
|
- The month grid remains the primary view and appointments remain navigable
|
||||||
|
inside day cells.
|
||||||
|
- Styling must use the terminal's default background and semantic ANSI palette.
|
||||||
|
Do not introduce hard-coded RGB themes. `NO_COLOR` must remain usable.
|
||||||
|
- Keep domain, storage, synchronization, and TUI concerns separated. Provider
|
||||||
|
APIs and iCalendar details must not leak into rendering code.
|
||||||
|
- Treat calendar writes as data-loss-sensitive. Mutations need validation,
|
||||||
|
atomic persistence, rollback on failure, and tests for failure paths.
|
||||||
|
- Keep terminal lifecycle code exception-safe: raw mode, cursor visibility, and
|
||||||
|
the alternate screen must always be restored.
|
||||||
|
- Remote synchronization is out of scope until the local mutation and durable
|
||||||
|
cache boundaries are reliable.
|
||||||
|
|
||||||
|
## Verification requirements
|
||||||
|
|
||||||
|
The lead's final review for a change should be proportional to its risk and
|
||||||
|
normally include:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix-shell --run 'meson compile -C build'
|
||||||
|
nix-shell --run 'meson test -C build --print-errorlogs'
|
||||||
|
```
|
||||||
|
|
||||||
|
Also run strict warning-as-error compilation for changed translation units.
|
||||||
|
For storage, parsing, terminal lifecycle, or mutation work, run the Address and
|
||||||
|
Undefined Behavior sanitizer build and exercise relevant failure paths. TUI
|
||||||
|
changes require a live PTY smoke test covering interaction and terminal-state
|
||||||
|
restoration.
|
||||||
|
|
||||||
|
Do not report completion while required tests fail, while delegated changes are
|
||||||
|
unreviewed, or while user data can be lost on an ordinary error path.
|
||||||
52
README.md
Normal file
52
README.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Nocal
|
||||||
|
|
||||||
|
Nocal is a fast, local-first C++20 month calendar designed for the Linux
|
||||||
|
terminal and Hyprland. It presents appointments inside a six-week month grid and
|
||||||
|
inherits its colors from your terminal theme.
|
||||||
|
|
||||||
|
This repository currently contains the first local-calendar vertical slice:
|
||||||
|
date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms,
|
||||||
|
a responsive ANSI terminal UI, tests, and Linux launch integration. See [the product specification](docs/PRODUCT.md),
|
||||||
|
[architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md).
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
Use arrow keys or `h j k l` to move, `PageUp`/`PageDown` or `p`/`n` to change
|
||||||
|
month, and `t` for today. On a day with appointments, `Tab` and `Shift-Tab`
|
||||||
|
move focus through them and `Enter` opens the appointment reader. In the
|
||||||
|
reader, arrows/Vim keys or Tab variants browse the day's appointments and
|
||||||
|
`Esc` returns to the month. Press `a` to add an appointment. Focus an
|
||||||
|
appointment and press `e` to edit it or `d` to delete it. Forms use `Tab` and
|
||||||
|
`Shift-Tab` (or arrows) between fields, `Space` for the all-day toggle,
|
||||||
|
`Ctrl-S` to save, and `Esc` to cancel. Back in the calendar, `u` undoes the
|
||||||
|
last successful mutation and `Ctrl-R` redoes it. Press `?` for help and `q` to
|
||||||
|
quit.
|
||||||
|
|
||||||
|
## Build and run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix-shell --run 'meson setup build && meson compile -C build'
|
||||||
|
./build/nocal --demo
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the tests with `meson test -C build --print-errorlogs`. Nocal reads
|
||||||
|
`$XDG_DATA_HOME/nocal/calendar.ics` by default (falling back to
|
||||||
|
`~/.local/share/nocal/calendar.ics`), or accepts another `.ics` path as its
|
||||||
|
positional argument. `--demo` adds a few unsaved sample appointments and
|
||||||
|
`--print` emits a non-interactive frame.
|
||||||
|
|
||||||
|
Nocal will not rewrite a calendar containing iCalendar data this version
|
||||||
|
cannot preserve, such as recurrence rules, alarms, attendees, or `TZID`
|
||||||
|
parameters. The calendar remains browsable, while mutation attempts explain
|
||||||
|
that it is read-only. This guard prevents a local edit from silently discarding
|
||||||
|
unknown data until those features are represented by the domain model.
|
||||||
|
|
||||||
|
Every replacement checks that the source has not changed since it was loaded.
|
||||||
|
If another Nocal instance or an external editor changes the file, the mutation
|
||||||
|
is rejected and the in-memory change is rolled back. Successful replacements
|
||||||
|
retain the immediately previous bytes as `calendar.ics.bak`. Run
|
||||||
|
`nocal --restore-backup [CALENDAR.ics]` for an explicit, atomic recovery; the
|
||||||
|
backup itself is kept so recovery can be repeated.
|
||||||
|
|
||||||
|
For a popup calendar binding and version-specific Hyprland rules, see
|
||||||
|
[Hyprland integration](docs/HYPRLAND.md).
|
||||||
89
docs/ARCHITECTURE.md
Normal file
89
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
Nocal is split into four layers. Dependencies point inward, keeping remote sync
|
||||||
|
and terminal rendering replaceable.
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main.cpp
|
||||||
|
├── tui/ ANSI rendering, input decoding, terminal lifecycle
|
||||||
|
├── storage/ iCalendar file adapter (later: SQLite cache)
|
||||||
|
└── domain/ Event, Calendar, civil dates, month layout and queries
|
||||||
|
|
||||||
|
future sync workers
|
||||||
|
├── caldav/ Nextcloud and generic CalDAV
|
||||||
|
├── google/ Google Calendar REST/OAuth
|
||||||
|
└── graph/ Microsoft Graph/OAuth
|
||||||
|
└──────────> domain/ through a SyncProvider interface
|
||||||
|
```
|
||||||
|
|
||||||
|
## Domain
|
||||||
|
|
||||||
|
The domain layer uses C++20 `<chrono>` civil dates and time points. It knows
|
||||||
|
nothing about escape sequences, files, OAuth, or HTTP. Month layout always
|
||||||
|
produces 42 cells, starting on Monday, which keeps redraw geometry stable.
|
||||||
|
|
||||||
|
Events retain a stable UID, title, half-open start/end interval, all-day flag,
|
||||||
|
optional descriptive fields, and calendar identity. Queries define overlap as
|
||||||
|
`event.start < range.end && event.end > range.start`; this matters for events
|
||||||
|
crossing midnight.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Version 0.1 uses a user-owned iCalendar file, defaulting to
|
||||||
|
`$XDG_DATA_HOME/nocal/calendar.ics` or `~/.local/share/nocal/calendar.ics`.
|
||||||
|
The adapter unfolds content lines before parsing and escapes text on output.
|
||||||
|
On POSIX systems, writes are serialized with an advisory lock, staged in a
|
||||||
|
same-directory private temporary file, flushed, and atomically renamed over the
|
||||||
|
destination. A load retains an immutable snapshot of the exact source bytes.
|
||||||
|
Before saving, the writer compares that snapshot with the destination while it
|
||||||
|
holds the same lock used for replacement, then checks again after staging and
|
||||||
|
immediately before commit. This catches same-size and same-timestamp changes
|
||||||
|
and fully serializes cooperating Nocal writers. The lock is advisory: an
|
||||||
|
unrelated program can ignore it, so no portable filesystem API can provide a
|
||||||
|
true compare-and-swap against every possible external writer.
|
||||||
|
|
||||||
|
Before an existing destination is replaced, its exact bytes are atomically
|
||||||
|
written to the adjacent `.bak` file. Explicit backup restoration uses the same
|
||||||
|
lock, source-revision check, private staging, and atomic replacement path; it
|
||||||
|
does not consume the backup. The TUI mutates a copyable in-memory model and
|
||||||
|
rolls it back if persistence fails. Its bounded undo/redo history contains only
|
||||||
|
mutations that crossed the persistence boundary successfully.
|
||||||
|
|
||||||
|
The current writer deliberately refuses to mutate an existing file when the
|
||||||
|
loader encounters information it cannot round-trip, including recurrence,
|
||||||
|
alarms, attendees, time-zone identifiers, unknown properties, or malformed
|
||||||
|
components. Browsing remains available. This conservative boundary is more
|
||||||
|
important than partial editing because a successful-looking edit must not
|
||||||
|
erase unrelated calendar data.
|
||||||
|
|
||||||
|
Provider synchronization will not write directly into this UI file. It will use
|
||||||
|
a transaction-capable local cache and preserve remote ETags/sync tokens in a
|
||||||
|
provider metadata table.
|
||||||
|
|
||||||
|
The planned cache schema has `calendars`, `events`, `event_instances`, and
|
||||||
|
`sync_state`. Raw provider payloads are retained alongside normalized fields so
|
||||||
|
an older Nocal cannot destroy fields it does not understand.
|
||||||
|
|
||||||
|
## Terminal backend
|
||||||
|
|
||||||
|
The initial backend depends only on POSIX `termios`, `poll`, and ANSI/ECMA-48.
|
||||||
|
It uses the alternate screen, hides the cursor while rendering, restores all
|
||||||
|
terminal state through RAII, and repaints from a complete frame buffer. A resize
|
||||||
|
signal only sets a flag; terminal size and rendering are handled safely in the
|
||||||
|
main loop.
|
||||||
|
|
||||||
|
Colors are semantic ANSI slots and default background, intentionally delegating
|
||||||
|
actual RGB values to the terminal theme. This is both simpler and more native
|
||||||
|
than shipping an application theme that fights the desktop palette.
|
||||||
|
|
||||||
|
## Sync roadmap
|
||||||
|
|
||||||
|
All providers implement the same conceptual operations: authenticate, list
|
||||||
|
calendars, perform an incremental pull, push a local mutation, and resolve a
|
||||||
|
conflict. CalDAV uses sync tokens/ETags, Google uses page/sync tokens, and
|
||||||
|
Microsoft uses Graph delta links. Secrets belong in the Freedesktop Secret
|
||||||
|
Service, never in the calendar database or command line.
|
||||||
|
|
||||||
|
Conflict resolution is deterministic: unchanged side wins; otherwise retain
|
||||||
|
both versions and mark a conflict for the user. Network work happens outside the
|
||||||
|
render loop and publishes immutable snapshots to it.
|
||||||
43
docs/HYPRLAND.md
Normal file
43
docs/HYPRLAND.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Hyprland integration
|
||||||
|
|
||||||
|
The installed `nocal-hyprland` launcher chooses Kitty, foot, WezTerm, or
|
||||||
|
Alacritty and gives its Wayland window the dedicated `nocal` class/app-id. Set
|
||||||
|
`NOCAL_TERMINAL` to an executable path to override that order.
|
||||||
|
|
||||||
|
Hyprland 0.55 moved its configuration from hyprlang to Lua. Add this to
|
||||||
|
`~/.config/hypr/hyprland.lua` for a large, centered calendar on `SUPER+C`:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
hl.bind("SUPER + C", hl.dsp.exec_cmd("nocal-hyprland"), {
|
||||||
|
description = "Open calendar"
|
||||||
|
})
|
||||||
|
|
||||||
|
hl.window_rule({
|
||||||
|
name = "nocal-calendar",
|
||||||
|
match = { class = "nocal" },
|
||||||
|
float = true,
|
||||||
|
center = true,
|
||||||
|
size = { "monitor_w*0.82", "monitor_h*0.82" },
|
||||||
|
persistent_size = true,
|
||||||
|
no_dim = true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
For Hyprland 0.54 and older hyprlang configurations, the equivalent is:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
bind = SUPER, C, exec, nocal-hyprland
|
||||||
|
windowrule = float, class:^(nocal)$
|
||||||
|
windowrule = center, class:^(nocal)$
|
||||||
|
windowrule = size 82% 82%, class:^(nocal)$
|
||||||
|
windowrule = persistentsize, class:^(nocal)$
|
||||||
|
windowrule = nodim, class:^(nocal)$
|
||||||
|
```
|
||||||
|
|
||||||
|
Nocal deliberately does not request a particular opacity or color. Configure
|
||||||
|
those on the terminal and Nocal will inherit them; the application uses default
|
||||||
|
foreground/background plus the ANSI semantic palette.
|
||||||
|
|
||||||
|
The syntax above follows the current official [Hyprland bind documentation]
|
||||||
|
(https://wiki.hypr.land/Configuring/Basics/Binds/) and [window-rule
|
||||||
|
documentation](https://wiki.hypr.land/Configuring/Basics/Window-Rules/).
|
||||||
92
docs/PRODUCT.md
Normal file
92
docs/PRODUCT.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Nocal product and interaction specification
|
||||||
|
|
||||||
|
Nocal is a local-first, keyboard-driven month calendar for Linux terminals. Its
|
||||||
|
visual target is the information density of a desktop month view without
|
||||||
|
pretending that a terminal is a pixel canvas. The terminal emulator owns the
|
||||||
|
typeface and palette; Nocal owns hierarchy, spacing, and restrained emphasis.
|
||||||
|
|
||||||
|
## Product principles
|
||||||
|
|
||||||
|
1. **The month is the home screen.** Launching Nocal immediately shows six
|
||||||
|
complete Monday-first weeks. There is no dashboard or splash screen.
|
||||||
|
2. **Useful at a glance.** Every visible day shows as many appointments as fit,
|
||||||
|
ordered as all-day first and then by start time. A final `+N more` line is
|
||||||
|
used instead of clipping silently.
|
||||||
|
3. **Keyboard-native.** Arrow keys and Vim keys move by day or week; month and
|
||||||
|
today jumps are single keystrokes. Every action remains discoverable in the
|
||||||
|
footer and help overlay.
|
||||||
|
4. **Terminal-native aesthetics.** Nocal uses default foreground/background and
|
||||||
|
the terminal's ANSI semantic colors. It never paints a fixed RGB theme over
|
||||||
|
the user's Kitty, foot, Alacritty, or WezTerm theme.
|
||||||
|
5. **Local-first, sync-ready.** The canonical domain model is independent of
|
||||||
|
iCalendar files and of any future remote API. Sync engines translate into
|
||||||
|
domain objects and never leak provider-specific types into the UI.
|
||||||
|
6. **Fast enough to feel instant.** Startup should remain below 50 ms for a
|
||||||
|
normal local calendar and redraw should be flicker-free at interactive
|
||||||
|
resize rates.
|
||||||
|
|
||||||
|
## Month view
|
||||||
|
|
||||||
|
The full terminal is treated as a responsive canvas:
|
||||||
|
|
||||||
|
```text
|
||||||
|
JULY 2026 Today Fri 17 Jul
|
||||||
|
Mon Tue Wed Thu Fri Sat Sun
|
||||||
|
┌────────────┬────────────┬────────────┬────────────┬────────────┬────────────┬────────────┐
|
||||||
|
│ 29 │ 30 │ 1 │ 2 │ 3 │ 4 │ 5 │
|
||||||
|
│ │ │ 09:30 Team │ │ All day … │ │ │
|
||||||
|
├────────────┼────────────┼────────────┼────────────┼────────────┼────────────┼────────────┤
|
||||||
|
│ … │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
←↓↑→ move PgUp/PgDn month t today ? help q quit 2 appointments
|
||||||
|
```
|
||||||
|
|
||||||
|
- At 100 columns and above, Nocal draws the full bordered grid.
|
||||||
|
- Narrow cells abbreviate times and ellipsize summaries by display width.
|
||||||
|
- When height is constrained, appointment lines are reduced before structural
|
||||||
|
chrome. At extremely small sizes a clear minimum-size message replaces a
|
||||||
|
broken grid.
|
||||||
|
- Days outside the focused month remain visible but dim.
|
||||||
|
- The selected date is reverse-video, today is bold/underlined, and collisions
|
||||||
|
use semantic ANSI accents. These attributes work on monochrome terminals.
|
||||||
|
- The footer reports the selected day's appointment count and the most useful
|
||||||
|
keys for the available width.
|
||||||
|
|
||||||
|
## Interaction map
|
||||||
|
|
||||||
|
| Action | Keys |
|
||||||
|
| --- | --- |
|
||||||
|
| Previous/next day | `Left` / `Right`, `h` / `l` |
|
||||||
|
| Previous/next week | `Up` / `Down`, `k` / `j` |
|
||||||
|
| Previous/next month | `PageUp` / `PageDown`, `p` / `n` |
|
||||||
|
| Jump to today | `t` |
|
||||||
|
| Next/previous appointment | `Tab` / `Shift-Tab` |
|
||||||
|
| Read focused appointment | `Enter` |
|
||||||
|
| Browse inside reader | arrows, `h j k l`, `Tab` / `Shift-Tab` |
|
||||||
|
| Add appointment | `a` |
|
||||||
|
| Edit focused appointment | `e` |
|
||||||
|
| Delete focused appointment | `d`, then `y` to confirm |
|
||||||
|
| Undo/redo successful mutation | `u` / `Ctrl-R` |
|
||||||
|
| Move between editor fields | `Tab` / `Shift-Tab`, arrows |
|
||||||
|
| Save/cancel editor | `Ctrl-S` / `Esc` |
|
||||||
|
| Return to month/unfocus | `Esc` |
|
||||||
|
| Help | `?` |
|
||||||
|
| Quit | `q` |
|
||||||
|
|
||||||
|
The first usable foundation supports day and appointment navigation, a
|
||||||
|
full-frame reader, validated add/edit/delete forms, and atomic `.ics` writes.
|
||||||
|
Dense days keep every appointment keyboard-reachable even when all lines do
|
||||||
|
not fit in the cell. Unknown or unsupported iCalendar content makes a source
|
||||||
|
read-only instead of being discarded. Saves reject external changes, retain a
|
||||||
|
last-known-good backup, and feed bounded session undo/redo history. The next
|
||||||
|
local-data work adds `/` search, calendar visibility toggles, and recurring-event
|
||||||
|
and time-zone expansion.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Information is never encoded by color alone.
|
||||||
|
- `NO_COLOR` and terminals without color remain fully usable.
|
||||||
|
- Borders and icons have ASCII fallbacks; Unicode width is calculated rather
|
||||||
|
than assumed.
|
||||||
|
- Focus is always visible and keyboard operation is complete.
|
||||||
|
- Motion is limited to direct redraws; there are no decorative animations.
|
||||||
50
docs/ROADMAP.md
Normal file
50
docs/ROADMAP.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
## Foundation — current milestone
|
||||||
|
|
||||||
|
- C++20 domain model and deterministic six-week month layout
|
||||||
|
- Responsive, theme-native ANSI month view with keyboard navigation
|
||||||
|
- Appointment focus, dense-day traversal, and full-frame detail reader
|
||||||
|
- Validated add/edit/delete forms with confirmation and failure rollback
|
||||||
|
- Guarded atomic `.ics` persistence, advisory locking, and round-trip tests
|
||||||
|
- Bounded session undo/redo, exact external-change detection, and backup recovery
|
||||||
|
- Meson build, Nix development shell, desktop entry, and Hyprland launcher
|
||||||
|
- Seeded sample data when explicitly requested, never silent data mutation
|
||||||
|
|
||||||
|
## 0.1 — complete local calendar
|
||||||
|
|
||||||
|
- Recurrence rules, exclusions, time zones, multi-day presentation
|
||||||
|
- Search, agenda view, calendar toggles, import/export, configurable week start
|
||||||
|
- Screen-reader-friendly linear view and full Unicode display-width handling
|
||||||
|
|
||||||
|
## 0.2 — durable cache and provider boundary
|
||||||
|
|
||||||
|
- SQLite WAL cache and migration framework
|
||||||
|
- Background job queue, immutable UI snapshots, observable sync status
|
||||||
|
- Secret Service credentials and OAuth callback helper
|
||||||
|
- Generic `SyncProvider` contract plus a fake provider test suite
|
||||||
|
|
||||||
|
## 0.3 — CalDAV
|
||||||
|
|
||||||
|
- Discovery and incremental sync for Nextcloud and standards-compliant servers
|
||||||
|
- ETag-aware writes, offline mutation queue, conflict UI
|
||||||
|
- TLS and failure-path integration tests
|
||||||
|
|
||||||
|
## 0.4 — hosted providers
|
||||||
|
|
||||||
|
- Google Calendar adapter with incremental synchronization
|
||||||
|
- Microsoft 365 adapter over Microsoft Graph delta queries
|
||||||
|
- Account/calendar management UI and per-calendar ANSI identity
|
||||||
|
|
||||||
|
## 1.0 — distribution quality
|
||||||
|
|
||||||
|
- Stable configuration/data format and documented recovery procedures
|
||||||
|
- Nomarchy package, Hyprland defaults, man page, shell completions
|
||||||
|
- Performance budgets exercised against large and adversarial calendars
|
||||||
|
- Fuzzed iCalendar parser, accessibility review, translations
|
||||||
|
|
||||||
|
## Quality gates
|
||||||
|
|
||||||
|
Every milestone requires clean warning-enabled builds, unit and integration
|
||||||
|
tests, sanitizer runs, terminal-state recovery after signals/errors, no network
|
||||||
|
access without explicit account setup, and no loss of unknown calendar data.
|
||||||
36
include/nocal/domain/date.hpp
Normal file
36
include/nocal/domain/date.hpp
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "nocal/domain/event.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace nocal {
|
||||||
|
|
||||||
|
using Date = std::chrono::year_month_day;
|
||||||
|
using YearMonth = std::chrono::year_month;
|
||||||
|
|
||||||
|
[[nodiscard]] constexpr bool valid_date(Date date) noexcept { return date.ok(); }
|
||||||
|
[[nodiscard]] std::chrono::sys_days to_sys_days(Date date);
|
||||||
|
[[nodiscard]] Date from_sys_days(std::chrono::sys_days days) noexcept;
|
||||||
|
|
||||||
|
// ISO 8601 calendar dates in YYYY-MM-DD form.
|
||||||
|
[[nodiscard]] Date parse_date(std::string_view text);
|
||||||
|
[[nodiscard]] std::string format_date(Date date);
|
||||||
|
|
||||||
|
// These helpers use the process' local time zone, including daylight-saving rules.
|
||||||
|
[[nodiscard]] Date local_date(TimePoint point);
|
||||||
|
[[nodiscard]] Date today();
|
||||||
|
[[nodiscard]] TimePoint local_day_start(Date date);
|
||||||
|
[[nodiscard]] TimePoint local_day_end(Date date);
|
||||||
|
[[nodiscard]] TimePoint make_local_time(Date date, unsigned hour = 0,
|
||||||
|
unsigned minute = 0, unsigned second = 0);
|
||||||
|
[[nodiscard]] bool same_local_day(TimePoint lhs, TimePoint rhs);
|
||||||
|
[[nodiscard]] bool is_today(Date date);
|
||||||
|
|
||||||
|
[[nodiscard]] Date first_day_of_month(YearMonth month);
|
||||||
|
[[nodiscard]] YearMonth following_month(YearMonth month);
|
||||||
|
[[nodiscard]] unsigned monday_first_weekday(Date date);
|
||||||
|
|
||||||
|
} // namespace nocal
|
||||||
6
include/nocal/domain/domain.hpp
Normal file
6
include/nocal/domain/domain.hpp
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "nocal/domain/date.hpp"
|
||||||
|
#include "nocal/domain/event.hpp"
|
||||||
|
#include "nocal/domain/event_query.hpp"
|
||||||
|
#include "nocal/domain/month_layout.hpp"
|
||||||
36
include/nocal/domain/event.hpp
Normal file
36
include/nocal/domain/event.hpp
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace nocal {
|
||||||
|
|
||||||
|
using Clock = std::chrono::system_clock;
|
||||||
|
using TimePoint = Clock::time_point;
|
||||||
|
|
||||||
|
struct Event {
|
||||||
|
std::string uid;
|
||||||
|
std::string title;
|
||||||
|
TimePoint start{};
|
||||||
|
TimePoint end{};
|
||||||
|
bool all_day{false};
|
||||||
|
std::string location;
|
||||||
|
std::string description;
|
||||||
|
std::string calendar_id;
|
||||||
|
std::string color;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Calendar {
|
||||||
|
std::string id;
|
||||||
|
std::string name;
|
||||||
|
std::string color;
|
||||||
|
bool visible{true};
|
||||||
|
bool read_only{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] constexpr bool has_valid_interval(const Event& event) noexcept
|
||||||
|
{
|
||||||
|
return event.end >= event.start;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nocal
|
||||||
29
include/nocal/domain/event_query.hpp
Normal file
29
include/nocal/domain/event_query.hpp
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "nocal/domain/date.hpp"
|
||||||
|
#include "nocal/domain/event.hpp"
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <span>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace nocal {
|
||||||
|
|
||||||
|
using EventRef = std::reference_wrapper<const Event>;
|
||||||
|
using EventRefs = std::vector<EventRef>;
|
||||||
|
|
||||||
|
[[nodiscard]] bool event_less(const Event& lhs, const Event& rhs) noexcept;
|
||||||
|
void sort_events(EventRefs& events);
|
||||||
|
|
||||||
|
// Event and query intervals are half-open. A zero-duration event is treated as
|
||||||
|
// an instant and belongs to the interval containing its start time.
|
||||||
|
[[nodiscard]] bool event_overlaps(const Event& event, TimePoint begin,
|
||||||
|
TimePoint end) noexcept;
|
||||||
|
[[nodiscard]] bool event_occurs_on(const Event& event, Date date);
|
||||||
|
[[nodiscard]] EventRefs events_overlapping(std::span<const Event> events,
|
||||||
|
TimePoint begin, TimePoint end);
|
||||||
|
[[nodiscard]] EventRefs events_on_day(std::span<const Event> events, Date date);
|
||||||
|
[[nodiscard]] EventRefs events_in_month(std::span<const Event> events,
|
||||||
|
YearMonth month);
|
||||||
|
|
||||||
|
} // namespace nocal
|
||||||
33
include/nocal/domain/month_layout.hpp
Normal file
33
include/nocal/domain/month_layout.hpp
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "nocal/domain/date.hpp"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
namespace nocal {
|
||||||
|
|
||||||
|
struct MonthCell {
|
||||||
|
Date date{std::chrono::year{1970} / std::chrono::January / 1};
|
||||||
|
bool in_month{false};
|
||||||
|
std::size_t row{0};
|
||||||
|
std::size_t column{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct MonthLayout {
|
||||||
|
static constexpr std::size_t rows = 6;
|
||||||
|
static constexpr std::size_t columns = 7;
|
||||||
|
static constexpr std::size_t cell_count = rows * columns;
|
||||||
|
|
||||||
|
YearMonth month{std::chrono::year{1970} / std::chrono::January};
|
||||||
|
std::array<MonthCell, cell_count> cells{};
|
||||||
|
|
||||||
|
[[nodiscard]] const MonthCell& at(std::size_t row, std::size_t column) const;
|
||||||
|
[[nodiscard]] const MonthCell* find(Date date) const noexcept;
|
||||||
|
[[nodiscard]] Date first_visible_date() const noexcept;
|
||||||
|
[[nodiscard]] Date last_visible_date() const noexcept;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] MonthLayout make_month_layout(YearMonth month);
|
||||||
|
|
||||||
|
} // namespace nocal
|
||||||
62
include/nocal/storage/ics_store.hpp
Normal file
62
include/nocal/storage/ics_store.hpp
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "nocal/domain/event.hpp"
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <span>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace nocal::storage {
|
||||||
|
|
||||||
|
class FileRevision {
|
||||||
|
public:
|
||||||
|
FileRevision() = default;
|
||||||
|
|
||||||
|
[[nodiscard]] bool existed() const noexcept { return existed_; }
|
||||||
|
[[nodiscard]] std::size_t size() const noexcept {
|
||||||
|
return contents_ ? contents_->size() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool existed_ = false;
|
||||||
|
std::shared_ptr<const std::string> contents_;
|
||||||
|
|
||||||
|
friend class IcsStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LoadResult {
|
||||||
|
std::vector<Event> events;
|
||||||
|
std::vector<std::string> warnings;
|
||||||
|
// False when saving the parsed events would discard source data that this
|
||||||
|
// version of nocal cannot represent faithfully.
|
||||||
|
bool safe_to_rewrite = true;
|
||||||
|
// Exact bytes that produced this result. Copies share immutable storage and
|
||||||
|
// can be supplied to save/restore as an optimistic concurrency token.
|
||||||
|
FileRevision revision;
|
||||||
|
};
|
||||||
|
|
||||||
|
class IcsStore {
|
||||||
|
public:
|
||||||
|
[[nodiscard]] static LoadResult load(const std::filesystem::path& path);
|
||||||
|
|
||||||
|
[[nodiscard]] static std::filesystem::path backup_path(
|
||||||
|
const std::filesystem::path& path);
|
||||||
|
|
||||||
|
// Writes a complete VCALENDAR. On POSIX systems replacement is atomic and
|
||||||
|
// requests durable filesystem flushes. I/O failures before replacement are
|
||||||
|
// reported as std::runtime_error. If expected is present, the destination
|
||||||
|
// must still contain the exact bytes represented by that revision.
|
||||||
|
static FileRevision save(
|
||||||
|
const std::filesystem::path& path, std::span<const Event> events,
|
||||||
|
std::optional<FileRevision> expected = std::nullopt);
|
||||||
|
|
||||||
|
// Restores the exact bytes in <path>.bak without changing the backup.
|
||||||
|
static FileRevision restore_backup(
|
||||||
|
const std::filesystem::path& path,
|
||||||
|
std::optional<FileRevision> expected = std::nullopt);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace nocal::storage
|
||||||
64
include/nocal/tui/EventEditor.hpp
Normal file
64
include/nocal/tui/EventEditor.hpp
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "nocal/domain/date.hpp"
|
||||||
|
#include "nocal/domain/event.hpp"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace nocal::tui {
|
||||||
|
|
||||||
|
enum class EditorOutcome {
|
||||||
|
active,
|
||||||
|
submit,
|
||||||
|
cancel,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class EditorField : std::size_t {
|
||||||
|
title,
|
||||||
|
start_date,
|
||||||
|
start_time,
|
||||||
|
end_date,
|
||||||
|
end_time,
|
||||||
|
all_day,
|
||||||
|
location,
|
||||||
|
notes,
|
||||||
|
count,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A small, stateful form controller. It owns no terminal or storage resources,
|
||||||
|
// so callers can feed it decoded key sequences and redraw whenever convenient.
|
||||||
|
class EventEditor {
|
||||||
|
public:
|
||||||
|
explicit EventEditor(Date selected_day);
|
||||||
|
explicit EventEditor(const Event& event);
|
||||||
|
|
||||||
|
[[nodiscard]] EditorOutcome handle_key(std::string_view key);
|
||||||
|
[[nodiscard]] std::string render(int width, int height, bool ansi = true) const;
|
||||||
|
|
||||||
|
// result() contains the validated event after handle_key() returns submit.
|
||||||
|
[[nodiscard]] const Event& result() const noexcept { return result_; }
|
||||||
|
[[nodiscard]] EditorField focused_field() const noexcept { return focused_; }
|
||||||
|
[[nodiscard]] std::string_view field_value(EditorField field) const noexcept;
|
||||||
|
[[nodiscard]] std::string_view error() const noexcept { return error_; }
|
||||||
|
[[nodiscard]] bool all_day() const noexcept { return all_day_; }
|
||||||
|
[[nodiscard]] bool editing() const noexcept { return editing_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
using Values = std::array<std::string, static_cast<std::size_t>(EditorField::count)>;
|
||||||
|
|
||||||
|
Event original_;
|
||||||
|
Event result_;
|
||||||
|
Values values_{};
|
||||||
|
EditorField focused_{EditorField::title};
|
||||||
|
std::string error_;
|
||||||
|
bool all_day_{false};
|
||||||
|
bool editing_{false};
|
||||||
|
|
||||||
|
void move_focus(int direction) noexcept;
|
||||||
|
[[nodiscard]] bool validate();
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace nocal::tui
|
||||||
83
include/nocal/tui/Screen.hpp
Normal file
83
include/nocal/tui/Screen.hpp
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <optional>
|
||||||
|
#include <span>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "nocal/domain/event.hpp"
|
||||||
|
|
||||||
|
namespace nocal::tui {
|
||||||
|
|
||||||
|
// A deliberately small view-model keeps the renderer independent of storage and
|
||||||
|
// makes it straightforward to unit test. TuiApp adapts domain Events to this.
|
||||||
|
struct CalendarItem {
|
||||||
|
std::string id;
|
||||||
|
std::string title;
|
||||||
|
std::chrono::year_month_day day;
|
||||||
|
std::optional<std::chrono::minutes> time_of_day;
|
||||||
|
bool all_day{false};
|
||||||
|
std::optional<std::chrono::minutes> end_time_of_day;
|
||||||
|
std::chrono::year_month_day start_day{};
|
||||||
|
std::chrono::year_month_day end_day{};
|
||||||
|
std::string location;
|
||||||
|
std::string description;
|
||||||
|
std::string detail_title;
|
||||||
|
bool detail_all_day{false};
|
||||||
|
std::optional<std::chrono::minutes> detail_start_time_of_day;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ScreenState {
|
||||||
|
std::chrono::year_month visible_month{std::chrono::year{1970}, std::chrono::month{1}};
|
||||||
|
std::chrono::year_month_day selected_day{std::chrono::year{1970}, std::chrono::month{1},
|
||||||
|
std::chrono::day{1}};
|
||||||
|
std::chrono::year_month_day today{std::chrono::year{1970}, std::chrono::month{1},
|
||||||
|
std::chrono::day{1}};
|
||||||
|
int width{80};
|
||||||
|
int height{24};
|
||||||
|
bool show_help{false};
|
||||||
|
bool ansi{true};
|
||||||
|
std::optional<std::string> focused_event_id;
|
||||||
|
bool show_event_details{false};
|
||||||
|
bool confirm_delete{false};
|
||||||
|
std::string notification;
|
||||||
|
bool notification_is_error{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render a complete frame. The result contains no cursor positioning, which
|
||||||
|
// makes it useful both for snapshots and for non-interactive output.
|
||||||
|
[[nodiscard]] std::string render_month(std::span<const CalendarItem> items,
|
||||||
|
const ScreenState& state);
|
||||||
|
|
||||||
|
// Convenience adapter for the domain model. Event timestamps are projected to
|
||||||
|
// the process's local civil time until explicit calendar time zones are added.
|
||||||
|
[[nodiscard]] std::string render_month(std::span<const Event> events,
|
||||||
|
const ScreenState& state);
|
||||||
|
|
||||||
|
enum class Action {
|
||||||
|
none,
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
up,
|
||||||
|
down,
|
||||||
|
previous_month,
|
||||||
|
next_month,
|
||||||
|
today,
|
||||||
|
toggle_help,
|
||||||
|
previous_event,
|
||||||
|
next_event,
|
||||||
|
select,
|
||||||
|
back,
|
||||||
|
add_event,
|
||||||
|
edit_event,
|
||||||
|
delete_event,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
quit,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decodes a complete key sequence, including common terminal arrow/Page keys.
|
||||||
|
[[nodiscard]] Action decode_key(std::string_view sequence) noexcept;
|
||||||
|
|
||||||
|
} // namespace nocal::tui
|
||||||
65
include/nocal/tui/Terminal.hpp
Normal file
65
include/nocal/tui/Terminal.hpp
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <termios.h>
|
||||||
|
|
||||||
|
namespace nocal::tui {
|
||||||
|
|
||||||
|
struct TerminalSize {
|
||||||
|
int columns{80};
|
||||||
|
int rows{24};
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] TerminalSize terminal_size(int fd) noexcept;
|
||||||
|
|
||||||
|
class AlternateScreen {
|
||||||
|
public:
|
||||||
|
explicit AlternateScreen(int output_fd);
|
||||||
|
~AlternateScreen();
|
||||||
|
|
||||||
|
AlternateScreen(const AlternateScreen&) = delete;
|
||||||
|
AlternateScreen& operator=(const AlternateScreen&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
int output_fd_;
|
||||||
|
bool active_;
|
||||||
|
};
|
||||||
|
|
||||||
|
class RawInput {
|
||||||
|
public:
|
||||||
|
explicit RawInput(int input_fd);
|
||||||
|
~RawInput();
|
||||||
|
|
||||||
|
RawInput(const RawInput&) = delete;
|
||||||
|
RawInput& operator=(const RawInput&) = delete;
|
||||||
|
|
||||||
|
[[nodiscard]] bool active() const noexcept { return active_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
int input_fd_;
|
||||||
|
termios original_{};
|
||||||
|
bool active_{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Installs a minimal SIGWINCH handler and restores the previous one on exit.
|
||||||
|
class ResizeWatcher {
|
||||||
|
public:
|
||||||
|
ResizeWatcher();
|
||||||
|
~ResizeWatcher();
|
||||||
|
|
||||||
|
ResizeWatcher(const ResizeWatcher&) = delete;
|
||||||
|
ResizeWatcher& operator=(const ResizeWatcher&) = delete;
|
||||||
|
|
||||||
|
[[nodiscard]] bool consume() noexcept;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct sigaction_storage;
|
||||||
|
sigaction_storage* storage_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Writes all bytes unless an unrecoverable error occurs.
|
||||||
|
[[nodiscard]] bool write_terminal(int fd, std::string_view bytes) noexcept;
|
||||||
|
|
||||||
|
} // namespace nocal::tui
|
||||||
92
include/nocal/tui/TuiApp.hpp
Normal file
92
include/nocal/tui/TuiApp.hpp
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <optional>
|
||||||
|
#include <span>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "nocal/domain/event.hpp"
|
||||||
|
#include "nocal/tui/EventEditor.hpp"
|
||||||
|
#include "nocal/tui/Screen.hpp"
|
||||||
|
|
||||||
|
namespace nocal::tui {
|
||||||
|
|
||||||
|
enum class ExitReason {
|
||||||
|
quit,
|
||||||
|
input_closed,
|
||||||
|
io_error,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The Event vector is retained by reference so future editor commands can
|
||||||
|
// update the caller's model without changing the application boundary.
|
||||||
|
class TuiApp {
|
||||||
|
public:
|
||||||
|
using SaveCallback = std::function<void(std::span<const Event>)>;
|
||||||
|
|
||||||
|
explicit TuiApp(std::vector<Event>& events, int input_fd = 0, int output_fd = 1);
|
||||||
|
TuiApp(std::vector<Event>& events, SaveCallback saver, int input_fd = 0,
|
||||||
|
int output_fd = 1);
|
||||||
|
|
||||||
|
[[nodiscard]] ExitReason run();
|
||||||
|
void dispatch(Action action);
|
||||||
|
void handle_input(std::string_view input);
|
||||||
|
|
||||||
|
[[nodiscard]] const ScreenState& state() const noexcept { return state_; }
|
||||||
|
[[nodiscard]] std::vector<Event>& events() noexcept { return events_; }
|
||||||
|
[[nodiscard]] bool editor_active() const noexcept { return editor_.has_value(); }
|
||||||
|
[[nodiscard]] const EventEditor* editor() const noexcept {
|
||||||
|
return editor_ ? &*editor_ : nullptr;
|
||||||
|
}
|
||||||
|
[[nodiscard]] bool delete_confirmation_active() const noexcept {
|
||||||
|
return state_.confirm_delete;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct HistorySnapshot {
|
||||||
|
std::vector<Event> events;
|
||||||
|
std::chrono::year_month visible_month;
|
||||||
|
std::chrono::year_month_day selected_day;
|
||||||
|
std::optional<std::string> focused_event_id;
|
||||||
|
bool show_event_details{false};
|
||||||
|
bool show_help{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HistoryEntry {
|
||||||
|
HistorySnapshot snapshot;
|
||||||
|
std::string operation;
|
||||||
|
};
|
||||||
|
|
||||||
|
static constexpr std::size_t history_limit = 100;
|
||||||
|
|
||||||
|
std::vector<Event>& events_;
|
||||||
|
int input_fd_;
|
||||||
|
int output_fd_;
|
||||||
|
ScreenState state_;
|
||||||
|
SaveCallback saver_;
|
||||||
|
std::optional<EventEditor> editor_;
|
||||||
|
std::optional<std::size_t> editing_index_;
|
||||||
|
std::optional<HistorySnapshot> pending_mutation_before_;
|
||||||
|
std::vector<HistoryEntry> undo_history_;
|
||||||
|
std::vector<HistoryEntry> redo_history_;
|
||||||
|
bool running_{true};
|
||||||
|
|
||||||
|
[[nodiscard]] std::optional<std::size_t> focused_event_index() const;
|
||||||
|
[[nodiscard]] HistorySnapshot capture_history_snapshot() const;
|
||||||
|
void restore_history_snapshot(HistorySnapshot snapshot);
|
||||||
|
void push_history(std::vector<HistoryEntry>& history, HistoryEntry entry);
|
||||||
|
[[nodiscard]] bool persist(std::span<const Event> events);
|
||||||
|
void record_mutation(std::string operation);
|
||||||
|
void undo();
|
||||||
|
void redo();
|
||||||
|
void begin_add();
|
||||||
|
void begin_edit();
|
||||||
|
void begin_delete();
|
||||||
|
void submit_editor();
|
||||||
|
void confirm_delete();
|
||||||
|
void cancel_delete();
|
||||||
|
void set_notification(std::string message, bool error = false);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace nocal::tui
|
||||||
6
include/nocal/tui/tui.hpp
Normal file
6
include/nocal/tui/tui.hpp
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "nocal/tui/EventEditor.hpp"
|
||||||
|
#include "nocal/tui/Screen.hpp"
|
||||||
|
#include "nocal/tui/Terminal.hpp"
|
||||||
|
#include "nocal/tui/TuiApp.hpp"
|
||||||
53
meson.build
Normal file
53
meson.build
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
project(
|
||||||
|
'nocal',
|
||||||
|
'cpp',
|
||||||
|
version: '0.1.0',
|
||||||
|
meson_version: '>=1.1.0',
|
||||||
|
default_options: [
|
||||||
|
'cpp_std=c++20',
|
||||||
|
'warning_level=3',
|
||||||
|
'werror=false',
|
||||||
|
'buildtype=debugoptimized',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
inc = include_directories('include')
|
||||||
|
nocal_sources = files(
|
||||||
|
'src/domain/date.cpp',
|
||||||
|
'src/domain/event_query.cpp',
|
||||||
|
'src/domain/month_layout.cpp',
|
||||||
|
'src/storage/ics_store.cpp',
|
||||||
|
'src/tui/Screen.cpp',
|
||||||
|
'src/tui/EventEditor.cpp',
|
||||||
|
'src/tui/Terminal.cpp',
|
||||||
|
'src/tui/TuiApp.cpp',
|
||||||
|
)
|
||||||
|
nocal_lib = static_library('nocal-core', nocal_sources, include_directories: inc)
|
||||||
|
|
||||||
|
nocal_executable = executable('nocal', 'src/main.cpp', include_directories: inc,
|
||||||
|
link_with: nocal_lib, install: true)
|
||||||
|
|
||||||
|
domain_tests = executable('domain-tests', 'tests/domain_tests.cpp',
|
||||||
|
include_directories: inc, link_with: nocal_lib)
|
||||||
|
test('domain', domain_tests)
|
||||||
|
ics_tests = executable('ics-tests', 'tests/ics_tests.cpp',
|
||||||
|
include_directories: inc, link_with: nocal_lib)
|
||||||
|
test('ics-storage', ics_tests)
|
||||||
|
tui_tests = executable('tui-tests', 'tests/tui_tests.cpp',
|
||||||
|
include_directories: inc, link_with: nocal_lib)
|
||||||
|
test('tui', tui_tests)
|
||||||
|
tui_focus_tests = executable('tui-focus-tests', 'tests/tui_focus_tests.cpp',
|
||||||
|
include_directories: inc, link_with: nocal_lib)
|
||||||
|
test('tui-focus', tui_focus_tests)
|
||||||
|
editor_tests = executable('editor-tests', 'tests/editor_tests.cpp',
|
||||||
|
include_directories: inc, link_with: nocal_lib)
|
||||||
|
test('editor', editor_tests)
|
||||||
|
|
||||||
|
shell = find_program('sh')
|
||||||
|
test('cli-recovery', shell,
|
||||||
|
args: [files('tests/cli_recovery_test.sh'), nocal_executable])
|
||||||
|
|
||||||
|
install_data('scripts/nocal-hyprland', install_dir: get_option('bindir'),
|
||||||
|
install_mode: 'rwxr-xr-x')
|
||||||
|
install_data('share/applications/dev.nomarchy.nocal.desktop',
|
||||||
|
install_dir: get_option('datadir') / 'applications')
|
||||||
25
scripts/nocal-hyprland
Normal file
25
scripts/nocal-hyprland
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# A dedicated app-id lets Hyprland distinguish Nocal from ordinary terminal
|
||||||
|
# windows. NOCAL_TERMINAL may be set to one executable path (without arguments).
|
||||||
|
if [ "${NOCAL_TERMINAL:-}" != "" ]; then
|
||||||
|
exec "$NOCAL_TERMINAL" -e nocal "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v kitty >/dev/null 2>&1; then
|
||||||
|
exec kitty --class nocal --name nocal --title Nocal nocal "$@"
|
||||||
|
elif command -v foot >/dev/null 2>&1; then
|
||||||
|
exec foot --app-id=nocal --title=Nocal nocal "$@"
|
||||||
|
elif command -v wezterm >/dev/null 2>&1; then
|
||||||
|
exec wezterm start --class nocal -- nocal "$@"
|
||||||
|
elif command -v alacritty >/dev/null 2>&1; then
|
||||||
|
exec alacritty --class nocal,Nocal --title Nocal -e nocal "$@"
|
||||||
|
elif command -v xdg-terminal-exec >/dev/null 2>&1; then
|
||||||
|
exec xdg-terminal-exec nocal "$@"
|
||||||
|
elif [ "${TERMINAL:-}" != "" ]; then
|
||||||
|
exec "$TERMINAL" -e nocal "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' 'nocal: no supported terminal emulator found' >&2
|
||||||
|
exit 127
|
||||||
13
share/applications/dev.nomarchy.nocal.desktop
Normal file
13
share/applications/dev.nomarchy.nocal.desktop
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Nocal
|
||||||
|
GenericName=Calendar
|
||||||
|
Comment=Local-first terminal month calendar
|
||||||
|
Exec=nocal-hyprland
|
||||||
|
Icon=x-office-calendar
|
||||||
|
Terminal=false
|
||||||
|
Categories=Office;Calendar;
|
||||||
|
Keywords=calendar;appointment;schedule;terminal;
|
||||||
|
MimeType=text/calendar;
|
||||||
|
StartupNotify=true
|
||||||
|
StartupWMClass=nocal
|
||||||
13
shell.nix
Normal file
13
shell.nix
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{ pkgs ? import <nixpkgs> {} }:
|
||||||
|
|
||||||
|
pkgs.mkShell {
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
meson
|
||||||
|
ninja
|
||||||
|
pkg-config
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
gcc
|
||||||
|
];
|
||||||
|
}
|
||||||
172
src/domain/date.cpp
Normal file
172
src/domain/date.cpp
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
#include "nocal/domain/date.hpp"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <ctime>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace nocal {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
[[nodiscard]] unsigned parse_component(std::string_view text)
|
||||||
|
{
|
||||||
|
unsigned value = 0;
|
||||||
|
for (const char ch : text) {
|
||||||
|
if (ch < '0' || ch > '9') {
|
||||||
|
throw std::invalid_argument("date contains a non-digit");
|
||||||
|
}
|
||||||
|
value = value * 10U + static_cast<unsigned>(ch - '0');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::tm local_tm(std::time_t time)
|
||||||
|
{
|
||||||
|
std::tm result{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
if (::localtime_s(&result, &time) != 0) {
|
||||||
|
throw std::runtime_error("could not convert timestamp to local time");
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
if (::localtime_r(&time, &result) == nullptr) {
|
||||||
|
throw std::runtime_error("could not convert timestamp to local time");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::chrono::sys_days to_sys_days(Date date)
|
||||||
|
{
|
||||||
|
if (!date.ok()) {
|
||||||
|
throw std::invalid_argument("invalid Gregorian date");
|
||||||
|
}
|
||||||
|
return std::chrono::sys_days{date};
|
||||||
|
}
|
||||||
|
|
||||||
|
Date from_sys_days(std::chrono::sys_days days) noexcept
|
||||||
|
{
|
||||||
|
return Date{days};
|
||||||
|
}
|
||||||
|
|
||||||
|
Date parse_date(std::string_view text)
|
||||||
|
{
|
||||||
|
if (text.size() != 10 || text[4] != '-' || text[7] != '-') {
|
||||||
|
throw std::invalid_argument("date must have YYYY-MM-DD form");
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto year = static_cast<int>(parse_component(text.substr(0, 4)));
|
||||||
|
const auto month = parse_component(text.substr(5, 2));
|
||||||
|
const auto day = parse_component(text.substr(8, 2));
|
||||||
|
const Date result{std::chrono::year{year}, std::chrono::month{month},
|
||||||
|
std::chrono::day{day}};
|
||||||
|
if (!result.ok()) {
|
||||||
|
throw std::invalid_argument("invalid Gregorian date");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string format_date(Date date)
|
||||||
|
{
|
||||||
|
if (!date.ok()) {
|
||||||
|
throw std::invalid_argument("invalid Gregorian date");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::array<char, 11> buffer{};
|
||||||
|
const int count = std::snprintf(buffer.data(), buffer.size(), "%04d-%02u-%02u",
|
||||||
|
static_cast<int>(date.year()),
|
||||||
|
static_cast<unsigned>(date.month()),
|
||||||
|
static_cast<unsigned>(date.day()));
|
||||||
|
if (count != 10) {
|
||||||
|
throw std::out_of_range("date cannot be represented as YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
return {buffer.data(), 10};
|
||||||
|
}
|
||||||
|
|
||||||
|
Date local_date(TimePoint point)
|
||||||
|
{
|
||||||
|
const auto time = Clock::to_time_t(point);
|
||||||
|
const auto tm = local_tm(time);
|
||||||
|
return Date{std::chrono::year{tm.tm_year + 1900},
|
||||||
|
std::chrono::month{static_cast<unsigned>(tm.tm_mon + 1)},
|
||||||
|
std::chrono::day{static_cast<unsigned>(tm.tm_mday)}};
|
||||||
|
}
|
||||||
|
|
||||||
|
Date today()
|
||||||
|
{
|
||||||
|
return local_date(Clock::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
TimePoint make_local_time(Date date, unsigned hour, unsigned minute, unsigned second)
|
||||||
|
{
|
||||||
|
if (!date.ok()) {
|
||||||
|
throw std::invalid_argument("invalid Gregorian date");
|
||||||
|
}
|
||||||
|
if (hour > 23 || minute > 59 || second > 59) {
|
||||||
|
throw std::invalid_argument("invalid local time of day");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::tm tm{};
|
||||||
|
tm.tm_year = static_cast<int>(date.year()) - 1900;
|
||||||
|
tm.tm_mon = static_cast<int>(static_cast<unsigned>(date.month())) - 1;
|
||||||
|
tm.tm_mday = static_cast<int>(static_cast<unsigned>(date.day()));
|
||||||
|
tm.tm_hour = static_cast<int>(hour);
|
||||||
|
tm.tm_min = static_cast<int>(minute);
|
||||||
|
tm.tm_sec = static_cast<int>(second);
|
||||||
|
tm.tm_isdst = -1;
|
||||||
|
|
||||||
|
const auto time = std::mktime(&tm);
|
||||||
|
const auto checked = local_tm(time);
|
||||||
|
if (checked.tm_year != tm.tm_year || checked.tm_mon != tm.tm_mon ||
|
||||||
|
checked.tm_mday != tm.tm_mday || checked.tm_hour != tm.tm_hour ||
|
||||||
|
checked.tm_min != tm.tm_min || checked.tm_sec != tm.tm_sec) {
|
||||||
|
throw std::runtime_error("local date-time is not representable");
|
||||||
|
}
|
||||||
|
return Clock::from_time_t(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
TimePoint local_day_start(Date date)
|
||||||
|
{
|
||||||
|
return make_local_time(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
TimePoint local_day_end(Date date)
|
||||||
|
{
|
||||||
|
const auto next = from_sys_days(to_sys_days(date) + std::chrono::days{1});
|
||||||
|
return local_day_start(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool same_local_day(TimePoint lhs, TimePoint rhs)
|
||||||
|
{
|
||||||
|
return local_date(lhs) == local_date(rhs);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is_today(Date date)
|
||||||
|
{
|
||||||
|
return date.ok() && date == today();
|
||||||
|
}
|
||||||
|
|
||||||
|
Date first_day_of_month(YearMonth month)
|
||||||
|
{
|
||||||
|
if (!month.ok()) {
|
||||||
|
throw std::invalid_argument("invalid Gregorian month");
|
||||||
|
}
|
||||||
|
return Date{month / std::chrono::day{1}};
|
||||||
|
}
|
||||||
|
|
||||||
|
YearMonth following_month(YearMonth month)
|
||||||
|
{
|
||||||
|
if (!month.ok()) {
|
||||||
|
throw std::invalid_argument("invalid Gregorian month");
|
||||||
|
}
|
||||||
|
return month + std::chrono::months{1};
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned monday_first_weekday(Date date)
|
||||||
|
{
|
||||||
|
const auto weekday = std::chrono::weekday{to_sys_days(date)}.c_encoding();
|
||||||
|
return (weekday + 6U) % 7U;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nocal
|
||||||
78
src/domain/event_query.cpp
Normal file
78
src/domain/event_query.cpp
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
#include "nocal/domain/event_query.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace nocal {
|
||||||
|
|
||||||
|
bool event_less(const Event& lhs, const Event& rhs) noexcept
|
||||||
|
{
|
||||||
|
if (lhs.all_day != rhs.all_day) {
|
||||||
|
return lhs.all_day;
|
||||||
|
}
|
||||||
|
if (lhs.start != rhs.start) {
|
||||||
|
return lhs.start < rhs.start;
|
||||||
|
}
|
||||||
|
if (lhs.end != rhs.end) {
|
||||||
|
return lhs.end < rhs.end;
|
||||||
|
}
|
||||||
|
if (lhs.title != rhs.title) {
|
||||||
|
return lhs.title < rhs.title;
|
||||||
|
}
|
||||||
|
return lhs.uid < rhs.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void sort_events(EventRefs& events)
|
||||||
|
{
|
||||||
|
std::stable_sort(events.begin(), events.end(), [](EventRef lhs, EventRef rhs) {
|
||||||
|
return event_less(lhs.get(), rhs.get());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool event_overlaps(const Event& event, TimePoint begin, TimePoint end) noexcept
|
||||||
|
{
|
||||||
|
if (begin >= end || !has_valid_interval(event)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (event.start == event.end) {
|
||||||
|
return event.start >= begin && event.start < end;
|
||||||
|
}
|
||||||
|
return event.start < end && event.end > begin;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool event_occurs_on(const Event& event, Date date)
|
||||||
|
{
|
||||||
|
return event_overlaps(event, local_day_start(date), local_day_end(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
EventRefs events_overlapping(std::span<const Event> events, TimePoint begin,
|
||||||
|
TimePoint end)
|
||||||
|
{
|
||||||
|
if (begin > end) {
|
||||||
|
throw std::invalid_argument("event query begins after it ends");
|
||||||
|
}
|
||||||
|
|
||||||
|
EventRefs result;
|
||||||
|
result.reserve(events.size());
|
||||||
|
for (const auto& event : events) {
|
||||||
|
if (event_overlaps(event, begin, end)) {
|
||||||
|
result.emplace_back(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort_events(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
EventRefs events_on_day(std::span<const Event> events, Date date)
|
||||||
|
{
|
||||||
|
return events_overlapping(events, local_day_start(date), local_day_end(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
EventRefs events_in_month(std::span<const Event> events, YearMonth month)
|
||||||
|
{
|
||||||
|
const auto begin = local_day_start(first_day_of_month(month));
|
||||||
|
const auto end = local_day_start(first_day_of_month(following_month(month)));
|
||||||
|
return events_overlapping(events, begin, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nocal
|
||||||
56
src/domain/month_layout.cpp
Normal file
56
src/domain/month_layout.cpp
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#include "nocal/domain/month_layout.hpp"
|
||||||
|
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace nocal {
|
||||||
|
|
||||||
|
const MonthCell& MonthLayout::at(std::size_t row, std::size_t column) const
|
||||||
|
{
|
||||||
|
if (row >= rows || column >= columns) {
|
||||||
|
throw std::out_of_range("month cell is outside the 6x7 grid");
|
||||||
|
}
|
||||||
|
return cells[row * columns + column];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MonthCell* MonthLayout::find(Date date) const noexcept
|
||||||
|
{
|
||||||
|
for (const auto& cell : cells) {
|
||||||
|
if (cell.date == date) {
|
||||||
|
return &cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
Date MonthLayout::first_visible_date() const noexcept
|
||||||
|
{
|
||||||
|
return cells.front().date;
|
||||||
|
}
|
||||||
|
|
||||||
|
Date MonthLayout::last_visible_date() const noexcept
|
||||||
|
{
|
||||||
|
return cells.back().date;
|
||||||
|
}
|
||||||
|
|
||||||
|
MonthLayout make_month_layout(YearMonth month)
|
||||||
|
{
|
||||||
|
const auto first = first_day_of_month(month);
|
||||||
|
const auto grid_start = to_sys_days(first) -
|
||||||
|
std::chrono::days{monday_first_weekday(first)};
|
||||||
|
|
||||||
|
MonthLayout result;
|
||||||
|
result.month = month;
|
||||||
|
for (std::size_t index = 0; index < result.cells.size(); ++index) {
|
||||||
|
const auto date = from_sys_days(grid_start +
|
||||||
|
std::chrono::days{static_cast<long long>(index)});
|
||||||
|
result.cells[index] = MonthCell{
|
||||||
|
.date = date,
|
||||||
|
.in_month = date.year() == month.year() && date.month() == month.month(),
|
||||||
|
.row = index / MonthLayout::columns,
|
||||||
|
.column = index % MonthLayout::columns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nocal
|
||||||
213
src/main.cpp
Normal file
213
src/main.cpp
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
#include "nocal/domain/date.hpp"
|
||||||
|
#include "nocal/storage/ics_store.hpp"
|
||||||
|
#include "nocal/tui/Screen.hpp"
|
||||||
|
#include "nocal/tui/TuiApp.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <iostream>
|
||||||
|
#include <optional>
|
||||||
|
#include <span>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr std::string_view version = "0.1.0";
|
||||||
|
|
||||||
|
struct Options {
|
||||||
|
std::filesystem::path calendar_path;
|
||||||
|
bool demo{false};
|
||||||
|
bool print{false};
|
||||||
|
bool restore_backup{false};
|
||||||
|
bool no_color{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
std::filesystem::path default_calendar_path()
|
||||||
|
{
|
||||||
|
if (const char* data_home = std::getenv("XDG_DATA_HOME");
|
||||||
|
data_home != nullptr && *data_home != '\0') {
|
||||||
|
return std::filesystem::path{data_home} / "nocal" / "calendar.ics";
|
||||||
|
}
|
||||||
|
if (const char* home = std::getenv("HOME"); home != nullptr && *home != '\0') {
|
||||||
|
return std::filesystem::path{home} / ".local" / "share" / "nocal" / "calendar.ics";
|
||||||
|
}
|
||||||
|
return "calendar.ics";
|
||||||
|
}
|
||||||
|
|
||||||
|
void print_help(std::ostream& output)
|
||||||
|
{
|
||||||
|
output <<
|
||||||
|
"Usage: nocal [OPTIONS] [CALENDAR.ics]\n\n"
|
||||||
|
"A theme-native terminal month calendar.\n\n"
|
||||||
|
" -c, --calendar PATH read events from PATH\n"
|
||||||
|
" --demo add sample appointments without saving them\n"
|
||||||
|
" --print print one plain-text frame and exit\n"
|
||||||
|
" --restore-backup restore PATH.bak over PATH and exit\n"
|
||||||
|
" --no-color disable ANSI styling\n"
|
||||||
|
" -h, --help show this help\n"
|
||||||
|
" -V, --version show the version\n\n"
|
||||||
|
"Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n"
|
||||||
|
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
|
||||||
|
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
|
||||||
|
" u undoes the last change, Ctrl-R redoes it,\n"
|
||||||
|
" t jumps to today, ? toggles help, q quits.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
Options parse_options(int argc, char** argv)
|
||||||
|
{
|
||||||
|
Options options{.calendar_path = default_calendar_path()};
|
||||||
|
bool positional_seen = false;
|
||||||
|
for (int index = 1; index < argc; ++index) {
|
||||||
|
const std::string_view argument{argv[index]};
|
||||||
|
if (argument == "-h" || argument == "--help") {
|
||||||
|
print_help(std::cout);
|
||||||
|
std::exit(EXIT_SUCCESS);
|
||||||
|
}
|
||||||
|
if (argument == "-V" || argument == "--version") {
|
||||||
|
std::cout << "nocal " << version << '\n';
|
||||||
|
std::exit(EXIT_SUCCESS);
|
||||||
|
}
|
||||||
|
if (argument == "--demo") {
|
||||||
|
options.demo = true;
|
||||||
|
} else if (argument == "--print") {
|
||||||
|
options.print = true;
|
||||||
|
} else if (argument == "--restore-backup") {
|
||||||
|
options.restore_backup = true;
|
||||||
|
} else if (argument == "--no-color") {
|
||||||
|
options.no_color = true;
|
||||||
|
} else if (argument == "-c" || argument == "--calendar") {
|
||||||
|
if (++index >= argc) {
|
||||||
|
throw std::invalid_argument(std::string{argument} + " requires a path");
|
||||||
|
}
|
||||||
|
options.calendar_path = argv[index];
|
||||||
|
positional_seen = true;
|
||||||
|
} else if (!argument.empty() && argument.front() == '-') {
|
||||||
|
throw std::invalid_argument("unknown option: " + std::string{argument});
|
||||||
|
} else if (positional_seen) {
|
||||||
|
throw std::invalid_argument("only one calendar path may be supplied");
|
||||||
|
} else {
|
||||||
|
options.calendar_path = argument;
|
||||||
|
positional_seen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.restore_backup && options.demo) {
|
||||||
|
throw std::invalid_argument("--restore-backup cannot be combined with --demo");
|
||||||
|
}
|
||||||
|
if (options.restore_backup && options.print) {
|
||||||
|
throw std::invalid_argument("--restore-backup cannot be combined with --print");
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
nocal::Date shifted(nocal::Date date, int offset)
|
||||||
|
{
|
||||||
|
return nocal::from_sys_days(nocal::to_sys_days(date) + std::chrono::days{offset});
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<nocal::Event> demo_events()
|
||||||
|
{
|
||||||
|
const auto day = nocal::today();
|
||||||
|
nocal::Event planning;
|
||||||
|
planning.uid = "demo-planning@nocal";
|
||||||
|
planning.title = "Nomarchy planning";
|
||||||
|
planning.start = nocal::make_local_time(day, 9, 30);
|
||||||
|
planning.end = nocal::make_local_time(day, 10, 15);
|
||||||
|
planning.location = "Terminal 1";
|
||||||
|
planning.calendar_id = "work";
|
||||||
|
|
||||||
|
nocal::Event release;
|
||||||
|
release.uid = "demo-release@nocal";
|
||||||
|
release.title = "Release window";
|
||||||
|
release.start = nocal::local_day_start(shifted(day, 2));
|
||||||
|
release.end = nocal::local_day_start(shifted(day, 3));
|
||||||
|
release.all_day = true;
|
||||||
|
release.calendar_id = "work";
|
||||||
|
|
||||||
|
nocal::Event coffee;
|
||||||
|
coffee.uid = "demo-coffee@nocal";
|
||||||
|
coffee.title = "Coffee with Luna";
|
||||||
|
coffee.start = nocal::make_local_time(shifted(day, -3), 14, 0);
|
||||||
|
coffee.end = nocal::make_local_time(shifted(day, -3), 14, 45);
|
||||||
|
coffee.location = "Nomarchy café";
|
||||||
|
coffee.calendar_id = "personal";
|
||||||
|
|
||||||
|
return {std::move(planning), std::move(release), std::move(coffee)};
|
||||||
|
}
|
||||||
|
|
||||||
|
int print_frame(std::span<const nocal::Event> events, bool no_color)
|
||||||
|
{
|
||||||
|
const auto day = nocal::today();
|
||||||
|
const nocal::tui::ScreenState state{
|
||||||
|
.visible_month = day.year() / day.month(), .selected_day = day, .today = day,
|
||||||
|
.width = 120, .height = 36, .show_help = false,
|
||||||
|
.ansi = !no_color && ::isatty(STDOUT_FILENO) != 0,
|
||||||
|
.focused_event_id = std::nullopt,
|
||||||
|
.show_event_details = false,
|
||||||
|
.confirm_delete = false,
|
||||||
|
.notification = {},
|
||||||
|
.notification_is_error = false,
|
||||||
|
};
|
||||||
|
std::cout << nocal::tui::render_month(events, state) << '\n';
|
||||||
|
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char** argv)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
const auto options = parse_options(argc, argv);
|
||||||
|
auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
|
||||||
|
for (const auto& warning : loaded.warnings) {
|
||||||
|
std::cerr << "nocal: " << warning << '\n';
|
||||||
|
}
|
||||||
|
if (options.restore_backup) {
|
||||||
|
const auto backup = nocal::storage::IcsStore::backup_path(options.calendar_path);
|
||||||
|
nocal::storage::IcsStore::restore_backup(
|
||||||
|
options.calendar_path, loaded.revision);
|
||||||
|
std::cout << "Restored backup " << backup.string() << " to "
|
||||||
|
<< options.calendar_path.string() << '\n';
|
||||||
|
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
if (options.demo) {
|
||||||
|
auto samples = demo_events();
|
||||||
|
loaded.events.insert(loaded.events.end(), samples.begin(), samples.end());
|
||||||
|
}
|
||||||
|
const bool non_interactive = ::isatty(STDIN_FILENO) == 0 ||
|
||||||
|
::isatty(STDOUT_FILENO) == 0;
|
||||||
|
if (options.print || non_interactive) {
|
||||||
|
return print_frame(loaded.events, true);
|
||||||
|
}
|
||||||
|
if (options.no_color) {
|
||||||
|
::setenv("NO_COLOR", "1", 1);
|
||||||
|
}
|
||||||
|
nocal::tui::TuiApp::SaveCallback saver;
|
||||||
|
if (!options.demo) {
|
||||||
|
if (loaded.safe_to_rewrite) {
|
||||||
|
const auto calendar_path = options.calendar_path;
|
||||||
|
saver = [calendar_path, revision = loaded.revision](
|
||||||
|
const std::span<const nocal::Event> events) mutable {
|
||||||
|
revision = nocal::storage::IcsStore::save(
|
||||||
|
calendar_path, events, revision);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
saver = [](std::span<const nocal::Event>) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"calendar is read-only because it contains unsupported iCalendar data");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nocal::tui::TuiApp app{loaded.events, std::move(saver)};
|
||||||
|
return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS;
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
std::cerr << "nocal: " << error.what() << '\n';
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
976
src/storage/ics_store.cpp
Normal file
976
src/storage/ics_store.cpp
Normal file
@@ -0,0 +1,976 @@
|
|||||||
|
#include "nocal/storage/ics_store.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <charconv>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cctype>
|
||||||
|
#include <ctime>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iterator>
|
||||||
|
#include <optional>
|
||||||
|
#include <sstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string_view>
|
||||||
|
#include <system_error>
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/file.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace nocal::storage {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using namespace std::chrono;
|
||||||
|
|
||||||
|
struct ParsedTime {
|
||||||
|
TimePoint value;
|
||||||
|
bool is_date = false;
|
||||||
|
std::optional<year_month_day> date;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PendingEvent {
|
||||||
|
std::optional<std::string> uid;
|
||||||
|
std::string title;
|
||||||
|
std::string location;
|
||||||
|
std::string description;
|
||||||
|
std::string calendar_id;
|
||||||
|
std::string color;
|
||||||
|
std::optional<ParsedTime> start;
|
||||||
|
std::optional<ParsedTime> end;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] std::string upper(std::string_view value) {
|
||||||
|
std::string result(value);
|
||||||
|
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char ch) {
|
||||||
|
return static_cast<char>(std::toupper(ch));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string unescape_text(std::string_view value) {
|
||||||
|
std::string result;
|
||||||
|
result.reserve(value.size());
|
||||||
|
for (std::size_t i = 0; i < value.size(); ++i) {
|
||||||
|
if (value[i] != '\\' || i + 1 == value.size()) {
|
||||||
|
result.push_back(value[i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char escaped = value[++i];
|
||||||
|
if (escaped == 'n' || escaped == 'N') {
|
||||||
|
result.push_back('\n');
|
||||||
|
} else {
|
||||||
|
// RFC 5545 TEXT escapes '\\', ';', and ','. Keeping the escaped
|
||||||
|
// character is the least surprising recovery for unknown escapes.
|
||||||
|
result.push_back(escaped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string escape_text(std::string_view value) {
|
||||||
|
std::string result;
|
||||||
|
result.reserve(value.size());
|
||||||
|
for (std::size_t i = 0; i < value.size(); ++i) {
|
||||||
|
switch (value[i]) {
|
||||||
|
case '\\': result += "\\\\"; break;
|
||||||
|
case ';': result += "\\;"; break;
|
||||||
|
case ',': result += "\\,"; break;
|
||||||
|
case '\r':
|
||||||
|
if (i + 1 < value.size() && value[i + 1] == '\n') {
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
result += "\\n";
|
||||||
|
break;
|
||||||
|
case '\n': result += "\\n"; break;
|
||||||
|
default: result.push_back(value[i]); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] bool parse_fixed_int(
|
||||||
|
std::string_view value, std::size_t offset, std::size_t count, int& destination) {
|
||||||
|
if (offset + count > value.size()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const char* first = value.data() + offset;
|
||||||
|
const char* last = first + count;
|
||||||
|
if (!std::all_of(first, last, [](unsigned char ch) { return std::isdigit(ch); })) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto [ptr, ec] = std::from_chars(first, last, destination);
|
||||||
|
return ec == std::errc{} && ptr == last;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::optional<year_month_day> parse_ymd(std::string_view value) {
|
||||||
|
if (value.size() != 8) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
int year_value = 0;
|
||||||
|
int month_value = 0;
|
||||||
|
int day_value = 0;
|
||||||
|
if (!parse_fixed_int(value, 0, 4, year_value)
|
||||||
|
|| !parse_fixed_int(value, 4, 2, month_value)
|
||||||
|
|| !parse_fixed_int(value, 6, 2, day_value)) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const year_month_day date{
|
||||||
|
year{year_value}, month{static_cast<unsigned>(month_value)}, day{static_cast<unsigned>(day_value)}};
|
||||||
|
return date.ok() ? std::optional{date} : std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::optional<TimePoint> make_local(
|
||||||
|
year_month_day date, int hour_value, int minute_value, int second_value) {
|
||||||
|
std::tm local{};
|
||||||
|
local.tm_year = static_cast<int>(date.year()) - 1900;
|
||||||
|
local.tm_mon = static_cast<int>(static_cast<unsigned>(date.month())) - 1;
|
||||||
|
local.tm_mday = static_cast<int>(static_cast<unsigned>(date.day()));
|
||||||
|
local.tm_hour = hour_value;
|
||||||
|
local.tm_min = minute_value;
|
||||||
|
local.tm_sec = second_value;
|
||||||
|
local.tm_isdst = -1;
|
||||||
|
const std::time_t converted = std::mktime(&local);
|
||||||
|
|
||||||
|
// mktime may normalize invalid or nonexistent local times. Accept timezone
|
||||||
|
// normalization only when the requested civil fields survive unchanged.
|
||||||
|
if (local.tm_year != static_cast<int>(date.year()) - 1900
|
||||||
|
|| local.tm_mon != static_cast<int>(static_cast<unsigned>(date.month())) - 1
|
||||||
|
|| local.tm_mday != static_cast<int>(static_cast<unsigned>(date.day()))
|
||||||
|
|| local.tm_hour != hour_value || local.tm_min != minute_value
|
||||||
|
|| local.tm_sec != second_value) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return system_clock::from_time_t(converted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::optional<ParsedTime> parse_time(
|
||||||
|
std::string_view parameters, std::string_view value, std::string& warning) {
|
||||||
|
const std::string normalized_parameters = upper(parameters);
|
||||||
|
bool value_is_date = false;
|
||||||
|
bool has_tzid = false;
|
||||||
|
std::size_t parameter_start = 0;
|
||||||
|
while (parameter_start <= normalized_parameters.size()) {
|
||||||
|
const std::size_t parameter_end = normalized_parameters.find(';', parameter_start);
|
||||||
|
const std::string_view parameter = std::string_view(normalized_parameters).substr(
|
||||||
|
parameter_start, parameter_end == std::string::npos
|
||||||
|
? std::string::npos : parameter_end - parameter_start);
|
||||||
|
value_is_date = value_is_date || parameter == "VALUE=DATE";
|
||||||
|
has_tzid = has_tzid || parameter.starts_with("TZID=");
|
||||||
|
if (parameter_end == std::string::npos) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parameter_start = parameter_end + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value_is_date || (parameters.empty() && value.size() == 8)) {
|
||||||
|
const auto date = parse_ymd(value);
|
||||||
|
if (!date) {
|
||||||
|
warning = "invalid DATE value '" + std::string(value) + "'";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const auto local = make_local(*date, 0, 0, 0);
|
||||||
|
if (!local) {
|
||||||
|
warning = "DATE does not map to a local midnight: '" + std::string(value) + "'";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return ParsedTime{*local, true, *date};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has_tzid) {
|
||||||
|
warning = "TZID is not supported; DATE-TIME was interpreted in the process local timezone";
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool utc = !value.empty() && value.back() == 'Z';
|
||||||
|
const std::size_t expected_size = utc ? 16 : 15;
|
||||||
|
if (value.size() != expected_size || value[8] != 'T') {
|
||||||
|
warning = "invalid DATE-TIME value '" + std::string(value) + "'";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const auto date = parse_ymd(value.substr(0, 8));
|
||||||
|
int hour_value = 0;
|
||||||
|
int minute_value = 0;
|
||||||
|
int second_value = 0;
|
||||||
|
if (!date || !parse_fixed_int(value, 9, 2, hour_value)
|
||||||
|
|| !parse_fixed_int(value, 11, 2, minute_value)
|
||||||
|
|| !parse_fixed_int(value, 13, 2, second_value)
|
||||||
|
|| hour_value > 23 || minute_value > 59 || second_value > 60) {
|
||||||
|
warning = "invalid DATE-TIME value '" + std::string(value) + "'";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
// POSIX time has no representation for a leap second. Normalize it to the
|
||||||
|
// first instant of the next minute.
|
||||||
|
const int represented_second = std::min(second_value, 59);
|
||||||
|
TimePoint result;
|
||||||
|
if (utc) {
|
||||||
|
result = sys_days{*date} + hours{hour_value} + minutes{minute_value}
|
||||||
|
+ seconds{represented_second};
|
||||||
|
} else {
|
||||||
|
const auto local = make_local(*date, hour_value, minute_value, represented_second);
|
||||||
|
if (!local) {
|
||||||
|
warning = "DATE-TIME does not exist in the local timezone: '" + std::string(value) + "'";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
result = *local;
|
||||||
|
}
|
||||||
|
if (second_value == 60) {
|
||||||
|
result += seconds{1};
|
||||||
|
}
|
||||||
|
return ParsedTime{result, false, std::nullopt};
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::vector<std::string> unfold(std::istream& input) {
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(input, line)) {
|
||||||
|
if (!line.empty() && line.back() == '\r') {
|
||||||
|
line.pop_back();
|
||||||
|
}
|
||||||
|
if (!line.empty() && (line.front() == ' ' || line.front() == '\t')) {
|
||||||
|
if (!lines.empty()) {
|
||||||
|
lines.back().append(line.begin() + 1, line.end());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push_back(std::move(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t content_separator(std::string_view line) {
|
||||||
|
bool quoted = false;
|
||||||
|
for (std::size_t i = 0; i < line.size(); ++i) {
|
||||||
|
if (line[i] == '"') {
|
||||||
|
quoted = !quoted;
|
||||||
|
} else if (line[i] == ':' && !quoted) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::string_view::npos;
|
||||||
|
}
|
||||||
|
|
||||||
|
void add_warning(LoadResult& result, std::size_t line, std::string message) {
|
||||||
|
result.warnings.push_back(
|
||||||
|
"line " + std::to_string(line) + ": " + std::move(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
void mark_unsafe_to_rewrite(LoadResult& result) {
|
||||||
|
if (result.safe_to_rewrite) {
|
||||||
|
result.safe_to_rewrite = false;
|
||||||
|
result.warnings.emplace_back(
|
||||||
|
"calendar contains data nocal cannot preserve; editing is disabled to prevent data loss");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] bool supported_time_parameters(std::string_view parameters) {
|
||||||
|
const std::string normalized = upper(parameters);
|
||||||
|
return normalized.empty() || normalized == "VALUE=DATE";
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::tm utc_tm(std::time_t time) {
|
||||||
|
std::tm result{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
gmtime_s(&result, &time);
|
||||||
|
#else
|
||||||
|
gmtime_r(&time, &result);
|
||||||
|
#endif
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::tm local_tm(std::time_t time) {
|
||||||
|
std::tm result{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
localtime_s(&result, &time);
|
||||||
|
#else
|
||||||
|
localtime_r(&time, &result);
|
||||||
|
#endif
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string two_digits(int value) {
|
||||||
|
std::string result(2, '0');
|
||||||
|
result[0] = static_cast<char>('0' + (value / 10) % 10);
|
||||||
|
result[1] = static_cast<char>('0' + value % 10);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string four_digits(int value) {
|
||||||
|
std::string result(4, '0');
|
||||||
|
for (int index = 3; index >= 0; --index) {
|
||||||
|
result[static_cast<std::size_t>(index)] = static_cast<char>('0' + value % 10);
|
||||||
|
value /= 10;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string format_date(TimePoint value) {
|
||||||
|
const std::tm local = local_tm(system_clock::to_time_t(value));
|
||||||
|
return four_digits(local.tm_year + 1900) + two_digits(local.tm_mon + 1)
|
||||||
|
+ two_digits(local.tm_mday);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::string format_utc_time(TimePoint value) {
|
||||||
|
const auto whole_seconds = floor<seconds>(value);
|
||||||
|
const std::tm utc = utc_tm(system_clock::to_time_t(whole_seconds));
|
||||||
|
return four_digits(utc.tm_year + 1900) + two_digits(utc.tm_mon + 1)
|
||||||
|
+ two_digits(utc.tm_mday) + "T" + two_digits(utc.tm_hour)
|
||||||
|
+ two_digits(utc.tm_min) + two_digits(utc.tm_sec) + "Z";
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t utf8_boundary(std::string_view value, std::size_t begin, std::size_t limit) {
|
||||||
|
std::size_t end = std::min(value.size(), begin + limit);
|
||||||
|
while (end > begin && end < value.size()
|
||||||
|
&& (static_cast<unsigned char>(value[end]) & 0xC0U) == 0x80U) {
|
||||||
|
--end;
|
||||||
|
}
|
||||||
|
return end == begin ? std::min(value.size(), begin + limit) : end;
|
||||||
|
}
|
||||||
|
|
||||||
|
void write_folded(std::ostream& output, std::string_view content_line) {
|
||||||
|
std::size_t offset = 0;
|
||||||
|
bool first = true;
|
||||||
|
do {
|
||||||
|
const std::size_t available = first ? 75 : 74;
|
||||||
|
const std::size_t end = utf8_boundary(content_line, offset, available);
|
||||||
|
if (!first) {
|
||||||
|
output.put(' ');
|
||||||
|
}
|
||||||
|
output.write(content_line.data() + offset, static_cast<std::streamsize>(end - offset));
|
||||||
|
output << "\r\n";
|
||||||
|
offset = end;
|
||||||
|
first = false;
|
||||||
|
} while (offset < content_line.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
void write_text_property(std::ostream& output, std::string_view name, std::string_view value) {
|
||||||
|
if (!value.empty()) {
|
||||||
|
write_folded(output, std::string(name) + ":" + escape_text(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void serialize_calendar(std::ostream& output, std::span<const Event> events) {
|
||||||
|
write_folded(output, "BEGIN:VCALENDAR");
|
||||||
|
write_folded(output, "VERSION:2.0");
|
||||||
|
write_folded(output, "PRODID:-//Nomarchy Linux//nocal 1.0//EN");
|
||||||
|
write_folded(output, "CALSCALE:GREGORIAN");
|
||||||
|
for (const Event& event : events) {
|
||||||
|
write_folded(output, "BEGIN:VEVENT");
|
||||||
|
write_folded(output, "UID:" + escape_text(event.uid));
|
||||||
|
if (event.all_day) {
|
||||||
|
write_folded(output, "DTSTART;VALUE=DATE:" + format_date(event.start));
|
||||||
|
write_folded(output, "DTEND;VALUE=DATE:" + format_date(event.end));
|
||||||
|
} else {
|
||||||
|
write_folded(output, "DTSTART:" + format_utc_time(event.start));
|
||||||
|
write_folded(output, "DTEND:" + format_utc_time(event.end));
|
||||||
|
}
|
||||||
|
write_text_property(output, "SUMMARY", event.title);
|
||||||
|
write_text_property(output, "LOCATION", event.location);
|
||||||
|
write_text_property(output, "DESCRIPTION", event.description);
|
||||||
|
write_text_property(output, "X-NOCAL-CALENDAR-ID", event.calendar_id);
|
||||||
|
write_text_property(output, "X-NOCAL-COLOR", event.color);
|
||||||
|
write_folded(output, "END:VEVENT");
|
||||||
|
}
|
||||||
|
write_folded(output, "END:VCALENDAR");
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FileSnapshot {
|
||||||
|
bool existed = false;
|
||||||
|
std::string bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] bool same_file_state(
|
||||||
|
bool expected_existed, const std::string* expected_bytes,
|
||||||
|
const FileSnapshot& current) noexcept {
|
||||||
|
if (expected_existed != current.existed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!expected_existed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return expected_bytes != nullptr && *expected_bytes == current.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
|
||||||
|
[[noreturn]] void throw_io_error(std::string_view operation, const std::filesystem::path& path) {
|
||||||
|
const int error = errno;
|
||||||
|
throw std::runtime_error(
|
||||||
|
std::string(operation) + " '" + path.string() + "': " + std::strerror(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileDescriptor {
|
||||||
|
public:
|
||||||
|
explicit FileDescriptor(int descriptor = -1) noexcept : descriptor_(descriptor) {}
|
||||||
|
FileDescriptor(const FileDescriptor&) = delete;
|
||||||
|
FileDescriptor& operator=(const FileDescriptor&) = delete;
|
||||||
|
FileDescriptor(FileDescriptor&& other) noexcept : descriptor_(other.release()) {}
|
||||||
|
~FileDescriptor() {
|
||||||
|
if (descriptor_ >= 0) {
|
||||||
|
::close(descriptor_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] int get() const noexcept { return descriptor_; }
|
||||||
|
|
||||||
|
int release() noexcept {
|
||||||
|
const int result = descriptor_;
|
||||||
|
descriptor_ = -1;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
int descriptor_;
|
||||||
|
};
|
||||||
|
|
||||||
|
class TemporaryFile {
|
||||||
|
public:
|
||||||
|
TemporaryFile(std::filesystem::path path, int descriptor)
|
||||||
|
: path_(std::move(path)), descriptor_(descriptor) {}
|
||||||
|
TemporaryFile(const TemporaryFile&) = delete;
|
||||||
|
TemporaryFile& operator=(const TemporaryFile&) = delete;
|
||||||
|
TemporaryFile(TemporaryFile&& other) noexcept
|
||||||
|
: path_(std::move(other.path_)), descriptor_(std::move(other.descriptor_)),
|
||||||
|
remove_on_destroy_(other.remove_on_destroy_) {
|
||||||
|
other.remove_on_destroy_ = false;
|
||||||
|
}
|
||||||
|
~TemporaryFile() {
|
||||||
|
if (descriptor_.get() >= 0) {
|
||||||
|
::close(descriptor_.release());
|
||||||
|
}
|
||||||
|
if (remove_on_destroy_) {
|
||||||
|
std::error_code ignored;
|
||||||
|
std::filesystem::remove(path_, ignored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] int descriptor() const noexcept { return descriptor_.get(); }
|
||||||
|
[[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; }
|
||||||
|
|
||||||
|
void close_checked() {
|
||||||
|
const int descriptor = descriptor_.release();
|
||||||
|
if (::close(descriptor) != 0) {
|
||||||
|
throw_io_error("unable to close temporary calendar", path_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void committed() noexcept { remove_on_destroy_ = false; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::filesystem::path path_;
|
||||||
|
FileDescriptor descriptor_;
|
||||||
|
bool remove_on_destroy_ = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] FileSnapshot read_regular_file(
|
||||||
|
const std::filesystem::path& path, std::string_view purpose) {
|
||||||
|
const int descriptor = ::open(path.c_str(), O_RDONLY | O_CLOEXEC);
|
||||||
|
if (descriptor < 0) {
|
||||||
|
if (errno == ENOENT) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw_io_error(purpose, path);
|
||||||
|
}
|
||||||
|
const FileDescriptor file(descriptor);
|
||||||
|
|
||||||
|
struct stat metadata {};
|
||||||
|
if (::fstat(file.get(), &metadata) != 0) {
|
||||||
|
throw_io_error(purpose, path);
|
||||||
|
}
|
||||||
|
if (!S_ISREG(metadata.st_mode)) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
std::string(purpose) + " '" + path.string() + "': not a regular file");
|
||||||
|
}
|
||||||
|
|
||||||
|
FileSnapshot snapshot;
|
||||||
|
snapshot.existed = true;
|
||||||
|
if (metadata.st_size > 0) {
|
||||||
|
snapshot.bytes.reserve(static_cast<std::size_t>(metadata.st_size));
|
||||||
|
}
|
||||||
|
char buffer[16 * 1024];
|
||||||
|
while (true) {
|
||||||
|
const ssize_t count = ::read(file.get(), buffer, sizeof(buffer));
|
||||||
|
if (count < 0) {
|
||||||
|
if (errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw_io_error(purpose, path);
|
||||||
|
}
|
||||||
|
if (count == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
snapshot.bytes.append(buffer, static_cast<std::size_t>(count));
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] FileDescriptor lock_destination(const std::filesystem::path& path) {
|
||||||
|
std::filesystem::path lock_path = path;
|
||||||
|
lock_path += ".lock";
|
||||||
|
const int descriptor = ::open(lock_path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, S_IRUSR | S_IWUSR);
|
||||||
|
if (descriptor < 0) {
|
||||||
|
throw_io_error("unable to open calendar lock", lock_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDescriptor lock(descriptor);
|
||||||
|
while (::flock(lock.get(), LOCK_EX) != 0) {
|
||||||
|
if (errno != EINTR) {
|
||||||
|
throw_io_error("unable to lock calendar", lock_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lock;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] TemporaryFile create_temporary_file(
|
||||||
|
const std::filesystem::path& directory, const std::filesystem::path& destination) {
|
||||||
|
const std::string pattern_string =
|
||||||
|
(directory / ("." + destination.filename().string() + ".tmp.XXXXXX")).string();
|
||||||
|
std::vector<char> pattern(pattern_string.begin(), pattern_string.end());
|
||||||
|
pattern.push_back('\0');
|
||||||
|
const int descriptor = ::mkstemp(pattern.data());
|
||||||
|
if (descriptor < 0) {
|
||||||
|
throw_io_error("unable to create temporary calendar", destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
TemporaryFile temporary{std::filesystem::path{pattern.data()}, descriptor};
|
||||||
|
const int flags = ::fcntl(descriptor, F_GETFD);
|
||||||
|
if (flags < 0 || ::fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC) != 0) {
|
||||||
|
throw_io_error("unable to secure temporary calendar", temporary.path());
|
||||||
|
}
|
||||||
|
if (::fchmod(descriptor, S_IRUSR | S_IWUSR) != 0) {
|
||||||
|
throw_io_error("unable to set temporary calendar permissions", temporary.path());
|
||||||
|
}
|
||||||
|
return temporary;
|
||||||
|
}
|
||||||
|
|
||||||
|
void write_all(int descriptor, std::string_view bytes, const std::filesystem::path& path) {
|
||||||
|
std::size_t offset = 0;
|
||||||
|
while (offset < bytes.size()) {
|
||||||
|
const ssize_t written = ::write(descriptor, bytes.data() + offset, bytes.size() - offset);
|
||||||
|
if (written < 0) {
|
||||||
|
if (errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw_io_error("unable to write temporary calendar", path);
|
||||||
|
}
|
||||||
|
if (written == 0) {
|
||||||
|
throw std::runtime_error("unable to write temporary calendar '" + path.string()
|
||||||
|
+ "': write made no progress");
|
||||||
|
}
|
||||||
|
offset += static_cast<std::size_t>(written);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] TemporaryFile stage_file(
|
||||||
|
const std::filesystem::path& directory, const std::filesystem::path& destination,
|
||||||
|
std::string_view bytes) {
|
||||||
|
TemporaryFile temporary = create_temporary_file(directory, destination);
|
||||||
|
write_all(temporary.descriptor(), bytes, temporary.path());
|
||||||
|
if (::fsync(temporary.descriptor()) != 0) {
|
||||||
|
throw_io_error("unable to flush temporary calendar", temporary.path());
|
||||||
|
}
|
||||||
|
temporary.close_checked();
|
||||||
|
return temporary;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] FileDescriptor open_directory(const std::filesystem::path& directory) {
|
||||||
|
const int directory_descriptor = ::open(directory.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC);
|
||||||
|
if (directory_descriptor < 0) {
|
||||||
|
throw_io_error("unable to open calendar directory", directory);
|
||||||
|
}
|
||||||
|
return FileDescriptor(directory_descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
void replace_with_temporary(
|
||||||
|
TemporaryFile& temporary, const std::filesystem::path& destination,
|
||||||
|
std::string_view operation) {
|
||||||
|
if (::rename(temporary.path().c_str(), destination.c_str()) != 0) {
|
||||||
|
throw_io_error(operation, destination);
|
||||||
|
}
|
||||||
|
temporary.committed();
|
||||||
|
}
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
[[nodiscard]] FileSnapshot read_regular_file(
|
||||||
|
const std::filesystem::path& path, std::string_view purpose) {
|
||||||
|
std::error_code error;
|
||||||
|
if (!std::filesystem::exists(path, error)) {
|
||||||
|
if (!error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw std::runtime_error(
|
||||||
|
std::string(purpose) + " '" + path.string() + "': " + error.message());
|
||||||
|
}
|
||||||
|
if (!std::filesystem::is_regular_file(path, error) || error) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
std::string(purpose) + " '" + path.string() + "': not a regular file");
|
||||||
|
}
|
||||||
|
std::ifstream input(path, std::ios::binary);
|
||||||
|
if (!input) {
|
||||||
|
throw std::runtime_error(std::string(purpose) + " '" + path.string() + "'");
|
||||||
|
}
|
||||||
|
FileSnapshot snapshot;
|
||||||
|
snapshot.existed = true;
|
||||||
|
snapshot.bytes.assign(std::istreambuf_iterator<char>{input}, {});
|
||||||
|
if (!input.eof()) {
|
||||||
|
throw std::runtime_error(std::string(purpose) + " '" + path.string() + "'");
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[[noreturn]] void throw_external_change(const std::filesystem::path& path) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"calendar changed externally; reload before saving '" + path.string() + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] FileSnapshot read_current_file(
|
||||||
|
const std::filesystem::path& path, bool has_expected_revision) {
|
||||||
|
try {
|
||||||
|
return read_regular_file(path, "unable to read current calendar");
|
||||||
|
} catch (const std::runtime_error&) {
|
||||||
|
// A file that became unreadable or changed type cannot match an exact
|
||||||
|
// revision. Keep the optimistic-concurrency diagnostic actionable.
|
||||||
|
if (has_expected_revision) {
|
||||||
|
throw_external_change(path);
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
LoadResult IcsStore::load(const std::filesystem::path& path) {
|
||||||
|
LoadResult result;
|
||||||
|
FileSnapshot snapshot;
|
||||||
|
try {
|
||||||
|
snapshot = read_regular_file(path, "unable to read calendar");
|
||||||
|
} catch (const std::runtime_error&) {
|
||||||
|
std::error_code error;
|
||||||
|
result.revision.existed_ = std::filesystem::exists(path, error) && !error;
|
||||||
|
result.warnings.push_back("unable to open '" + path.string() + "' for reading");
|
||||||
|
result.safe_to_rewrite = false;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.revision.existed_ = snapshot.existed;
|
||||||
|
if (!snapshot.existed) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.revision.contents_ = std::make_shared<const std::string>(snapshot.bytes);
|
||||||
|
|
||||||
|
std::istringstream input(snapshot.bytes, std::ios::in | std::ios::binary);
|
||||||
|
const auto lines = unfold(input);
|
||||||
|
std::optional<PendingEvent> pending;
|
||||||
|
std::size_t event_start_line = 0;
|
||||||
|
for (std::size_t index = 0; index < lines.size(); ++index) {
|
||||||
|
const std::string_view line = lines[index];
|
||||||
|
const std::size_t separator = content_separator(line);
|
||||||
|
if (separator == std::string_view::npos) {
|
||||||
|
if (pending) {
|
||||||
|
add_warning(result, index + 1, "ignored malformed content line");
|
||||||
|
}
|
||||||
|
if (!line.empty()) {
|
||||||
|
mark_unsafe_to_rewrite(result);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string_view head = line.substr(0, separator);
|
||||||
|
const std::string_view value = line.substr(separator + 1);
|
||||||
|
const std::size_t semicolon = head.find(';');
|
||||||
|
const std::string name = upper(head.substr(0, semicolon));
|
||||||
|
const std::string_view parameters = semicolon == std::string_view::npos
|
||||||
|
? std::string_view{} : head.substr(semicolon + 1);
|
||||||
|
|
||||||
|
if (name == "BEGIN" && upper(value) == "VEVENT") {
|
||||||
|
if (pending) {
|
||||||
|
add_warning(result, event_start_line, "nested VEVENT; discarded incomplete event");
|
||||||
|
mark_unsafe_to_rewrite(result);
|
||||||
|
}
|
||||||
|
pending.emplace();
|
||||||
|
event_start_line = index + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name == "END" && upper(value) == "VEVENT") {
|
||||||
|
if (!pending) {
|
||||||
|
add_warning(result, index + 1, "END:VEVENT without BEGIN:VEVENT");
|
||||||
|
mark_unsafe_to_rewrite(result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pending->uid || pending->uid->empty()) {
|
||||||
|
add_warning(result, event_start_line, "VEVENT has no UID and was skipped");
|
||||||
|
mark_unsafe_to_rewrite(result);
|
||||||
|
} else if (!pending->start) {
|
||||||
|
add_warning(result, event_start_line, "VEVENT has no valid DTSTART and was skipped");
|
||||||
|
mark_unsafe_to_rewrite(result);
|
||||||
|
} else if (pending->end && pending->start->is_date != pending->end->is_date) {
|
||||||
|
add_warning(result, event_start_line, "DTSTART and DTEND value types differ; event was skipped");
|
||||||
|
mark_unsafe_to_rewrite(result);
|
||||||
|
} else {
|
||||||
|
const bool all_day = pending->start->is_date;
|
||||||
|
TimePoint end = pending->start->value;
|
||||||
|
if (pending->end) {
|
||||||
|
end = pending->end->value;
|
||||||
|
} else if (all_day) {
|
||||||
|
const auto next_date = year_month_day{
|
||||||
|
sys_days{*pending->start->date} + days{1}};
|
||||||
|
const auto next_midnight = make_local(next_date, 0, 0, 0);
|
||||||
|
if (!next_midnight) {
|
||||||
|
add_warning(result, event_start_line,
|
||||||
|
"could not determine implicit all-day DTEND; event was skipped");
|
||||||
|
mark_unsafe_to_rewrite(result);
|
||||||
|
pending.reset();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
end = *next_midnight;
|
||||||
|
}
|
||||||
|
if (end < pending->start->value || (all_day && end == pending->start->value)) {
|
||||||
|
add_warning(result, event_start_line, "DTEND is before DTSTART; event was skipped");
|
||||||
|
mark_unsafe_to_rewrite(result);
|
||||||
|
} else {
|
||||||
|
Event event;
|
||||||
|
event.uid = std::move(*pending->uid);
|
||||||
|
event.title = std::move(pending->title);
|
||||||
|
event.start = pending->start->value;
|
||||||
|
event.end = end;
|
||||||
|
event.all_day = all_day;
|
||||||
|
event.location = std::move(pending->location);
|
||||||
|
event.description = std::move(pending->description);
|
||||||
|
event.calendar_id = std::move(pending->calendar_id);
|
||||||
|
event.color = std::move(pending->color);
|
||||||
|
result.events.push_back(std::move(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.reset();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
const std::string normalized_value = upper(value);
|
||||||
|
const bool structural = (name == "BEGIN" && normalized_value == "VCALENDAR")
|
||||||
|
|| (name == "END" && normalized_value == "VCALENDAR");
|
||||||
|
const bool supported_property = name == "VERSION" || name == "PRODID"
|
||||||
|
|| name == "CALSCALE";
|
||||||
|
if ((!structural && !supported_property)
|
||||||
|
|| (supported_property && !parameters.empty())) {
|
||||||
|
mark_unsafe_to_rewrite(result);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bool time_property = name == "DTSTART" || name == "DTEND";
|
||||||
|
const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION"
|
||||||
|
|| name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID"
|
||||||
|
|| name == "X-NOCAL-COLOR";
|
||||||
|
if ((!time_property && !text_property)
|
||||||
|
|| (time_property && !supported_time_parameters(parameters))
|
||||||
|
|| (text_property && !parameters.empty())) {
|
||||||
|
mark_unsafe_to_rewrite(result);
|
||||||
|
}
|
||||||
|
if (name == "UID") {
|
||||||
|
pending->uid = unescape_text(value);
|
||||||
|
} else if (name == "SUMMARY") {
|
||||||
|
pending->title = unescape_text(value);
|
||||||
|
} else if (name == "LOCATION") {
|
||||||
|
pending->location = unescape_text(value);
|
||||||
|
} else if (name == "DESCRIPTION") {
|
||||||
|
pending->description = unescape_text(value);
|
||||||
|
} else if (name == "X-NOCAL-CALENDAR-ID") {
|
||||||
|
pending->calendar_id = unescape_text(value);
|
||||||
|
} else if (name == "X-NOCAL-COLOR") {
|
||||||
|
pending->color = unescape_text(value);
|
||||||
|
} else if (name == "DTSTART" || name == "DTEND") {
|
||||||
|
std::string warning;
|
||||||
|
auto parsed = parse_time(parameters, value, warning);
|
||||||
|
if (!parsed) {
|
||||||
|
add_warning(result, index + 1, std::move(warning));
|
||||||
|
mark_unsafe_to_rewrite(result);
|
||||||
|
} else {
|
||||||
|
if (!warning.empty()) {
|
||||||
|
add_warning(result, index + 1, std::move(warning));
|
||||||
|
}
|
||||||
|
if (name == "DTSTART") {
|
||||||
|
pending->start = *parsed;
|
||||||
|
} else {
|
||||||
|
pending->end = *parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending) {
|
||||||
|
add_warning(result, event_start_line, "unterminated VEVENT was skipped");
|
||||||
|
mark_unsafe_to_rewrite(result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::path IcsStore::backup_path(const std::filesystem::path& path) {
|
||||||
|
std::filesystem::path backup = path;
|
||||||
|
backup += ".bak";
|
||||||
|
return backup;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileRevision IcsStore::save(
|
||||||
|
const std::filesystem::path& path, std::span<const Event> events,
|
||||||
|
std::optional<FileRevision> expected) {
|
||||||
|
if (path.empty() || path.filename().empty()) {
|
||||||
|
throw std::invalid_argument("calendar path must name a file");
|
||||||
|
}
|
||||||
|
for (const Event& event : events) {
|
||||||
|
if (event.uid.empty()) {
|
||||||
|
throw std::invalid_argument("cannot save an event with an empty UID");
|
||||||
|
}
|
||||||
|
if (event.end < event.start || (event.all_day && event.end == event.start)) {
|
||||||
|
throw std::invalid_argument("cannot save event '" + event.uid + "' with an invalid interval");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::ostringstream serialized(std::ios::out | std::ios::binary);
|
||||||
|
serialize_calendar(serialized, events);
|
||||||
|
if (!serialized) {
|
||||||
|
throw std::runtime_error("unable to serialize calendar '" + path.string() + "'");
|
||||||
|
}
|
||||||
|
std::string bytes = serialized.str();
|
||||||
|
|
||||||
|
const std::filesystem::path directory =
|
||||||
|
path.parent_path().empty() ? std::filesystem::path{"."} : path.parent_path();
|
||||||
|
std::error_code directory_error;
|
||||||
|
std::filesystem::create_directories(directory, directory_error);
|
||||||
|
if (directory_error) {
|
||||||
|
throw std::runtime_error("unable to create calendar directory '" + directory.string()
|
||||||
|
+ "': " + directory_error.message());
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
const FileDescriptor lock = lock_destination(path);
|
||||||
|
const FileSnapshot current = read_current_file(path, expected.has_value());
|
||||||
|
if (expected && !same_file_state(
|
||||||
|
expected->existed_, expected->contents_.get(), current)) {
|
||||||
|
throw_external_change(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare and flush every byte before changing either destination.
|
||||||
|
TemporaryFile calendar_temporary = stage_file(directory, path, bytes);
|
||||||
|
std::optional<TemporaryFile> backup_temporary;
|
||||||
|
const auto backup = backup_path(path);
|
||||||
|
if (current.existed) {
|
||||||
|
backup_temporary.emplace(stage_file(directory, backup, current.bytes));
|
||||||
|
}
|
||||||
|
const FileDescriptor directory_handle = open_directory(directory);
|
||||||
|
|
||||||
|
const FileSnapshot rechecked = read_current_file(path, true);
|
||||||
|
if (!same_file_state(current.existed, ¤t.bytes, rechecked)) {
|
||||||
|
throw_external_change(path);
|
||||||
|
}
|
||||||
|
// The advisory lock fully serializes Nocal writers. A program that ignores
|
||||||
|
// it can still replace the path after this comparison and before rename;
|
||||||
|
// portable POSIX rename does not provide a true compare-and-swap primitive.
|
||||||
|
|
||||||
|
if (backup_temporary) {
|
||||||
|
replace_with_temporary(*backup_temporary, backup, "unable to replace calendar backup");
|
||||||
|
(void)::fsync(directory_handle.get());
|
||||||
|
}
|
||||||
|
replace_with_temporary(calendar_temporary, path, "unable to replace calendar");
|
||||||
|
// The calendar rename is the commit point. Reporting a later directory
|
||||||
|
// fsync failure as an unchanged save would make caller rollback incorrect.
|
||||||
|
(void)::fsync(directory_handle.get());
|
||||||
|
#else
|
||||||
|
const FileSnapshot current = read_current_file(path, expected.has_value());
|
||||||
|
if (expected && !same_file_state(
|
||||||
|
expected->existed_, expected->contents_.get(), current)) {
|
||||||
|
throw_external_change(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto write_replacement = [](const std::filesystem::path& destination,
|
||||||
|
std::string_view replacement) {
|
||||||
|
std::filesystem::path temporary = destination;
|
||||||
|
temporary += ".tmp";
|
||||||
|
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!output) {
|
||||||
|
throw std::runtime_error("unable to open temporary file '" + temporary.string() + "'");
|
||||||
|
}
|
||||||
|
output.write(replacement.data(), static_cast<std::streamsize>(replacement.size()));
|
||||||
|
output.flush();
|
||||||
|
if (!output) {
|
||||||
|
throw std::runtime_error("failed while writing temporary file '" + temporary.string() + "'");
|
||||||
|
}
|
||||||
|
output.close();
|
||||||
|
std::error_code ignored;
|
||||||
|
std::filesystem::remove(destination, ignored);
|
||||||
|
std::filesystem::rename(temporary, destination);
|
||||||
|
};
|
||||||
|
if (current.existed) {
|
||||||
|
write_replacement(backup_path(path), current.bytes);
|
||||||
|
}
|
||||||
|
write_replacement(path, bytes);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
FileRevision revision;
|
||||||
|
revision.existed_ = true;
|
||||||
|
revision.contents_ = std::make_shared<const std::string>(std::move(bytes));
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileRevision IcsStore::restore_backup(
|
||||||
|
const std::filesystem::path& path, std::optional<FileRevision> expected) {
|
||||||
|
if (path.empty() || path.filename().empty()) {
|
||||||
|
throw std::invalid_argument("calendar path must name a file");
|
||||||
|
}
|
||||||
|
const std::filesystem::path directory =
|
||||||
|
path.parent_path().empty() ? std::filesystem::path{"."} : path.parent_path();
|
||||||
|
const auto backup = backup_path(path);
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
const FileDescriptor lock = lock_destination(path);
|
||||||
|
const FileSnapshot current = read_current_file(path, expected.has_value());
|
||||||
|
if (expected && !same_file_state(
|
||||||
|
expected->existed_, expected->contents_.get(), current)) {
|
||||||
|
throw_external_change(path);
|
||||||
|
}
|
||||||
|
const FileSnapshot saved = read_regular_file(backup, "unable to read calendar backup");
|
||||||
|
if (!saved.existed) {
|
||||||
|
throw std::runtime_error("calendar backup does not exist '" + backup.string() + "'");
|
||||||
|
}
|
||||||
|
TemporaryFile temporary = stage_file(directory, path, saved.bytes);
|
||||||
|
const FileDescriptor directory_handle = open_directory(directory);
|
||||||
|
const FileSnapshot rechecked = read_current_file(path, true);
|
||||||
|
if (!same_file_state(current.existed, ¤t.bytes, rechecked)) {
|
||||||
|
throw_external_change(path);
|
||||||
|
}
|
||||||
|
replace_with_temporary(temporary, path, "unable to restore calendar backup");
|
||||||
|
(void)::fsync(directory_handle.get());
|
||||||
|
#else
|
||||||
|
const FileSnapshot current = read_current_file(path, expected.has_value());
|
||||||
|
if (expected && !same_file_state(
|
||||||
|
expected->existed_, expected->contents_.get(), current)) {
|
||||||
|
throw_external_change(path);
|
||||||
|
}
|
||||||
|
const FileSnapshot saved = read_regular_file(backup, "unable to read calendar backup");
|
||||||
|
if (!saved.existed) {
|
||||||
|
throw std::runtime_error("calendar backup does not exist '" + backup.string() + "'");
|
||||||
|
}
|
||||||
|
std::ofstream output(path, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!output) {
|
||||||
|
throw std::runtime_error("unable to open '" + path.string() + "' for restoring");
|
||||||
|
}
|
||||||
|
output.write(saved.bytes.data(), static_cast<std::streamsize>(saved.bytes.size()));
|
||||||
|
output.flush();
|
||||||
|
if (!output) {
|
||||||
|
throw std::runtime_error("failed while restoring '" + path.string() + "'");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
FileRevision revision;
|
||||||
|
revision.existed_ = true;
|
||||||
|
revision.contents_ = std::make_shared<const std::string>(saved.bytes);
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nocal::storage
|
||||||
459
src/tui/EventEditor.cpp
Normal file
459
src/tui/EventEditor.cpp
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
#include "nocal/tui/EventEditor.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <ctime>
|
||||||
|
#include <random>
|
||||||
|
#include <sstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace nocal::tui {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr std::size_t field_count = static_cast<std::size_t>(EditorField::count);
|
||||||
|
constexpr std::array<std::string_view, field_count> labels{
|
||||||
|
"Title", "Start date", "Start time", "End date", "End time", "All day", "Location", "Notes",
|
||||||
|
};
|
||||||
|
|
||||||
|
std::size_t index_of(const EditorField field) noexcept
|
||||||
|
{
|
||||||
|
return static_cast<std::size_t>(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string make_uid()
|
||||||
|
{
|
||||||
|
static std::atomic<std::uint64_t> sequence{0};
|
||||||
|
static const std::uint64_t seed = [] {
|
||||||
|
std::random_device random;
|
||||||
|
const auto high = static_cast<std::uint64_t>(random()) << 32U;
|
||||||
|
return high ^ static_cast<std::uint64_t>(random());
|
||||||
|
}();
|
||||||
|
const auto now = std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||||
|
std::chrono::system_clock::now().time_since_epoch())
|
||||||
|
.count();
|
||||||
|
std::ostringstream stream;
|
||||||
|
stream << "nocal-" << std::hex << static_cast<std::uint64_t>(now) << '-'
|
||||||
|
<< (seed ^ sequence.fetch_add(1, std::memory_order_relaxed)) << "@local";
|
||||||
|
return stream.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string two_digits(const unsigned value)
|
||||||
|
{
|
||||||
|
std::array<char, 3> buffer{};
|
||||||
|
std::snprintf(buffer.data(), buffer.size(), "%02u", value);
|
||||||
|
return {buffer.data(), 2};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::pair<unsigned, unsigned> local_hour_minute(const TimePoint point)
|
||||||
|
{
|
||||||
|
const auto time = Clock::to_time_t(point);
|
||||||
|
std::tm value{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
if (::localtime_s(&value, &time) != 0) {
|
||||||
|
throw std::runtime_error("could not convert event time");
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
if (::localtime_r(&time, &value) == nullptr) {
|
||||||
|
throw std::runtime_error("could not convert event time");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return {static_cast<unsigned>(value.tm_hour), static_cast<unsigned>(value.tm_min)};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string format_time(const TimePoint point)
|
||||||
|
{
|
||||||
|
const auto [hour, minute] = local_hour_minute(point);
|
||||||
|
return two_digits(hour) + ':' + two_digits(minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parse_time(const std::string_view text, unsigned& hour, unsigned& minute) noexcept
|
||||||
|
{
|
||||||
|
if (text.size() != 5 || text[2] != ':' || text[0] < '0' || text[0] > '9' ||
|
||||||
|
text[1] < '0' || text[1] > '9' || text[3] < '0' || text[3] > '9' ||
|
||||||
|
text[4] < '0' || text[4] > '9') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
hour = static_cast<unsigned>((text[0] - '0') * 10 + text[1] - '0');
|
||||||
|
minute = static_cast<unsigned>((text[3] - '0') * 10 + text[4] - '0');
|
||||||
|
return hour < 24 && minute < 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
void erase_last_codepoint(std::string& text)
|
||||||
|
{
|
||||||
|
if (text.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto at = text.size() - 1;
|
||||||
|
while (at > 0 && (static_cast<unsigned char>(text[at]) & 0xc0U) == 0x80U) {
|
||||||
|
--at;
|
||||||
|
}
|
||||||
|
text.erase(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool printable_input(const std::string_view key) noexcept
|
||||||
|
{
|
||||||
|
if (key.empty() || key.front() == '\x1b') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return std::all_of(key.begin(), key.end(), [](const char character) {
|
||||||
|
const auto byte = static_cast<unsigned char>(character);
|
||||||
|
return byte >= 0x20U && byte != 0x7fU;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string single_line(std::string_view value)
|
||||||
|
{
|
||||||
|
std::string result{value};
|
||||||
|
std::replace(result.begin(), result.end(), '\n', ' ');
|
||||||
|
std::replace(result.begin(), result.end(), '\r', ' ');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DecodedCodepoint {
|
||||||
|
std::uint32_t value;
|
||||||
|
std::size_t length;
|
||||||
|
};
|
||||||
|
|
||||||
|
DecodedCodepoint decode_codepoint(const std::string_view text, const std::size_t at) noexcept
|
||||||
|
{
|
||||||
|
const auto first = static_cast<unsigned char>(text[at]);
|
||||||
|
if (first < 0x80U) return {first, 1};
|
||||||
|
std::uint32_t value = 0;
|
||||||
|
std::size_t length = 1;
|
||||||
|
if ((first & 0xe0U) == 0xc0U) {
|
||||||
|
value = first & 0x1fU;
|
||||||
|
length = 2;
|
||||||
|
} else if ((first & 0xf0U) == 0xe0U) {
|
||||||
|
value = first & 0x0fU;
|
||||||
|
length = 3;
|
||||||
|
} else if ((first & 0xf8U) == 0xf0U) {
|
||||||
|
value = first & 0x07U;
|
||||||
|
length = 4;
|
||||||
|
} else {
|
||||||
|
return {0xfffdU, 1};
|
||||||
|
}
|
||||||
|
if (at + length > text.size()) return {0xfffdU, 1};
|
||||||
|
for (std::size_t offset = 1; offset < length; ++offset) {
|
||||||
|
const auto byte = static_cast<unsigned char>(text[at + offset]);
|
||||||
|
if ((byte & 0xc0U) != 0x80U) return {0xfffdU, 1};
|
||||||
|
value = (value << 6U) | (byte & 0x3fU);
|
||||||
|
}
|
||||||
|
return {value, length};
|
||||||
|
}
|
||||||
|
|
||||||
|
int codepoint_width(const std::uint32_t value) noexcept
|
||||||
|
{
|
||||||
|
if (value == 0 || (value >= 0x0300U && value <= 0x036fU) ||
|
||||||
|
(value >= 0x1ab0U && value <= 0x1affU) ||
|
||||||
|
(value >= 0x1dc0U && value <= 0x1dffU) ||
|
||||||
|
(value >= 0x20d0U && value <= 0x20ffU) ||
|
||||||
|
(value >= 0xfe00U && value <= 0xfe0fU) ||
|
||||||
|
(value >= 0xfe20U && value <= 0xfe2fU)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ((value >= 0x1100U && value <= 0x115fU) || value == 0x2329U || value == 0x232aU ||
|
||||||
|
(value >= 0x2e80U && value <= 0xa4cfU) ||
|
||||||
|
(value >= 0xac00U && value <= 0xd7a3U) ||
|
||||||
|
(value >= 0xf900U && value <= 0xfaffU) ||
|
||||||
|
(value >= 0xfe10U && value <= 0xfe19U) ||
|
||||||
|
(value >= 0xfe30U && value <= 0xfe6fU) ||
|
||||||
|
(value >= 0xff00U && value <= 0xff60U) ||
|
||||||
|
(value >= 0xffe0U && value <= 0xffe6U) ||
|
||||||
|
(value >= 0x2600U && value <= 0x27bfU) ||
|
||||||
|
(value >= 0x1f300U && value <= 0x1faffU) ||
|
||||||
|
(value >= 0x20000U && value <= 0x3fffdU)) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string fit(std::string_view text, const int width)
|
||||||
|
{
|
||||||
|
if (width <= 0) return {};
|
||||||
|
std::string result;
|
||||||
|
int used = 0;
|
||||||
|
for (std::size_t at = 0; at < text.size();) {
|
||||||
|
const auto decoded = decode_codepoint(text, at);
|
||||||
|
const auto cell_width = codepoint_width(decoded.value);
|
||||||
|
if (used + cell_width > width) break;
|
||||||
|
result.append(text.substr(at, decoded.length));
|
||||||
|
at += decoded.length;
|
||||||
|
used += cell_width;
|
||||||
|
}
|
||||||
|
result.append(static_cast<std::size_t>(width - used), ' ');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string style(std::string text, const std::string_view code, const bool ansi)
|
||||||
|
{
|
||||||
|
if (!ansi || text.empty()) return text;
|
||||||
|
return "\x1b[" + std::string{code} + 'm' + std::move(text) + "\x1b[0m";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string interior_line(std::string content, const int width)
|
||||||
|
{
|
||||||
|
if (width <= 0) return {};
|
||||||
|
if (width == 1) return "│";
|
||||||
|
return "│" + fit(content, width - 2) + "│";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string styled_interior_line(std::string content, const int width,
|
||||||
|
const std::string_view code, const bool ansi)
|
||||||
|
{
|
||||||
|
if (width <= 0) return {};
|
||||||
|
if (width == 1) return "│";
|
||||||
|
return "│" + style(fit(content, width - 2), code, ansi) + "│";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string horizontal_line(const int width, const std::string_view left,
|
||||||
|
const std::string_view fill, const std::string_view right)
|
||||||
|
{
|
||||||
|
if (width <= 0) return {};
|
||||||
|
if (width == 1) return std::string{left};
|
||||||
|
std::string result{left};
|
||||||
|
for (int column = 0; column < width - 2; ++column) result += fill;
|
||||||
|
result += right;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
EventEditor::EventEditor(const Date selected_day)
|
||||||
|
{
|
||||||
|
if (!selected_day.ok()) {
|
||||||
|
throw std::invalid_argument("event editor requires a valid selected date");
|
||||||
|
}
|
||||||
|
original_.uid = make_uid();
|
||||||
|
result_ = original_;
|
||||||
|
values_[index_of(EditorField::start_date)] = format_date(selected_day);
|
||||||
|
values_[index_of(EditorField::start_time)] = "09:00";
|
||||||
|
values_[index_of(EditorField::end_date)] = format_date(selected_day);
|
||||||
|
values_[index_of(EditorField::end_time)] = "10:00";
|
||||||
|
}
|
||||||
|
|
||||||
|
EventEditor::EventEditor(const Event& event)
|
||||||
|
: original_{event}, result_{event}, all_day_{event.all_day}, editing_{true}
|
||||||
|
{
|
||||||
|
values_[index_of(EditorField::title)] = event.title;
|
||||||
|
values_[index_of(EditorField::start_date)] = format_date(local_date(event.start));
|
||||||
|
values_[index_of(EditorField::start_time)] = format_time(event.start);
|
||||||
|
values_[index_of(EditorField::end_date)] = format_date(local_date(event.end));
|
||||||
|
values_[index_of(EditorField::end_time)] = format_time(event.end);
|
||||||
|
values_[index_of(EditorField::location)] = event.location;
|
||||||
|
values_[index_of(EditorField::notes)] = event.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string_view EventEditor::field_value(const EditorField field) const noexcept
|
||||||
|
{
|
||||||
|
if (field == EditorField::all_day || field == EditorField::count) return {};
|
||||||
|
return values_[index_of(field)];
|
||||||
|
}
|
||||||
|
|
||||||
|
void EventEditor::move_focus(const int direction) noexcept
|
||||||
|
{
|
||||||
|
const auto current = static_cast<int>(index_of(focused_));
|
||||||
|
const auto count = static_cast<int>(field_count);
|
||||||
|
focused_ = static_cast<EditorField>((current + direction + count) % count);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorOutcome EventEditor::handle_key(const std::string_view key)
|
||||||
|
{
|
||||||
|
if (key == "\x1b") return EditorOutcome::cancel;
|
||||||
|
if (key == "\x13") return validate() ? EditorOutcome::submit : EditorOutcome::active;
|
||||||
|
if (key == "\t" || key == "\x1b[B" || key == "\x1b[C") {
|
||||||
|
move_focus(1);
|
||||||
|
return EditorOutcome::active;
|
||||||
|
}
|
||||||
|
if (key == "\x1b[Z" || key == "\x1b[A" || key == "\x1b[D") {
|
||||||
|
move_focus(-1);
|
||||||
|
return EditorOutcome::active;
|
||||||
|
}
|
||||||
|
if (key == "\r" || key == "\n") {
|
||||||
|
if (focused_ == EditorField::notes) {
|
||||||
|
values_[index_of(focused_)] += '\n';
|
||||||
|
} else {
|
||||||
|
move_focus(1);
|
||||||
|
}
|
||||||
|
return EditorOutcome::active;
|
||||||
|
}
|
||||||
|
if (focused_ == EditorField::all_day) {
|
||||||
|
if (key == " ") {
|
||||||
|
all_day_ = !all_day_;
|
||||||
|
if (all_day_) {
|
||||||
|
try {
|
||||||
|
const auto start = parse_date(values_[index_of(EditorField::start_date)]);
|
||||||
|
const auto end = parse_date(values_[index_of(EditorField::end_date)]);
|
||||||
|
if (start == end) {
|
||||||
|
values_[index_of(EditorField::end_date)] =
|
||||||
|
format_date(from_sys_days(to_sys_days(start) + std::chrono::days{1}));
|
||||||
|
}
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
// Leave malformed input untouched; submit validation will
|
||||||
|
// focus it and provide the specific correction message.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return EditorOutcome::active;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto& value = values_[index_of(focused_)];
|
||||||
|
if (key == "\x7f" || key == "\b") {
|
||||||
|
erase_last_codepoint(value);
|
||||||
|
error_.clear();
|
||||||
|
} else if (printable_input(key)) {
|
||||||
|
value.append(key);
|
||||||
|
error_.clear();
|
||||||
|
}
|
||||||
|
return EditorOutcome::active;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool EventEditor::validate()
|
||||||
|
{
|
||||||
|
const auto fail = [this](const EditorField field, std::string message) {
|
||||||
|
focused_ = field;
|
||||||
|
error_ = std::move(message);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const auto& title = values_[index_of(EditorField::title)];
|
||||||
|
if (title.find_first_not_of(" \t\r\n") == std::string::npos) {
|
||||||
|
return fail(EditorField::title, "Title is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Date start_day;
|
||||||
|
Date end_day;
|
||||||
|
try {
|
||||||
|
start_day = parse_date(values_[index_of(EditorField::start_date)]);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return fail(EditorField::start_date, "Start date must be a valid YYYY-MM-DD date.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
end_day = parse_date(values_[index_of(EditorField::end_date)]);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return fail(EditorField::end_date, "End date must be a valid YYYY-MM-DD date.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Event candidate = original_;
|
||||||
|
if (candidate.uid.empty()) candidate.uid = make_uid();
|
||||||
|
candidate.title = title;
|
||||||
|
candidate.location = values_[index_of(EditorField::location)];
|
||||||
|
candidate.description = values_[index_of(EditorField::notes)];
|
||||||
|
candidate.all_day = all_day_;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (all_day_) {
|
||||||
|
if (to_sys_days(end_day) <= to_sys_days(start_day)) {
|
||||||
|
return fail(EditorField::end_date,
|
||||||
|
"All-day end is exclusive and must be at least the next day.");
|
||||||
|
}
|
||||||
|
candidate.start = local_day_start(start_day);
|
||||||
|
candidate.end = local_day_start(end_day);
|
||||||
|
} else {
|
||||||
|
unsigned start_hour = 0;
|
||||||
|
unsigned start_minute = 0;
|
||||||
|
unsigned end_hour = 0;
|
||||||
|
unsigned end_minute = 0;
|
||||||
|
if (!parse_time(values_[index_of(EditorField::start_time)], start_hour, start_minute)) {
|
||||||
|
return fail(EditorField::start_time, "Start time must be a valid HH:MM time.");
|
||||||
|
}
|
||||||
|
if (!parse_time(values_[index_of(EditorField::end_time)], end_hour, end_minute)) {
|
||||||
|
return fail(EditorField::end_time, "End time must be a valid HH:MM time.");
|
||||||
|
}
|
||||||
|
candidate.start = make_local_time(start_day, start_hour, start_minute);
|
||||||
|
candidate.end = make_local_time(end_day, end_hour, end_minute);
|
||||||
|
if (candidate.end <= candidate.start) {
|
||||||
|
return fail(EditorField::end_time, "End must be after start.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return fail(EditorField::start_date,
|
||||||
|
"That local date and time cannot be represented in this time zone.");
|
||||||
|
}
|
||||||
|
|
||||||
|
result_ = std::move(candidate);
|
||||||
|
error_.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string EventEditor::render(const int requested_width, const int requested_height,
|
||||||
|
const bool ansi) const
|
||||||
|
{
|
||||||
|
const int width = std::max(0, requested_width);
|
||||||
|
const int height = std::max(0, requested_height);
|
||||||
|
if (height == 0) return {};
|
||||||
|
|
||||||
|
const auto top = horizontal_line(width, "┌", "─", "┐");
|
||||||
|
const auto rule = horizontal_line(width, "├", "─", "┤");
|
||||||
|
const auto bottom = horizontal_line(width, "└", "─", "┘");
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
lines.reserve(16);
|
||||||
|
lines.push_back(top);
|
||||||
|
lines.push_back(styled_interior_line(editing_ ? " NOCAL · EDIT APPOINTMENT"
|
||||||
|
: " NOCAL · NEW APPOINTMENT",
|
||||||
|
width, "1", ansi));
|
||||||
|
lines.push_back(styled_interior_line(" Tab/↓ next Shift-Tab/↑ back Space toggle",
|
||||||
|
width, "2", ansi));
|
||||||
|
lines.push_back(rule);
|
||||||
|
|
||||||
|
for (std::size_t index = 0; index < field_count; ++index) {
|
||||||
|
const auto field = static_cast<EditorField>(index);
|
||||||
|
const bool selected = focused_ == field;
|
||||||
|
std::string value;
|
||||||
|
if (field == EditorField::all_day) {
|
||||||
|
value = all_day_ ? "[x] spans whole days (end date is exclusive)"
|
||||||
|
: "[ ] timed appointment";
|
||||||
|
} else if (all_day_ && (field == EditorField::start_time || field == EditorField::end_time)) {
|
||||||
|
value = "— not used for all-day appointments —";
|
||||||
|
} else {
|
||||||
|
value = single_line(values_[index]);
|
||||||
|
if (selected) value += "_";
|
||||||
|
}
|
||||||
|
std::string row = selected ? " › " : " ";
|
||||||
|
row += fit(labels[index], 11);
|
||||||
|
row += " ";
|
||||||
|
row += value;
|
||||||
|
lines.push_back(styled_interior_line(std::move(row), width, selected ? "7" : "", ansi));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push_back(rule);
|
||||||
|
lines.push_back(interior_line(error_.empty() ? " Ready" : " ! " + error_, width));
|
||||||
|
lines.push_back(styled_interior_line(" Ctrl-S save Esc cancel", width, "1", ansi));
|
||||||
|
lines.push_back(bottom);
|
||||||
|
|
||||||
|
// Keep the active field visible on short terminals by selecting a window
|
||||||
|
// from the complete form, while retaining a top and bottom frame.
|
||||||
|
if (static_cast<int>(lines.size()) > height) {
|
||||||
|
std::vector<std::string> compact;
|
||||||
|
compact.reserve(static_cast<std::size_t>(height));
|
||||||
|
compact.push_back(top);
|
||||||
|
if (height > 2) {
|
||||||
|
const int available = height - 2;
|
||||||
|
const int selected_line = 4 + static_cast<int>(index_of(focused_));
|
||||||
|
const int maximum_start = static_cast<int>(lines.size()) - 1 - available;
|
||||||
|
const int start = std::clamp(selected_line - available / 2, 1,
|
||||||
|
std::max(1, maximum_start));
|
||||||
|
for (int offset = 0; offset < available; ++offset) {
|
||||||
|
compact.push_back(lines[static_cast<std::size_t>(start + offset)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (height > 1) compact.push_back(bottom);
|
||||||
|
lines = std::move(compact);
|
||||||
|
}
|
||||||
|
while (static_cast<int>(lines.size()) < height) {
|
||||||
|
lines.insert(lines.end() - 1, interior_line({}, width));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string frame;
|
||||||
|
for (int row = 0; row < height; ++row) {
|
||||||
|
if (row != 0) frame += '\n';
|
||||||
|
frame += lines[static_cast<std::size_t>(row)];
|
||||||
|
}
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nocal::tui
|
||||||
732
src/tui/Screen.cpp
Normal file
732
src/tui/Screen.cpp
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
#include "nocal/tui/Screen.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <ctime>
|
||||||
|
#include <map>
|
||||||
|
#include <sstream>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace nocal::tui {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using namespace std::chrono;
|
||||||
|
|
||||||
|
constexpr std::array<std::string_view, 12> month_names{
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December"};
|
||||||
|
constexpr std::array<std::string_view, 7> weekday_long{
|
||||||
|
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};
|
||||||
|
constexpr std::array<std::string_view, 7> weekday_short{
|
||||||
|
"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"};
|
||||||
|
|
||||||
|
std::uint32_t decode_codepoint(const std::string_view text, const std::size_t at,
|
||||||
|
std::size_t& length) {
|
||||||
|
const auto first = static_cast<unsigned char>(text[at]);
|
||||||
|
if (first < 0x80) {
|
||||||
|
length = 1;
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
int continuation = 0;
|
||||||
|
std::uint32_t result = 0;
|
||||||
|
if ((first & 0xe0) == 0xc0) {
|
||||||
|
continuation = 1;
|
||||||
|
result = first & 0x1f;
|
||||||
|
} else if ((first & 0xf0) == 0xe0) {
|
||||||
|
continuation = 2;
|
||||||
|
result = first & 0x0f;
|
||||||
|
} else if ((first & 0xf8) == 0xf0) {
|
||||||
|
continuation = 3;
|
||||||
|
result = first & 0x07;
|
||||||
|
} else {
|
||||||
|
length = 1;
|
||||||
|
return 0xfffd;
|
||||||
|
}
|
||||||
|
if (at + static_cast<std::size_t>(continuation) >= text.size()) {
|
||||||
|
length = 1;
|
||||||
|
return 0xfffd;
|
||||||
|
}
|
||||||
|
for (int offset = 1; offset <= continuation; ++offset) {
|
||||||
|
const auto byte = static_cast<unsigned char>(text[at + static_cast<std::size_t>(offset)]);
|
||||||
|
if ((byte & 0xc0) != 0x80) {
|
||||||
|
length = 1;
|
||||||
|
return 0xfffd;
|
||||||
|
}
|
||||||
|
result = (result << 6) | (byte & 0x3f);
|
||||||
|
}
|
||||||
|
length = static_cast<std::size_t>(continuation + 1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
int codepoint_width(const std::uint32_t value) {
|
||||||
|
if ((value >= 0x0300 && value <= 0x036f) || (value >= 0xfe00 && value <= 0xfe0f)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ((value >= 0x1100 && value <= 0x115f) || (value >= 0x2e80 && value <= 0xa4cf) ||
|
||||||
|
(value >= 0xac00 && value <= 0xd7a3) || (value >= 0xf900 && value <= 0xfaff) ||
|
||||||
|
(value >= 0x1f300 && value <= 0x1faff)) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int display_width(const std::string_view text) {
|
||||||
|
int width = 0;
|
||||||
|
for (std::size_t at = 0; at < text.size();) {
|
||||||
|
std::size_t length = 1;
|
||||||
|
const auto cp = decode_codepoint(text, at, length);
|
||||||
|
width += codepoint_width(cp);
|
||||||
|
at += length;
|
||||||
|
}
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string printable_text(const std::string_view text, const bool preserve_newlines = false) {
|
||||||
|
std::string result{text};
|
||||||
|
for (auto& character : result) {
|
||||||
|
const auto byte = static_cast<unsigned char>(character);
|
||||||
|
if ((byte < 0x20 && !(preserve_newlines && character == '\n')) || byte == 0x7f) {
|
||||||
|
character = ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string fit_text(const std::string_view text, const int requested_width,
|
||||||
|
const bool ellipsis = true) {
|
||||||
|
const int width = std::max(0, requested_width);
|
||||||
|
if (width == 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (display_width(text) <= width) {
|
||||||
|
std::string result{text};
|
||||||
|
result.append(static_cast<std::size_t>(width - display_width(text)), ' ');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int content_limit = std::max(0, width - (ellipsis && width > 1 ? 1 : 0));
|
||||||
|
std::string result;
|
||||||
|
int used = 0;
|
||||||
|
for (std::size_t at = 0; at < text.size();) {
|
||||||
|
std::size_t length = 1;
|
||||||
|
const auto cp = decode_codepoint(text, at, length);
|
||||||
|
const int cp_width = codepoint_width(cp);
|
||||||
|
if (used + cp_width > content_limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result.append(text.substr(at, length));
|
||||||
|
used += cp_width;
|
||||||
|
at += length;
|
||||||
|
}
|
||||||
|
if (ellipsis && width > 1) {
|
||||||
|
result += "…";
|
||||||
|
++used;
|
||||||
|
}
|
||||||
|
result.append(static_cast<std::size_t>(std::max(0, width - used)), ' ');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string centred(const std::string_view text, const int width) {
|
||||||
|
const int visible = std::min(display_width(text), width);
|
||||||
|
const int left = std::max(0, (width - visible) / 2);
|
||||||
|
std::string result(static_cast<std::size_t>(left), ' ');
|
||||||
|
result += fit_text(text, width - left);
|
||||||
|
return fit_text(result, width, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string styled(const std::string_view text, const std::string_view code, const bool ansi) {
|
||||||
|
if (!ansi || code.empty()) {
|
||||||
|
return std::string{text};
|
||||||
|
}
|
||||||
|
return "\x1b[" + std::string{code} + "m" + std::string{text} + "\x1b[0m";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string two_digits(const unsigned value) {
|
||||||
|
std::array<char, 3> result{'0', '0', '\0'};
|
||||||
|
result[0] = static_cast<char>('0' + (value / 10) % 10);
|
||||||
|
result[1] = static_cast<char>('0' + value % 10);
|
||||||
|
return std::string{result.data(), 2};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string iso_date(const year_month_day day) {
|
||||||
|
return std::to_string(static_cast<int>(day.year())) + "-" +
|
||||||
|
two_digits(static_cast<unsigned>(day.month())) + "-" +
|
||||||
|
two_digits(static_cast<unsigned>(day.day()));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string event_label(const CalendarItem& item, const int width) {
|
||||||
|
const auto title = item.title.empty() ? std::string{"(untitled)"} : printable_text(item.title);
|
||||||
|
if (item.all_day || !item.time_of_day) {
|
||||||
|
return fit_text("• " + title, width);
|
||||||
|
}
|
||||||
|
const auto total = item.time_of_day->count();
|
||||||
|
const auto hour = static_cast<unsigned>((total / 60 + 24) % 24);
|
||||||
|
const auto minute = static_cast<unsigned>((total % 60 + 60) % 60);
|
||||||
|
if (width < 8) {
|
||||||
|
return fit_text(title, width);
|
||||||
|
}
|
||||||
|
return fit_text(two_digits(hour) + ":" + two_digits(minute) + " " + title, width);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string event_label(const CalendarItem& item, const int width, const bool focused) {
|
||||||
|
if (!focused) return event_label(item, width);
|
||||||
|
if (width <= 2) return fit_text("›", width);
|
||||||
|
auto label = event_label(item, width - 2);
|
||||||
|
return fit_text("› " + label, width, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string border(const std::vector<int>& widths, const char* left,
|
||||||
|
const char* join, const char* right) {
|
||||||
|
std::string result{left};
|
||||||
|
for (std::size_t column = 0; column < widths.size(); ++column) {
|
||||||
|
for (int position = 0; position < widths[column]; ++position) {
|
||||||
|
result += "─";
|
||||||
|
}
|
||||||
|
result += column + 1 == widths.size() ? right : join;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
sys_days monday_on_or_before(const sys_days date) {
|
||||||
|
const weekday day{date};
|
||||||
|
const auto offset = (day.iso_encoding() + 6) % 7;
|
||||||
|
return date - days{offset};
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocalMoment {
|
||||||
|
year_month_day date;
|
||||||
|
minutes time_of_day;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string clock_time(const minutes value) {
|
||||||
|
const auto total = value.count();
|
||||||
|
const auto hour = static_cast<unsigned>((total / 60 + 24) % 24);
|
||||||
|
const auto minute = static_cast<unsigned>((total % 60 + 60) % 60);
|
||||||
|
return two_digits(hour) + ":" + two_digits(minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string friendly_date(const year_month_day value) {
|
||||||
|
if (!value.ok()) return {};
|
||||||
|
const auto weekday_index = weekday{sys_days{value}}.iso_encoding() - 1;
|
||||||
|
return std::string{weekday_long[weekday_index]} + ", " +
|
||||||
|
std::to_string(static_cast<unsigned>(value.day())) + " " +
|
||||||
|
std::string{month_names[static_cast<unsigned>(value.month()) - 1]} + " " +
|
||||||
|
std::to_string(static_cast<int>(value.year()));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> wrap_text(const std::string_view text, const int requested_width) {
|
||||||
|
const int width = std::max(1, requested_width);
|
||||||
|
std::vector<std::string> result;
|
||||||
|
std::size_t paragraph_start = 0;
|
||||||
|
while (paragraph_start <= text.size()) {
|
||||||
|
const auto paragraph_end = text.find('\n', paragraph_start);
|
||||||
|
const auto paragraph = text.substr(paragraph_start,
|
||||||
|
paragraph_end == std::string_view::npos ? text.size() - paragraph_start
|
||||||
|
: paragraph_end - paragraph_start);
|
||||||
|
if (paragraph.empty()) {
|
||||||
|
result.emplace_back();
|
||||||
|
} else {
|
||||||
|
std::string line;
|
||||||
|
std::size_t at = 0;
|
||||||
|
while (at < paragraph.size()) {
|
||||||
|
while (at < paragraph.size() && paragraph[at] == ' ') ++at;
|
||||||
|
const auto word_end = paragraph.find(' ', at);
|
||||||
|
const auto word = paragraph.substr(at,
|
||||||
|
word_end == std::string_view::npos ? paragraph.size() - at : word_end - at);
|
||||||
|
if (!word.empty() && display_width(word) > width) {
|
||||||
|
if (!line.empty()) {
|
||||||
|
result.push_back(line);
|
||||||
|
line.clear();
|
||||||
|
}
|
||||||
|
std::size_t word_at = 0;
|
||||||
|
while (word_at < word.size()) {
|
||||||
|
std::string part;
|
||||||
|
int used = 0;
|
||||||
|
while (word_at < word.size()) {
|
||||||
|
std::size_t length = 1;
|
||||||
|
const auto cp = decode_codepoint(word, word_at, length);
|
||||||
|
const auto cp_width = codepoint_width(cp);
|
||||||
|
if (used + cp_width > width) {
|
||||||
|
if (used == 0) {
|
||||||
|
part.append(word.substr(word_at, length));
|
||||||
|
word_at += length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
part.append(word.substr(word_at, length));
|
||||||
|
word_at += length;
|
||||||
|
used += cp_width;
|
||||||
|
}
|
||||||
|
if (word_at < word.size() || !part.empty()) result.push_back(part);
|
||||||
|
}
|
||||||
|
} else if (!word.empty() &&
|
||||||
|
display_width(line) + (line.empty() ? 0 : 1) + display_width(word) <= width) {
|
||||||
|
if (!line.empty()) line += ' ';
|
||||||
|
line += word;
|
||||||
|
} else if (!word.empty()) {
|
||||||
|
result.push_back(line);
|
||||||
|
line = std::string{word};
|
||||||
|
}
|
||||||
|
if (word_end == std::string_view::npos) break;
|
||||||
|
at = word_end + 1;
|
||||||
|
}
|
||||||
|
if (!line.empty()) result.push_back(line);
|
||||||
|
}
|
||||||
|
if (paragraph_end == std::string_view::npos) break;
|
||||||
|
paragraph_start = paragraph_end + 1;
|
||||||
|
}
|
||||||
|
if (result.empty()) result.emplace_back();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarItem* focused_item(std::span<const CalendarItem> items, const ScreenState& state) {
|
||||||
|
if (!state.focused_event_id) return nullptr;
|
||||||
|
const auto on_selected_day = std::find_if(items.begin(), items.end(), [&](const auto& item) {
|
||||||
|
return item.id == *state.focused_event_id && item.day == state.selected_day;
|
||||||
|
});
|
||||||
|
if (on_selected_day != items.end()) return &*on_selected_day;
|
||||||
|
const auto anywhere = std::find_if(items.begin(), items.end(), [&](const auto& item) {
|
||||||
|
return item.id == *state.focused_event_id;
|
||||||
|
});
|
||||||
|
return anywhere == items.end() ? nullptr : &*anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool calendar_item_less(const CalendarItem* left, const CalendarItem* right) {
|
||||||
|
const auto true_all_day = [](const CalendarItem& item) {
|
||||||
|
return item.detail_all_day || (item.detail_title.empty() && item.all_day);
|
||||||
|
};
|
||||||
|
const bool left_all_day = true_all_day(*left);
|
||||||
|
const bool right_all_day = true_all_day(*right);
|
||||||
|
if (left_all_day != right_all_day) return left_all_day;
|
||||||
|
const auto left_time = left->detail_start_time_of_day
|
||||||
|
? left->detail_start_time_of_day : left->time_of_day;
|
||||||
|
const auto right_time = right->detail_start_time_of_day
|
||||||
|
? right->detail_start_time_of_day : right->time_of_day;
|
||||||
|
return left_time.value_or(minutes{-1}) < right_time.value_or(minutes{-1});
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string detail_frame(std::span<const CalendarItem> items, const ScreenState& state,
|
||||||
|
const CalendarItem& focused) {
|
||||||
|
const int width = std::max(1, state.width);
|
||||||
|
const int height = std::max(1, state.height);
|
||||||
|
if (width < 4 || height < 4) {
|
||||||
|
std::vector<std::string> tiny(static_cast<std::size_t>(height), std::string{});
|
||||||
|
const auto& tiny_title = focused.detail_title.empty() ? focused.title : focused.detail_title;
|
||||||
|
tiny[0] = fit_text(tiny_title.empty() ? "(untitled)" : printable_text(tiny_title), width);
|
||||||
|
std::ostringstream output;
|
||||||
|
for (int line = 0; line < height; ++line) {
|
||||||
|
if (line != 0) output << '\n';
|
||||||
|
output << tiny[static_cast<std::size_t>(line)];
|
||||||
|
}
|
||||||
|
return output.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<const CalendarItem*> selected_items;
|
||||||
|
for (const auto& item : items) {
|
||||||
|
if (item.day == state.selected_day) selected_items.push_back(&item);
|
||||||
|
}
|
||||||
|
std::stable_sort(selected_items.begin(), selected_items.end(), calendar_item_less);
|
||||||
|
const auto current = std::find_if(selected_items.begin(), selected_items.end(),
|
||||||
|
[&](const auto* item) { return item->id == focused.id; });
|
||||||
|
const auto ordinal = current == selected_items.end()
|
||||||
|
? 1U : static_cast<unsigned>(current - selected_items.begin() + 1);
|
||||||
|
const auto total = std::max<std::size_t>(1, selected_items.size());
|
||||||
|
|
||||||
|
const int inner = width - 2;
|
||||||
|
std::vector<std::pair<std::string, std::string>> content;
|
||||||
|
const auto add = [&](std::string text, std::string style = {}) {
|
||||||
|
content.emplace_back(fit_text(text, inner, false), std::move(style));
|
||||||
|
};
|
||||||
|
add(" APPOINTMENT", "2;1");
|
||||||
|
const auto& title = focused.detail_title.empty() ? focused.title : focused.detail_title;
|
||||||
|
add(" " + (title.empty() ? std::string{"(untitled)"} : printable_text(title)), "1");
|
||||||
|
add("");
|
||||||
|
|
||||||
|
const auto start_day = focused.start_day.ok() ? focused.start_day : focused.day;
|
||||||
|
const auto end_day = focused.end_day.ok() ? focused.end_day : start_day;
|
||||||
|
add(" Date " + friendly_date(start_day));
|
||||||
|
if (end_day != start_day) add(" Ends " + friendly_date(end_day));
|
||||||
|
const bool event_all_day = focused.detail_all_day ||
|
||||||
|
(focused.detail_title.empty() && focused.all_day);
|
||||||
|
const auto start_time = focused.detail_start_time_of_day
|
||||||
|
? focused.detail_start_time_of_day : focused.time_of_day;
|
||||||
|
if (event_all_day || !start_time) {
|
||||||
|
add(" Time All day");
|
||||||
|
} else {
|
||||||
|
auto value = clock_time(*start_time);
|
||||||
|
if (focused.end_time_of_day) value += " – " + clock_time(*focused.end_time_of_day);
|
||||||
|
add(" Time " + value + " local");
|
||||||
|
}
|
||||||
|
if (!focused.location.empty()) add(" Location " + printable_text(focused.location));
|
||||||
|
add("");
|
||||||
|
if (!focused.description.empty()) {
|
||||||
|
add(" NOTES", "2;1");
|
||||||
|
for (const auto& line : wrap_text(printable_text(focused.description, true),
|
||||||
|
std::max(1, inner - 2))) {
|
||||||
|
add(" " + line);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add(" No notes for this appointment.", "2;3");
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto footer = " ↑↓/Tab browse e edit d delete u undo Ctrl-R redo Esc back " +
|
||||||
|
std::to_string(ordinal) + "/" + std::to_string(total) + " ";
|
||||||
|
const int body_rows = height - 2;
|
||||||
|
std::ostringstream output;
|
||||||
|
output << border({inner}, "┌", "", "┐") << '\n';
|
||||||
|
for (int line = 0; line < body_rows; ++line) {
|
||||||
|
std::string value(static_cast<std::size_t>(inner), ' ');
|
||||||
|
std::string style;
|
||||||
|
if (line == body_rows - 1) {
|
||||||
|
value = fit_text(footer, inner);
|
||||||
|
style = "7";
|
||||||
|
} else if (line < static_cast<int>(content.size())) {
|
||||||
|
value = content[static_cast<std::size_t>(line)].first;
|
||||||
|
style = content[static_cast<std::size_t>(line)].second;
|
||||||
|
}
|
||||||
|
output << "│" << styled(value, style, state.ansi) << "│\n";
|
||||||
|
}
|
||||||
|
output << border({inner}, "└", "", "┘");
|
||||||
|
return output.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string delete_confirmation_frame(const ScreenState& state, const CalendarItem& focused) {
|
||||||
|
const int width = std::max(1, state.width);
|
||||||
|
const int height = std::max(1, state.height);
|
||||||
|
if (width < 4 || height < 4) {
|
||||||
|
return fit_text("Delete? y/n", width);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int inner = width - 2;
|
||||||
|
const auto& detail_title = focused.detail_title.empty() ? focused.title : focused.detail_title;
|
||||||
|
const auto title = detail_title.empty() ? std::string{"(untitled)"}
|
||||||
|
: printable_text(detail_title);
|
||||||
|
const auto question = fit_text(" Delete “" + title + "”?", inner);
|
||||||
|
const auto controls = fit_text(" y confirm n/Esc cancel", inner);
|
||||||
|
const int question_line = std::max(1, height / 2 - 1);
|
||||||
|
std::ostringstream output;
|
||||||
|
output << border({inner}, "┌", "", "┐") << '\n';
|
||||||
|
for (int line = 0; line < height - 2; ++line) {
|
||||||
|
std::string content(static_cast<std::size_t>(inner), ' ');
|
||||||
|
std::string style;
|
||||||
|
if (line == question_line) {
|
||||||
|
content = question;
|
||||||
|
style = "1;31";
|
||||||
|
} else if (line == question_line + 2) {
|
||||||
|
content = controls;
|
||||||
|
style = "7";
|
||||||
|
} else if (!state.notification.empty() && line == question_line + 3) {
|
||||||
|
content = fit_text(" " + printable_text(state.notification, true), inner);
|
||||||
|
style = state.notification_is_error ? "1;31" : "1;32";
|
||||||
|
}
|
||||||
|
output << "│" << styled(content, style, state.ansi) << "│\n";
|
||||||
|
}
|
||||||
|
output << border({inner}, "└", "", "┘");
|
||||||
|
return output.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalMoment to_local(const system_clock::time_point point) {
|
||||||
|
const auto value = system_clock::to_time_t(point);
|
||||||
|
std::tm local{};
|
||||||
|
::localtime_r(&value, &local);
|
||||||
|
return {
|
||||||
|
year{local.tm_year + 1900} / month{static_cast<unsigned>(local.tm_mon + 1)} /
|
||||||
|
day{static_cast<unsigned>(local.tm_mday)},
|
||||||
|
hours{local.tm_hour} + minutes{local.tm_min},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
|
||||||
|
const int width = std::max(1, state.width);
|
||||||
|
const int height = std::max(1, state.height);
|
||||||
|
const auto first = sys_days{state.visible_month / day{1}};
|
||||||
|
const auto grid_start = monday_on_or_before(first);
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
const auto title = std::string{month_names[static_cast<unsigned>(state.visible_month.month()) - 1]} +
|
||||||
|
" " + std::to_string(static_cast<int>(state.visible_month.year()));
|
||||||
|
lines.push_back(styled(centred(title, width), "1", state.ansi));
|
||||||
|
std::array<int, 7> tokens{};
|
||||||
|
for (int column = 0; column < 7; ++column) {
|
||||||
|
tokens[static_cast<std::size_t>(column)] =
|
||||||
|
width / 7 + (column < width % 7 ? 1 : 0);
|
||||||
|
}
|
||||||
|
std::string weekdays;
|
||||||
|
for (std::size_t column = 0; column < weekday_short.size(); ++column) {
|
||||||
|
weekdays += centred(weekday_short[column], tokens[column]);
|
||||||
|
}
|
||||||
|
lines.push_back(styled(weekdays, "2;1", state.ansi));
|
||||||
|
for (int week = 0; week < 6; ++week) {
|
||||||
|
std::string week_line;
|
||||||
|
for (int column = 0; column < 7; ++column) {
|
||||||
|
const auto date = year_month_day{grid_start + days{week * 7 + column}};
|
||||||
|
const auto count = std::count_if(items.begin(), items.end(),
|
||||||
|
[date](const auto& item) { return item.day == date; });
|
||||||
|
std::string label = std::to_string(static_cast<unsigned>(date.day()));
|
||||||
|
const auto token = tokens[static_cast<std::size_t>(column)];
|
||||||
|
if (count > 0 && token >= 3) {
|
||||||
|
label += "•";
|
||||||
|
}
|
||||||
|
auto text = centred(label, token);
|
||||||
|
if (date == state.selected_day) {
|
||||||
|
text = styled(text, date == state.today ? "7;1;36" : "7;1", state.ansi);
|
||||||
|
} else if (date == state.today) {
|
||||||
|
text = styled(text, "1;36", state.ansi);
|
||||||
|
} else if (date.month() != state.visible_month.month()) {
|
||||||
|
text = styled(text, "2", state.ansi);
|
||||||
|
}
|
||||||
|
week_line += text;
|
||||||
|
}
|
||||||
|
lines.push_back(std::move(week_line));
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto focused = focused_item(items, state);
|
||||||
|
if (height > 9 && focused != nullptr) {
|
||||||
|
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
|
||||||
|
: printable_text(focused->title);
|
||||||
|
lines.push_back(styled(fit_text("› " + focus_title, width),
|
||||||
|
"7;1", state.ansi));
|
||||||
|
}
|
||||||
|
while (static_cast<int>(lines.size()) < height - 1) {
|
||||||
|
lines.emplace_back(static_cast<std::size_t>(width), ' ');
|
||||||
|
}
|
||||||
|
std::string controls;
|
||||||
|
if (!state.notification.empty()) {
|
||||||
|
controls = printable_text(state.notification, true);
|
||||||
|
} else if (focused == nullptr) {
|
||||||
|
controls = "a add u undo Ctrl-R redo ? help n/p month t today q quit";
|
||||||
|
} else {
|
||||||
|
controls = "Tab appointment Enter open e edit d delete u undo Ctrl-R redo";
|
||||||
|
}
|
||||||
|
if (static_cast<int>(lines.size()) < height) {
|
||||||
|
const auto style = state.notification.empty() ? "2"
|
||||||
|
: state.notification_is_error ? "1;31" : "1;32";
|
||||||
|
lines.push_back(styled(fit_text(controls, width), style, state.ansi));
|
||||||
|
}
|
||||||
|
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
|
||||||
|
|
||||||
|
std::ostringstream output;
|
||||||
|
for (int line = 0; line < height; ++line) {
|
||||||
|
if (line != 0) output << '\n';
|
||||||
|
output << lines[static_cast<std::size_t>(line)];
|
||||||
|
}
|
||||||
|
return output.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Action decode_key(const std::string_view sequence) noexcept {
|
||||||
|
if (sequence == "h" || sequence == "\x1b[D" || sequence == "\x1bOD") return Action::left;
|
||||||
|
if (sequence == "l" || sequence == "\x1b[C" || sequence == "\x1bOC") return Action::right;
|
||||||
|
if (sequence == "k" || sequence == "\x1b[A" || sequence == "\x1bOA") return Action::up;
|
||||||
|
if (sequence == "j" || sequence == "\x1b[B" || sequence == "\x1bOB") return Action::down;
|
||||||
|
if (sequence == "n" || sequence == "\x1b[6~") return Action::next_month;
|
||||||
|
if (sequence == "p" || sequence == "\x1b[5~") return Action::previous_month;
|
||||||
|
if (sequence == "t") return Action::today;
|
||||||
|
if (sequence == "?") return Action::toggle_help;
|
||||||
|
if (sequence == "\x1b[Z") return Action::previous_event;
|
||||||
|
if (sequence == "\t" || sequence == "]") return Action::next_event;
|
||||||
|
if (sequence == "[") return Action::previous_event;
|
||||||
|
if (sequence == "\r" || sequence == "\n") return Action::select;
|
||||||
|
if (sequence == "\x1b" || sequence == "\x7f") return Action::back;
|
||||||
|
if (sequence == "a") return Action::add_event;
|
||||||
|
if (sequence == "e") return Action::edit_event;
|
||||||
|
if (sequence == "d") return Action::delete_event;
|
||||||
|
if (sequence == "u") return Action::undo;
|
||||||
|
if (sequence == "\x12") return Action::redo;
|
||||||
|
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
|
||||||
|
return Action::none;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string render_month(std::span<const CalendarItem> items, const ScreenState& state) {
|
||||||
|
const auto focused = focused_item(items, state);
|
||||||
|
if (state.confirm_delete && focused != nullptr) {
|
||||||
|
return delete_confirmation_frame(state, *focused);
|
||||||
|
}
|
||||||
|
if (state.show_event_details && focused != nullptr) {
|
||||||
|
return detail_frame(items, state, *focused);
|
||||||
|
}
|
||||||
|
if (state.width < 35 || state.height < 16) {
|
||||||
|
return compact_frame(items, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int width = state.width;
|
||||||
|
const int inner_total = width - 8; // eight vertical border glyphs
|
||||||
|
std::vector<int> widths(7, inner_total / 7);
|
||||||
|
for (int column = 0; column < inner_total % 7; ++column) {
|
||||||
|
++widths[static_cast<std::size_t>(column)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const int content_rows = std::max(6, state.height - 10);
|
||||||
|
std::vector<int> row_heights(6, content_rows / 6);
|
||||||
|
for (int row = 0; row < content_rows % 6; ++row) {
|
||||||
|
++row_heights[static_cast<std::size_t>(row)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto first = sys_days{state.visible_month / day{1}};
|
||||||
|
const auto grid_start = monday_on_or_before(first);
|
||||||
|
std::map<sys_days, std::vector<const CalendarItem*>> by_day;
|
||||||
|
for (const auto& item : items) {
|
||||||
|
if (item.day.ok()) {
|
||||||
|
by_day[sys_days{item.day}].push_back(&item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (auto& [_, day_items] : by_day) {
|
||||||
|
std::stable_sort(day_items.begin(), day_items.end(), calendar_item_less);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto month_index = static_cast<unsigned>(state.visible_month.month()) - 1;
|
||||||
|
const auto title = "‹ " + std::string{month_names[month_index]} + " " +
|
||||||
|
std::to_string(static_cast<int>(state.visible_month.year())) + " ›";
|
||||||
|
std::ostringstream output;
|
||||||
|
output << styled(centred(title, width), "1", state.ansi) << '\n';
|
||||||
|
output << ' ';
|
||||||
|
for (std::size_t column = 0; column < widths.size(); ++column) {
|
||||||
|
const auto name = widths[column] >= 9 ? weekday_long[column] : weekday_short[column];
|
||||||
|
output << styled(centred(name, widths[column]), "2;1", state.ansi);
|
||||||
|
output << (column + 1 == widths.size() ? ' ' : ' ');
|
||||||
|
}
|
||||||
|
output << '\n' << border(widths, "┌", "┬", "┐") << '\n';
|
||||||
|
|
||||||
|
for (int week = 0; week < 6; ++week) {
|
||||||
|
for (int line = 0; line < row_heights[static_cast<std::size_t>(week)]; ++line) {
|
||||||
|
output << "│";
|
||||||
|
for (int column = 0; column < 7; ++column) {
|
||||||
|
const auto date_days = grid_start + days{week * 7 + column};
|
||||||
|
const auto date = year_month_day{date_days};
|
||||||
|
const auto cell_width = widths[static_cast<std::size_t>(column)];
|
||||||
|
std::string cell;
|
||||||
|
std::string style;
|
||||||
|
if (line == 0) {
|
||||||
|
cell = " " + std::to_string(static_cast<unsigned>(date.day()));
|
||||||
|
cell = fit_text(cell, cell_width, false);
|
||||||
|
if (date == state.selected_day) style = date == state.today ? "7;1;36" : "7;1";
|
||||||
|
else if (date == state.today) style = "1;36";
|
||||||
|
else if (date.month() != state.visible_month.month()) style = "2";
|
||||||
|
} else {
|
||||||
|
const auto found = by_day.find(date_days);
|
||||||
|
const auto count = found == by_day.end() ? 0U : found->second.size();
|
||||||
|
const auto appointment_line = static_cast<std::size_t>(line - 1);
|
||||||
|
const auto slots = static_cast<std::size_t>(row_heights[static_cast<std::size_t>(week)] - 1);
|
||||||
|
const bool focused_on_day = found != by_day.end() && date == state.selected_day &&
|
||||||
|
state.focused_event_id &&
|
||||||
|
std::any_of(found->second.begin(), found->second.end(),
|
||||||
|
[&](const auto* item) {
|
||||||
|
return item->id == *state.focused_event_id;
|
||||||
|
});
|
||||||
|
// With only one appointment row, focus is more useful than
|
||||||
|
// an overflow counter. Taller cells retain both.
|
||||||
|
const bool show_overflow = count > slots &&
|
||||||
|
!(slots == 1 && focused_on_day);
|
||||||
|
const auto event_capacity = show_overflow && slots > 0 ? slots - 1 : slots;
|
||||||
|
std::size_t window_start = 0;
|
||||||
|
if (found != by_day.end() && event_capacity > 0 && date == state.selected_day &&
|
||||||
|
state.focused_event_id) {
|
||||||
|
const auto focus = std::find_if(found->second.begin(), found->second.end(),
|
||||||
|
[&](const auto* item) { return item->id == *state.focused_event_id; });
|
||||||
|
if (focus != found->second.end()) {
|
||||||
|
const auto focus_index = static_cast<std::size_t>(focus - found->second.begin());
|
||||||
|
if (focus_index >= event_capacity) window_start = focus_index - event_capacity + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (show_overflow && appointment_line + 1 == slots) {
|
||||||
|
cell = fit_text(" +" + std::to_string(count - event_capacity) + " hidden", cell_width);
|
||||||
|
style = "2";
|
||||||
|
} else if (appointment_line < event_capacity &&
|
||||||
|
window_start + appointment_line < count) {
|
||||||
|
const auto* item = found->second[window_start + appointment_line];
|
||||||
|
const bool is_focused = date == state.selected_day && state.focused_event_id &&
|
||||||
|
item->id == *state.focused_event_id;
|
||||||
|
cell = event_label(*item, cell_width, is_focused);
|
||||||
|
if (is_focused) style = "7;1";
|
||||||
|
} else {
|
||||||
|
cell.assign(static_cast<std::size_t>(cell_width), ' ');
|
||||||
|
}
|
||||||
|
if (date.month() != state.visible_month.month() && style.empty()) style = "2";
|
||||||
|
}
|
||||||
|
output << styled(cell, style, state.ansi) << "│";
|
||||||
|
}
|
||||||
|
output << '\n';
|
||||||
|
}
|
||||||
|
output << (week == 5 ? border(widths, "└", "┴", "┘")
|
||||||
|
: border(widths, "├", "┼", "┤"))
|
||||||
|
<< '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto selected_count = by_day.contains(sys_days{state.selected_day})
|
||||||
|
? by_day.at(sys_days{state.selected_day}).size() : 0U;
|
||||||
|
std::string status;
|
||||||
|
if (state.show_help) {
|
||||||
|
status = " arrows/hjkl day Tab/⇧Tab appointment Enter read a add e edit d delete u undo Ctrl-R redo n/p month t today ? close q quit";
|
||||||
|
} else if (!state.notification.empty()) {
|
||||||
|
status = " " + printable_text(state.notification, true);
|
||||||
|
} else if (focused != nullptr) {
|
||||||
|
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
|
||||||
|
: printable_text(focused->title);
|
||||||
|
status = " " + iso_date(state.selected_day) + " " +
|
||||||
|
focus_title + " Tab next Enter read e edit d delete u undo Ctrl-R redo";
|
||||||
|
} else {
|
||||||
|
status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) +
|
||||||
|
(selected_count == 1 ? " appointment" : " appointments") +
|
||||||
|
(selected_count > 0 ? " Tab focus" : "") +
|
||||||
|
" a add u undo Ctrl-R redo ? help q quit";
|
||||||
|
}
|
||||||
|
const auto status_style = !state.notification.empty()
|
||||||
|
? (state.notification_is_error ? "1;31" : "1;32")
|
||||||
|
: focused != nullptr ? "7" : "2";
|
||||||
|
output << styled(fit_text(status, width), status_style, state.ansi);
|
||||||
|
return output.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string render_month(const std::span<const Event> events, const ScreenState& state) {
|
||||||
|
std::vector<CalendarItem> items;
|
||||||
|
items.reserve(events.size() * 2);
|
||||||
|
std::map<std::string, std::size_t> uid_counts;
|
||||||
|
for (const auto& event : events) {
|
||||||
|
if (!event.uid.empty()) ++uid_counts[event.uid];
|
||||||
|
}
|
||||||
|
const auto visible_start = monday_on_or_before(sys_days{state.visible_month / day{1}});
|
||||||
|
const auto visible_end = visible_start + days{41};
|
||||||
|
for (std::size_t index = 0; index < events.size(); ++index) {
|
||||||
|
const auto& event = events[index];
|
||||||
|
const auto identity = !event.uid.empty() && uid_counts[event.uid] == 1
|
||||||
|
? event.uid : "@nocal:" + std::to_string(index);
|
||||||
|
const auto local_start = to_local(event.start);
|
||||||
|
const auto local_finish = to_local(event.end);
|
||||||
|
const auto one_second = duration_cast<system_clock::duration>(seconds{1});
|
||||||
|
const auto final_instant = event.end > event.start
|
||||||
|
? event.end - std::min(event.end - event.start, one_second)
|
||||||
|
: event.start;
|
||||||
|
const auto local_end = to_local(final_instant);
|
||||||
|
const auto event_start = sys_days{local_start.date};
|
||||||
|
const auto event_last = sys_days{local_end.date};
|
||||||
|
const auto first_day = std::max(event_start, visible_start);
|
||||||
|
const auto last_day = std::min(event_last, visible_end);
|
||||||
|
for (auto date = first_day; date <= last_day; date += days{1}) {
|
||||||
|
const bool continuation = date != event_start;
|
||||||
|
items.push_back(CalendarItem{
|
||||||
|
.id = identity,
|
||||||
|
.title = continuation ? "↳ " + event.title : event.title,
|
||||||
|
.day = year_month_day{date},
|
||||||
|
.time_of_day = event.all_day || continuation
|
||||||
|
? std::nullopt
|
||||||
|
: std::optional{local_start.time_of_day},
|
||||||
|
.all_day = event.all_day || continuation,
|
||||||
|
.end_time_of_day = event.all_day ? std::nullopt
|
||||||
|
: std::optional{local_finish.time_of_day},
|
||||||
|
.start_day = local_start.date,
|
||||||
|
.end_day = event.all_day ? local_end.date : local_finish.date,
|
||||||
|
.location = event.location,
|
||||||
|
.description = event.description,
|
||||||
|
.detail_title = event.title,
|
||||||
|
.detail_all_day = event.all_day,
|
||||||
|
.detail_start_time_of_day = event.all_day
|
||||||
|
? std::nullopt
|
||||||
|
: std::optional{local_start.time_of_day},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return render_month(std::span<const CalendarItem>{items}, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nocal::tui
|
||||||
112
src/tui/Terminal.cpp
Normal file
112
src/tui/Terminal.cpp
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#include "nocal/tui/Terminal.hpp"
|
||||||
|
|
||||||
|
#include <cerrno>
|
||||||
|
#include <csignal>
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
#include <sys/ioctl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace nocal::tui {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
volatile std::sig_atomic_t resize_pending = 0;
|
||||||
|
|
||||||
|
extern "C" void mark_resize(int) noexcept {
|
||||||
|
resize_pending = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
struct ResizeWatcher::sigaction_storage {
|
||||||
|
struct sigaction previous {};
|
||||||
|
bool installed{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
TerminalSize terminal_size(const int fd) noexcept {
|
||||||
|
struct winsize size {};
|
||||||
|
if (::ioctl(fd, TIOCGWINSZ, &size) == 0 && size.ws_col > 0 && size.ws_row > 0) {
|
||||||
|
return {static_cast<int>(size.ws_col), static_cast<int>(size.ws_row)};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool write_terminal(const int fd, const std::string_view bytes) noexcept {
|
||||||
|
std::size_t written = 0;
|
||||||
|
while (written < bytes.size()) {
|
||||||
|
const auto result = ::write(fd, bytes.data() + written, bytes.size() - written);
|
||||||
|
if (result > 0) {
|
||||||
|
written += static_cast<std::size_t>(result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (result < 0 && errno == EINTR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
AlternateScreen::AlternateScreen(const int output_fd)
|
||||||
|
: output_fd_(output_fd), active_(::isatty(output_fd) != 0) {
|
||||||
|
if (active_) {
|
||||||
|
// Save/restore the title too: popup terminals in Hyprland often retain it.
|
||||||
|
(void)write_terminal(output_fd_, "\x1b[?1049h\x1b[?25l\x1b[0m");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlternateScreen::~AlternateScreen() {
|
||||||
|
if (active_) {
|
||||||
|
(void)write_terminal(output_fd_, "\x1b[0m\x1b[?25h\x1b[?1049l");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RawInput::RawInput(const int input_fd) : input_fd_(input_fd) {
|
||||||
|
if (::isatty(input_fd_) == 0 || ::tcgetattr(input_fd_, &original_) != 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto raw = original_;
|
||||||
|
raw.c_iflag &= static_cast<tcflag_t>(~(BRKINT | ICRNL | INPCK | ISTRIP | IXON));
|
||||||
|
// Leave output processing enabled: frames use '\n' and benefit from the
|
||||||
|
// terminal's normal newline mapping.
|
||||||
|
raw.c_cflag |= CS8;
|
||||||
|
// Deliver Ctrl-C as input so TuiApp can unwind and reliably restore the
|
||||||
|
// alternate screen and cursor before exiting.
|
||||||
|
raw.c_lflag &= static_cast<tcflag_t>(~(ECHO | ICANON | IEXTEN | ISIG));
|
||||||
|
raw.c_cc[VMIN] = 0;
|
||||||
|
raw.c_cc[VTIME] = 0;
|
||||||
|
active_ = ::tcsetattr(input_fd_, TCSAFLUSH, &raw) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
RawInput::~RawInput() {
|
||||||
|
if (active_) {
|
||||||
|
::tcsetattr(input_fd_, TCSAFLUSH, &original_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ResizeWatcher::ResizeWatcher() : storage_(new sigaction_storage) {
|
||||||
|
resize_pending = 1;
|
||||||
|
struct sigaction action {};
|
||||||
|
action.sa_handler = mark_resize;
|
||||||
|
::sigemptyset(&action.sa_mask);
|
||||||
|
action.sa_flags = 0; // Ensure a blocked poll wakes up for resize.
|
||||||
|
storage_->installed = ::sigaction(SIGWINCH, &action, &storage_->previous) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResizeWatcher::~ResizeWatcher() {
|
||||||
|
if (storage_->installed) {
|
||||||
|
::sigaction(SIGWINCH, &storage_->previous, nullptr);
|
||||||
|
}
|
||||||
|
delete storage_;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ResizeWatcher::consume() noexcept {
|
||||||
|
if (resize_pending == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
resize_pending = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nocal::tui
|
||||||
573
src/tui/TuiApp.cpp
Normal file
573
src/tui/TuiApp.cpp
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
#include "nocal/tui/TuiApp.hpp"
|
||||||
|
|
||||||
|
#include "nocal/domain/event_query.hpp"
|
||||||
|
#include "nocal/tui/Terminal.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <ctime>
|
||||||
|
#include <exception>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <poll.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace nocal::tui {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using namespace std::chrono;
|
||||||
|
|
||||||
|
year_month_day current_day() {
|
||||||
|
const auto now = system_clock::to_time_t(system_clock::now());
|
||||||
|
std::tm local{};
|
||||||
|
::localtime_r(&now, &local);
|
||||||
|
return year{local.tm_year + 1900} / month{static_cast<unsigned>(local.tm_mon + 1)} /
|
||||||
|
day{static_cast<unsigned>(local.tm_mday)};
|
||||||
|
}
|
||||||
|
|
||||||
|
year_month_day clamp_to_month(const year_month month, const unsigned requested_day) {
|
||||||
|
const auto last_day = static_cast<unsigned>((month / std::chrono::last).day());
|
||||||
|
return month / day{std::min(requested_day, last_day)};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t complete_sequence_length(const std::string& input) {
|
||||||
|
if (input.empty()) return 0;
|
||||||
|
if (input.front() != '\x1b') return 1;
|
||||||
|
if (input.size() == 1) return 0;
|
||||||
|
if (input[1] == 'O') return input.size() >= 3 ? 3 : 0;
|
||||||
|
if (input[1] != '[') return 1;
|
||||||
|
if (input.size() < 3) return 0;
|
||||||
|
if ((input[2] >= 'A' && input[2] <= 'D') || input[2] == 'Z') return 3;
|
||||||
|
if ((input[2] == '5' || input[2] == '6') && input.size() >= 4 && input[3] == '~') return 4;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string event_identity(const std::span<const Event> events, const std::size_t index) {
|
||||||
|
const auto& uid = events[index].uid;
|
||||||
|
const bool unique = !uid.empty() &&
|
||||||
|
std::count_if(events.begin(), events.end(), [&uid](const Event& event) {
|
||||||
|
return event.uid == uid;
|
||||||
|
}) == 1;
|
||||||
|
return unique ? uid : "@nocal:" + std::to_string(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t source_index(const std::span<const Event> events, const Event& event) {
|
||||||
|
const auto found = std::find_if(events.begin(), events.end(), [&event](const Event& candidate) {
|
||||||
|
return &candidate == &event;
|
||||||
|
});
|
||||||
|
return static_cast<std::size_t>(std::distance(events.begin(), found));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TuiApp::TuiApp(std::vector<Event>& events, const int input_fd, const int output_fd)
|
||||||
|
: TuiApp(events, SaveCallback{}, input_fd, output_fd) {}
|
||||||
|
|
||||||
|
TuiApp::TuiApp(std::vector<Event>& events, SaveCallback saver, const int input_fd,
|
||||||
|
const int output_fd)
|
||||||
|
: events_(events), input_fd_(input_fd), output_fd_(output_fd), saver_(std::move(saver)) {
|
||||||
|
undo_history_.reserve(history_limit);
|
||||||
|
redo_history_.reserve(history_limit);
|
||||||
|
state_.today = current_day();
|
||||||
|
state_.selected_day = state_.today;
|
||||||
|
state_.visible_month = state_.today.year() / state_.today.month();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::size_t> TuiApp::focused_event_index() const {
|
||||||
|
if (!state_.focused_event_id) return std::nullopt;
|
||||||
|
const auto event_span = std::span<const Event>{events_};
|
||||||
|
for (std::size_t index = 0; index < events_.size(); ++index) {
|
||||||
|
if (event_identity(event_span, index) == *state_.focused_event_id) return index;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::set_notification(std::string message, const bool error) {
|
||||||
|
state_.notification = std::move(message);
|
||||||
|
state_.notification_is_error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
TuiApp::HistorySnapshot TuiApp::capture_history_snapshot() const {
|
||||||
|
return {
|
||||||
|
.events = events_,
|
||||||
|
.visible_month = state_.visible_month,
|
||||||
|
.selected_day = state_.selected_day,
|
||||||
|
.focused_event_id = state_.focused_event_id,
|
||||||
|
.show_event_details = state_.show_event_details,
|
||||||
|
.show_help = state_.show_help,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::restore_history_snapshot(HistorySnapshot snapshot) {
|
||||||
|
events_ = std::move(snapshot.events);
|
||||||
|
state_.visible_month = snapshot.visible_month;
|
||||||
|
state_.selected_day = snapshot.selected_day;
|
||||||
|
state_.focused_event_id = std::move(snapshot.focused_event_id);
|
||||||
|
state_.show_event_details = snapshot.show_event_details;
|
||||||
|
state_.show_help = snapshot.show_help;
|
||||||
|
state_.confirm_delete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::push_history(std::vector<HistoryEntry>& history, HistoryEntry entry) {
|
||||||
|
if (history.size() == history_limit) history.erase(history.begin());
|
||||||
|
history.push_back(std::move(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TuiApp::persist(const std::span<const Event> events) {
|
||||||
|
try {
|
||||||
|
if (saver_) saver_(events);
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
set_notification("Could not save changes: " + std::string{error.what()}, true);
|
||||||
|
} catch (...) {
|
||||||
|
set_notification("Could not save changes: unknown error", true);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::record_mutation(std::string operation) {
|
||||||
|
if (pending_mutation_before_) {
|
||||||
|
push_history(undo_history_,
|
||||||
|
{.snapshot = std::move(*pending_mutation_before_),
|
||||||
|
.operation = std::move(operation)});
|
||||||
|
}
|
||||||
|
pending_mutation_before_.reset();
|
||||||
|
redo_history_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::undo() {
|
||||||
|
if (undo_history_.empty()) {
|
||||||
|
set_notification("Nothing to undo.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto current = capture_history_snapshot();
|
||||||
|
const auto operation = undo_history_.back().operation;
|
||||||
|
if (!persist(std::span<const Event>{undo_history_.back().snapshot.events})) return;
|
||||||
|
|
||||||
|
auto target = std::move(undo_history_.back());
|
||||||
|
undo_history_.pop_back();
|
||||||
|
push_history(redo_history_,
|
||||||
|
{.snapshot = std::move(current), .operation = target.operation});
|
||||||
|
restore_history_snapshot(std::move(target.snapshot));
|
||||||
|
set_notification("Undid " + operation + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::redo() {
|
||||||
|
if (redo_history_.empty()) {
|
||||||
|
set_notification("Nothing to redo.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto current = capture_history_snapshot();
|
||||||
|
const auto operation = redo_history_.back().operation;
|
||||||
|
if (!persist(std::span<const Event>{redo_history_.back().snapshot.events})) return;
|
||||||
|
|
||||||
|
auto target = std::move(redo_history_.back());
|
||||||
|
redo_history_.pop_back();
|
||||||
|
push_history(undo_history_,
|
||||||
|
{.snapshot = std::move(current), .operation = target.operation});
|
||||||
|
restore_history_snapshot(std::move(target.snapshot));
|
||||||
|
set_notification("Redid " + operation + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::begin_add() {
|
||||||
|
pending_mutation_before_ = capture_history_snapshot();
|
||||||
|
editor_.emplace(state_.selected_day);
|
||||||
|
editing_index_.reset();
|
||||||
|
state_.show_event_details = false;
|
||||||
|
state_.show_help = false;
|
||||||
|
state_.confirm_delete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::begin_edit() {
|
||||||
|
const auto index = focused_event_index();
|
||||||
|
if (!index) {
|
||||||
|
set_notification("Focus an appointment before editing.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending_mutation_before_ = capture_history_snapshot();
|
||||||
|
editor_.emplace(events_[*index]);
|
||||||
|
editing_index_ = index;
|
||||||
|
state_.show_event_details = false;
|
||||||
|
state_.show_help = false;
|
||||||
|
state_.confirm_delete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::begin_delete() {
|
||||||
|
if (!focused_event_index()) {
|
||||||
|
set_notification("Focus an appointment before deleting.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending_mutation_before_ = capture_history_snapshot();
|
||||||
|
state_.confirm_delete = true;
|
||||||
|
state_.show_event_details = false;
|
||||||
|
state_.show_help = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::submit_editor() {
|
||||||
|
if (!editor_) return;
|
||||||
|
|
||||||
|
auto original = events_;
|
||||||
|
const auto original_focus = state_.focused_event_id;
|
||||||
|
std::size_t changed_index = events_.size();
|
||||||
|
std::string success;
|
||||||
|
const bool editing = editing_index_ && *editing_index_ < events_.size();
|
||||||
|
if (editing) {
|
||||||
|
changed_index = *editing_index_;
|
||||||
|
auto replacement = editor_->result();
|
||||||
|
replacement.uid = events_[changed_index].uid;
|
||||||
|
events_[changed_index] = std::move(replacement);
|
||||||
|
success = "Appointment updated.";
|
||||||
|
} else {
|
||||||
|
events_.push_back(editor_->result());
|
||||||
|
changed_index = events_.size() - 1;
|
||||||
|
success = "Appointment added.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!persist(std::span<const Event>{events_})) {
|
||||||
|
events_.swap(original);
|
||||||
|
state_.focused_event_id = original_focus;
|
||||||
|
editor_.reset();
|
||||||
|
editing_index_.reset();
|
||||||
|
pending_mutation_before_.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor_.reset();
|
||||||
|
editing_index_.reset();
|
||||||
|
state_.selected_day = local_date(events_[changed_index].start);
|
||||||
|
state_.visible_month = state_.selected_day.year() / state_.selected_day.month();
|
||||||
|
state_.focused_event_id = event_identity(std::span<const Event>{events_}, changed_index);
|
||||||
|
state_.show_event_details = false;
|
||||||
|
record_mutation(editing ? "edit appointment" : "add appointment");
|
||||||
|
set_notification(std::move(success));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::confirm_delete() {
|
||||||
|
const auto index = focused_event_index();
|
||||||
|
if (!index) {
|
||||||
|
state_.confirm_delete = false;
|
||||||
|
pending_mutation_before_.reset();
|
||||||
|
set_notification("The focused appointment no longer exists.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto original = events_;
|
||||||
|
const auto original_focus = state_.focused_event_id;
|
||||||
|
events_.erase(events_.begin() + static_cast<std::ptrdiff_t>(*index));
|
||||||
|
if (!persist(std::span<const Event>{events_})) {
|
||||||
|
events_.swap(original);
|
||||||
|
state_.focused_event_id = original_focus;
|
||||||
|
state_.confirm_delete = false;
|
||||||
|
pending_mutation_before_.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state_.focused_event_id.reset();
|
||||||
|
state_.show_event_details = false;
|
||||||
|
state_.confirm_delete = false;
|
||||||
|
record_mutation("delete appointment");
|
||||||
|
set_notification("Appointment deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::cancel_delete() {
|
||||||
|
state_.confirm_delete = false;
|
||||||
|
pending_mutation_before_.reset();
|
||||||
|
set_notification("Deletion cancelled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::handle_input(const std::string_view input) {
|
||||||
|
if (input == "\x03") {
|
||||||
|
dispatch(Action::quit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editor_) {
|
||||||
|
if (input == "\x12") {
|
||||||
|
set_notification("Finish or cancel editing before redoing.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto outcome = editor_->handle_key(input);
|
||||||
|
if (outcome == EditorOutcome::submit) {
|
||||||
|
submit_editor();
|
||||||
|
} else if (outcome == EditorOutcome::cancel) {
|
||||||
|
editor_.reset();
|
||||||
|
editing_index_.reset();
|
||||||
|
pending_mutation_before_.reset();
|
||||||
|
set_notification("Edit cancelled.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state_.confirm_delete) {
|
||||||
|
if (input == "y" || input == "Y") {
|
||||||
|
confirm_delete();
|
||||||
|
} else if (input == "n" || input == "N" || input == "\x1b" || input == "\x7f") {
|
||||||
|
cancel_delete();
|
||||||
|
} else if (input == "u" || input == "\x12") {
|
||||||
|
set_notification("Finish or cancel deletion before using history.", true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch(decode_key(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TuiApp::dispatch(const Action action) {
|
||||||
|
if (action != Action::none) {
|
||||||
|
state_.notification.clear();
|
||||||
|
state_.notification_is_error = false;
|
||||||
|
}
|
||||||
|
if (editor_) {
|
||||||
|
if (action == Action::back) {
|
||||||
|
editor_.reset();
|
||||||
|
editing_index_.reset();
|
||||||
|
pending_mutation_before_.reset();
|
||||||
|
set_notification("Edit cancelled.");
|
||||||
|
} else if (action == Action::undo || action == Action::redo) {
|
||||||
|
set_notification("Finish or cancel editing before using history.", true);
|
||||||
|
} else if (action == Action::quit) {
|
||||||
|
running_ = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state_.confirm_delete) {
|
||||||
|
if (action == Action::back) cancel_delete();
|
||||||
|
else if (action == Action::undo || action == Action::redo) {
|
||||||
|
set_notification("Finish or cancel deletion before using history.", true);
|
||||||
|
}
|
||||||
|
else if (action == Action::quit) running_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto clear_event_focus = [this] {
|
||||||
|
state_.focused_event_id.reset();
|
||||||
|
state_.show_event_details = false;
|
||||||
|
};
|
||||||
|
const auto cycle_event = [this](const int direction) {
|
||||||
|
const auto event_span = std::span<const Event>{events_};
|
||||||
|
const auto day_events = events_on_day(event_span, state_.selected_day);
|
||||||
|
if (day_events.empty()) {
|
||||||
|
state_.focused_event_id.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t current = day_events.size();
|
||||||
|
if (state_.focused_event_id) {
|
||||||
|
for (std::size_t index = 0; index < day_events.size(); ++index) {
|
||||||
|
const auto event_index = source_index(event_span, day_events[index].get());
|
||||||
|
if (event_identity(event_span, event_index) == *state_.focused_event_id) {
|
||||||
|
current = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t target = 0;
|
||||||
|
if (current == day_events.size()) {
|
||||||
|
target = direction < 0 ? day_events.size() - 1 : 0;
|
||||||
|
} else if (direction < 0) {
|
||||||
|
target = current == 0 ? day_events.size() - 1 : current - 1;
|
||||||
|
} else {
|
||||||
|
target = (current + 1) % day_events.size();
|
||||||
|
}
|
||||||
|
const auto event_index = source_index(event_span, day_events[target].get());
|
||||||
|
state_.focused_event_id = event_identity(event_span, event_index);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state_.show_event_details) {
|
||||||
|
switch (action) {
|
||||||
|
case Action::next_event:
|
||||||
|
case Action::right:
|
||||||
|
case Action::down:
|
||||||
|
cycle_event(1);
|
||||||
|
return;
|
||||||
|
case Action::previous_event:
|
||||||
|
case Action::left:
|
||||||
|
case Action::up:
|
||||||
|
cycle_event(-1);
|
||||||
|
return;
|
||||||
|
case Action::select:
|
||||||
|
case Action::back:
|
||||||
|
state_.show_event_details = false;
|
||||||
|
return;
|
||||||
|
case Action::add_event:
|
||||||
|
begin_add();
|
||||||
|
return;
|
||||||
|
case Action::edit_event:
|
||||||
|
begin_edit();
|
||||||
|
return;
|
||||||
|
case Action::delete_event:
|
||||||
|
begin_delete();
|
||||||
|
return;
|
||||||
|
case Action::undo:
|
||||||
|
undo();
|
||||||
|
return;
|
||||||
|
case Action::redo:
|
||||||
|
redo();
|
||||||
|
return;
|
||||||
|
case Action::quit:
|
||||||
|
running_ = false;
|
||||||
|
return;
|
||||||
|
case Action::none:
|
||||||
|
case Action::previous_month:
|
||||||
|
case Action::next_month:
|
||||||
|
case Action::today:
|
||||||
|
case Action::toggle_help:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto selected = sys_days{state_.selected_day};
|
||||||
|
switch (action) {
|
||||||
|
case Action::left:
|
||||||
|
clear_event_focus();
|
||||||
|
selected -= days{1};
|
||||||
|
break;
|
||||||
|
case Action::right:
|
||||||
|
clear_event_focus();
|
||||||
|
selected += days{1};
|
||||||
|
break;
|
||||||
|
case Action::up:
|
||||||
|
clear_event_focus();
|
||||||
|
selected -= days{7};
|
||||||
|
break;
|
||||||
|
case Action::down:
|
||||||
|
clear_event_focus();
|
||||||
|
selected += days{7};
|
||||||
|
break;
|
||||||
|
case Action::previous_month: {
|
||||||
|
clear_event_focus();
|
||||||
|
const auto target = state_.visible_month - months{1};
|
||||||
|
state_.selected_day = clamp_to_month(target, static_cast<unsigned>(state_.selected_day.day()));
|
||||||
|
state_.visible_month = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case Action::next_month: {
|
||||||
|
clear_event_focus();
|
||||||
|
const auto target = state_.visible_month + months{1};
|
||||||
|
state_.selected_day = clamp_to_month(target, static_cast<unsigned>(state_.selected_day.day()));
|
||||||
|
state_.visible_month = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case Action::today:
|
||||||
|
clear_event_focus();
|
||||||
|
state_.today = current_day();
|
||||||
|
state_.selected_day = state_.today;
|
||||||
|
state_.visible_month = state_.today.year() / state_.today.month();
|
||||||
|
return;
|
||||||
|
case Action::toggle_help:
|
||||||
|
state_.show_help = !state_.show_help;
|
||||||
|
return;
|
||||||
|
case Action::previous_event:
|
||||||
|
cycle_event(-1);
|
||||||
|
return;
|
||||||
|
case Action::next_event:
|
||||||
|
cycle_event(1);
|
||||||
|
return;
|
||||||
|
case Action::select:
|
||||||
|
if (!state_.focused_event_id) {
|
||||||
|
cycle_event(1);
|
||||||
|
}
|
||||||
|
if (state_.focused_event_id) {
|
||||||
|
state_.show_event_details = true;
|
||||||
|
state_.show_help = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case Action::back:
|
||||||
|
clear_event_focus();
|
||||||
|
state_.show_help = false;
|
||||||
|
return;
|
||||||
|
case Action::add_event:
|
||||||
|
begin_add();
|
||||||
|
return;
|
||||||
|
case Action::edit_event:
|
||||||
|
begin_edit();
|
||||||
|
return;
|
||||||
|
case Action::delete_event:
|
||||||
|
begin_delete();
|
||||||
|
return;
|
||||||
|
case Action::undo:
|
||||||
|
undo();
|
||||||
|
return;
|
||||||
|
case Action::redo:
|
||||||
|
redo();
|
||||||
|
return;
|
||||||
|
case Action::quit:
|
||||||
|
running_ = false;
|
||||||
|
return;
|
||||||
|
case Action::none:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state_.selected_day = year_month_day{selected};
|
||||||
|
state_.visible_month = state_.selected_day.year() / state_.selected_day.month();
|
||||||
|
}
|
||||||
|
|
||||||
|
ExitReason TuiApp::run() {
|
||||||
|
RawInput raw{input_fd_};
|
||||||
|
AlternateScreen screen{output_fd_};
|
||||||
|
ResizeWatcher resize;
|
||||||
|
const bool terminal_output = ::isatty(output_fd_) != 0;
|
||||||
|
state_.ansi = terminal_output && std::getenv("NO_COLOR") == nullptr;
|
||||||
|
running_ = true;
|
||||||
|
|
||||||
|
std::string pending;
|
||||||
|
bool redraw = true;
|
||||||
|
while (running_) {
|
||||||
|
if (resize.consume()) {
|
||||||
|
const auto size = terminal_size(output_fd_);
|
||||||
|
state_.width = size.columns;
|
||||||
|
state_.height = size.rows;
|
||||||
|
redraw = true;
|
||||||
|
}
|
||||||
|
if (redraw) {
|
||||||
|
const auto content = editor_
|
||||||
|
? editor_->render(state_.width, state_.height, state_.ansi)
|
||||||
|
: render_month(std::span<const Event>{events_}, state_);
|
||||||
|
// Cursor control is independent of color styling: NO_COLOR still
|
||||||
|
// needs in-place redraws rather than appending a new month per key.
|
||||||
|
const auto frame = terminal_output ? "\x1b[H" + content + "\x1b[J" : content;
|
||||||
|
if (!write_terminal(output_fd_, frame)) return ExitReason::io_error;
|
||||||
|
redraw = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct pollfd descriptor { input_fd_, POLLIN, 0 };
|
||||||
|
const auto result = ::poll(&descriptor, 1, pending.empty() ? -1 : 30);
|
||||||
|
if (result < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
return ExitReason::io_error;
|
||||||
|
}
|
||||||
|
if (result == 0) {
|
||||||
|
// Escape is ambiguous with the prefix of a terminal control sequence.
|
||||||
|
// Once the short sequence window expires, treat it as a key in its own
|
||||||
|
// right so it can close the appointment reader.
|
||||||
|
handle_input(pending);
|
||||||
|
pending.clear();
|
||||||
|
redraw = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((descriptor.revents & (POLLERR | POLLNVAL)) != 0) return ExitReason::io_error;
|
||||||
|
if ((descriptor.revents & POLLHUP) != 0 && (descriptor.revents & POLLIN) == 0) {
|
||||||
|
return ExitReason::input_closed;
|
||||||
|
}
|
||||||
|
if ((descriptor.revents & POLLIN) == 0) continue;
|
||||||
|
|
||||||
|
std::array<char, 64> buffer{};
|
||||||
|
const auto count = ::read(input_fd_, buffer.data(), buffer.size());
|
||||||
|
if (count == 0) return ExitReason::input_closed;
|
||||||
|
if (count < 0) {
|
||||||
|
if (errno == EINTR || errno == EAGAIN) continue;
|
||||||
|
return ExitReason::io_error;
|
||||||
|
}
|
||||||
|
pending.append(buffer.data(), static_cast<std::size_t>(count));
|
||||||
|
while (const auto length = complete_sequence_length(pending)) {
|
||||||
|
handle_input(std::string_view{pending}.substr(0, length));
|
||||||
|
pending.erase(0, length);
|
||||||
|
redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ExitReason::quit;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nocal::tui
|
||||||
83
tests/cli_recovery_test.sh
Normal file
83
tests/cli_recovery_test.sh
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
echo "usage: cli_recovery_test.sh /path/to/nocal" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
nocal=$1
|
||||||
|
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/nocal-cli-recovery.XXXXXX")
|
||||||
|
trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM
|
||||||
|
|
||||||
|
calendar=$tmpdir/calendar.ics
|
||||||
|
backup=$calendar.bak
|
||||||
|
expected=$tmpdir/expected.ics
|
||||||
|
|
||||||
|
cat >"$calendar" <<'EOF'
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Nocal CLI test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:current@nocal.test
|
||||||
|
DTSTART:20260717T090000
|
||||||
|
DTEND:20260717T100000
|
||||||
|
SUMMARY:Current appointment
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat >"$backup" <<'EOF'
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Nocal CLI test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:backup@nocal.test
|
||||||
|
DTSTART:20260718T140000
|
||||||
|
DTEND:20260718T150000
|
||||||
|
SUMMARY:Recovered appointment
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
EOF
|
||||||
|
cp "$backup" "$expected"
|
||||||
|
|
||||||
|
help=$("$nocal" --help)
|
||||||
|
case $help in
|
||||||
|
*--restore-backup*"u undoes the last change, Ctrl-R redoes it"*) ;;
|
||||||
|
*)
|
||||||
|
echo "help does not document recovery and undo/redo" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if "$nocal" --restore-backup --demo --calendar "$calendar" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||||
|
echo "--restore-backup with --demo unexpectedly succeeded" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -F -- "--restore-backup cannot be combined with --demo" "$tmpdir/err" >/dev/null
|
||||||
|
|
||||||
|
if "$nocal" --restore-backup --print --calendar "$calendar" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||||
|
echo "--restore-backup with --print unexpectedly succeeded" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -F -- "--restore-backup cannot be combined with --print" "$tmpdir/err" >/dev/null
|
||||||
|
|
||||||
|
missing_calendar=$tmpdir/missing-backup.ics
|
||||||
|
cp "$calendar" "$missing_calendar"
|
||||||
|
cp "$missing_calendar" "$tmpdir/missing-before.ics"
|
||||||
|
if "$nocal" --restore-backup --calendar "$missing_calendar" \
|
||||||
|
>"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||||
|
echo "restore without an adjacent backup unexpectedly succeeded" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cmp "$missing_calendar" "$tmpdir/missing-before.ics"
|
||||||
|
|
||||||
|
actual=$("$nocal" --restore-backup --calendar "$calendar")
|
||||||
|
expected_summary="Restored backup $backup to $calendar"
|
||||||
|
if [ "$actual" != "$expected_summary" ]; then
|
||||||
|
echo "unexpected restore summary: $actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cmp "$calendar" "$expected"
|
||||||
|
cmp "$backup" "$expected"
|
||||||
134
tests/domain_tests.cpp
Normal file
134
tests/domain_tests.cpp
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#include "nocal/domain/domain.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string_view>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void check(bool condition, std::string_view message)
|
||||||
|
{
|
||||||
|
if (!condition) {
|
||||||
|
++failures;
|
||||||
|
std::cerr << "FAIL: " << message << '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template <class Function>
|
||||||
|
void check_throws(Function&& function, std::string_view message)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
function();
|
||||||
|
check(false, message);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_dates()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const nocal::Date leap{2024y / February / 29d};
|
||||||
|
check(nocal::valid_date(leap), "leap day is valid");
|
||||||
|
check(nocal::format_date(leap) == "2024-02-29", "date formats as ISO 8601");
|
||||||
|
check(nocal::parse_date("2024-02-29") == leap, "date parses from ISO 8601");
|
||||||
|
check(nocal::from_sys_days(nocal::to_sys_days(leap) + days{1}) ==
|
||||||
|
nocal::Date{2024y / March / 1d},
|
||||||
|
"civil date arithmetic crosses a leap month");
|
||||||
|
check(nocal::monday_first_weekday(nocal::Date{2026y / July / 6d}) == 0,
|
||||||
|
"Monday has index zero");
|
||||||
|
check(nocal::monday_first_weekday(nocal::Date{2026y / July / 12d}) == 6,
|
||||||
|
"Sunday has index six");
|
||||||
|
check_throws([] { (void)nocal::parse_date("2023-02-29"); },
|
||||||
|
"invalid date is rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_month_layout()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const auto layout = nocal::make_month_layout(2026y / July);
|
||||||
|
check(layout.cells.size() == 42, "month layout always has 42 cells");
|
||||||
|
check(layout.first_visible_date() == nocal::Date{2026y / June / 29d},
|
||||||
|
"July 2026 begins after Monday-leading spill cells");
|
||||||
|
check(layout.last_visible_date() == nocal::Date{2026y / August / 9d},
|
||||||
|
"six-week grid has the expected final date");
|
||||||
|
check(layout.at(0, 0).column == 0 && layout.at(5, 6).row == 5,
|
||||||
|
"cell coordinates match their positions");
|
||||||
|
check(!layout.at(0, 0).in_month && layout.at(0, 2).in_month,
|
||||||
|
"spill cells are distinguished from current-month cells");
|
||||||
|
const auto* july_17 = layout.find(nocal::Date{2026y / July / 17d});
|
||||||
|
check(july_17 != nullptr && july_17->row == 2 && july_17->column == 4,
|
||||||
|
"date lookup finds the correct Friday cell");
|
||||||
|
check_throws([&] { (void)layout.at(6, 0); }, "out-of-grid access is rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
nocal::Event event(std::string uid, nocal::Date start_date, unsigned start_hour,
|
||||||
|
nocal::Date end_date, unsigned end_hour, bool all_day = false)
|
||||||
|
{
|
||||||
|
nocal::Event result;
|
||||||
|
result.uid = std::move(uid);
|
||||||
|
result.title = result.uid;
|
||||||
|
result.start = nocal::make_local_time(start_date, start_hour);
|
||||||
|
result.end = nocal::make_local_time(end_date, end_hour);
|
||||||
|
result.all_day = all_day;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_event_queries()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const nocal::Date previous{2026y / July / 16d};
|
||||||
|
const nocal::Date day{2026y / July / 17d};
|
||||||
|
const nocal::Date next{2026y / July / 18d};
|
||||||
|
|
||||||
|
std::vector<nocal::Event> events;
|
||||||
|
events.push_back(event("late", day, 15, day, 16));
|
||||||
|
events.push_back(event("all-day", day, 0, next, 0, true));
|
||||||
|
events.push_back(event("early", day, 9, day, 10));
|
||||||
|
events.push_back(event("carry", previous, 23, day, 1));
|
||||||
|
events.push_back(event("ends-at-boundary", previous, 22, day, 0));
|
||||||
|
events.push_back(event("next-day", next, 0, next, 1));
|
||||||
|
const nocal::Date august_first{2026y / August / 1d};
|
||||||
|
events.push_back(event("next-month", august_first, 0, august_first, 1));
|
||||||
|
|
||||||
|
nocal::Event instant = event("instant", day, 12, day, 12);
|
||||||
|
events.push_back(instant);
|
||||||
|
|
||||||
|
const auto on_day = nocal::events_on_day(events, day);
|
||||||
|
check(on_day.size() == 5, "day query uses overlap and half-open boundaries");
|
||||||
|
check(on_day[0].get().uid == "all-day", "all-day events sort before timed events");
|
||||||
|
check(on_day[1].get().uid == "carry", "overlapping prior-day event is included");
|
||||||
|
check(on_day[2].get().uid == "early" && on_day[3].get().uid == "instant" &&
|
||||||
|
on_day[4].get().uid == "late",
|
||||||
|
"timed events sort by start time");
|
||||||
|
check(nocal::event_occurs_on(instant, day),
|
||||||
|
"zero-duration event occurs on the day containing its instant");
|
||||||
|
|
||||||
|
const auto in_july = nocal::events_in_month(events, 2026y / July);
|
||||||
|
check(in_july.size() == 7, "month query excludes an event beginning August 1");
|
||||||
|
|
||||||
|
nocal::Event invalid = event("invalid", day, 12, day, 11);
|
||||||
|
check(!nocal::event_overlaps(invalid, nocal::local_day_start(day),
|
||||||
|
nocal::local_day_end(day)),
|
||||||
|
"invalid backwards event never overlaps");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
test_dates();
|
||||||
|
test_month_layout();
|
||||||
|
test_event_queries();
|
||||||
|
if (failures != 0) {
|
||||||
|
std::cerr << failures << " domain test(s) failed\n";
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
std::cout << "domain tests passed\n";
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
234
tests/editor_tests.cpp
Normal file
234
tests/editor_tests.cpp
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
#include "nocal/domain/date.hpp"
|
||||||
|
#include "nocal/tui/EventEditor.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void check(const bool condition, const std::string_view message)
|
||||||
|
{
|
||||||
|
if (!condition) {
|
||||||
|
++failures;
|
||||||
|
std::cerr << "FAIL: " << message << '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void replace_field(nocal::tui::EventEditor& editor, const std::string_view value)
|
||||||
|
{
|
||||||
|
while (!editor.field_value(editor.focused_field()).empty()) {
|
||||||
|
(void)editor.handle_key("\x7f");
|
||||||
|
}
|
||||||
|
(void)editor.handle_key(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void focus(nocal::tui::EventEditor& editor, const nocal::tui::EditorField target)
|
||||||
|
{
|
||||||
|
while (editor.focused_field() != target) (void)editor.handle_key("\t");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_create_and_validation()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const nocal::Date day{2026y / July / 17d};
|
||||||
|
nocal::tui::EventEditor editor{day};
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::start_date) == "2026-07-17",
|
||||||
|
"new editor starts on selected date");
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::start_time) == "09:00" &&
|
||||||
|
editor.field_value(nocal::tui::EditorField::end_time) == "10:00",
|
||||||
|
"new editor supplies useful default times");
|
||||||
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
|
||||||
|
editor.focused_field() == nocal::tui::EditorField::title && !editor.error().empty(),
|
||||||
|
"blank title blocks submission and focuses the invalid field");
|
||||||
|
|
||||||
|
(void)editor.handle_key("Design review");
|
||||||
|
focus(editor, nocal::tui::EditorField::end_time);
|
||||||
|
replace_field(editor, "08:00");
|
||||||
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
|
||||||
|
editor.error().find("after") != std::string_view::npos,
|
||||||
|
"backwards timed interval is rejected");
|
||||||
|
replace_field(editor, "10:30");
|
||||||
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
|
||||||
|
"valid timed event submits");
|
||||||
|
check(editor.result().title == "Design review" && !editor.result().uid.empty(),
|
||||||
|
"submitted event contains title and generated UID");
|
||||||
|
check(editor.result().end > editor.result().start && !editor.result().all_day,
|
||||||
|
"submitted timed event has a strict interval");
|
||||||
|
|
||||||
|
nocal::tui::EventEditor second{day};
|
||||||
|
check(second.result().uid != editor.result().uid, "locally generated UIDs do not collide");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_invalid_civil_inputs()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
|
||||||
|
(void)editor.handle_key("Planning");
|
||||||
|
focus(editor, nocal::tui::EditorField::start_date);
|
||||||
|
replace_field(editor, "2026-02-30");
|
||||||
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
|
||||||
|
editor.focused_field() == nocal::tui::EditorField::start_date,
|
||||||
|
"invalid civil date is rejected");
|
||||||
|
replace_field(editor, "2026-07-17");
|
||||||
|
focus(editor, nocal::tui::EditorField::start_time);
|
||||||
|
replace_field(editor, "25:10");
|
||||||
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
|
||||||
|
editor.focused_field() == nocal::tui::EditorField::start_time,
|
||||||
|
"out-of-range clock time is rejected");
|
||||||
|
check(editor.render(64, 18, false).find("Start time must be") != std::string::npos,
|
||||||
|
"validation error is visible in the rendered form");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_edit_preserves_identity_and_metadata()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const nocal::Date day{2026y / July / 17d};
|
||||||
|
nocal::Event existing;
|
||||||
|
existing.uid = "stable-id";
|
||||||
|
existing.title = "Old title";
|
||||||
|
existing.start = nocal::make_local_time(day, 14, 0);
|
||||||
|
existing.end = nocal::make_local_time(day, 15, 0);
|
||||||
|
existing.location = "Studio";
|
||||||
|
existing.description = "Original notes";
|
||||||
|
existing.calendar_id = "work";
|
||||||
|
existing.color = "blue";
|
||||||
|
|
||||||
|
nocal::tui::EventEditor editor{existing};
|
||||||
|
replace_field(editor, "New title");
|
||||||
|
focus(editor, nocal::tui::EditorField::notes);
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::notes) == "Original notes",
|
||||||
|
"edit initializes notes from description");
|
||||||
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
|
||||||
|
"edited event submits");
|
||||||
|
check(editor.result().uid == "stable-id" && editor.result().calendar_id == "work" &&
|
||||||
|
editor.result().color == "blue" && editor.result().description == "Original notes",
|
||||||
|
"editing preserves identity, calendar, color, and unchanged description");
|
||||||
|
check(editor.result().title == "New title", "editing updates changed fields");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_all_day_exclusive_end()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const nocal::Date day{2026y / July / 17d};
|
||||||
|
nocal::tui::EventEditor editor{day};
|
||||||
|
(void)editor.handle_key("Conference");
|
||||||
|
focus(editor, nocal::tui::EditorField::all_day);
|
||||||
|
(void)editor.handle_key(" ");
|
||||||
|
check(editor.all_day(), "Space toggles all-day on its control");
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::end_date) == "2026-07-18",
|
||||||
|
"all-day toggle supplies the normal exclusive next-day end");
|
||||||
|
focus(editor, nocal::tui::EditorField::end_date);
|
||||||
|
replace_field(editor, "2026-07-17");
|
||||||
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
|
||||||
|
editor.focused_field() == nocal::tui::EditorField::end_date,
|
||||||
|
"same-day exclusive all-day end is rejected");
|
||||||
|
replace_field(editor, "2026-07-18");
|
||||||
|
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
|
||||||
|
"next-day exclusive all-day end submits");
|
||||||
|
check(editor.result().all_day && nocal::local_date(editor.result().start) == day &&
|
||||||
|
nocal::local_date(editor.result().end) == nocal::Date{2026y / July / 18d},
|
||||||
|
"all-day result uses exclusive civil-day bounds");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_unicode_backspace_navigation_and_cancel()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
|
||||||
|
(void)editor.handle_key("Café ☕");
|
||||||
|
(void)editor.handle_key("\x7f");
|
||||||
|
check(editor.field_value(nocal::tui::EditorField::title) == "Café ",
|
||||||
|
"Backspace removes one complete UTF-8 code point");
|
||||||
|
(void)editor.handle_key("\x1b[B");
|
||||||
|
check(editor.focused_field() == nocal::tui::EditorField::start_date,
|
||||||
|
"Down arrow advances field focus");
|
||||||
|
(void)editor.handle_key("\x1b[A");
|
||||||
|
check(editor.focused_field() == nocal::tui::EditorField::title,
|
||||||
|
"Up arrow reverses field focus");
|
||||||
|
check(editor.handle_key("\x1b") == nocal::tui::EditorOutcome::cancel,
|
||||||
|
"Escape cancels without submission");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string strip_ansi(const std::string_view input)
|
||||||
|
{
|
||||||
|
std::string output;
|
||||||
|
for (std::size_t index = 0; index < input.size();) {
|
||||||
|
if (input[index] == '\x1b' && index + 1 < input.size() && input[index + 1] == '[') {
|
||||||
|
index += 2;
|
||||||
|
while (index < input.size() && input[index] != 'm') ++index;
|
||||||
|
if (index < input.size()) ++index;
|
||||||
|
} else {
|
||||||
|
output += input[index++];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_rendering()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
|
||||||
|
const auto plain = editor.render(72, 18, false);
|
||||||
|
check(std::count(plain.begin(), plain.end(), '\n') == 17,
|
||||||
|
"render fills requested height exactly");
|
||||||
|
check(plain.find("NEW APPOINTMENT") != std::string::npos &&
|
||||||
|
plain.find("Ctrl-S save") != std::string::npos &&
|
||||||
|
plain.find("[ ] timed appointment") != std::string::npos,
|
||||||
|
"render exposes form identity and controls");
|
||||||
|
check(plain.find("\x1b[") == std::string::npos, "ANSI can be disabled");
|
||||||
|
const auto styled = editor.render(72, 18, true);
|
||||||
|
check(styled.find("\x1b[") != std::string::npos && strip_ansi(styled) == plain,
|
||||||
|
"semantic ANSI attributes do not alter frame geometry");
|
||||||
|
const auto compact = editor.render(24, 6, false);
|
||||||
|
check(std::count(compact.begin(), compact.end(), '\n') == 5,
|
||||||
|
"compact render also fills requested height");
|
||||||
|
|
||||||
|
(void)editor.handle_key("会議 ☕");
|
||||||
|
const auto wide = editor.render(40, 18, false);
|
||||||
|
std::size_t start = 0;
|
||||||
|
bool exact_width = true;
|
||||||
|
while (start <= wide.size()) {
|
||||||
|
const auto end = wide.find('\n', start);
|
||||||
|
const auto line = wide.substr(start, end == std::string::npos ? wide.size() - start
|
||||||
|
: end - start);
|
||||||
|
int cells = 0;
|
||||||
|
for (std::size_t at = 0; at < line.size();) {
|
||||||
|
const auto first = static_cast<unsigned char>(line[at]);
|
||||||
|
std::size_t length = 1;
|
||||||
|
if ((first & 0xe0U) == 0xc0U) length = 2;
|
||||||
|
else if ((first & 0xf0U) == 0xe0U) length = 3;
|
||||||
|
else if ((first & 0xf8U) == 0xf0U) length = 4;
|
||||||
|
const auto fragment = std::string_view{line}.substr(at, length);
|
||||||
|
if (fragment == "会" || fragment == "議" || fragment == "☕") cells += 2;
|
||||||
|
else ++cells;
|
||||||
|
at += length;
|
||||||
|
}
|
||||||
|
exact_width = exact_width && cells == 40;
|
||||||
|
if (end == std::string::npos) break;
|
||||||
|
start = end + 1;
|
||||||
|
}
|
||||||
|
check(exact_width, "wide Unicode input retains exact terminal-cell width");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
test_create_and_validation();
|
||||||
|
test_invalid_civil_inputs();
|
||||||
|
test_edit_preserves_identity_and_metadata();
|
||||||
|
test_all_day_exclusive_end();
|
||||||
|
test_unicode_backspace_navigation_and_cancel();
|
||||||
|
test_rendering();
|
||||||
|
if (failures != 0) {
|
||||||
|
std::cerr << failures << " editor test(s) failed\n";
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
std::cout << "editor tests passed\n";
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
448
tests/ics_tests.cpp
Normal file
448
tests/ics_tests.cpp
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
#include "nocal/storage/ics_store.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <ctime>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <iterator>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using namespace std::chrono;
|
||||||
|
|
||||||
|
void require(bool condition, const char* message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw std::runtime_error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void write_fixture(const std::filesystem::path& path, const std::string& contents) {
|
||||||
|
std::ofstream output(path, std::ios::binary);
|
||||||
|
output << contents;
|
||||||
|
require(static_cast<bool>(output), "failed to write test fixture");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string read_fixture(const std::filesystem::path& path) {
|
||||||
|
std::ifstream input(path, std::ios::binary);
|
||||||
|
require(static_cast<bool>(input), "failed to open test fixture");
|
||||||
|
return {std::istreambuf_iterator<char>{input}, {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
void require_no_temporary_files(const std::filesystem::path& path) {
|
||||||
|
const std::string prefix = "." + path.filename().string() + ".tmp.";
|
||||||
|
for (const auto& entry : std::filesystem::directory_iterator(path.parent_path())) {
|
||||||
|
require(!entry.path().filename().string().starts_with(prefix),
|
||||||
|
"atomic save left a temporary file behind");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nocal::Event event_named(std::string uid, std::string title, int day_offset = 0) {
|
||||||
|
nocal::Event event;
|
||||||
|
event.uid = std::move(uid);
|
||||||
|
event.title = std::move(title);
|
||||||
|
event.start = sys_days{year{2026}/July/17} + days{day_offset} + 9h;
|
||||||
|
event.end = event.start + 1h;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_round_trip_and_folding(const std::filesystem::path& path) {
|
||||||
|
nocal::Event timed;
|
||||||
|
timed.uid = "meeting-1@example.test";
|
||||||
|
timed.title = "Planning, review; and \\ decisions";
|
||||||
|
timed.start = sys_days{year{2026}/July/17} + 9h + 30min + 15s;
|
||||||
|
timed.end = timed.start + 45min;
|
||||||
|
timed.location = "Nomarchy HQ";
|
||||||
|
timed.description = "First line\nA long UTF-8 line: café café café café café café café café café café café café";
|
||||||
|
timed.calendar_id = "work";
|
||||||
|
timed.color = "#89b4fa";
|
||||||
|
|
||||||
|
nocal::Event all_day;
|
||||||
|
all_day.uid = "holiday-1@example.test";
|
||||||
|
all_day.title = "Release day";
|
||||||
|
all_day.start = sys_days{year{2026}/July/20};
|
||||||
|
all_day.end = sys_days{year{2026}/July/22};
|
||||||
|
all_day.all_day = true;
|
||||||
|
|
||||||
|
const std::vector<nocal::Event> source{timed, all_day};
|
||||||
|
const auto saved_revision = nocal::storage::IcsStore::save(path, source);
|
||||||
|
const auto loaded = nocal::storage::IcsStore::load(path);
|
||||||
|
|
||||||
|
require(saved_revision.existed() && saved_revision.size() > 0,
|
||||||
|
"save did not return an existing non-empty revision");
|
||||||
|
require(loaded.revision.existed() && loaded.revision.size() == saved_revision.size(),
|
||||||
|
"load did not retain the exact saved revision");
|
||||||
|
require(loaded.warnings.empty(), "saved calendar produced load warnings");
|
||||||
|
require(loaded.safe_to_rewrite, "nocal-generated calendar was not safe to rewrite");
|
||||||
|
require(loaded.events.size() == 2, "round-trip event count differs");
|
||||||
|
const auto& loaded_timed = loaded.events[0];
|
||||||
|
require(loaded_timed.uid == timed.uid, "UID did not round-trip");
|
||||||
|
require(loaded_timed.title == timed.title, "escaped SUMMARY did not round-trip");
|
||||||
|
require(loaded_timed.start == timed.start && loaded_timed.end == timed.end,
|
||||||
|
"timed interval did not round-trip");
|
||||||
|
require(loaded_timed.description == timed.description, "DESCRIPTION did not round-trip");
|
||||||
|
require(loaded_timed.calendar_id == timed.calendar_id && loaded_timed.color == timed.color,
|
||||||
|
"nocal extension properties did not round-trip");
|
||||||
|
require(loaded.events[1].all_day, "all-day flag did not round-trip");
|
||||||
|
require(loaded.events[1].start == all_day.start && loaded.events[1].end == all_day.end,
|
||||||
|
"all-day interval did not round-trip");
|
||||||
|
|
||||||
|
std::ifstream input(path, std::ios::binary);
|
||||||
|
const std::string bytes{std::istreambuf_iterator<char>{input}, {}};
|
||||||
|
require(bytes.find("\r\n ") != std::string::npos, "long property was not folded");
|
||||||
|
require(bytes.find("Planning\\, review\\; and \\\\ decisions") != std::string::npos,
|
||||||
|
"TEXT escaping was not written");
|
||||||
|
std::size_t offset = 0;
|
||||||
|
while (offset < bytes.size()) {
|
||||||
|
const std::size_t end = bytes.find("\r\n", offset);
|
||||||
|
require(end != std::string::npos, "physical line is not CRLF terminated");
|
||||||
|
require(end - offset <= 75, "folded physical line exceeds 75 octets");
|
||||||
|
offset = end + 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_local_utc_and_recovery(const std::filesystem::path& path) {
|
||||||
|
write_fixture(path,
|
||||||
|
"BEGIN:VCALENDAR\r\n"
|
||||||
|
"VERSION:2.0\r\n"
|
||||||
|
"END:VEVENT\r\n"
|
||||||
|
"BEGIN:VEVENT\r\n"
|
||||||
|
"UID:local\r\n"
|
||||||
|
"DTSTART:20260717T101500\r\n"
|
||||||
|
"DTEND:20260717T111500\r\n"
|
||||||
|
"SUMMARY:Local time\r\n"
|
||||||
|
"END:VEVENT\r\n"
|
||||||
|
"BEGIN:VEVENT\r\n"
|
||||||
|
"UID:utc\r\n"
|
||||||
|
"DTSTART:20260717T101500Z\r\n"
|
||||||
|
"DTEND:20260717T111500Z\r\n"
|
||||||
|
"DESCRIPTION:folded text begins here and continues over a deliberately long value \r\n"
|
||||||
|
" with the rest\r\n"
|
||||||
|
"END:VEVENT\r\n"
|
||||||
|
"BEGIN:VEVENT\r\n"
|
||||||
|
"UID:one-day\r\n"
|
||||||
|
"DTSTART;VALUE=DATE:20260718\r\n"
|
||||||
|
"END:VEVENT\r\n"
|
||||||
|
"BEGIN:VEVENT\r\n"
|
||||||
|
"UID:broken\r\n"
|
||||||
|
"DTSTART:not-a-date\r\n"
|
||||||
|
"END:VEVENT\r\n"
|
||||||
|
"END:VCALENDAR\r\n");
|
||||||
|
|
||||||
|
const auto loaded = nocal::storage::IcsStore::load(path);
|
||||||
|
require(loaded.events.size() == 3, "malformed VEVENT was not isolated");
|
||||||
|
require(!loaded.warnings.empty(), "malformed VEVENT was not diagnosed");
|
||||||
|
require(!loaded.safe_to_rewrite, "malformed source was considered safe to rewrite");
|
||||||
|
require(loaded.events[0].start == loaded.events[1].start,
|
||||||
|
"local and UTC DATE-TIME should agree while tests run in UTC");
|
||||||
|
require(loaded.events[2].all_day, "DATE DTSTART was not recognized");
|
||||||
|
require(loaded.events[2].end - loaded.events[2].start == 24h,
|
||||||
|
"implicit all-day DTEND should be the following day in UTC");
|
||||||
|
require(loaded.events[1].description.find("value with the rest") != std::string::npos,
|
||||||
|
"folded content line was not unfolded");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_missing_file(const std::filesystem::path& path) {
|
||||||
|
std::error_code ignored;
|
||||||
|
std::filesystem::remove(path, ignored);
|
||||||
|
const auto loaded = nocal::storage::IcsStore::load(path);
|
||||||
|
require(loaded.events.empty() && loaded.warnings.empty(),
|
||||||
|
"missing calendar should load as empty without warnings");
|
||||||
|
require(loaded.safe_to_rewrite, "missing calendar should be safe to create");
|
||||||
|
require(!loaded.revision.existed() && loaded.revision.size() == 0,
|
||||||
|
"missing calendar produced an existing revision");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_nested_creation_and_permissions(const std::filesystem::path& root) {
|
||||||
|
const auto path = root / "new" / "nested" / "calendar.ics";
|
||||||
|
nocal::storage::IcsStore::save(path, {});
|
||||||
|
require(std::filesystem::is_regular_file(path), "save did not create a nested calendar path");
|
||||||
|
require(read_fixture(path).starts_with("BEGIN:VCALENDAR\r\n"),
|
||||||
|
"new calendar does not contain a VCALENDAR");
|
||||||
|
require_no_temporary_files(path);
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
struct stat metadata {};
|
||||||
|
require(::stat(path.c_str(), &metadata) == 0, "could not stat saved calendar");
|
||||||
|
require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0,
|
||||||
|
"saved calendar is not readable and writable by its owner");
|
||||||
|
require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0,
|
||||||
|
"saved calendar grants group or other permissions");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_atomic_replacement(const std::filesystem::path& root) {
|
||||||
|
const auto path = root / "replace.ics";
|
||||||
|
write_fixture(path, "old calendar bytes");
|
||||||
|
|
||||||
|
nocal::Event replacement;
|
||||||
|
replacement.uid = "replacement@example.test";
|
||||||
|
replacement.title = "Replacement";
|
||||||
|
replacement.start = sys_days{year{2026}/July/23} + 10h;
|
||||||
|
replacement.end = replacement.start + 30min;
|
||||||
|
const std::vector<nocal::Event> events{replacement};
|
||||||
|
nocal::storage::IcsStore::save(path, events);
|
||||||
|
|
||||||
|
const std::string bytes = read_fixture(path);
|
||||||
|
require(bytes.find("old calendar bytes") == std::string::npos,
|
||||||
|
"save did not replace the existing calendar");
|
||||||
|
require(bytes.find("UID:replacement@example.test") != std::string::npos,
|
||||||
|
"replacement calendar bytes are incomplete");
|
||||||
|
require_no_temporary_files(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_revisions_reject_external_changes(const std::filesystem::path& root) {
|
||||||
|
const auto path = root / "revisions.ics";
|
||||||
|
const auto backup = nocal::storage::IcsStore::backup_path(path);
|
||||||
|
const std::vector<nocal::Event> original_events{
|
||||||
|
event_named("revision-1@example.test", "Original")};
|
||||||
|
const auto missing = nocal::storage::IcsStore::load(path);
|
||||||
|
const auto created = nocal::storage::IcsStore::save(path, original_events, missing.revision);
|
||||||
|
require(created.existed(), "created revision says the calendar does not exist");
|
||||||
|
require(!std::filesystem::exists(backup), "initial creation unexpectedly made a backup");
|
||||||
|
|
||||||
|
const auto loaded = nocal::storage::IcsStore::load(path);
|
||||||
|
const std::string original = read_fixture(path);
|
||||||
|
std::string externally_changed = original;
|
||||||
|
const auto title = externally_changed.find("SUMMARY:Original");
|
||||||
|
require(title != std::string::npos, "test fixture lacks the expected title");
|
||||||
|
externally_changed[title + std::string("SUMMARY:").size()] = 'X';
|
||||||
|
require(externally_changed.size() == original.size(), "external edit changed fixture size");
|
||||||
|
write_fixture(path, externally_changed);
|
||||||
|
write_fixture(backup, "existing backup must remain");
|
||||||
|
|
||||||
|
bool failed = false;
|
||||||
|
std::string message;
|
||||||
|
try {
|
||||||
|
const std::vector<nocal::Event> replacement{
|
||||||
|
event_named("revision-2@example.test", "Replacement")};
|
||||||
|
(void)nocal::storage::IcsStore::save(path, replacement, loaded.revision);
|
||||||
|
} catch (const std::runtime_error& error) {
|
||||||
|
failed = true;
|
||||||
|
message = error.what();
|
||||||
|
}
|
||||||
|
require(failed, "same-size external change was not rejected");
|
||||||
|
require(message.find("reload") != std::string::npos,
|
||||||
|
"external-change error did not tell the caller to reload");
|
||||||
|
require(read_fixture(path) == externally_changed,
|
||||||
|
"conflicting save changed the externally modified calendar");
|
||||||
|
require(read_fixture(backup) == "existing backup must remain",
|
||||||
|
"conflicting save changed the existing backup");
|
||||||
|
require_no_temporary_files(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_backup_replacement_failure_preserves_calendar(const std::filesystem::path& root) {
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
const auto path = root / "backup-replacement-failure.ics";
|
||||||
|
const std::vector<nocal::Event> original_events{
|
||||||
|
event_named("survivor@example.test", "Must survive")};
|
||||||
|
const auto revision = nocal::storage::IcsStore::save(path, original_events);
|
||||||
|
const std::string before = read_fixture(path);
|
||||||
|
const auto backup = nocal::storage::IcsStore::backup_path(path);
|
||||||
|
std::filesystem::create_directory(backup);
|
||||||
|
write_fixture(backup / "sentinel", "backup destination must survive");
|
||||||
|
|
||||||
|
bool failed = false;
|
||||||
|
try {
|
||||||
|
const std::vector<nocal::Event> replacement{
|
||||||
|
event_named("replacement@example.test", "Replacement")};
|
||||||
|
(void)nocal::storage::IcsStore::save(path, replacement, revision);
|
||||||
|
} catch (const std::runtime_error&) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
require(failed, "save unexpectedly replaced a non-empty backup directory");
|
||||||
|
require(read_fixture(path) == before, "backup failure changed the calendar");
|
||||||
|
require(read_fixture(backup / "sentinel") == "backup destination must survive",
|
||||||
|
"backup failure damaged the existing backup destination");
|
||||||
|
require_no_temporary_files(path);
|
||||||
|
require_no_temporary_files(backup);
|
||||||
|
#else
|
||||||
|
(void)root;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_backup_rotation_permissions_and_restore(const std::filesystem::path& root) {
|
||||||
|
const auto path = root / "backup.ics";
|
||||||
|
const auto backup = nocal::storage::IcsStore::backup_path(path);
|
||||||
|
const std::vector<nocal::Event> first_events{event_named("first@example.test", "First")};
|
||||||
|
const auto first_revision = nocal::storage::IcsStore::save(path, first_events);
|
||||||
|
const std::string first_bytes = read_fixture(path);
|
||||||
|
require(!std::filesystem::exists(backup), "initial save created a backup");
|
||||||
|
|
||||||
|
const std::vector<nocal::Event> second_events{event_named("second@example.test", "Second", 1)};
|
||||||
|
const auto second_revision = nocal::storage::IcsStore::save(path, second_events, first_revision);
|
||||||
|
const std::string second_bytes = read_fixture(path);
|
||||||
|
require(read_fixture(backup) == first_bytes, "backup does not contain exact previous bytes");
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
struct stat metadata {};
|
||||||
|
require(::stat(backup.c_str(), &metadata) == 0, "could not stat calendar backup");
|
||||||
|
require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0,
|
||||||
|
"calendar backup grants group or other permissions");
|
||||||
|
require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0,
|
||||||
|
"calendar backup is not owner-readable and owner-writable");
|
||||||
|
#endif
|
||||||
|
|
||||||
|
const std::vector<nocal::Event> third_events{event_named("third@example.test", "Third", 2)};
|
||||||
|
const auto third_revision = nocal::storage::IcsStore::save(path, third_events, second_revision);
|
||||||
|
require(read_fixture(backup) == second_bytes, "backup did not rotate to the previous revision");
|
||||||
|
|
||||||
|
const std::string backup_before_restore = read_fixture(backup);
|
||||||
|
const auto restored = nocal::storage::IcsStore::restore_backup(path, third_revision);
|
||||||
|
require(restored.existed() && restored.size() == backup_before_restore.size(),
|
||||||
|
"restore returned an incorrect revision");
|
||||||
|
require(read_fixture(path) == second_bytes, "restore did not install exact backup bytes");
|
||||||
|
require(read_fixture(backup) == backup_before_restore, "restore changed the backup");
|
||||||
|
|
||||||
|
std::string external = read_fixture(path);
|
||||||
|
external[0] = external[0] == 'B' ? 'X' : 'B';
|
||||||
|
write_fixture(path, external);
|
||||||
|
bool conflict = false;
|
||||||
|
try {
|
||||||
|
(void)nocal::storage::IcsStore::restore_backup(path, restored);
|
||||||
|
} catch (const std::runtime_error&) {
|
||||||
|
conflict = true;
|
||||||
|
}
|
||||||
|
require(conflict, "restore ignored an external calendar change");
|
||||||
|
require(read_fixture(path) == external, "conflicting restore changed the calendar");
|
||||||
|
require(read_fixture(backup) == backup_before_restore,
|
||||||
|
"conflicting restore changed the backup");
|
||||||
|
require_no_temporary_files(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_restore_requires_backup(const std::filesystem::path& root) {
|
||||||
|
const auto path = root / "missing-backup.ics";
|
||||||
|
const std::vector<nocal::Event> events{event_named("only@example.test", "Only")};
|
||||||
|
const auto revision = nocal::storage::IcsStore::save(path, events);
|
||||||
|
const std::string before = read_fixture(path);
|
||||||
|
bool failed = false;
|
||||||
|
try {
|
||||||
|
(void)nocal::storage::IcsStore::restore_backup(path, revision);
|
||||||
|
} catch (const std::runtime_error&) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
require(failed, "restore unexpectedly succeeded without a backup");
|
||||||
|
require(read_fixture(path) == before, "failed restore changed the calendar");
|
||||||
|
require(!std::filesystem::exists(nocal::storage::IcsStore::backup_path(path)),
|
||||||
|
"failed restore created a backup");
|
||||||
|
require_no_temporary_files(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_lock_failure_preserves_existing_bytes(const std::filesystem::path& root) {
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
const auto path = root / "must-survive.ics";
|
||||||
|
const std::string original = "existing bytes must survive";
|
||||||
|
write_fixture(path, original);
|
||||||
|
const auto backup = nocal::storage::IcsStore::backup_path(path);
|
||||||
|
write_fixture(backup, "existing backup must survive");
|
||||||
|
|
||||||
|
std::filesystem::path lock_path = path;
|
||||||
|
lock_path += ".lock";
|
||||||
|
std::filesystem::create_directory(lock_path);
|
||||||
|
bool failed = false;
|
||||||
|
try {
|
||||||
|
nocal::storage::IcsStore::save(path, {});
|
||||||
|
} catch (const std::runtime_error&) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
require(failed, "save unexpectedly succeeded without acquiring its destination lock");
|
||||||
|
require(read_fixture(path) == original, "failed save modified the existing calendar");
|
||||||
|
require(read_fixture(backup) == "existing backup must survive",
|
||||||
|
"failed save modified the existing backup");
|
||||||
|
require_no_temporary_files(path);
|
||||||
|
std::filesystem::remove(lock_path);
|
||||||
|
#else
|
||||||
|
(void)root;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_rename_failure_cleans_temporary_file(const std::filesystem::path& root) {
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
const auto path = root / "destination-is-a-directory.ics";
|
||||||
|
std::filesystem::create_directory(path);
|
||||||
|
const auto sentinel = path / "existing-data";
|
||||||
|
write_fixture(sentinel, "must remain");
|
||||||
|
|
||||||
|
bool failed = false;
|
||||||
|
try {
|
||||||
|
nocal::storage::IcsStore::save(path, {});
|
||||||
|
} catch (const std::runtime_error&) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
require(failed, "save unexpectedly replaced a non-empty directory");
|
||||||
|
require(read_fixture(sentinel) == "must remain", "failed replacement damaged existing data");
|
||||||
|
require_no_temporary_files(path);
|
||||||
|
#else
|
||||||
|
(void)root;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_unsupported_data_is_not_rewrite_safe(const std::filesystem::path& root) {
|
||||||
|
const auto path = root / "unsupported.ics";
|
||||||
|
write_fixture(path,
|
||||||
|
"BEGIN:VCALENDAR\r\n"
|
||||||
|
"VERSION:2.0\r\n"
|
||||||
|
"BEGIN:VEVENT\r\n"
|
||||||
|
"UID:recurring\r\n"
|
||||||
|
"DTSTART;TZID=Europe/London:20260717T101500\r\n"
|
||||||
|
"DTEND;TZID=Europe/London:20260717T111500\r\n"
|
||||||
|
"SUMMARY;LANGUAGE=en:Recurring appointment\r\n"
|
||||||
|
"RRULE:FREQ=WEEKLY\r\n"
|
||||||
|
"ATTENDEE:mailto:person@example.test\r\n"
|
||||||
|
"BEGIN:VALARM\r\n"
|
||||||
|
"ACTION:DISPLAY\r\n"
|
||||||
|
"TRIGGER:-PT10M\r\n"
|
||||||
|
"END:VALARM\r\n"
|
||||||
|
"END:VEVENT\r\n"
|
||||||
|
"X-UNKNOWN-CALENDAR-PROPERTY:keep me\r\n"
|
||||||
|
"END:VCALENDAR\r\n");
|
||||||
|
|
||||||
|
const auto loaded = nocal::storage::IcsStore::load(path);
|
||||||
|
require(!loaded.safe_to_rewrite,
|
||||||
|
"unsupported recurrence, alarm, attendee, parameters, or properties were considered safe");
|
||||||
|
require(!loaded.warnings.empty(), "unsafe calendar did not explain why editing is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
setenv("TZ", "UTC", 1);
|
||||||
|
tzset();
|
||||||
|
#endif
|
||||||
|
const auto root = std::filesystem::temp_directory_path() / "nocal-ics-tests";
|
||||||
|
const auto path = root / "calendar.ics";
|
||||||
|
try {
|
||||||
|
std::error_code ignored;
|
||||||
|
std::filesystem::remove_all(root, ignored);
|
||||||
|
std::filesystem::create_directories(root);
|
||||||
|
test_round_trip_and_folding(path);
|
||||||
|
test_local_utc_and_recovery(path);
|
||||||
|
test_missing_file(path);
|
||||||
|
test_nested_creation_and_permissions(root);
|
||||||
|
test_atomic_replacement(root);
|
||||||
|
test_revisions_reject_external_changes(root);
|
||||||
|
test_backup_replacement_failure_preserves_calendar(root);
|
||||||
|
test_backup_rotation_permissions_and_restore(root);
|
||||||
|
test_restore_requires_backup(root);
|
||||||
|
test_lock_failure_preserves_existing_bytes(root);
|
||||||
|
test_rename_failure_cleans_temporary_file(root);
|
||||||
|
test_unsupported_data_is_not_rewrite_safe(root);
|
||||||
|
std::filesystem::remove_all(root, ignored);
|
||||||
|
std::cout << "ics_tests: ok\n";
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
std::error_code ignored;
|
||||||
|
std::filesystem::remove_all(root, ignored);
|
||||||
|
std::cerr << "ics_tests: " << error.what() << '\n';
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
730
tests/tui_focus_tests.cpp
Normal file
730
tests/tui_focus_tests.cpp
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
#include "nocal/domain/date.hpp"
|
||||||
|
#include "nocal/tui/Screen.hpp"
|
||||||
|
#include "nocal/tui/TuiApp.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
#include <span>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void check(const bool condition, const std::string_view message)
|
||||||
|
{
|
||||||
|
if (!condition) {
|
||||||
|
++failures;
|
||||||
|
std::cerr << "FAIL: " << message << '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nocal::Event make_event(std::string uid, std::string title, const nocal::Date start_day,
|
||||||
|
const unsigned start_hour, const unsigned start_minute,
|
||||||
|
const nocal::Date end_day, const unsigned end_hour,
|
||||||
|
const unsigned end_minute, const bool all_day = false)
|
||||||
|
{
|
||||||
|
nocal::Event event;
|
||||||
|
event.uid = std::move(uid);
|
||||||
|
event.title = std::move(title);
|
||||||
|
event.start = nocal::make_local_time(start_day, start_hour, start_minute);
|
||||||
|
event.end = nocal::make_local_time(end_day, end_hour, end_minute);
|
||||||
|
event.all_day = all_day;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
nocal::tui::ScreenState screen_for(const nocal::Date day, const int width = 100,
|
||||||
|
const int height = 32)
|
||||||
|
{
|
||||||
|
nocal::tui::ScreenState state;
|
||||||
|
state.visible_month = day.year() / day.month();
|
||||||
|
state.selected_day = day;
|
||||||
|
state.today = day;
|
||||||
|
state.width = width;
|
||||||
|
state.height = height;
|
||||||
|
state.ansi = false;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool focus_is(const nocal::tui::TuiApp& app, const std::string_view uid)
|
||||||
|
{
|
||||||
|
return app.state().focused_event_id && *app.state().focused_event_id == uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool same_event(const nocal::Event& left, const nocal::Event& right)
|
||||||
|
{
|
||||||
|
return left.uid == right.uid && left.title == right.title && left.start == right.start &&
|
||||||
|
left.end == right.end && left.all_day == right.all_day &&
|
||||||
|
left.location == right.location && left.description == right.description &&
|
||||||
|
left.calendar_id == right.calendar_id && left.color == right.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
void replace_editor_title(nocal::tui::TuiApp& app, const std::string_view title)
|
||||||
|
{
|
||||||
|
const auto* editor = app.editor();
|
||||||
|
check(editor != nullptr, "title replacement requires an active editor");
|
||||||
|
if (editor == nullptr) return;
|
||||||
|
const auto length = editor->field_value(nocal::tui::EditorField::title).size();
|
||||||
|
for (std::size_t index = 0; index < length; ++index) app.handle_input("\x7f");
|
||||||
|
app.handle_input(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_event_focus_cycles_in_display_order()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const auto day = nocal::today();
|
||||||
|
const auto tomorrow = nocal::from_sys_days(nocal::to_sys_days(day) + days{1});
|
||||||
|
std::vector<nocal::Event> events;
|
||||||
|
events.push_back(make_event("late", "Late review", day, 14, 30, day, 15, 0));
|
||||||
|
events.push_back(make_event("all-day", "Release day", day, 0, 0, tomorrow, 0, 0, true));
|
||||||
|
events.push_back(make_event("early", "Morning review", day, 9, 15, day, 10, 0));
|
||||||
|
|
||||||
|
nocal::tui::TuiApp app{events, -1, -1};
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
check(focus_is(app, "all-day"), "Tab initially focuses the all-day event shown first");
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
check(focus_is(app, "early"), "Tab advances to the earliest timed event");
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
check(focus_is(app, "late"), "Tab advances through timed events chronologically");
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
check(focus_is(app, "all-day"), "Tab wraps from the final event to the first");
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::previous_event);
|
||||||
|
check(focus_is(app, "late"), "Shift-Tab moves backwards and wraps");
|
||||||
|
|
||||||
|
std::vector<nocal::Event> reverse_events = events;
|
||||||
|
nocal::tui::TuiApp reverse_app{reverse_events, -1, -1};
|
||||||
|
reverse_app.dispatch(nocal::tui::Action::previous_event);
|
||||||
|
check(focus_is(reverse_app, "late"),
|
||||||
|
"Shift-Tab with no focus starts at the final event of the day");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_open_close_and_day_movement()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const auto day = nocal::today();
|
||||||
|
std::vector<nocal::Event> events{
|
||||||
|
make_event("reader", "Reader event", day, 11, 0, day, 12, 0),
|
||||||
|
};
|
||||||
|
nocal::tui::TuiApp app{events, -1, -1};
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::select);
|
||||||
|
check(focus_is(app, "reader"), "Enter focuses the first event when none is focused");
|
||||||
|
check(app.state().show_event_details, "Enter opens the focused event reader");
|
||||||
|
app.dispatch(nocal::tui::Action::back);
|
||||||
|
check(!app.state().show_event_details, "Escape closes the event reader");
|
||||||
|
check(focus_is(app, "reader"), "closing the reader keeps the event focused");
|
||||||
|
|
||||||
|
const auto selected = nocal::to_sys_days(app.state().selected_day);
|
||||||
|
app.dispatch(nocal::tui::Action::right);
|
||||||
|
check(nocal::to_sys_days(app.state().selected_day) == selected + days{1},
|
||||||
|
"day navigation still works after closing the reader");
|
||||||
|
check(!app.state().focused_event_id, "moving to another day clears event focus");
|
||||||
|
check(!app.state().show_event_details, "moving to another day leaves the reader closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_empty_day_is_safe()
|
||||||
|
{
|
||||||
|
std::vector<nocal::Event> events;
|
||||||
|
nocal::tui::TuiApp app{events, -1, -1};
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
app.dispatch(nocal::tui::Action::previous_event);
|
||||||
|
app.dispatch(nocal::tui::Action::select);
|
||||||
|
app.dispatch(nocal::tui::Action::back);
|
||||||
|
check(!app.state().focused_event_id, "event navigation on an empty day keeps focus empty");
|
||||||
|
check(!app.state().show_event_details, "Enter on an empty day does not open the reader");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_reader_navigation_and_synthetic_ids()
|
||||||
|
{
|
||||||
|
const auto day = nocal::today();
|
||||||
|
std::vector<nocal::Event> events;
|
||||||
|
events.push_back(make_event("duplicate", "First duplicate", day, 8, 0, day, 9, 0));
|
||||||
|
events.push_back(make_event("duplicate", "Second duplicate", day, 10, 0, day, 11, 0));
|
||||||
|
events.push_back(make_event("", "No UID", day, 12, 0, day, 13, 0));
|
||||||
|
nocal::tui::TuiApp app{events, -1, -1};
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::select);
|
||||||
|
check(focus_is(app, "@nocal:0"), "duplicate UIDs receive a focusable source identity");
|
||||||
|
check(app.state().show_event_details, "synthetic identities resolve in the reader");
|
||||||
|
app.dispatch(nocal::tui::Action::down);
|
||||||
|
check(focus_is(app, "@nocal:1"), "reader Down advances to the next appointment");
|
||||||
|
app.dispatch(nocal::tui::Action::up);
|
||||||
|
check(focus_is(app, "@nocal:0"), "reader Up returns to the previous appointment");
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
check(focus_is(app, "@nocal:2"), "an empty UID remains focusable and readable");
|
||||||
|
const auto frame = nocal::tui::render_month(events, app.state());
|
||||||
|
check(frame.find("No UID") != std::string::npos,
|
||||||
|
"renderer shares the controller's synthetic identity scheme");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_key_decoding()
|
||||||
|
{
|
||||||
|
using nocal::tui::Action;
|
||||||
|
using nocal::tui::decode_key;
|
||||||
|
check(decode_key("\t") == Action::next_event, "Tab decodes as next event");
|
||||||
|
check(decode_key("\x1b[Z") == Action::previous_event,
|
||||||
|
"terminal Shift-Tab decodes as previous event");
|
||||||
|
check(decode_key("\r") == Action::select, "carriage-return Enter decodes as select");
|
||||||
|
check(decode_key("\n") == Action::select, "newline Enter decodes as select");
|
||||||
|
check(decode_key("\x1b") == Action::back, "a lone Escape decodes as back");
|
||||||
|
check(decode_key("\x1b[A") == Action::up, "arrow decoding remains intact");
|
||||||
|
check(decode_key("a") == Action::add_event, "a decodes as add appointment");
|
||||||
|
check(decode_key("e") == Action::edit_event, "e decodes as edit appointment");
|
||||||
|
check(decode_key("d") == Action::delete_event, "d decodes as delete appointment");
|
||||||
|
check(decode_key("u") == Action::undo, "u decodes as undo");
|
||||||
|
check(decode_key("\x12") == Action::redo, "Ctrl-R decodes as redo");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_crud_preconditions_and_add()
|
||||||
|
{
|
||||||
|
std::vector<nocal::Event> events;
|
||||||
|
int save_calls = 0;
|
||||||
|
std::vector<nocal::Event> saved;
|
||||||
|
nocal::tui::TuiApp app{
|
||||||
|
events,
|
||||||
|
[&](const std::span<const nocal::Event> values) {
|
||||||
|
++save_calls;
|
||||||
|
saved.assign(values.begin(), values.end());
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
};
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::edit_event);
|
||||||
|
check(!app.editor_active(), "edit without a focused appointment does not open an editor");
|
||||||
|
check(app.state().notification_is_error,
|
||||||
|
"edit without focus explains its appointment precondition");
|
||||||
|
app.dispatch(nocal::tui::Action::delete_event);
|
||||||
|
check(!app.delete_confirmation_active(),
|
||||||
|
"delete without a focused appointment does not open confirmation");
|
||||||
|
check(app.state().notification_is_error,
|
||||||
|
"delete without focus explains its appointment precondition");
|
||||||
|
|
||||||
|
const auto selected_day = app.state().selected_day;
|
||||||
|
app.dispatch(nocal::tui::Action::add_event);
|
||||||
|
check(app.editor_active(), "add opens the event editor on an empty day");
|
||||||
|
check(app.editor() != nullptr &&
|
||||||
|
app.editor()->field_value(nocal::tui::EditorField::start_date) ==
|
||||||
|
nocal::format_date(selected_day),
|
||||||
|
"add initializes the editor from the selected date");
|
||||||
|
check(app.editor() != nullptr &&
|
||||||
|
app.editor()->render(60, 18, false).find("NEW APPOINTMENT") != std::string::npos,
|
||||||
|
"the active add editor can be rendered by the controller");
|
||||||
|
app.handle_input("Created locally");
|
||||||
|
app.handle_input("\x13");
|
||||||
|
|
||||||
|
check(!app.editor_active(), "a valid Ctrl-S submit closes the add editor");
|
||||||
|
check(events.size() == 1 && events.front().title == "Created locally",
|
||||||
|
"add commits the validated editor result to the local model");
|
||||||
|
check(save_calls == 1 && saved.size() == 1 && saved.front().title == "Created locally",
|
||||||
|
"add calls the persistence boundary with the mutated model");
|
||||||
|
check(!events.front().uid.empty() && focus_is(app, events.front().uid),
|
||||||
|
"a successful add focuses the new appointment");
|
||||||
|
check(app.state().notification == "Appointment added." &&
|
||||||
|
!app.state().notification_is_error,
|
||||||
|
"a successful add reports a non-error status");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_edit_preserves_uid_and_rolls_back_on_save_failure()
|
||||||
|
{
|
||||||
|
const auto day = nocal::today();
|
||||||
|
auto original = make_event("stable-uid", "Original title", day, 9, 0, day, 10, 0);
|
||||||
|
original.location = "Desk";
|
||||||
|
original.description = "Original notes";
|
||||||
|
original.calendar_id = "personal";
|
||||||
|
original.color = "blue";
|
||||||
|
std::vector<nocal::Event> events{original};
|
||||||
|
int save_calls = 0;
|
||||||
|
nocal::tui::TuiApp app{
|
||||||
|
events,
|
||||||
|
[&](std::span<const nocal::Event>) {
|
||||||
|
++save_calls;
|
||||||
|
throw std::runtime_error{"disk full"};
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
};
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
app.dispatch(nocal::tui::Action::edit_event);
|
||||||
|
check(app.editor_active() && app.editor() != nullptr && app.editor()->editing(),
|
||||||
|
"edit opens a populated editing form for the focused appointment");
|
||||||
|
replace_editor_title(app, "Changed title");
|
||||||
|
app.handle_input("\x13");
|
||||||
|
|
||||||
|
check(save_calls == 1, "edit attempts persistence exactly once");
|
||||||
|
check(events.size() == 1 && same_event(events.front(), original),
|
||||||
|
"a throwing saver restores the exact event vector after edit");
|
||||||
|
check(focus_is(app, "stable-uid"), "edit rollback restores the original focus identity");
|
||||||
|
check(!app.editor_active(), "save failure returns to the month so its error is visible");
|
||||||
|
check(app.state().notification_is_error &&
|
||||||
|
app.state().notification.find("disk full") != std::string::npos,
|
||||||
|
"save failure is shown to the user with the persistence error");
|
||||||
|
|
||||||
|
int successful_saves = 0;
|
||||||
|
nocal::tui::TuiApp successful_app{
|
||||||
|
events,
|
||||||
|
[&](std::span<const nocal::Event>) { ++successful_saves; },
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
};
|
||||||
|
successful_app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
successful_app.dispatch(nocal::tui::Action::edit_event);
|
||||||
|
replace_editor_title(successful_app, "Changed title");
|
||||||
|
successful_app.handle_input("\x13");
|
||||||
|
check(successful_saves == 1 && events.front().title == "Changed title",
|
||||||
|
"a successful edit commits and persists its changed fields");
|
||||||
|
check(events.front().uid == "stable-uid",
|
||||||
|
"editing preserves the appointment UID used by storage and focus");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_delete_cancel_confirm_and_rollback()
|
||||||
|
{
|
||||||
|
const auto day = nocal::today();
|
||||||
|
const auto target = make_event("delete-me", "Delete target", day, 9, 0, day, 10, 0);
|
||||||
|
const auto keep = make_event("keep-me", "Keep target", day, 11, 0, day, 12, 0);
|
||||||
|
std::vector<nocal::Event> events{target, keep};
|
||||||
|
int save_calls = 0;
|
||||||
|
nocal::tui::TuiApp app{
|
||||||
|
events,
|
||||||
|
[&](std::span<const nocal::Event>) { ++save_calls; },
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
};
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
app.dispatch(nocal::tui::Action::delete_event);
|
||||||
|
check(app.delete_confirmation_active(), "delete requires an explicit confirmation step");
|
||||||
|
const auto confirmation = nocal::tui::render_month(events, app.state());
|
||||||
|
check(confirmation.find("Delete target") != std::string::npos &&
|
||||||
|
confirmation.find("y confirm") != std::string::npos,
|
||||||
|
"delete confirmation names the appointment and shows its keys");
|
||||||
|
app.handle_input("n");
|
||||||
|
check(!app.delete_confirmation_active() && events.size() == 2 && save_calls == 0,
|
||||||
|
"n cancels deletion without mutating or saving");
|
||||||
|
check(focus_is(app, "delete-me"), "cancelled deletion retains appointment focus");
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::delete_event);
|
||||||
|
app.handle_input("y");
|
||||||
|
check(events.size() == 1 && events.front().uid == "keep-me" && save_calls == 1,
|
||||||
|
"y deletes the focused appointment and persists once");
|
||||||
|
check(!app.state().focused_event_id &&
|
||||||
|
app.state().notification == "Appointment deleted.",
|
||||||
|
"successful deletion clears stale focus and reports success");
|
||||||
|
|
||||||
|
std::vector<nocal::Event> rollback_events{target};
|
||||||
|
nocal::tui::TuiApp rollback_app{
|
||||||
|
rollback_events,
|
||||||
|
[](std::span<const nocal::Event>) { throw std::runtime_error{"read only"}; },
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
};
|
||||||
|
rollback_app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
rollback_app.dispatch(nocal::tui::Action::delete_event);
|
||||||
|
rollback_app.handle_input("y");
|
||||||
|
check(rollback_events.size() == 1 && same_event(rollback_events.front(), target),
|
||||||
|
"a throwing saver restores the exact vector after delete");
|
||||||
|
check(focus_is(rollback_app, "delete-me"),
|
||||||
|
"delete rollback restores the focused appointment identity");
|
||||||
|
check(rollback_app.state().notification_is_error,
|
||||||
|
"delete rollback displays a persistence error");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_crud_targets_synthetic_identities()
|
||||||
|
{
|
||||||
|
const auto day = nocal::today();
|
||||||
|
std::vector<nocal::Event> events{
|
||||||
|
make_event("duplicate", "First duplicate", day, 8, 0, day, 9, 0),
|
||||||
|
make_event("duplicate", "Second duplicate", day, 10, 0, day, 11, 0),
|
||||||
|
make_event("", "Empty UID", day, 12, 0, day, 13, 0),
|
||||||
|
};
|
||||||
|
nocal::tui::TuiApp app{events, -1, -1};
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
check(focus_is(app, "@nocal:0"), "the first duplicate starts with its synthetic identity");
|
||||||
|
app.dispatch(nocal::tui::Action::edit_event);
|
||||||
|
replace_editor_title(app, "Edited first duplicate");
|
||||||
|
app.handle_input("\x13");
|
||||||
|
check(events[0].title == "Edited first duplicate" &&
|
||||||
|
events[1].title == "Second duplicate" && events[0].uid == "duplicate",
|
||||||
|
"edit resolves a synthetic identity to exactly the intended source event");
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
check(focus_is(app, "@nocal:2"), "empty UID remains targetable after editing a duplicate");
|
||||||
|
app.dispatch(nocal::tui::Action::delete_event);
|
||||||
|
app.handle_input("y");
|
||||||
|
check(events.size() == 2 && events[0].title == "Edited first duplicate" &&
|
||||||
|
events[1].title == "Second duplicate",
|
||||||
|
"delete resolves an empty-UID synthetic identity to the intended source event");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_add_edit_delete_undo_redo()
|
||||||
|
{
|
||||||
|
std::vector<nocal::Event> events;
|
||||||
|
int save_calls = 0;
|
||||||
|
nocal::tui::TuiApp app{
|
||||||
|
events,
|
||||||
|
[&](std::span<const nocal::Event>) { ++save_calls; },
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
};
|
||||||
|
const auto original_day = app.state().selected_day;
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::add_event);
|
||||||
|
app.handle_input("History event");
|
||||||
|
app.handle_input("\x13");
|
||||||
|
check(events.size() == 1, "history test creates an appointment");
|
||||||
|
const auto added = events.front();
|
||||||
|
check(focus_is(app, added.uid), "successful add establishes the post-mutation focus");
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::undo);
|
||||||
|
check(events.empty() && !app.state().focused_event_id &&
|
||||||
|
app.state().selected_day == original_day,
|
||||||
|
"undo add restores the exact pre-add model and selection state");
|
||||||
|
check(app.state().notification == "Undid add appointment.",
|
||||||
|
"undo add names the reversed operation");
|
||||||
|
app.dispatch(nocal::tui::Action::redo);
|
||||||
|
check(events.size() == 1 && same_event(events.front(), added) && focus_is(app, added.uid),
|
||||||
|
"redo add restores the exact event metadata and post-add focus");
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::select);
|
||||||
|
check(app.state().show_event_details, "reader is open before starting the edit");
|
||||||
|
app.dispatch(nocal::tui::Action::edit_event);
|
||||||
|
replace_editor_title(app, "Edited through history");
|
||||||
|
app.handle_input("\x13");
|
||||||
|
const auto edited = events.front();
|
||||||
|
check(edited.title == "Edited through history", "history test commits an edit");
|
||||||
|
app.dispatch(nocal::tui::Action::undo);
|
||||||
|
check(events.size() == 1 && same_event(events.front(), added) && focus_is(app, added.uid) &&
|
||||||
|
app.state().show_event_details,
|
||||||
|
"undo edit restores metadata, focus, and the pre-editor reader state");
|
||||||
|
app.dispatch(nocal::tui::Action::redo);
|
||||||
|
check(events.size() == 1 && same_event(events.front(), edited) && focus_is(app, edited.uid) &&
|
||||||
|
!app.state().show_event_details,
|
||||||
|
"redo edit restores the exact edited model and post-editor view state");
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::delete_event);
|
||||||
|
app.handle_input("y");
|
||||||
|
check(events.empty(), "history test commits a deletion");
|
||||||
|
app.dispatch(nocal::tui::Action::undo);
|
||||||
|
check(events.size() == 1 && same_event(events.front(), edited) && focus_is(app, edited.uid),
|
||||||
|
"undo delete restores the exact deleted event and its focus");
|
||||||
|
app.dispatch(nocal::tui::Action::redo);
|
||||||
|
check(events.empty() && !app.state().focused_event_id,
|
||||||
|
"redo delete restores the empty model and cleared focus");
|
||||||
|
check(save_calls == 9,
|
||||||
|
"three mutations and their undo/redo operations each persist exactly once");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_history_persistence_failure_is_transactional()
|
||||||
|
{
|
||||||
|
const auto day = nocal::today();
|
||||||
|
const auto original = make_event("history-failure", "Original", day, 9, 0, day, 10, 0);
|
||||||
|
std::vector<nocal::Event> events{original};
|
||||||
|
bool fail = false;
|
||||||
|
int save_calls = 0;
|
||||||
|
nocal::tui::TuiApp app{
|
||||||
|
events,
|
||||||
|
[&](std::span<const nocal::Event>) {
|
||||||
|
++save_calls;
|
||||||
|
if (fail) throw std::runtime_error{"history disk failure"};
|
||||||
|
},
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
};
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
app.dispatch(nocal::tui::Action::edit_event);
|
||||||
|
replace_editor_title(app, "Changed");
|
||||||
|
app.handle_input("\x13");
|
||||||
|
const auto changed = events.front();
|
||||||
|
|
||||||
|
fail = true;
|
||||||
|
app.dispatch(nocal::tui::Action::undo);
|
||||||
|
check(events.size() == 1 && same_event(events.front(), changed) &&
|
||||||
|
focus_is(app, "history-failure"),
|
||||||
|
"failed undo persistence leaves the current model and focus untouched");
|
||||||
|
check(app.state().notification_is_error &&
|
||||||
|
app.state().notification.find("history disk failure") != std::string::npos,
|
||||||
|
"failed undo reports the persistence error");
|
||||||
|
|
||||||
|
fail = false;
|
||||||
|
app.dispatch(nocal::tui::Action::undo);
|
||||||
|
check(events.size() == 1 && same_event(events.front(), original),
|
||||||
|
"undo remains available after a failed persistence attempt");
|
||||||
|
|
||||||
|
fail = true;
|
||||||
|
app.dispatch(nocal::tui::Action::redo);
|
||||||
|
check(events.size() == 1 && same_event(events.front(), original) &&
|
||||||
|
focus_is(app, "history-failure"),
|
||||||
|
"failed redo persistence leaves the restored model and focus untouched");
|
||||||
|
fail = false;
|
||||||
|
app.dispatch(nocal::tui::Action::redo);
|
||||||
|
check(events.size() == 1 && same_event(events.front(), changed),
|
||||||
|
"redo remains available after a failed persistence attempt");
|
||||||
|
check(save_calls == 5, "successful mutation plus four history attempts call the saver");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_new_mutation_invalidates_redo()
|
||||||
|
{
|
||||||
|
const auto day = nocal::today();
|
||||||
|
std::vector<nocal::Event> events{
|
||||||
|
make_event("redo-invalidation", "Original", day, 9, 0, day, 10, 0),
|
||||||
|
};
|
||||||
|
nocal::tui::TuiApp app{events, -1, -1};
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
app.dispatch(nocal::tui::Action::edit_event);
|
||||||
|
replace_editor_title(app, "First branch");
|
||||||
|
app.handle_input("\x13");
|
||||||
|
app.dispatch(nocal::tui::Action::undo);
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::edit_event);
|
||||||
|
replace_editor_title(app, "Second branch");
|
||||||
|
app.handle_input("\x13");
|
||||||
|
app.dispatch(nocal::tui::Action::redo);
|
||||||
|
check(events.front().title == "Second branch",
|
||||||
|
"a new successful mutation after undo invalidates the redo branch");
|
||||||
|
check(app.state().notification_is_error && app.state().notification == "Nothing to redo.",
|
||||||
|
"an invalidated redo branch gives contextual feedback");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_history_is_bounded_to_one_hundred_mutations()
|
||||||
|
{
|
||||||
|
const auto day = nocal::today();
|
||||||
|
std::vector<nocal::Event> events{
|
||||||
|
make_event("bounded-history", "Version 0", day, 9, 0, day, 10, 0),
|
||||||
|
};
|
||||||
|
nocal::tui::TuiApp app{events, -1, -1};
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
for (int version = 1; version <= 101; ++version) {
|
||||||
|
app.dispatch(nocal::tui::Action::edit_event);
|
||||||
|
replace_editor_title(app, "Version " + std::to_string(version));
|
||||||
|
app.handle_input("\x13");
|
||||||
|
}
|
||||||
|
for (int count = 0; count < 100; ++count) app.dispatch(nocal::tui::Action::undo);
|
||||||
|
check(events.front().title == "Version 1",
|
||||||
|
"the bounded history retains the most recent one hundred mutations");
|
||||||
|
app.dispatch(nocal::tui::Action::undo);
|
||||||
|
check(events.front().title == "Version 1" &&
|
||||||
|
app.state().notification == "Nothing to undo.",
|
||||||
|
"the oldest mutation is discarded once the one-hundred entry bound is reached");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_history_is_unavailable_in_modal_states()
|
||||||
|
{
|
||||||
|
const auto day = nocal::today();
|
||||||
|
std::vector<nocal::Event> events{
|
||||||
|
make_event("modal-history", "Modal event", day, 9, 0, day, 10, 0),
|
||||||
|
};
|
||||||
|
nocal::tui::TuiApp app{events, -1, -1};
|
||||||
|
app.dispatch(nocal::tui::Action::add_event);
|
||||||
|
app.dispatch(nocal::tui::Action::undo);
|
||||||
|
check(app.editor_active() && app.state().notification_is_error,
|
||||||
|
"undo is unavailable while the editor owns input");
|
||||||
|
app.dispatch(nocal::tui::Action::back);
|
||||||
|
|
||||||
|
app.dispatch(nocal::tui::Action::next_event);
|
||||||
|
auto footer_state = screen_for(day, 180, 32);
|
||||||
|
footer_state.focused_event_id = "modal-history";
|
||||||
|
const auto month_frame = nocal::tui::render_month(events, footer_state);
|
||||||
|
check(month_frame.find("u undo") != std::string::npos &&
|
||||||
|
month_frame.find("Ctrl-R redo") != std::string::npos,
|
||||||
|
"the month footer advertises both history shortcuts");
|
||||||
|
app.dispatch(nocal::tui::Action::delete_event);
|
||||||
|
app.handle_input("u");
|
||||||
|
check(app.delete_confirmation_active() && app.state().notification_is_error &&
|
||||||
|
app.state().notification.find("deletion") != std::string::npos,
|
||||||
|
"history shortcuts are unavailable during delete confirmation");
|
||||||
|
const auto confirmation = nocal::tui::render_month(events, app.state());
|
||||||
|
check(confirmation.find("Finish or cancel deletion") != std::string::npos,
|
||||||
|
"delete confirmation renders its contextual history error");
|
||||||
|
app.handle_input("n");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_focused_line_rendering()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const auto day = year{2026} / July / std::chrono::day{17};
|
||||||
|
auto state = screen_for(day, 112, 38);
|
||||||
|
state.focused_event_id = "focused";
|
||||||
|
const std::vector<nocal::tui::CalendarItem> items{
|
||||||
|
{.id = "plain",
|
||||||
|
.title = "Plain item",
|
||||||
|
.day = day,
|
||||||
|
.time_of_day = hours{8},
|
||||||
|
.all_day = false,
|
||||||
|
.end_time_of_day = hours{8} + minutes{30},
|
||||||
|
.start_day = day,
|
||||||
|
.end_day = day,
|
||||||
|
.location = {},
|
||||||
|
.description = {},
|
||||||
|
.detail_title = "Plain item",
|
||||||
|
.detail_all_day = false,
|
||||||
|
.detail_start_time_of_day = hours{8}},
|
||||||
|
{.id = "focused",
|
||||||
|
.title = "Focused item",
|
||||||
|
.day = day,
|
||||||
|
.time_of_day = hours{9} + minutes{15},
|
||||||
|
.all_day = false,
|
||||||
|
.end_time_of_day = hours{10},
|
||||||
|
.start_day = day,
|
||||||
|
.end_day = day,
|
||||||
|
.location = {},
|
||||||
|
.description = {},
|
||||||
|
.detail_title = "Focused item",
|
||||||
|
.detail_all_day = false,
|
||||||
|
.detail_start_time_of_day = hours{9} + minutes{15}},
|
||||||
|
};
|
||||||
|
|
||||||
|
const auto frame = nocal::tui::render_month(items, state);
|
||||||
|
check(frame.find("› 09:15 Focu") != std::string::npos,
|
||||||
|
"the focused appointment line has a visible cursor when ANSI is disabled");
|
||||||
|
check(frame.find("› 08:00 Plain") == std::string::npos,
|
||||||
|
"the focus cursor is only rendered on the focused appointment");
|
||||||
|
check(frame.find("\x1b[") == std::string::npos,
|
||||||
|
"focused line rendering respects disabled ANSI styling");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_focus_brings_hidden_event_into_view()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const auto day = year{2026} / July / std::chrono::day{17};
|
||||||
|
auto state = screen_for(day, 100, 30);
|
||||||
|
state.focused_event_id = "last";
|
||||||
|
std::vector<nocal::tui::CalendarItem> items;
|
||||||
|
for (unsigned index = 0; index < 6; ++index) {
|
||||||
|
nocal::tui::CalendarItem item;
|
||||||
|
item.id = index == 5 ? "last" : "event-" + std::to_string(index);
|
||||||
|
item.title = index == 5 ? "Hidden focus target" : "Earlier event";
|
||||||
|
item.day = day;
|
||||||
|
item.time_of_day = hours{8 + index};
|
||||||
|
items.push_back(std::move(item));
|
||||||
|
}
|
||||||
|
const auto frame = nocal::tui::render_month(items, state);
|
||||||
|
check(frame.find("› 13:00") != std::string::npos,
|
||||||
|
"focused overflow event is windowed into the visible cell");
|
||||||
|
check(frame.find("hidden") != std::string::npos,
|
||||||
|
"dense cells retain an overflow indicator while focus is windowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_timed_event_details()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const auto day = year{2026} / July / std::chrono::day{17};
|
||||||
|
auto event = make_event("detail", "Architecture review", day, 9, 15, day, 10, 45);
|
||||||
|
event.location = "Studio 4B";
|
||||||
|
event.description = "Review keyboard navigation and approve the terminal layout.";
|
||||||
|
const std::vector<nocal::Event> events{event};
|
||||||
|
auto state = screen_for(day, 80, 24);
|
||||||
|
state.focused_event_id = "detail";
|
||||||
|
state.show_event_details = true;
|
||||||
|
|
||||||
|
const auto frame = nocal::tui::render_month(events, state);
|
||||||
|
check(frame.find("APPOINTMENT") != std::string::npos, "the reader has a clear heading");
|
||||||
|
check(frame.find("Architecture review") != std::string::npos,
|
||||||
|
"the reader renders the full appointment title");
|
||||||
|
check(frame.find("Friday, 17 July 2026") != std::string::npos,
|
||||||
|
"the reader renders the full friendly date");
|
||||||
|
check(frame.find("09:15 – 10:45") != std::string::npos,
|
||||||
|
"the reader renders the complete local time range");
|
||||||
|
check(frame.find("Studio 4B") != std::string::npos,
|
||||||
|
"the reader renders the event location");
|
||||||
|
check(frame.find("Review keyboard navigation and approve the terminal layout.") !=
|
||||||
|
std::string::npos,
|
||||||
|
"the reader renders the event description");
|
||||||
|
check(frame.find("Esc back") != std::string::npos, "the reader footer explains how to close it");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_all_day_and_multiday_details()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const auto first = year{2026} / July / std::chrono::day{16};
|
||||||
|
const auto middle = year{2026} / July / std::chrono::day{17};
|
||||||
|
const auto last = year{2026} / July / std::chrono::day{18};
|
||||||
|
const auto after = year{2026} / July / std::chrono::day{19};
|
||||||
|
|
||||||
|
const std::vector<nocal::Event> all_day{
|
||||||
|
make_event("festival", "Terminal festival", first, 0, 0, after, 0, 0, true),
|
||||||
|
};
|
||||||
|
auto all_day_state = screen_for(middle, 80, 24);
|
||||||
|
all_day_state.focused_event_id = "festival";
|
||||||
|
all_day_state.show_event_details = true;
|
||||||
|
const auto all_day_frame = nocal::tui::render_month(all_day, all_day_state);
|
||||||
|
check(all_day_frame.find("Time All day") != std::string::npos,
|
||||||
|
"all-day events are described as all day");
|
||||||
|
check(all_day_frame.find("Ends Saturday, 18 July 2026") != std::string::npos,
|
||||||
|
"exclusive all-day ends are presented as the last occupied day");
|
||||||
|
|
||||||
|
const std::vector<nocal::Event> timed{
|
||||||
|
make_event("overnight", "Long migration", first, 20, 0, last, 11, 30),
|
||||||
|
};
|
||||||
|
auto timed_state = screen_for(middle, 80, 24);
|
||||||
|
timed_state.focused_event_id = "overnight";
|
||||||
|
timed_state.show_event_details = true;
|
||||||
|
const auto timed_frame = nocal::tui::render_month(timed, timed_state);
|
||||||
|
check(timed_frame.find("Thursday, 16 July 2026") != std::string::npos,
|
||||||
|
"a multi-day reader retains the event's actual start date");
|
||||||
|
check(timed_frame.find("Ends Saturday, 18 July 2026") != std::string::npos,
|
||||||
|
"a timed multi-day reader renders its end date");
|
||||||
|
check(timed_frame.find("20:00 – 11:30") != std::string::npos,
|
||||||
|
"a timed multi-day continuation retains its real times");
|
||||||
|
check(timed_frame.find("Time All day") == std::string::npos,
|
||||||
|
"a timed multi-day continuation is not mislabeled all day");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_narrow_detail_wrapping_without_ansi()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const auto day = year{2026} / July / std::chrono::day{17};
|
||||||
|
auto event = make_event("wrap", "Wrap check", day, 13, 0, day, 14, 0);
|
||||||
|
event.description = "Alpha beta gamma delta epsilon zeta eta theta.";
|
||||||
|
const std::vector<nocal::Event> events{event};
|
||||||
|
auto state = screen_for(day, 32, 20);
|
||||||
|
state.focused_event_id = "wrap";
|
||||||
|
state.show_event_details = true;
|
||||||
|
|
||||||
|
const auto frame = nocal::tui::render_month(events, state);
|
||||||
|
check(frame.find("Alpha beta gamma delta epsilon zeta eta theta.") == std::string::npos,
|
||||||
|
"narrow readers wrap descriptions instead of overflowing a line");
|
||||||
|
check(frame.find("Alpha beta gamma delta") != std::string::npos,
|
||||||
|
"the first wrapped description line is retained");
|
||||||
|
check(frame.find("epsilon zeta eta theta.") != std::string::npos,
|
||||||
|
"the remaining wrapped description text is retained");
|
||||||
|
check(frame.find("\x1b[") == std::string::npos,
|
||||||
|
"narrow detail rendering emits no ANSI when styling is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
test_event_focus_cycles_in_display_order();
|
||||||
|
test_open_close_and_day_movement();
|
||||||
|
test_empty_day_is_safe();
|
||||||
|
test_reader_navigation_and_synthetic_ids();
|
||||||
|
test_key_decoding();
|
||||||
|
test_crud_preconditions_and_add();
|
||||||
|
test_edit_preserves_uid_and_rolls_back_on_save_failure();
|
||||||
|
test_delete_cancel_confirm_and_rollback();
|
||||||
|
test_crud_targets_synthetic_identities();
|
||||||
|
test_add_edit_delete_undo_redo();
|
||||||
|
test_history_persistence_failure_is_transactional();
|
||||||
|
test_new_mutation_invalidates_redo();
|
||||||
|
test_history_is_bounded_to_one_hundred_mutations();
|
||||||
|
test_history_is_unavailable_in_modal_states();
|
||||||
|
test_focused_line_rendering();
|
||||||
|
test_focus_brings_hidden_event_into_view();
|
||||||
|
test_timed_event_details();
|
||||||
|
test_all_day_and_multiday_details();
|
||||||
|
test_narrow_detail_wrapping_without_ansi();
|
||||||
|
if (failures != 0) {
|
||||||
|
std::cerr << failures << " TUI focus test(s) failed\n";
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
std::cout << "TUI focus tests passed\n";
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
115
tests/tui_tests.cpp
Normal file
115
tests/tui_tests.cpp
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#include "nocal/domain/date.hpp"
|
||||||
|
#include "nocal/tui/Screen.hpp"
|
||||||
|
#include "nocal/tui/TuiApp.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void check(bool condition, const char* message)
|
||||||
|
{
|
||||||
|
if (!condition) {
|
||||||
|
++failures;
|
||||||
|
std::cerr << "FAIL: " << message << '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_full_month_render()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const nocal::tui::ScreenState state{
|
||||||
|
.visible_month = year{2026} / July,
|
||||||
|
.selected_day = year{2026} / July / day{17},
|
||||||
|
.today = year{2026} / July / day{17},
|
||||||
|
.width = 100,
|
||||||
|
.height = 30,
|
||||||
|
.show_help = false,
|
||||||
|
.ansi = false,
|
||||||
|
.focused_event_id = std::nullopt,
|
||||||
|
.show_event_details = false,
|
||||||
|
.confirm_delete = false,
|
||||||
|
.notification = {},
|
||||||
|
.notification_is_error = false,
|
||||||
|
};
|
||||||
|
const std::vector<nocal::tui::CalendarItem> items{
|
||||||
|
{.id = "1", .title = "Design review", .day = year{2026} / July / day{17},
|
||||||
|
.time_of_day = hours{9} + minutes{30}, .all_day = false,
|
||||||
|
.end_time_of_day = std::nullopt, .start_day = {}, .end_day = {},
|
||||||
|
.location = {}, .description = {}, .detail_title = {},
|
||||||
|
.detail_all_day = false, .detail_start_time_of_day = std::nullopt},
|
||||||
|
{.id = "2", .title = "Release day", .day = year{2026} / July / day{17},
|
||||||
|
.time_of_day = std::nullopt, .all_day = true,
|
||||||
|
.end_time_of_day = std::nullopt, .start_day = {}, .end_day = {},
|
||||||
|
.location = {}, .description = {}, .detail_title = {},
|
||||||
|
.detail_all_day = true, .detail_start_time_of_day = std::nullopt},
|
||||||
|
};
|
||||||
|
|
||||||
|
const auto frame = nocal::tui::render_month(items, state);
|
||||||
|
check(frame.find("Design") != std::string::npos, "appointment appears in its day cell");
|
||||||
|
check(frame.find("Release") != std::string::npos, "all-day appointment appears in its day cell");
|
||||||
|
check(frame.find("July 2026") != std::string::npos, "month title is rendered");
|
||||||
|
check(std::count(frame.begin(), frame.end(), '\n') == 29,
|
||||||
|
"renderer fills the requested height deterministically");
|
||||||
|
check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_compact_and_input()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
const nocal::tui::ScreenState state{
|
||||||
|
.visible_month = year{2026} / July,
|
||||||
|
.selected_day = year{2026} / July / day{17},
|
||||||
|
.today = year{2026} / July / day{17},
|
||||||
|
.width = 30,
|
||||||
|
.height = 12,
|
||||||
|
.show_help = false,
|
||||||
|
.ansi = false,
|
||||||
|
.focused_event_id = std::nullopt,
|
||||||
|
.show_event_details = false,
|
||||||
|
.confirm_delete = false,
|
||||||
|
.notification = {},
|
||||||
|
.notification_is_error = false,
|
||||||
|
};
|
||||||
|
const auto frame = nocal::tui::render_month(std::span<const nocal::tui::CalendarItem>{}, state);
|
||||||
|
check(frame.find("July 2026") != std::string::npos, "compact mode keeps month context");
|
||||||
|
check(nocal::tui::decode_key("\x1b[6~") == nocal::tui::Action::next_month,
|
||||||
|
"PageDown is decoded");
|
||||||
|
check(nocal::tui::decode_key("k") == nocal::tui::Action::up, "Vim movement is decoded");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_navigation()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
std::vector<nocal::Event> events;
|
||||||
|
nocal::tui::TuiApp app{events, -1, -1};
|
||||||
|
const auto initial = app.state().selected_day;
|
||||||
|
app.dispatch(nocal::tui::Action::right);
|
||||||
|
check(sys_days{app.state().selected_day} == sys_days{initial} + days{1},
|
||||||
|
"right moves by one civil day");
|
||||||
|
app.dispatch(nocal::tui::Action::down);
|
||||||
|
check(sys_days{app.state().selected_day} == sys_days{initial} + days{8},
|
||||||
|
"down moves by one week");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
test_full_month_render();
|
||||||
|
test_compact_and_input();
|
||||||
|
test_navigation();
|
||||||
|
if (failures != 0) {
|
||||||
|
std::cerr << failures << " TUI test(s) failed\n";
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
std::cout << "TUI tests passed\n";
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user