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:
+11
-4
@@ -45,8 +45,10 @@ struct Note: SyncDocument, VersionedDocument, Equatable {
|
|||||||
/// own writing, and every transcription attempt is preserved. v1/v2 files
|
/// own writing, and every transcription attempt is preserved. v1/v2 files
|
||||||
/// decode unchanged (missing keys ⇒ nil/empty); `migrateIfNeeded` back-fills
|
/// decode unchanged (missing keys ⇒ nil/empty); `migrateIfNeeded` back-fills
|
||||||
/// v2 voice notes by moving a completed transcript from `text` into
|
/// v2 voice notes by moving a completed transcript from `text` into
|
||||||
/// `attempts` at read time.
|
/// `attempts` at read time. v4 added optional `payload.project` — purely
|
||||||
static let currentSchemaVersion = 3
|
/// 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 {
|
struct Meta: Codable, Sendable, Equatable {
|
||||||
var id: ULID
|
var id: ULID
|
||||||
@@ -149,6 +151,10 @@ struct Note: SyncDocument, VersionedDocument, Equatable {
|
|||||||
/// Voice-note sidecar metadata, nil for a plain typed note. Optional so
|
/// Voice-note sidecar metadata, nil for a plain typed note. Optional so
|
||||||
/// a v1 document (no `audio` key) decodes as a text note unchanged.
|
/// a v1 document (no `audio` key) decodes as a text note unchanged.
|
||||||
var audio: AudioInfo?
|
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
|
var meta: Meta
|
||||||
@@ -186,12 +192,13 @@ struct Note: SyncDocument, VersionedDocument, Equatable {
|
|||||||
source: Source = .typed,
|
source: Source = .typed,
|
||||||
tags: [String] = [],
|
tags: [String] = [],
|
||||||
context: CaptureContext,
|
context: CaptureContext,
|
||||||
audio: AudioInfo? = nil
|
audio: AudioInfo? = nil,
|
||||||
|
project: String? = nil
|
||||||
) -> Note {
|
) -> Note {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
return Note(
|
return Note(
|
||||||
meta: Meta(id: ULID(), schemaVersion: currentSchemaVersion, createdAt: now, modifiedAt: now),
|
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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ final class NoteEntity {
|
|||||||
var text: String = ""
|
var text: String = ""
|
||||||
var sourceRaw: String = Note.Source.typed.rawValue
|
var sourceRaw: String = Note.Source.typed.rawValue
|
||||||
var tags: [String] = []
|
var tags: [String] = []
|
||||||
|
/// The project (or "category") this note is filed under, nil when unfiled.
|
||||||
|
var project: String?
|
||||||
var createdAt: Date = Date()
|
var createdAt: Date = Date()
|
||||||
var modifiedAt: Date = Date()
|
var modifiedAt: Date = Date()
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ enum NoteMapper {
|
|||||||
entity.text = note.payload.text
|
entity.text = note.payload.text
|
||||||
entity.sourceRaw = note.payload.source.rawValue
|
entity.sourceRaw = note.payload.source.rawValue
|
||||||
entity.tags = note.payload.tags
|
entity.tags = note.payload.tags
|
||||||
|
entity.project = note.payload.project
|
||||||
entity.createdAt = note.meta.createdAt
|
entity.createdAt = note.meta.createdAt
|
||||||
entity.modifiedAt = note.meta.modifiedAt
|
entity.modifiedAt = note.meta.modifiedAt
|
||||||
entity.jsonRelativePath = relativePath
|
entity.jsonRelativePath = relativePath
|
||||||
@@ -54,7 +55,8 @@ enum NoteMapper {
|
|||||||
source: entity.source,
|
source: entity.source,
|
||||||
tags: entity.tags,
|
tags: entity.tags,
|
||||||
context: entity.context,
|
context: entity.context,
|
||||||
audio: entity.audio
|
audio: entity.audio,
|
||||||
|
project: entity.project
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,9 +136,10 @@ final class SyncEngine {
|
|||||||
text: String,
|
text: String,
|
||||||
source: Note.Source = .typed,
|
source: Note.Source = .typed,
|
||||||
tags: [String] = [],
|
tags: [String] = [],
|
||||||
context: CaptureContext
|
context: CaptureContext,
|
||||||
|
project: String? = nil
|
||||||
) async throws -> Note {
|
) 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)
|
try await save(note)
|
||||||
return note
|
return note
|
||||||
}
|
}
|
||||||
@@ -202,7 +203,8 @@ final class SyncEngine {
|
|||||||
text: String = "",
|
text: String = "",
|
||||||
tags: [String] = [],
|
tags: [String] = [],
|
||||||
context: CaptureContext,
|
context: CaptureContext,
|
||||||
transcriberDeviceID: String
|
transcriberDeviceID: String,
|
||||||
|
project: String? = nil
|
||||||
) async throws -> Note {
|
) async throws -> Note {
|
||||||
guard let store else {
|
guard let store else {
|
||||||
report("Note not saved — iCloud not connected")
|
report("Note not saved — iCloud not connected")
|
||||||
@@ -215,7 +217,7 @@ final class SyncEngine {
|
|||||||
transcriptionLocaleID: nil,
|
transcriptionLocaleID: nil,
|
||||||
transcribedAt: 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 {
|
do {
|
||||||
let audioData = try Data(contentsOf: audioFileURL)
|
let audioData = try Data(contentsOf: audioFileURL)
|
||||||
try await store.writeData(audioData, to: Self.dataPath(Note.audioRelativePath(forID: note.id)))
|
try await store.writeData(audioData, to: Self.dataPath(Note.audioRelativePath(forID: note.id)))
|
||||||
@@ -322,6 +324,20 @@ final class SyncEngine {
|
|||||||
try await updateNote(note)
|
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
|
// MARK: - Watch ingestion
|
||||||
|
|
||||||
/// Local directory where the phone receiver (`PhoneWatchReceiver`, a later
|
/// 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
|
/// the transcript merged with user typing — indistinguishable by then) in
|
||||||
/// `text`; move that whole text into the first attempt so the v3 invariant
|
/// `text`; move that whole text into the first attempt so the v3 invariant
|
||||||
/// "text is user writing only" holds. Confidence was never recorded ⇒ nil.
|
/// "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.
|
/// Internal (not private) so the migration policy is unit-testable.
|
||||||
func migrateIfNeeded(_ note: Note) -> Note {
|
func migrateIfNeeded(_ note: Note) -> Note {
|
||||||
guard note.meta.schemaVersion < Note.currentSchemaVersion else { return note }
|
guard note.meta.schemaVersion < Note.currentSchemaVersion else { return note }
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import SwiftUI
|
|||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(SyncEngine.self) private var syncEngine
|
@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 {
|
var body: some View {
|
||||||
if syncEngine.iCloudStatus == .unavailable {
|
if syncEngine.iCloudStatus == .unavailable {
|
||||||
ICloudGateView()
|
ICloudGateView()
|
||||||
@@ -16,6 +19,10 @@ struct ContentView: View {
|
|||||||
TagsView()
|
TagsView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Tab(naming.plural, systemImage: "folder") {
|
||||||
|
ProjectsView()
|
||||||
|
}
|
||||||
|
|
||||||
Tab("Settings", systemImage: "gearshape") {
|
Tab("Settings", systemImage: "gearshape") {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ struct NoteEditorView: View {
|
|||||||
|
|
||||||
let mode: Mode
|
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(SyncEngine.self) private var syncEngine
|
||||||
@Environment(ContextService.self) private var contextService
|
@Environment(ContextService.self) private var contextService
|
||||||
@Environment(TranscriptionService.self) private var transcriptionService
|
@Environment(TranscriptionService.self) private var transcriptionService
|
||||||
@@ -326,7 +332,8 @@ struct NoteEditorView: View {
|
|||||||
text: trimmed,
|
text: trimmed,
|
||||||
tags: parsedTags,
|
tags: parsedTags,
|
||||||
context: contextService.current,
|
context: contextService.current,
|
||||||
transcriberDeviceID: TranscriptionSettings.installID
|
transcriberDeviceID: TranscriptionSettings.installID,
|
||||||
|
project: defaultProject
|
||||||
)
|
)
|
||||||
// Fast path: hand the recording's temp file straight to the
|
// Fast path: hand the recording's temp file straight to the
|
||||||
// transcriber so it doesn't round-trip through the container.
|
// transcriber so it doesn't round-trip through the container.
|
||||||
@@ -335,7 +342,8 @@ struct NoteEditorView: View {
|
|||||||
try await syncEngine.createNote(
|
try await syncEngine.createNote(
|
||||||
text: trimmed,
|
text: trimmed,
|
||||||
tags: parsedTags,
|
tags: parsedTags,
|
||||||
context: contextService.current
|
context: contextService.current,
|
||||||
|
project: defaultProject
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .edit(let entity):
|
case .edit(let entity):
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ struct NotesListView: View {
|
|||||||
|
|
||||||
@State private var composing = false
|
@State private var composing = false
|
||||||
@State private var editingNote: NoteEntity?
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -33,6 +37,9 @@ struct NotesListView: View {
|
|||||||
.sheet(item: $editingNote) { note in
|
.sheet(item: $editingNote) { note in
|
||||||
NoteEditorView(mode: .edit(note))
|
NoteEditorView(mode: .edit(note))
|
||||||
}
|
}
|
||||||
|
.sheet(item: $assigningNote) { note in
|
||||||
|
AssignProjectSheet(note: note)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +97,14 @@ struct NotesListView: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.swipeActions(edge: .leading) {
|
||||||
|
Button {
|
||||||
|
assigningNote = note
|
||||||
|
} label: {
|
||||||
|
Label(naming.singular, systemImage: "folder")
|
||||||
|
}
|
||||||
|
.tint(.indigo)
|
||||||
|
}
|
||||||
.swipeActions(edge: .trailing) {
|
.swipeActions(edge: .trailing) {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
delete(note)
|
delete(note)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ struct SettingsView: View {
|
|||||||
@Environment(ContextService.self) private var contextService
|
@Environment(ContextService.self) private var contextService
|
||||||
@EnvironmentObject private var backupController: BackupController
|
@EnvironmentObject private var backupController: BackupController
|
||||||
|
|
||||||
|
@AppStorage(ProjectsNaming.storageKey) private var namingRaw = ProjectsNaming.projects.rawValue
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
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") {
|
Section("Context") {
|
||||||
ContextSummaryRow(
|
ContextSummaryRow(
|
||||||
context: contextService.current,
|
context: contextService.current,
|
||||||
|
|||||||
@@ -137,6 +137,44 @@ struct NoteDocumentTests {
|
|||||||
#expect(Note.audioRelativePath(forJSONPath: legacyJSONPath) == "1999/12/\(id.stringValue).m4a")
|
#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() {
|
@Test func createIngestedDerivesCreatedAtFromULID() {
|
||||||
let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 5, randomLo: 6)
|
let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 5, randomLo: 6)
|
||||||
var context = CaptureContext.empty
|
var context = CaptureContext.empty
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ struct NoteMapperTests {
|
|||||||
#expect(all.first?.tags == ["coffee", "people"])
|
#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 {
|
@Test func entityRoundTripsBackToNote() throws {
|
||||||
let (container, context) = try makeStore()
|
let (container, context) = try makeStore()
|
||||||
defer { withExtendedLifetime(container) {} }
|
defer { withExtendedLifetime(container) {} }
|
||||||
|
|||||||
@@ -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)
|
- **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
|
- **"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
|
- **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
|
- **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
|
- **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
|
- **iOS + macOS + watchOS** — same app and data across iPhone and Mac, with an Apple Watch companion for voice capture
|
||||||
|
|||||||
Reference in New Issue
Block a user