diff --git a/TakeNote/Library/NoteImageManager.swift b/TakeNote/Library/NoteImageManager.swift new file mode 100644 index 0000000..1d6adf8 --- /dev/null +++ b/TakeNote/Library/NoteImageManager.swift @@ -0,0 +1,130 @@ +// +// NoteImageManager.swift +// TakeNote +// +// Created by Adam Drew on 9/9/25. +// + +import SwiftData +import SwiftUI +import UniformTypeIdentifiers + +@MainActor +@Observable +class NoteImageManager { + var modelContext: ModelContext + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } + + func ingestImage(from url: URL, note: Note) -> NoteImage? { + guard url.isFileURL else { return nil } + guard let type = UTType(filenameExtension: url.pathExtension), + type.conforms(to: .image) + else { return nil } + guard let data = try? Data(contentsOf: url) else { return nil } + + let mimeType = type.preferredMIMEType ?? "application/octet-stream" + let image = NoteImage(data: data, mimeType: mimeType, referenceCount: 1) + modelContext.insert(image) + let link = NoteImageLink(sourceNote: note, image: image) + modelContext.insert(link) + return image + } + + func updateImageLinks(for note: Note) { + let existingLinks = getLinksForSourceNote(note) + let existingUUIDs = Set( + existingLinks.compactMap { $0.image?.uuid } + ) + let currentUUIDs = Set(extractImageUUIDs(from: note.content)) + + let linksToRemove = existingLinks.filter { link in + guard let uuid = link.image?.uuid else { return true } + return !currentUUIDs.contains(uuid) + } + + for link in linksToRemove { + if let image = link.image { + image.referenceCount = max(0, image.referenceCount - 1) + if image.referenceCount == 0 { + modelContext.delete(image) + } + } + modelContext.delete(link) + } + + let uuidsToAdd = currentUUIDs.subtracting(existingUUIDs) + if !uuidsToAdd.isEmpty { + let images = getImagesForUUIDs(uuidsToAdd) + let imageByUUID = makeUUIDImageMap(images) + for uuid in uuidsToAdd { + guard let image = imageByUUID[uuid] else { continue } + let link = NoteImageLink(sourceNote: note, image: image) + image.referenceCount += 1 + modelContext.insert(link) + } + } + + try? modelContext.save() + } + + func removeImageLinks(for note: Note) { + let existingLinks = getLinksForSourceNote(note) + for link in existingLinks { + if let image = link.image { + image.referenceCount = max(0, image.referenceCount - 1) + if image.referenceCount == 0 { + modelContext.delete(image) + } + } + modelContext.delete(link) + } + try? modelContext.save() + } + + private func extractImageUUIDs(from text: String) -> [UUID] { + let pattern = + #/(?i)takenote:\/\/image\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/# + var seen = Set() + var result: [UUID] = [] + + for match in text.matches(of: pattern) { + let uuidSub = match.1 + if let uuid = UUID(uuidString: String(uuidSub)), + seen.insert(uuid).inserted + { + result.append(uuid) + } + } + return result + } + + private func getLinksForSourceNote(_ note: Note) -> [NoteImageLink] { + let uuid = note.uuid + return + (try? modelContext.fetch( + FetchDescriptor( + predicate: #Predicate { $0.sourceNote?.uuid == uuid } + ) + )) ?? [] + } + + private func getImagesForUUIDs(_ uuids: Set) -> [NoteImage] { + return + (try? modelContext.fetch( + FetchDescriptor( + predicate: #Predicate { uuids.contains($0.uuid) } + ) + )) ?? [] + } + + private func makeUUIDImageMap(_ images: [NoteImage]) -> [UUID: NoteImage] { + var imageByUUID: [UUID: NoteImage] = [:] + for image in images { + imageByUUID[image.uuid] = image + } + return imageByUUID + } +} diff --git a/TakeNote/Library/TakeNoteImageURLProtocol.swift b/TakeNote/Library/TakeNoteImageURLProtocol.swift new file mode 100644 index 0000000..4b8ce1f --- /dev/null +++ b/TakeNote/Library/TakeNoteImageURLProtocol.swift @@ -0,0 +1,85 @@ +// +// TakeNoteImageURLProtocol.swift +// TakeNote +// +// Created by Adam Drew on 9/9/25. +// + +import Foundation +import SwiftData + +enum TakeNoteImageURLProtocolRegistrar { + private static var isRegistered = false + + static func registerIfNeeded(container: ModelContainer) { + guard !isRegistered else { return } + TakeNoteImageURLProtocol.modelContainer = container + URLProtocol.registerClass(TakeNoteImageURLProtocol.self) + isRegistered = true + } +} + +final class TakeNoteImageURLProtocol: URLProtocol { + static var modelContainer: ModelContainer? + + override class func canInit(with request: URLRequest) -> Bool { + guard let url = request.url else { return false } + return url.scheme == "takenote" && url.host == "image" + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + guard let url = request.url else { return } + guard let uuid = UUID(uuidString: url.lastPathComponent) else { + client?.urlProtocol( + self, + didFailWithError: URLError(.badURL) + ) + return + } + guard let container = Self.modelContainer else { + client?.urlProtocol( + self, + didFailWithError: URLError(.resourceUnavailable) + ) + return + } + + Task { [weak self] @MainActor in + guard let self else { return } + let context = ModelContext(container) + let image = (try? context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.uuid == uuid } + ) + ))?.first + + guard let image else { + self.client?.urlProtocol( + self, + didFailWithError: URLError(.fileDoesNotExist) + ) + return + } + + let response = URLResponse( + url: url, + mimeType: image.mimeType, + expectedContentLength: image.data.count, + textEncodingName: nil + ) + self.client?.urlProtocol( + self, + didReceive: response, + cacheStoragePolicy: .notAllowed + ) + self.client?.urlProtocol(self, didLoad: image.data) + self.client?.urlProtocolDidFinishLoading(self) + } + } + + override func stopLoading() {} +} diff --git a/TakeNote/Models/Note.swift b/TakeNote/Models/Note.swift index f417ba4..dfcede5 100644 --- a/TakeNote/Models/Note.swift +++ b/TakeNote/Models/Note.swift @@ -75,6 +75,7 @@ class Note: Identifiable { // (we'll specify inverses on NoteLink to avoid macro circularity). @Relationship var outgoingLinks: [NoteLink]? = [] @Relationship var incomingLinks: [NoteLink]? = [] + @Relationship var imageLinks: [NoteImageLink]? = [] init(folder: NoteContainer) { self.title = self.defaultTitle diff --git a/TakeNote/Models/NoteImage.swift b/TakeNote/Models/NoteImage.swift new file mode 100644 index 0000000..553091c --- /dev/null +++ b/TakeNote/Models/NoteImage.swift @@ -0,0 +1,38 @@ +// +// NoteImage.swift +// TakeNote +// +// Created by Adam Drew on 9/9/25. +// + +import Foundation +import SwiftData +import SwiftUI + +// Hey! +// Hey you! +// If you change model schema remember to bump ckBootstrapVersionCurrent +// in TakeNoteApp.swift +// +// And don't forget to promote to prod!!! + +@Model +class NoteImage { + private(set) var uuid: UUID = UUID() + @Attribute(.externalStorage) var data: Data + var mimeType: String + var referenceCount: Int + var createdDate: Date = Date() + @Relationship var noteLinks: [NoteImageLink]? = [] + + init(data: Data, mimeType: String, referenceCount: Int = 0) { + self.data = data + self.mimeType = mimeType + self.referenceCount = referenceCount + self.uuid = UUID() + } + + func getURL() -> String { + return "takenote://image/\(uuid.uuidString)" + } +} diff --git a/TakeNote/Models/NoteImageLink.swift b/TakeNote/Models/NoteImageLink.swift new file mode 100644 index 0000000..1b9877a --- /dev/null +++ b/TakeNote/Models/NoteImageLink.swift @@ -0,0 +1,29 @@ +// +// NoteImageLink.swift +// TakeNote +// +// Created by Adam Drew on 9/9/25. +// + +import SwiftData +import SwiftUI + +// Hey! +// Hey you! +// If you change model schema remember to bump ckBootstrapVersionCurrent +// in TakeNoteApp.swift +// +// And don't forget to promote to prod!!! + +@Model +class NoteImageLink { + @Relationship(inverse: \Note.imageLinks) + var sourceNote: Note? + @Relationship(inverse: \NoteImage.noteLinks) + var image: NoteImage? + + init(sourceNote: Note, image: NoteImage) { + self.sourceNote = sourceNote + self.image = image + } +} diff --git a/TakeNote/TakeNoteApp.swift b/TakeNote/TakeNoteApp.swift index c1d41b3..b9d872a 100644 --- a/TakeNote/TakeNoteApp.swift +++ b/TakeNote/TakeNoteApp.swift @@ -11,7 +11,7 @@ private let onboardingVersionKey = "onboarding.version.seen" #if DEBUG // Bump this to get the schema to update, for example if there have been model changes - private let ckBootstrapVersionCurrent = 8 + private let ckBootstrapVersionCurrent = 9 private let ckBootstrapVersionKey = "takenote.ck.bootstrap.version" #endif @@ -73,7 +73,13 @@ struct TakeNoteApp: App { "CKBootstrap-\(UUID().uuidString).sqlite" ) AppBootstrapper.bootstrapDevSchemaIfNeeded( - modelTypes: [Note.self, NoteContainer.self, NoteLink.self], + modelTypes: [ + Note.self, + NoteContainer.self, + NoteLink.self, + NoteImage.self, + NoteImageLink.self + ], storeURL: tempBootstrapURL, containerID: "iCloud.com.adamdrew.takenote", userDefaultsKey: ckBootstrapVersionKey, @@ -88,12 +94,18 @@ struct TakeNoteApp: App { for: Note.self, NoteContainer.self, NoteLink.self, + NoteImage.self, + NoteImageLink.self, configurations: config ) } catch { fatalError("Failed to initialize ModelContainer: \(error)") } + TakeNoteImageURLProtocolRegistrar.registerIfNeeded( + container: container + ) + // Capture values in locals to avoid capturing `self` in escaping closures let modelContainer = container let viewModel = takeNoteVM diff --git a/TakeNote/TakeNoteVM.swift b/TakeNote/TakeNoteVM.swift index b2234a0..9ffa78a 100644 --- a/TakeNote/TakeNoteVM.swift +++ b/TakeNote/TakeNoteVM.swift @@ -284,10 +284,12 @@ class TakeNoteVM { errorAlertIsVisible = true return } + let imageManager = NoteImageManager(modelContext: modelContext) for note in trash.notes { if openNote == note { openNote = nil } + imageManager.removeImageLinks(for: note) modelContext.delete(note) } do { diff --git a/TakeNote/Views/MainWindow/MainWindow.swift b/TakeNote/Views/MainWindow/MainWindow.swift index dae5709..a9caca2 100644 --- a/TakeNote/Views/MainWindow/MainWindow.swift +++ b/TakeNote/Views/MainWindow/MainWindow.swift @@ -22,6 +22,8 @@ struct MainWindow: View { @Query() var notes: [Note] @Query() var containers: [NoteContainer] @Query() var noteLinks: [NoteLink] + @Query() var noteImageLinks: [NoteImageLink] + @Query() var noteImages: [NoteImage] @State var notesInBufferMessagePresented: Bool = false @State var showDeleteEverythingAlert: Bool = false @@ -71,6 +73,12 @@ struct MainWindow: View { for noteLink in noteLinks { modelContext.delete(noteLink) } + for imageLink in noteImageLinks { + modelContext.delete(imageLink) + } + for image in noteImages { + modelContext.delete(image) + } try? modelContext.save() } diff --git a/TakeNote/Views/NoteEditor/NoteEditor.swift b/TakeNote/Views/NoteEditor/NoteEditor.swift index 64207ae..54ff038 100644 --- a/TakeNote/Views/NoteEditor/NoteEditor.swift +++ b/TakeNote/Views/NoteEditor/NoteEditor.swift @@ -211,6 +211,34 @@ struct NoteEditor: View { position.selections = [NSRange(location: newLoc, length: 0)] } + private func insertImageMarkdown(_ markdown: String) { + guard let note = openNote else { return } + if showPreview { + let prefix = note.content.isEmpty ? "" : "\n" + note.setContent(note.content + prefix + markdown) + } else { + insertAtCaret(markdown) + } + } + + @MainActor + private func handleImageDrop(_ urls: [URL]) -> Bool { + guard let note = openNote else { return false } + let imageManager = NoteImageManager(modelContext: modelContext) + var markdownLinks: [String] = [] + + for url in urls { + guard let image = imageManager.ingestImage(from: url, note: note) + else { continue } + markdownLinks.append("![](\(image.getURL()))") + } + + guard !markdownLinks.isEmpty else { return false } + insertImageMarkdown(markdownLinks.joined(separator: "\n")) + try? modelContext.save() + return true + } + fileprivate func setShowBacklinks() { if let on = openNote { openNoteHasBacklinks = NoteLinkManager( @@ -436,6 +464,9 @@ struct NoteEditor: View { .focusedSceneValue(\.showAssistantPopover, showAssistantPopover) .focusedSceneValue(\.openNoteHasBacklinks, openNoteHasBacklinks) .focusedSceneValue(\.showBacklinks, showBacklinks) + .dropDestination(for: URL.self, isEnabled: true) { items, _ in + handleImageDrop(items) + } } else { VStack { diff --git a/TakeNote/Views/NoteList/NoteList.swift b/TakeNote/Views/NoteList/NoteList.swift index f9d3379..cf12f0e 100644 --- a/TakeNote/Views/NoteList/NoteList.swift +++ b/TakeNote/Views/NoteList/NoteList.swift @@ -256,6 +256,8 @@ struct NoteList: View { search.reindex(note: note) NoteLinkManager(modelContext: modelContext) .generateLinksFor(note) + NoteImageManager(modelContext: modelContext) + .updateImageLinks(for: note) note.setTitle() }