diff --git a/Notes/Model/Note.swift b/Notes/Model/Note.swift index 271568c..2786294 100644 --- a/Notes/Model/Note.swift +++ b/Notes/Model/Note.swift @@ -45,8 +45,10 @@ struct Note: SyncDocument, VersionedDocument, Equatable { /// own writing, and every transcription attempt is preserved. v1/v2 files /// decode unchanged (missing keys ⇒ nil/empty); `migrateIfNeeded` back-fills /// v2 voice notes by moving a completed transcript from `text` into - /// `attempts` at read time. - static let currentSchemaVersion = 3 + /// `attempts` at read time. v4 added optional `payload.project` — purely + /// additive, so v1–v3 files decode unchanged (missing key ⇒ nil) with no + /// read-time transformation needed. + static let currentSchemaVersion = 4 struct Meta: Codable, Sendable, Equatable { var id: ULID @@ -149,6 +151,10 @@ struct Note: SyncDocument, VersionedDocument, Equatable { /// Voice-note sidecar metadata, nil for a plain typed note. Optional so /// a v1 document (no `audio` key) decodes as a text note unchanged. var audio: AudioInfo? + /// The single project (or "category" — user's choice, see + /// `ProjectsNaming`) this note is filed under. Nil means unfiled. + /// Optional so a pre-v4 document (no `project` key) decodes unchanged. + var project: String? = nil } var meta: Meta @@ -186,12 +192,13 @@ struct Note: SyncDocument, VersionedDocument, Equatable { source: Source = .typed, tags: [String] = [], context: CaptureContext, - audio: AudioInfo? = nil + audio: AudioInfo? = nil, + project: String? = nil ) -> Note { let now = Date() return Note( meta: Meta(id: ULID(), schemaVersion: currentSchemaVersion, createdAt: now, modifiedAt: now), - payload: Payload(text: text, source: source, tags: tags, context: context, audio: audio) + payload: Payload(text: text, source: source, tags: tags, context: context, audio: audio, project: project) ) } diff --git a/Notes/Model/NoteEntity.swift b/Notes/Model/NoteEntity.swift index bd6e57b..989f5da 100644 --- a/Notes/Model/NoteEntity.swift +++ b/Notes/Model/NoteEntity.swift @@ -10,6 +10,8 @@ final class NoteEntity { var text: String = "" var sourceRaw: String = Note.Source.typed.rawValue var tags: [String] = [] + /// The project (or "category") this note is filed under, nil when unfiled. + var project: String? var createdAt: Date = Date() var modifiedAt: Date = Date() diff --git a/Notes/Model/NoteMapper.swift b/Notes/Model/NoteMapper.swift index 16ef9d6..8b12c9c 100644 --- a/Notes/Model/NoteMapper.swift +++ b/Notes/Model/NoteMapper.swift @@ -31,6 +31,7 @@ enum NoteMapper { entity.text = note.payload.text entity.sourceRaw = note.payload.source.rawValue entity.tags = note.payload.tags + entity.project = note.payload.project entity.createdAt = note.meta.createdAt entity.modifiedAt = note.meta.modifiedAt entity.jsonRelativePath = relativePath @@ -54,7 +55,8 @@ enum NoteMapper { source: entity.source, tags: entity.tags, context: entity.context, - audio: entity.audio + audio: entity.audio, + project: entity.project ) ) } diff --git a/Notes/Storage/SyncEngine.swift b/Notes/Storage/SyncEngine.swift index b7bfb69..02ad6ca 100644 --- a/Notes/Storage/SyncEngine.swift +++ b/Notes/Storage/SyncEngine.swift @@ -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 } diff --git a/Notes/Views/App/ContentView.swift b/Notes/Views/App/ContentView.swift index 0722db0..8706e99 100644 --- a/Notes/Views/App/ContentView.swift +++ b/Notes/Views/App/ContentView.swift @@ -3,6 +3,9 @@ import SwiftUI struct ContentView: View { @Environment(SyncEngine.self) private var syncEngine + @AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue + private var naming: ProjectsNaming { ProjectsNaming(rawValue: namingRaw) ?? .projects } + var body: some View { if syncEngine.iCloudStatus == .unavailable { ICloudGateView() @@ -16,6 +19,10 @@ struct ContentView: View { TagsView() } + Tab(naming.plural, systemImage: "folder") { + ProjectsView() + } + Tab("Settings", systemImage: "gearshape") { SettingsView() } diff --git a/Notes/Views/Notes/NoteEditorView.swift b/Notes/Views/Notes/NoteEditorView.swift index b6eeee9..82d45df 100644 --- a/Notes/Views/Notes/NoteEditorView.swift +++ b/Notes/Views/Notes/NoteEditorView.swift @@ -37,6 +37,12 @@ struct NoteEditorView: View { let mode: Mode + /// Project a note created from this editor is pre-filed under (e.g. when + /// composing from inside `ProjectNotesView`). Ignored on the edit path — + /// `updateNote` only rewrites text/tags on the loaded note, so an + /// existing note's `payload.project` is left untouched. + var defaultProject: String? = nil + @Environment(SyncEngine.self) private var syncEngine @Environment(ContextService.self) private var contextService @Environment(TranscriptionService.self) private var transcriptionService @@ -326,7 +332,8 @@ struct NoteEditorView: View { text: trimmed, tags: parsedTags, context: contextService.current, - transcriberDeviceID: TranscriptionSettings.installID + transcriberDeviceID: TranscriptionSettings.installID, + project: defaultProject ) // Fast path: hand the recording's temp file straight to the // transcriber so it doesn't round-trip through the container. @@ -335,7 +342,8 @@ struct NoteEditorView: View { try await syncEngine.createNote( text: trimmed, tags: parsedTags, - context: contextService.current + context: contextService.current, + project: defaultProject ) } case .edit(let entity): diff --git a/Notes/Views/Notes/NotesListView.swift b/Notes/Views/Notes/NotesListView.swift index 30c4441..a0b6e6f 100644 --- a/Notes/Views/Notes/NotesListView.swift +++ b/Notes/Views/Notes/NotesListView.swift @@ -12,6 +12,10 @@ struct NotesListView: View { @State private var composing = false @State private var editingNote: NoteEntity? + @State private var assigningNote: NoteEntity? + + @AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue + private var naming: ProjectsNaming { ProjectsNaming(rawValue: namingRaw) ?? .projects } var body: some View { NavigationStack { @@ -33,6 +37,9 @@ struct NotesListView: View { .sheet(item: $editingNote) { note in NoteEditorView(mode: .edit(note)) } + .sheet(item: $assigningNote) { note in + AssignProjectSheet(note: note) + } } } @@ -90,6 +97,14 @@ struct NotesListView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .swipeActions(edge: .leading) { + Button { + assigningNote = note + } label: { + Label(naming.singular, systemImage: "folder") + } + .tint(.indigo) + } .swipeActions(edge: .trailing) { Button(role: .destructive) { delete(note) diff --git a/Notes/Views/Projects/AssignProjectSheet.swift b/Notes/Views/Projects/AssignProjectSheet.swift new file mode 100644 index 0000000..b13ce2d --- /dev/null +++ b/Notes/Views/Projects/AssignProjectSheet.swift @@ -0,0 +1,102 @@ +import IndieSync +import SwiftData +import SwiftUI + +/// Sheet for filing one note under a project: pick an existing one, clear the +/// current assignment, or create a new one on the spot. Opened from a leading +/// swipe in `NotesListView`/`ProjectNotesView`. +struct AssignProjectSheet: View { + let note: NoteEntity + + @Environment(SyncEngine.self) private var syncEngine + @Environment(\.dismiss) private var dismiss + @Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity] + + @State private var newName = "" + + @AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue + private var naming: ProjectsNaming { ProjectsNaming(rawValue: namingRaw) ?? .projects } + + var body: some View { + NavigationStack { + List { + if note.project != nil { + Section { + Button(role: .destructive) { + assign(nil) + } label: { + Label("Remove from \(naming.singular)", systemImage: "folder.badge.minus") + } + } + } + + if !existingProjects.isEmpty { + Section(naming.plural) { + ForEach(existingProjects, id: \.project) { entry in + Button { + assign(entry.project) + } label: { + HStack { + Label(entry.project, systemImage: "folder") + Spacer() + Text("\(entry.count)") + .foregroundStyle(.secondary) + .monospacedDigit() + if note.project == entry.project { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } + } + } + .buttonStyle(.plain) + } + } + } + + Section("New \(naming.singular)") { + TextField("Name", text: $newName) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.words) + #endif + Button("File Under New \(naming.singular)") { + assign(trimmedNewName) + } + .disabled(trimmedNewName.isEmpty) + } + } + .navigationTitle("File Under…") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + } + + private var trimmedNewName: String { + newName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Distinct project names across live notes, count-sorted like + /// `ProjectsView` so the most-used projects surface first. + private var existingProjects: [(project: String, count: Int)] { + var counts: [String: Int] = [:] + for note in notes where note.isLive { + guard let project = note.project else { continue } + counts[project, default: 0] += 1 + } + return counts + .map { (project: $0.key, count: $0.value) } + .sorted { $0.count == $1.count ? $0.project < $1.project : $0.count > $1.count } + } + + private func assign(_ project: String?) { + guard let id = ULID(string: note.id) else { return } + Task { + try? await syncEngine.assignProject(id: id, project: project) + dismiss() + } + } +} diff --git a/Notes/Views/Projects/ProjectsNaming.swift b/Notes/Views/Projects/ProjectsNaming.swift new file mode 100644 index 0000000..0eb09a0 --- /dev/null +++ b/Notes/Views/Projects/ProjectsNaming.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// What the user calls the third tab's grouping. "Projects" by default, but +/// people organize differently — the label is a Settings choice, applied to +/// the tab title, list headers, and swipe/assign UI. More options will come; +/// adding one means adding a case here. +enum ProjectsNaming: String, CaseIterable, Identifiable { + case projects + case categories + + static let storageKey = "projectsNaming" + + var id: String { rawValue } + + var plural: String { + switch self { + case .projects: "Projects" + case .categories: "Categories" + } + } + + var singular: String { + switch self { + case .projects: "Project" + case .categories: "Category" + } + } +} diff --git a/Notes/Views/Projects/ProjectsView.swift b/Notes/Views/Projects/ProjectsView.swift new file mode 100644 index 0000000..2ed2fd1 --- /dev/null +++ b/Notes/Views/Projects/ProjectsView.swift @@ -0,0 +1,130 @@ +import IndieSync +import SwiftData +import SwiftUI + +/// Browse notes by project — the manual-organization counterpart to the +/// automatic context ranking, and a sibling to `TagsView`. Unlike tags a note +/// carries at most one project, so browsing here is filing rather than +/// cross-cutting search. +struct ProjectsView: View { + @Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity] + + @AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue + private var naming: ProjectsNaming { ProjectsNaming(rawValue: namingRaw) ?? .projects } + + var body: some View { + NavigationStack { + List { + ForEach(projectCounts, id: \.project) { entry in + NavigationLink(value: entry.project) { + HStack { + Label(entry.project, systemImage: "folder") + Spacer() + Text("\(entry.count)") + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + } + } + .navigationTitle(naming.plural) + .navigationDestination(for: String.self) { project in + ProjectNotesView(project: project) + } + .overlay { + if projectCounts.isEmpty { + ContentUnavailableView( + "No \(naming.plural.lowercased()) yet", + systemImage: "folder", + description: Text("Swipe a note in the list to file it under a \(naming.singular.lowercased()).") + ) + } + } + } + } + + private var projectCounts: [(project: String, count: Int)] { + var counts: [String: Int] = [:] + for note in notes where note.isLive { + guard let project = note.project else { continue } + counts[project, default: 0] += 1 + } + return counts + .map { (project: $0.key, count: $0.value) } + .sorted { $0.count == $1.count ? $0.project < $1.project : $0.count > $1.count } + } +} + +struct ProjectNotesView: View { + let project: String + + @Environment(SyncEngine.self) private var syncEngine + @Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity] + + @State private var composing = false + @State private var editingNote: NoteEntity? + @State private var assigningNote: NoteEntity? + + @AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue + private var naming: ProjectsNaming { ProjectsNaming(rawValue: namingRaw) ?? .projects } + + var body: some View { + List { + ForEach(notes.filter { $0.isLive && $0.project == project }) { note in + row(note) + } + } + .navigationTitle(project) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + composing = true + } label: { + Label("New Note", systemImage: "square.and.pencil") + } + } + } + .sheet(isPresented: $composing) { + NoteEditorView(mode: .create, defaultProject: project) + } + .sheet(item: $editingNote) { note in + NoteEditorView(mode: .edit(note)) + } + .sheet(item: $assigningNote) { note in + AssignProjectSheet(note: note) + } + } + + private func row(_ note: NoteEntity) -> some View { + Button { + editingNote = note + } label: { + NoteRowView(note: note) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .swipeActions(edge: .leading) { + Button { + assigningNote = note + } label: { + Label(naming.singular, systemImage: "folder") + } + .tint(.indigo) + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + delete(note) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + + private func delete(_ note: NoteEntity) { + guard let id = ULID(string: note.id) else { return } + Task { + try? await syncEngine.deleteNote(id: id) + } + } +} diff --git a/Notes/Views/Settings/SettingsView.swift b/Notes/Views/Settings/SettingsView.swift index e4d5298..9280163 100644 --- a/Notes/Views/Settings/SettingsView.swift +++ b/Notes/Views/Settings/SettingsView.swift @@ -7,6 +7,8 @@ struct SettingsView: View { @Environment(ContextService.self) private var contextService @EnvironmentObject private var backupController: BackupController + @AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue + var body: some View { NavigationStack { Form { @@ -44,6 +46,15 @@ struct SettingsView: View { } } + Section("Organization") { + // Renames the Projects tab plus its list headers and swipe/assign UI. + Picker("Group Notes By", selection: $namingRaw) { + ForEach(ProjectsNaming.allCases) { naming in + Text(naming.plural).tag(naming.rawValue) + } + } + } + Section("Context") { ContextSummaryRow( context: contextService.current, diff --git a/NotesTests/NoteDocumentTests.swift b/NotesTests/NoteDocumentTests.swift index 75f6ffb..0bd7e3c 100644 --- a/NotesTests/NoteDocumentTests.swift +++ b/NotesTests/NoteDocumentTests.swift @@ -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 diff --git a/NotesTests/NoteMapperTests.swift b/NotesTests/NoteMapperTests.swift index a9ee56c..d45f1b8 100644 --- a/NotesTests/NoteMapperTests.swift +++ b/NotesTests/NoteMapperTests.swift @@ -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) {} } diff --git a/README.md b/README.md index 17ed282..058d3cb 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ the top of the list. - **Automatic context capture** — location, place name (reverse-geocoded), time zone, and device are stamped onto every note, best-effort and privacy-friendly (all data stays in your iCloud) - **"Right here, right now" recall** — a ranked shelf of notes whose capture context matches your current place and time, above the regular newest-first list - **Tags** — manual organization alongside the automatic context; browse notes by tag +- **Projects** — file notes under a project (or "category" — the term is your choice in Settings) with a swipe, browse them in their own tab, and add notes directly into a project - **Search** — full-text over note text, voice transcripts (weighted by transcription confidence), tags, and captured place names - **iCloud sync** — notes live as JSON files in your iCloud Drive (visible in Files.app) and sync across iPhone and Mac; the local database is just a rebuildable cache - **iOS + macOS + watchOS** — same app and data across iPhone and Mac, with an Apple Watch companion for voice capture