Skip to content

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.

FieldTypeDescription
idUUIDStable identifier, set on creation
titleStringShort title of the note
bodyStringPlain-text / Markdown content. Used by AI, search, snippets, and the body line parser in NoteEditorView.
tags[String]Free-form user tags
createdAtDateImmutable creation timestamp
updatedAtDateUpdated by save(_:) on every write
aiSummaryString?AI-generated one-sentence summary
aiActionItems[String]AI-extracted action items (empty array by default)
aiCategoryNoteCategory?AI-assigned category (nil until enriched)
sourceNoteSourceHow the note was created (default .manual)
isPinnedBoolWhether the note is pinned (default false)
lastReferencedAtDate?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

CaseIconLabel
.idea💡Idea
.taskTask
.journal📓Journal
.health🏥Health
.goal🎯Goal
.memory🧠Memory
.finance💰Finance
.other📝Note

NoteSource Enum

NoteSource: String, Codable, Sendable

CaseSF SymbolOrigin
.manualpencilUser typed directly
.conversationbubble.left.and.bubble.rightSaved from a chat session
.voicemic.fillVoice dictation
.photocamera.fillCaptured via photo/vision
.siriwaveformCreated via Siri/SaveNoteIntent

Storage Layout

PropertyValue
Filenamelucidpal_notes.json
DirectoryNSDocumentDirectory (user domain)
FallbackNSTemporaryDirectory if Documents unavailable
FormatJSON array of NoteItem objects
Write options.atomic + .completeFileProtection
Max notes500 (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 id exists → updates in place and stamps updatedAt = .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(query: String) -> [NoteItem]

  • In-memory — operates on the live notes array; no file I/O.
  • Case-insensitive substring match across title, body, and each element of tags.
  • 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.

FieldTypeDescription
idUUIDMatches the source NoteItem.id
titleStringNote title
snippetStringFirst 200 characters of body
stateNotePreviewState.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.

Internal — not for distribution