diff --git a/Meetingnotes.xcodeproj/project.pbxproj b/Meetingnotes.xcodeproj/project.pbxproj
index c8ac7eb..0286b81 100644
--- a/Meetingnotes.xcodeproj/project.pbxproj
+++ b/Meetingnotes.xcodeproj/project.pbxproj
@@ -276,7 +276,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 14;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = ML6HYR5LUR;
ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
- MARKETING_VERSION = 1.1.2;
+ MARKETING_VERSION = 1.1.3;
ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 14;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = ML6HYR5LUR;
ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
- MARKETING_VERSION = 1.1.2;
+ MARKETING_VERSION = 1.1.3;
ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
@@ -366,7 +366,7 @@
repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
requirement = {
kind = upToNextMajorVersion;
- minimumVersion = 2.7.1;
+ minimumVersion = 2.9.4;
};
};
CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */ = {
diff --git a/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 8606731..743f3ed 100644
--- a/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Meetingnotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -15,8 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle.git",
"state" : {
- "revision" : "df074165274afaa39539c05d57b0832620775b11",
- "version" : "2.7.1"
+ "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
+ "version" : "2.9.4"
}
},
{
diff --git a/meetingnotes/Info.plist b/meetingnotes/Info.plist
index c4bff09..4094c80 100644
--- a/meetingnotes/Info.plist
+++ b/meetingnotes/Info.plist
@@ -12,9 +12,13 @@
NSMicrophoneUsageDescription
Meetingnotes needs access to your microphone for transcription.
SUFeedURL
- https://raw.githubusercontent.com/superdooper86/meetingnotes/main/appcast.xml
+ https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml
SUPublicEDKey
- BVXHOV8ZxPxKZ1swhFndymzew9nyd3si7849JA9cqsg=
+ 9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0=
+ SUEnableAutomaticChecks
+
+ SUAutomaticallyUpdate
+
SUEnableInstallerLauncherService
diff --git a/meetingnotes/Managers/LocalStorageManager.swift b/meetingnotes/Managers/LocalStorageManager.swift
index 4e5990d..d19c6eb 100644
--- a/meetingnotes/Managers/LocalStorageManager.swift
+++ b/meetingnotes/Managers/LocalStorageManager.swift
@@ -6,6 +6,11 @@ import Foundation
/// Manages local file storage for meetings and app data
class LocalStorageManager {
static let shared = LocalStorageManager()
+
+ struct MeetingImportResult {
+ let importedCount: Int
+ let skippedCount: Int
+ }
private let documentsDirectory: URL
private let meetingsDirectory: URL
@@ -47,7 +52,11 @@ class LocalStorageManager {
// Write atomically using a temp file then replace
let tmpURL = fileURL.appendingPathExtension("tmp")
try data.write(to: tmpURL, options: .atomic)
- try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil)
+ if FileManager.default.fileExists(atPath: fileURL.path) {
+ try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL)
+ } else {
+ try FileManager.default.moveItem(at: tmpURL, to: fileURL)
+ }
print("✅ Saved meeting: \(meeting.id)")
return true
@@ -128,6 +137,51 @@ class LocalStorageManager {
return false
}
}
+
+ /// Imports meeting JSON files from a folder selected by the user.
+ func importMeetings(from directory: URL) throws -> MeetingImportResult {
+ let didStartAccess = directory.startAccessingSecurityScopedResource()
+ defer {
+ if didStartAccess {
+ directory.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ let existingIDs = Set(loadMeetings().map(\.id))
+ var importedIDs = Set()
+ var skippedCount = 0
+
+ guard let enumerator = FileManager.default.enumerator(
+ at: directory,
+ includingPropertiesForKeys: [.isRegularFileKey],
+ options: [.skipsHiddenFiles, .skipsPackageDescendants]
+ ) else {
+ throw CocoaError(.fileReadUnknown)
+ }
+
+ for case let fileURL as URL in enumerator where fileURL.pathExtension.lowercased() == "json" {
+ do {
+ let values = try fileURL.resourceValues(forKeys: [.isRegularFileKey])
+ guard values.isRegularFile == true else { continue }
+ let data = try Data(contentsOf: fileURL)
+ let meeting = try decoder.decode(Meeting.self, from: data)
+ guard meeting.dataVersion <= Meeting.currentDataVersion,
+ !existingIDs.contains(meeting.id),
+ !importedIDs.contains(meeting.id),
+ saveMeeting(meeting) else {
+ skippedCount += 1
+ continue
+ }
+ importedIDs.insert(meeting.id)
+ } catch {
+ skippedCount += 1
+ }
+ }
+
+ return MeetingImportResult(importedCount: importedIDs.count, skippedCount: skippedCount)
+ }
// MARK: - Template Management
@@ -146,7 +200,11 @@ class LocalStorageManager {
// Write atomically using a temp file then replace
let tmpURL = fileURL.appendingPathExtension("tmp")
try data.write(to: tmpURL, options: .atomic)
- try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil)
+ if FileManager.default.fileExists(atPath: fileURL.path) {
+ try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL)
+ } else {
+ try FileManager.default.moveItem(at: tmpURL, to: fileURL)
+ }
print("✅ Saved template: \(template.id)")
return true
@@ -241,4 +299,4 @@ class LocalStorageManager {
var meetingsDirectoryURL: URL {
meetingsDirectory
}
-}
\ No newline at end of file
+}
diff --git a/meetingnotes/Views/SettingsView.swift b/meetingnotes/Views/SettingsView.swift
index 25a9cc8..97a44b5 100644
--- a/meetingnotes/Views/SettingsView.swift
+++ b/meetingnotes/Views/SettingsView.swift
@@ -1,10 +1,14 @@
import SwiftUI
+import UniformTypeIdentifiers
struct SettingsView: View {
@ObservedObject var viewModel: SettingsViewModel
@StateObject private var localAPIServer = LocalAPIServer.shared
@State private var showingTemplateManager = false
@State private var confirmingTokenRegeneration = false
+ @State private var showingMeetingImporter = false
+ @State private var meetingImportMessage = ""
+ @State private var showingMeetingImportResult = false
@Binding var navigationPath: NavigationPath
init(viewModel: SettingsViewModel, navigationPath: Binding = .constant(NavigationPath())) {
@@ -119,6 +123,17 @@ struct SettingsView: View {
}
}
}
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Meeting Storage")
+ .font(.headline)
+
+ Button {
+ showingMeetingImporter = true
+ } label: {
+ Label("Import Meetings...", systemImage: "square.and.arrow.down")
+ }
+ }
// Note Templates Section: only the Manage Templates button
VStack(alignment: .leading, spacing: 8) {
@@ -276,6 +291,29 @@ struct SettingsView: View {
} message: {
Text(viewModel.saveMessage)
}
+ .fileImporter(
+ isPresented: $showingMeetingImporter,
+ allowedContentTypes: [.folder],
+ allowsMultipleSelection: false
+ ) { result in
+ do {
+ guard let directory = try result.get().first else { return }
+ let importResult = try LocalStorageManager.shared.importMeetings(from: directory)
+ meetingImportMessage = "Imported \(importResult.importedCount) meeting\(importResult.importedCount == 1 ? "" : "s")."
+ if importResult.skippedCount > 0 {
+ meetingImportMessage += " Skipped \(importResult.skippedCount) existing or invalid file\(importResult.skippedCount == 1 ? "" : "s")."
+ }
+ NotificationCenter.default.post(name: .meetingSaved, object: nil)
+ } catch {
+ meetingImportMessage = "Meeting import failed: \(error.localizedDescription)"
+ }
+ showingMeetingImportResult = true
+ }
+ .alert("Meeting Import", isPresented: $showingMeetingImportResult) {
+ Button("OK") { }
+ } message: {
+ Text(meetingImportMessage)
+ }
.confirmationDialog("Regenerate API token?", isPresented: $confirmingTokenRegeneration, titleVisibility: .visible) {
Button("Regenerate", role: .destructive) {
viewModel.regenerateMuteDeckAPIToken()
diff --git a/meetingnotes/meetingnotes.entitlements b/meetingnotes/meetingnotes.entitlements
index 42da2af..e1c5df7 100644
--- a/meetingnotes/meetingnotes.entitlements
+++ b/meetingnotes/meetingnotes.entitlements
@@ -4,10 +4,6 @@
com.apple.security.app-sandbox
- com.apple.security.application-groups
-
- group.owen.meetingnotes
-
com.apple.security.device.audio-input
com.apple.security.device.screen-capture