Add Projects: third tab, swipe-to-file, per-project note capture

Notes gain an optional project (schema v4, additive). Projects derive
from note values like tags — no separate entity. Tab naming
(Projects/Categories) is a Settings choice applied across the UI.

Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
This commit is contained in:
2026-07-16 18:24:11 -04:00
parent a8085865bc
commit b2f16e8600
14 changed files with 395 additions and 11 deletions
+22 -4
View File
@@ -136,9 +136,10 @@ final class SyncEngine {
text: String,
source: Note.Source = .typed,
tags: [String] = [],
context: CaptureContext
context: CaptureContext,
project: String? = nil
) async throws -> Note {
let note = Note.create(text: text, source: source, tags: tags, context: context)
let note = Note.create(text: text, source: source, tags: tags, context: context, project: project)
try await save(note)
return note
}
@@ -202,7 +203,8 @@ final class SyncEngine {
text: String = "",
tags: [String] = [],
context: CaptureContext,
transcriberDeviceID: String
transcriberDeviceID: String,
project: String? = nil
) async throws -> Note {
guard let store else {
report("Note not saved — iCloud not connected")
@@ -215,7 +217,7 @@ final class SyncEngine {
transcriptionLocaleID: nil,
transcribedAt: nil
)
let note = Note.create(text: text, source: .transcribed, tags: tags, context: context, audio: audio)
let note = Note.create(text: text, source: .transcribed, tags: tags, context: context, audio: audio, project: project)
do {
let audioData = try Data(contentsOf: audioFileURL)
try await store.writeData(audioData, to: Self.dataPath(Note.audioRelativePath(forID: note.id)))
@@ -322,6 +324,20 @@ final class SyncEngine {
try await updateNote(note)
}
// MARK: - Organization
/// Assign a note to a project (or clear it with nil). Re-reads the file
/// the authority so a concurrent edit isn't clobbered; the cache catches
/// up via the monitor as usual.
func assignProject(id: ULID, project: String?) async throws {
guard let store else { throw SyncError.iCloudUnavailable }
guard let raw = try? await store.read(Note.self, from: Self.dataPath(Note.relativePath(forID: id))),
raw.isReadable else { return }
var note = migrateIfNeeded(raw)
note.payload.project = project
try await updateNote(note)
}
// MARK: - Watch ingestion
/// Local directory where the phone receiver (`PhoneWatchReceiver`, a later
@@ -763,6 +779,8 @@ final class SyncEngine {
/// the transcript merged with user typing indistinguishable by then) in
/// `text`; move that whole text into the first attempt so the v3 invariant
/// "text is user writing only" holds. Confidence was never recorded nil.
/// v3 v4 is additive-only (new optional `payload.project`), so no
/// migration action is needed beyond the version stamp.
/// Internal (not private) so the migration policy is unit-testable.
func migrateIfNeeded(_ note: Note) -> Note {
guard note.meta.schemaVersion < Note.currentSchemaVersion else { return note }