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
+38
View File
@@ -137,6 +137,44 @@ struct NoteDocumentTests {
#expect(Note.audioRelativePath(forJSONPath: legacyJSONPath) == "1999/12/\(id.stringValue).m4a")
}
// MARK: - Schema v4 (project)
@Test func projectCodableRoundTrip() throws {
var context = CaptureContext.empty
context.device = "iPhone"
let note = Note.create(text: "Ship the release notes", context: context, project: "Contextful v0.3")
let data = try DocumentCoder.encode(note)
let decoded = try DocumentCoder.decode(Note.self, from: data)
#expect(decoded.payload == note.payload)
#expect(decoded.payload.project == "Contextful v0.3")
}
/// A v3 file predates the `project` key. It must decode unchanged as an
/// unfiled note with `project == nil` and pass the forward gate (3 current).
@Test func v3DocumentDecodesWithNoProject() throws {
let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 7, randomLo: 8)
let json = """
{
"meta": {
"id": "\(id.stringValue)",
"schemaVersion": 3,
"createdAt": "2023-11-14T22:13:20Z",
"modifiedAt": "2023-11-14T22:13:20Z"
},
"payload": {
"text": "A v3 note",
"source": "typed",
"tags": [],
"context": { "device": "iPhone", "timeZoneID": "UTC" }
}
}
"""
let note = try DocumentCoder.decode(Note.self, from: Data(json.utf8))
#expect(note.meta.schemaVersion == 3)
#expect(note.payload.project == nil)
#expect(note.isReadable)
}
@Test func createIngestedDerivesCreatedAtFromULID() {
let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 5, randomLo: 6)
var context = CaptureContext.empty
+15
View File
@@ -43,6 +43,21 @@ struct NoteMapperTests {
#expect(all.first?.tags == ["coffee", "people"])
}
@Test func upsertWritesProjectAndNoteRestoresIt() throws {
let (container, context) = try makeStore()
defer { withExtendedLifetime(container) {} }
var note = makeNote()
note.payload.project = "Kitchen Remodel"
NoteMapper.upsert(note, relativePath: note.relativePath, into: context)
let entity = try #require(NoteMapper.fetch(id: note.meta.id.stringValue, in: context))
#expect(entity.project == "Kitchen Remodel")
let restored = try #require(NoteMapper.note(from: entity))
#expect(restored.payload.project == "Kitchen Remodel")
#expect(restored.payload == note.payload)
}
@Test func entityRoundTripsBackToNote() throws {
let (container, context) = try makeStore()
defer { withExtendedLifetime(container) {} }