Forward-looking design decisions that are not yet implemented, plus a record of the architectural decisions already made, so we can pick work up later without re-deriving it.
Yoro browses any number of sources at once. A source is one backend: a local
vdir tree (store.NewLocal) or a remote DAV account (store.../dav). Both
implement the read-only store.Backend; memStore aggregates them, keying
everything by a source-namespaced collection ID so IDs stay unique across
sources. model.Collection.Source records which source a collection came from,
and Store.Sources() exposes source metadata to the UI for provenance glyphs.
Principles (deliberate scope boundaries):
- Yoro is a pure client; it never syncs sources to each other. Reconciling a
local vdir with a DAV server is vdirsyncer's job (see
docs/vdirsyncer.md). This avoids reimplementing vdirsyncer and the double-sync trap. KDE Akonadi and GNOME Evolution Data Server are themselves peer clients of DAV, not storage backends, so they are out of scope — wiring Yoro to both them and the DAV they mirror would create update loops. - Provenance is always visible. When more than one source is configured, the UI marks each collection with its source, so when writes land it is unambiguous whether a mutation targets a remote DAV collection or a local file.
Per-domain presentation (these differ on purpose):
- Calendars are an overlay: all sources' calendars are listed together, tagged by source, and merged in the agenda with the existing per-collection toggles. If a local vdir mirrors a DAV calendar, just don't toggle the duplicate on.
- Contacts are a single list: one active source at a time (switch with
s), because a contact list is a lookup and showing every person twice when a vdir mirrors a DAV account would be noise.
Item loading is eager for every source today (same as the original local-only
flow), tolerating per-source/per-collection failures so one unreachable DAV
server can't take down browsing of the others. The Store.Reload(colID) seam
(routed to the owning backend) is where lazy/per-collection refresh or fsnotify
would later hang.
The DAV backend re-encodes the parsed go-ical/go-vcard objects returned by
go-webdav back to bytes and feeds them through the existing internal/ical
and internal/vcard decoders, so there is a single parse path. ETag is
captured for the future If-Match write seam; calendar color is not exposed by
the CalDAV client structs, so DAV calendars currently have no color.
Yoro edits structured records (vCard fields, iCal event fields), not freeform text, so "vim editing" splits into two layers designed separately.
This is the yazi-style layer and maps cleanly onto bubbletea, which is already a
modal state machine; adding modes is idiomatic. Introduce an edit-mode enum
(Normal, Insert, Visual, VisualLine, Command).
- Normal over a field list or the agenda/contact list:
j/kmove;i/a/cedit the field under the cursor;o/Oadd a field (new email, new phone);dddelete a field;ccreplace a value. - Visual = range-select, like yazi's file selection:
- In the list (events/contacts):
Vselects a range of records →dbulk-delete,yyank,m/cmove to another collection. - In a record's field list:
Vselects a range of fields to delete/yank.
- In the list (events/contacts):
- Command
:w/:q/ZZto save/quit;u/ctrl+rundo (an in-memory snapshot stack per record taken before each save).
When editing a value (an email, a NOTE/DESCRIPTION):
- Single-line fields (name, email, phone) need little — a plain insert-mode line editor covers nearly everything.
- Multi-line fields (NOTE, DESCRIPTION):
bubbles/textareaprovides editing but not vim motions, so true vim-in-field means layering a small motion engine (w/b/ciw/dt,…) on top. Ship a plain insert editor first; upgrade the motion set later.
store.WriteBackend(already defined, unimplemented) is where writes land — the same interface for local files now and CalDAV/CardDAV PUTs later.- Models already retain
Rawbytes +UID/Rev/Sequence/ETag. Refinement when implementing: edit by mutating the re-parsed go-ical/go-vcard component and re-encoding, rather than serializing our simplifiedmodeltype — this preserves unknown properties we don't model (customX-props, attachments) losslessly. Both libraries retain the full property tree, so this is clean. - Saves should be atomic (write temp file + rename) and bump
SEQUENCE/REV. That same conditional-write discipline becomesIf-MatchETag PUTs for DAV with no rework.
- Done — create new records.
aopens a minimal modal form (Summary/Date/ Time/Duration for events; Name/Email/Phone for contacts) targeting the selected collection.store.CreateEvent/CreateContactroute toWriteBackend(local atomic temp+rename, or DAVPUT), generate a UUID, thenReloadthe collection. Builders live ininternal/ical/encode.goandinternal/vcard/encode.go(BuildEvent/BuildContact+Marshal), shared by both backends; timed events are written in UTC to avoid aTZID=Local. - Done — edit existing records.
eopens the same form pre-filled from the selection. Persistence mutates the item's originalRaw(decode → set the edited props on the component whose UID matches → re-encode), so unmodeled properties and sibling components in a multi-item file survive;SEQUENCE/REVare bumped. Each item carries aPathlocator (local file path / DAV href), captured at read time, so the update overwrites the same object instead of a UID-derived path (which would duplicate foreign items).ical.UpdateEvent/vcard.UpdateContactdo the mutation. Recurring events are refused for now. - Done — delete records.
dremoves the selected item after ay/nconfirmation: local file remove, or DAV delete by href. Recurring events are refused for now. Mutations run async so a slow DAV round-trip never blocks the UI. - Done — recurring events (whole series). A structured "Repeat" picker in the
create/edit form (None/Daily/Weekly/Monthly/Yearly + Interval + Until) composes an
RRULE;applyEventPropsnow serializes it as a RECUR value (setRecurininternal/ical/encode.go), so create/edit/delete all act on the master VEVENT for local and DAV alike. Edit and delete are no longer refused. To avoid dropping rule parts the picker can't model (BYDAY,COUNT, …), an edited event whose recurrence rows are untouched keeps its original rule verbatim; changing the cadence regenerates from the picker (createForm.recurrence). Still future work: per-instance edit/ delete (singleRECURRENCE-IDoverride /EXDATE), this-and-future splitting, andBYDAY/weekday multiselect in the picker. - Visual mode for bulk record operations; richer in-field vim motions.
(
If-Matchconditional PUTs land once go-webdav exposes the option; until then create, update, and delete are last-write-wins.)
The form now covers every field the model carries: events expose Summary, Date,
Time, Duration, Location, Description, and URL; contacts expose the structured
Name, multiple Emails/Phones/Addresses with TYPE labels, Org, Title, Role, URL,
Birthday, Anniversary, and Note. Multi-value rows are added/removed with
ctrl+n/ctrl+d and TYPE labels cycled with ctrl+t; the form shows its own
key hints (see internal/ui/create.go). Events also expose a structured Repeat/
Interval/Until recurrence picker that composes the RRULE (whole-series). Still
unmodeled in the form: event attendees and alarms (and recurrence beyond the
picker's vocabulary) — these round-trip losslessly via the mutate-Raw
persistence path, so editing an event that has them does not drop them.
A collection ID is source <US> domain/relpath (e.g. local<US>calendars/icloud).
The calendars/ vs contacts/ segment is essential: a calendar and an address
book can share a directory basename (e.g. both icloud), and without the domain
segment they collapse to the same colByID key — which silently mis-routed
Reload (and thus create) before this was fixed.