diff --git a/cull/CullApp.swift b/cull/CullApp.swift --- a/cull/CullApp.swift +++ b/cull/CullApp.swift @@ -5,6 +5,7 @@ struct CullApp: App { @State private var session = CullSession() @State private var thumbnailCache = ThumbnailCache() @AppStorage("recentFolders") private var recentFoldersData: Data = Data() + @AppStorage("importRecursive") private var importRecursive: Bool = true private var recentFolders: [URL] { (try? JSONDecoder().decode([String].self, from: recentFoldersData))?.compactMap { URL(fileURLWithPath: $0) } ?? [] @@ -28,6 +29,9 @@ if let url = notification.object as? URL { addRecentFolder(url) } } + .onAppear { + session.importRecursive = importRecursive + } } .windowStyle(.automatic) .commands { @@ -39,8 +43,11 @@ } .keyboardShortcut("o") Toggle("Include Subfolders", isOn: Binding( - get: { session.importRecursive }, - set: { session.importRecursive = $0 } + get: { importRecursive }, + set: { newValue in + importRecursive = newValue + session.importRecursive = newValue + } )) Menu("Open Recent") { @@ -160,6 +167,10 @@ .keyboardShortcut(.leftArrow, modifiers: []) .disabled(session.groups.isEmpty) } + } + + Settings { + SettingsView() } } diff --git a/cull/Models/CullSession.swift b/cull/Models/CullSession.swift --- a/cull/Models/CullSession.swift +++ b/cull/Models/CullSession.swift @@ -3,6 +3,11 @@ import SwiftUI @Observable final class CullSession { + /// Whether XMP sidecars are auto-written on rating/flag changes (mirrors @AppStorage) + var autoWriteXMP: Bool { + get { UserDefaults.standard.object(forKey: "autoWriteXMP") as? Bool ?? true } + set { UserDefaults.standard.set(newValue, forKey: "autoWriteXMP") } + } var sourceFolder: URL? var groups: [PhotoGroup] = [] var selectedGroupIndex: Int = 0 @@ -267,6 +272,9 @@ session.applyPhotoState(photo, rating: oldRating, flag: oldFlag, actionName: actionName) } undoManager?.setActionName(actionName) scheduleSave() + if autoWriteXMP { + XMPSidecar.write(photo) + } } func setRating(_ rating: Int) { diff --git a/cull/Services/PhotoExporter.swift b/cull/Services/PhotoExporter.swift --- a/cull/Services/PhotoExporter.swift +++ b/cull/Services/PhotoExporter.swift @@ -37,7 +37,8 @@ photos: [Photo], destination: URL, fileType: ExportFileType, mode: ExportMode, - folderStructure: ExportFolderStructure = .flat + folderStructure: ExportFolderStructure = .flat, + includeXMP: Bool = true ) async -> ExportResult { let fm = FileManager.default @@ -90,7 +91,23 @@ } catch { errors.append("\(sourceURL.lastPathComponent): \(error.localizedDescription)") } } - if photoExported { exported += 1 } + if photoExported { + exported += 1 + if includeXMP { + let primaryURL = urls.first! + let subfolder = subfolder(for: primaryURL, photo: photo, structure: folderStructure) + let destDir = subfolder.isEmpty ? destination : destination.appendingPathComponent(subfolder) + let xmpName = primaryURL.deletingPathExtension().lastPathComponent + ".xmp" + let xmpDest = destDir.appendingPathComponent(xmpName) + let xmpRating = photo.flag == .reject ? -1 : photo.rating + let xmpContent = XMPSidecar.freshXMP(rating: xmpRating) + do { + try xmpContent.data(using: .utf8)?.write(to: xmpDest) + } catch { + errors.append("\(xmpName): XMP write failed — \(error.localizedDescription)") + } + } + } } return ExportResult(exported: exported, skipped: skipped, errors: errors) diff --git a/cull/Services/PhotoImporter.swift b/cull/Services/PhotoImporter.swift --- a/cull/Services/PhotoImporter.swift +++ b/cull/Services/PhotoImporter.swift @@ -112,6 +112,13 @@ } photos.sort { ($0.captureDate ?? .distantPast) < ($1.captureDate ?? .distantPast) } + // Read existing XMP sidecars + for photo in photos { + if let meta = XMPSidecar.read(for: photo.url) { + XMPSidecar.apply(meta, to: photo) + } + } + return ImportResult(photos: photos, paired: pairedCount) } diff --git a/cull/Services/XMPSidecar.swift b/cull/Services/XMPSidecar.swift new file mode 100644 --- /dev/null +++ b/cull/Services/XMPSidecar.swift @@ -0,0 +1,136 @@ +import Foundation + +/// Reads and writes XMP sidecar files for photo metadata interoperability. +/// Uses `.xmp` naming (Lightroom/Bridge compatible). +struct XMPSidecar { + + /// Metadata stored in an XMP sidecar + struct Metadata { + var rating: Int = 0 // 0 = unrated, 1-5 = stars, -1 = rejected + var label: String? // color label: "Red", "Yellow", "Green", "Blue", "Purple" + } + + /// Returns the XMP sidecar URL for a given photo URL + static func sidecarURL(for photoURL: URL) -> URL { + photoURL.deletingPathExtension().appendingPathExtension("xmp") + } + + // MARK: - Read + + /// Read metadata from an existing XMP sidecar, if present + static func read(for photoURL: URL) -> Metadata? { + let url = sidecarURL(for: photoURL) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + guard let doc = try? XMLDocument(contentsOf: url, options: []) else { return nil } + + var meta = Metadata() + + // Try attribute form: xmp:Rating="3" on rdf:Description + if let ratingStr = try? doc.nodes(forXPath: "//@xmp:Rating").first?.stringValue, + let rating = Int(ratingStr) { + meta.rating = rating + } + // Try element form: 3 + else if let ratingStr = try? doc.nodes(forXPath: "//xmp:Rating").first?.stringValue, + let rating = Int(ratingStr) { + meta.rating = rating + } + + if let label = try? doc.nodes(forXPath: "//@xmp:Label").first?.stringValue { + meta.label = label + } else if let label = try? doc.nodes(forXPath: "//xmp:Label").first?.stringValue { + meta.label = label + } + + return meta + } + + // MARK: - Write + + /// Write metadata to an XMP sidecar, merging with existing content if present + static func write(_ photo: Photo) { + let url = sidecarURL(for: photo.url) + let xmpRating = xmpRating(for: photo) + + // If an existing XMP exists, try to merge + if FileManager.default.fileExists(atPath: url.path), + let doc = try? XMLDocument(contentsOf: url, options: [.nodePreserveWhitespace]) { + if mergeInto(doc, rating: xmpRating) { + try? doc.xmlData(options: [.nodePrettyPrint]).write(to: url) + return + } + } + + // Write fresh XMP + let xml = freshXMP(rating: xmpRating) + try? xml.data(using: .utf8)?.write(to: url) + } + + // MARK: - Mapping + + /// Map Cull's rating/flag to XMP rating value + private static func xmpRating(for photo: Photo) -> Int { + if photo.flag == .reject { return -1 } + return photo.rating // 0 = unrated, 1-5 = stars + } + + /// Map XMP metadata back to Cull's rating/flag + static func apply(_ meta: Metadata, to photo: Photo) { + if meta.rating == -1 { + photo.flag = .reject + } else if meta.rating >= 1 && meta.rating <= 5 { + photo.rating = meta.rating + } + // Don't overwrite existing state with "unrated" + } + + // MARK: - XMP Generation + + static func freshXMP(rating: Int) -> String { + """ + + + + + + + + + """ + } + + /// Merge rating into an existing XMLDocument's rdf:Description + private static func mergeInto(_ doc: XMLDocument, rating: Int) -> Bool { + // Find rdf:Description element + guard let descriptions = try? doc.nodes(forXPath: "//rdf:Description"), + let desc = descriptions.first as? XMLElement else { return false } + + // Update or add xmp:Rating attribute + let ns = "http://ns.adobe.com/xap/1.0/" + if let existing = desc.attribute(forLocalName: "Rating", uri: ns) { + existing.stringValue = "\(rating)" + } else { + let attr = XMLNode.attribute( + withName: "xmp:Rating", + uri: ns, + stringValue: "\(rating)" + ) as! XMLNode + desc.addAttribute(attr) + } + + // Ensure CreatorTool mentions Cull + if desc.attribute(forLocalName: "CreatorTool", uri: ns) == nil { + let attr = XMLNode.attribute( + withName: "xmp:CreatorTool", + uri: ns, + stringValue: "Cull" + ) as! XMLNode + desc.addAttribute(attr) + } + + return true + } +} diff --git a/cull/Views/ExportSheet.swift b/cull/Views/ExportSheet.swift --- a/cull/Views/ExportSheet.swift +++ b/cull/Views/ExportSheet.swift @@ -11,6 +11,7 @@ @State private var destination: URL? @State private var isExporting: Bool = false @State private var result: ExportResult? @State private var useCurrentFilters: Bool = true + @State private var includeXMP: Bool = true private var eligiblePhotos: [Photo] { if useCurrentFilters { @@ -26,6 +27,7 @@ .font(.title2.bold()) Form { Toggle("Export only visible photos", isOn: $useCurrentFilters) + Toggle("Include XMP sidecars", isOn: $includeXMP) Picker("File Type", selection: $fileType) { ForEach(ExportFileType.allCases) { type in @@ -132,7 +134,8 @@ photos: photos, destination: destination, fileType: fileType, mode: exportMode, - folderStructure: folderStructure + folderStructure: folderStructure, + includeXMP: includeXMP ) await MainActor.run { result = exportResult diff --git a/cull/Views/SettingsView.swift b/cull/Views/SettingsView.swift new file mode 100644 --- /dev/null +++ b/cull/Views/SettingsView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct SettingsView: View { + @AppStorage("autoWriteXMP") private var autoWriteXMP: Bool = true + @AppStorage("importRecursive") private var importRecursive: Bool = true + + var body: some View { + Form { + Section("Sidecars") { + Toggle("Automatically write XMP sidecars", isOn: $autoWriteXMP) + Text("Writes rating and flag changes to .xmp files alongside your photos for Lightroom/Bridge/darktable compatibility.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Import") { + Toggle("Include subfolders by default", isOn: $importRecursive) + } + } + .formStyle(.grouped) + .frame(width: 450) + .fixedSize() + } +} diff --git a/cull/cull.xcodeproj/project.pbxproj b/cull/cull.xcodeproj/project.pbxproj --- a/cull/cull.xcodeproj/project.pbxproj +++ b/cull/cull.xcodeproj/project.pbxproj @@ -25,6 +25,8 @@ 0B0EC2972F722491004523FA /* PhotoExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC27D2F722491004523FA /* PhotoExporter.swift */; }; 0B0EC2982F722491004523FA /* ThumbnailCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC2812F722491004523FA /* ThumbnailCache.swift */; }; 0B0EC2A12F72570F004523FA /* icon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0B0EC2A02F72570F004523FA /* icon.icon */; }; 0B0EC2A52F727789004523FA /* WorkspaceDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC2A42F727789004523FA /* WorkspaceDB.swift */; }; + 0B0EC2A72F7314F5004523FA /* XMPSidecar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC2A62F7314F5004523FA /* XMPSidecar.swift */; }; + 0B0EC2A92F73150B004523FA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC2A82F73150B004523FA /* SettingsView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -47,6 +49,8 @@ 0B0EC2882F722491004523FA /* PhotoViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoViewer.swift; sourceTree = ""; }; 0B0EC2992F724FE5004523FA /* cull.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cull.app; sourceTree = BUILT_PRODUCTS_DIR; }; 0B0EC2A02F72570F004523FA /* icon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = icon.icon; sourceTree = ""; }; 0B0EC2A42F727789004523FA /* WorkspaceDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceDB.swift; sourceTree = ""; }; + 0B0EC2A62F7314F5004523FA /* XMPSidecar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XMPSidecar.swift; sourceTree = ""; }; + 0B0EC2A82F73150B004523FA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -86,6 +90,7 @@ }; 0B0EC2822F722491004523FA /* Services */ = { isa = PBXGroup; children = ( + 0B0EC2A62F7314F5004523FA /* XMPSidecar.swift */, 0B0EC2A42F727789004523FA /* WorkspaceDB.swift */, 0B0EC27D2F722491004523FA /* PhotoExporter.swift */, 0B0EC27E2F722491004523FA /* PhotoImporter.swift */, @@ -99,6 +104,7 @@ }; 0B0EC2892F722491004523FA /* Views */ = { isa = PBXGroup; children = ( + 0B0EC2A82F73150B004523FA /* SettingsView.swift */, 0B0EC2832F722491004523FA /* ContentView.swift */, 0B0EC2842F722491004523FA /* ExportSheet.swift */, 0B0EC2852F722491004523FA /* GroupDetailView.swift */, @@ -185,6 +191,8 @@ files = ( 0B0EC28A2F722491004523FA /* ImportView.swift in Sources */, 0B0EC2A52F727789004523FA /* WorkspaceDB.swift in Sources */, 0B0EC28B2F722491004523FA /* ExportSheet.swift in Sources */, + 0B0EC2A92F73150B004523FA /* SettingsView.swift in Sources */, + 0B0EC2A72F7314F5004523FA /* XMPSidecar.swift in Sources */, 0B0EC28C2F722491004523FA /* ShotGrouper.swift in Sources */, 0B0EC28D2F722491004523FA /* Photo.swift in Sources */, 0B0EC28E2F722491004523FA /* GroupListView.swift in Sources */, @@ -348,6 +356,8 @@ ENABLE_RESOURCE_ACCESS_PRINTING = NO; ENABLE_RESOURCE_ACCESS_USB = NO; ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Cull; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -390,6 +400,8 @@ ENABLE_RESOURCE_ACCESS_PRINTING = NO; ENABLE_RESOURCE_ACCESS_USB = NO; ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Cull; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/cull/icon.icon/Assets/C.png b/cull/icon.icon/Assets/C.png new file mode 100644 --- /dev/null +++ b/cull/icon.icon/Assets/C.png diff --git a/cull/icon.icon/Assets/Subtract.svg b/cull/icon.icon/Assets/Subtract.svg deleted file mode 100644 --- a/cull/icon.icon/Assets/Subtract.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/cull/icon.icon/Assets/emoji_u1f39e 1 (1).svg b/cull/icon.icon/Assets/emoji_u1f39e 1 (1).svg deleted file mode 100644 --- a/cull/icon.icon/Assets/emoji_u1f39e 1 (1).svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/cull/icon.icon/Assets/emoji_u1f52a 1.svg b/cull/icon.icon/Assets/emoji_u1f52a 1.svg deleted file mode 100644 --- a/cull/icon.icon/Assets/emoji_u1f52a 1.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/cull/icon.icon/icon.json b/cull/icon.icon/icon.json --- a/cull/icon.icon/icon.json +++ b/cull/icon.icon/icon.json @@ -1,9 +1,7 @@ { "fill-specializations" : [ { - "value" : { - "solid" : "display-p3:0.12880,0.13019,0.13434,1.00000" - } + "value" : "system-dark" }, { "appearance" : "dark", @@ -16,41 +14,29 @@ "groups" : [ { "layers" : [ { - "blend-mode" : "normal", - "fill" : "none", - "glass" : true, - "hidden" : false, - "image-name" : "emoji_u1f52a 1.svg", - "name" : "emoji_u1f52a 1", - "opacity" : 1, + "fill" : { + "linear-gradient" : [ + "display-p3:1.00000,0.12046,0.22478,1.00000", + "display-p3:0.50511,0.67153,0.96783,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 0 + }, + "stop" : { + "x" : 0.5652511760752689, + "y" : 0.8858258928571429 + } + } + }, + "image-name" : "C.png", + "name" : "C", "position" : { "scale" : 1, "translation-in-points" : [ - -97.0625, - -105.04424700203003 - ] - } - }, - { - "image-name" : "emoji_u1f39e 1 (1).svg", - "name" : "emoji_u1f39e 1 (1)", - "position" : { - "scale" : 1.23, - "translation-in-points" : [ - -0.7779750000000831, - 23.765625 - ] - } - }, - { - "glass" : true, - "image-name" : "Subtract.svg", - "name" : "Subtract", - "position" : { - "scale" : 1.07, - "translation-in-points" : [ - 0.4913672408982279, - 106.2578125 + 0, + 0 ] } }