From 0f615b685f93aa42f4e13a4ec5f2f64c8e281382 Mon Sep 17 00:00:00 2001 From: Frank Barrett Date: Thu, 9 Jul 2026 20:53:12 -0700 Subject: [PATCH] feat: generate AI titles and summaries for Mac-authored notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notes created or edited on the Mac never got a stored title or card summary — the web client is the one that calls /api/notes/title, so Mac-authored notes showed raw first lines everywhere. The store now fetches metadata on create and folds regeneration into body saves when the lead line changes, the same policy as the web autosave. Meta failures (offline, rate-limited, body too large) degrade to a plain save. Co-Authored-By: Claude Fable 5 --- ios/Keep/Models/Note.swift | 5 +++++ ios/Keep/Services/KeepAPI.swift | 17 ++++++++++++++--- ios/Keep/Services/NotesStore.swift | 21 ++++++++++++++++++++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/ios/Keep/Models/Note.swift b/ios/Keep/Models/Note.swift index b1c4e4e..61a359f 100644 --- a/ios/Keep/Models/Note.swift +++ b/ios/Keep/Models/Note.swift @@ -37,3 +37,8 @@ struct Note: Identifiable, Codable, Equatable { struct NotesResponse: Codable { let notes: [Note] } struct NoteResponse: Codable { let note: Note } struct OkResponse: Codable { let ok: Bool } +/// POST /api/notes/title — model-generated (or heuristic) note metadata. +struct NoteMeta: Codable { + let title: String + let summary: String? +} diff --git a/ios/Keep/Services/KeepAPI.swift b/ios/Keep/Services/KeepAPI.swift index 3e66891..af96f5b 100644 --- a/ios/Keep/Services/KeepAPI.swift +++ b/ios/Keep/Services/KeepAPI.swift @@ -36,14 +36,25 @@ actor KeepAPI { try await request("/api/notes", method: "GET", as: NotesResponse.self).notes } - func create(body: String) async throws -> Note { - try await request( + func create(body: String, title: String? = nil, summary: String? = nil) async throws -> Note { + var json: [String: Any] = ["body": body] + if let title { json["title"] = title } + if let summary, !summary.isEmpty { json["summary"] = summary } + return try await request( "/api/notes", method: "POST", - json: ["body": body], + json: json, as: NoteResponse.self ).note } + func generateMeta(body: String) async throws -> NoteMeta { + try await request( + "/api/notes/title", method: "POST", + json: ["body": body], + as: NoteMeta.self + ) + } + func update(id: String, patch: [String: Any]) async throws -> Note { try await request( "/api/notes/\(id)", method: "PATCH", diff --git a/ios/Keep/Services/NotesStore.swift b/ios/Keep/Services/NotesStore.swift index 3d7b76a..6cbc82f 100644 --- a/ios/Keep/Services/NotesStore.swift +++ b/ios/Keep/Services/NotesStore.swift @@ -41,7 +41,8 @@ final class NotesStore { @discardableResult func create(body: String) async -> Note? { do { - let note = try await api.create(body: body) + let meta = try? await api.generateMeta(body: body) + let note = try await api.create(body: body, title: meta?.title, summary: meta?.summary) notes.insert(note, at: 0) return note } catch { @@ -50,13 +51,31 @@ final class NotesStore { } } + /// Body saves regenerate the title/summary when the lead line changes (or + /// the note has no title yet), folded into the same PATCH — mirroring the + /// web's autosave. Meta failures degrade to a plain body save. func update(_ id: String, patch: [String: Any]) async { + var patch = patch + if let body = patch["body"] as? String, + patch["title"] == nil, + let current = notes.first(where: { $0.id == id }), + Self.firstLine(body) != Self.firstLine(current.body) + || current.title.trimmingCharacters(in: .whitespaces).isEmpty, + let meta = try? await api.generateMeta(body: body) { + patch["title"] = meta.title + if let summary = meta.summary, !summary.isEmpty { patch["summary"] = summary } + } do { let updated = try await api.update(id: id, patch: patch) if let i = notes.firstIndex(where: { $0.id == id }) { notes[i] = updated } } catch { handle(error) } } + private static func firstLine(_ body: String) -> String { + (body.components(separatedBy: "\n").first ?? "") + .trimmingCharacters(in: .whitespaces) + } + func togglePin(_ note: Note) async { await update(note.id, patch: ["pinned": !note.pinned]) }