Appearance
NotesStore
NotesStore is the single source of truth for all user notes. It persists an in-memory array to a JSON file on disk and exposes a simple CRUD + search surface via NotesStoreProtocol.
NoteItem Model
NoteItem is a value type (struct) that is Identifiable, Codable, Equatable, and Sendable.
| Field | Type | Description |
|---|---|---|
id | UUID | Stable identifier, set on creation |
title | String | Short title of the note |
body | String | Plain-text / Markdown content. Used by AI, search, snippets, and the body line parser in NoteEditorView. |
tags | [String] | Free-form user tags |
createdAt | Date | Immutable creation timestamp |
updatedAt | Date | Updated by save(_:) on every write |
aiSummary | String? | AI-generated one-sentence summary |
aiActionItems | [String] | AI-extracted action items (empty array by default) |
aiCategory | NoteCategory? | AI-assigned category (nil until enriched) |
source | NoteSource | How the note was created (default .manual) |
isPinned | Bool | Whether the note is pinned (default false) |
lastReferencedAt | Date? | Last time this note was surfaced as a search result in a chat session; nil for notes never referenced in conversation |
The Codable implementation uses decodeIfPresent with safe defaults for all AI fields, source, and isPinned, ensuring backward compatibility when older persisted files lack those keys.
Markdown body
NoteItem.body stores plain text that may contain Markdown syntax (# Heading, - [ ] task, **bold**, etc.). The NoteEditorView body-line parser converts this into structured BodyLine values at render time — no intermediate rich-text format is stored.
NoteCategory Enum
NoteCategory: String, Codable, CaseIterable, Sendable
| Case | Icon | Label |
|---|---|---|
.idea | 💡 | Idea |
.task | ✅ | Task |
.journal | 📓 | Journal |
.health | 🏥 | Health |
.goal | 🎯 | Goal |
.memory | 🧠 | Memory |
.finance | 💰 | Finance |
.other | 📝 | Note |
NoteSource Enum
NoteSource: String, Codable, Sendable
| Case | SF Symbol | Origin |
|---|---|---|
.manual | pencil | User typed directly |
.conversation | bubble.left.and.bubble.right | Saved from a chat session |
.voice | mic.fill | Voice dictation |
.photo | camera.fill | Captured via photo/vision |
.siri | waveform | Created via Siri/SaveNoteIntent |
Storage Layout
| Property | Value |
|---|---|
| Filename | lucidpal_notes.json |
| Directory | NSDocumentDirectory (user domain) |
| Fallback | NSTemporaryDirectory if Documents unavailable |
| Format | JSON array of NoteItem objects |
| Write options | .atomic + .completeFileProtection |
| Max notes | 500 (oldest note evicted when cap is reached) |
The filename constant notesStoreFilename is shared between the main app and SaveNoteIntent so both targets write to the same file.
CRUD Operations
Create / Update — save(_ note: NoteItem)
- If a note with the same
idexists → updates in place and stampsupdatedAt = .now. - If the note is new → inserts at index 0 (most-recent-first order).
- If the store is at capacity (500 notes) → removes the last (oldest) entry before inserting.
- Calls
persist()after every mutation.
Delete — delete(id: UUID)
Removes all notes matching the given id (at most one, since IDs are unique) then calls persist().
Pin / Unpin
There is no dedicated pin method. Callers toggle note.isPinned then call save(_:). The store treats pinning like any other field update.
Search
search(query: String) -> [NoteItem]
- In-memory — operates on the live
notesarray; no file I/O. - Case-insensitive substring match across
title,body, and each element oftags. - Returns all matching notes in their current sort order (insertion order, newest first).
NotesStoreProtocol
swift
@MainActor
protocol NotesStoreProtocol: AnyObject {
var notes: [NoteItem] { get }
func save(_ note: NoteItem)
func delete(id: UUID)
func search(query: String) -> [NoteItem]
}The protocol is annotated @MainActor, so all conformers and callers must run on the main actor. This keeps mutation and UI observation on a single actor without explicit locking.
Views and view models depend on the protocol, not the concrete type, enabling injection of a mock store in tests.
Reactive Update Pattern
NotesStore is a @MainActor final class. The notes property is declared private(set) var, so external observers cannot mutate it directly.
Because NotesStore is consumed by @Observable or ObservableObject view models, any call to save(_:) or delete(id:) mutates notes on the main actor, which triggers SwiftUI view invalidation automatically when the view model exposes notes as a published/observable property.
There are no @Published wrappers inside NotesStore itself; reactivity is delegated to whichever view model holds the store reference.
NotePreview
NotePreview is a compact snapshot stored in ChatMessage for rendering note cards inside a conversation without embedding the full note body.
| Field | Type | Description |
|---|---|---|
id | UUID | Matches the source NoteItem.id |
title | String | Note title |
snippet | String | First 200 characters of body |
state | NotePreviewState | .created, .updated, .deleted, .searchResult |
lastReferencedAt
NoteItem.lastReferencedAt is set by NoteActionController.searchNotes() each time a note is returned as a search result during a conversation. The timestamp records when the note was most recently surfaced, not when it was last edited.
NotesStore exposes a computed helper for the UI:
swift
var recentlyReferenced: [NoteItem] {
let cutoff = Calendar.current.date(byAdding: .day, value: -7, to: .now)!
return notes.filter { ($0.lastReferencedAt ?? .distantPast) >= cutoff }
.sorted { ($0.lastReferencedAt ?? .distantPast) > ($1.lastReferencedAt ?? .distantPast) }
}Notes in this list are shown in the Recently Referenced section at the top of the Notes tab.
Widget Snapshot Trigger
NoteActionController calls WidgetSnapshotWriter.writeNote(pinnedNote:) after create and update operations. The writer reads the existing snapshot, updates only the pinnedNote field (the title of the most recently pinned note, or the most recently created note if none are pinned), and writes back atomically.
This ensures the widget always reflects the latest pinned note without overwriting habit fields written by HabitStore.
See architecture/widget-data-flow for the full snapshot model.
Relationship to NoteEnrichmentService
After save(_:) is called with a new note, NoteEnrichmentService asynchronously enriches it with AI metadata (aiSummary, aiActionItems, aiCategory). Enrichment results are written back through another save(_:) call, updating the existing note in place.
See NoteEnrichmentService architecture for the full enrichment pipeline.