From a4edf1f31f3a74942ce88c4ef57a86db93957d38 Mon Sep 17 00:00:00 2001 From: dernerl Date: Mon, 8 Dec 2025 13:36:22 +0100 Subject: [PATCH 1/4] feat: Add import configuration feature for JSON and Plist files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ImportService for file selection with NSOpenPanel - Add FormatParser for JSON/Plist parsing with folder support - Add MockImportService for testing - Extend AppError with import-specific error types - Add importConfiguration() method to FavoritesViewModel - Add Import button to toolbar with ⌘I keyboard shortcut - Support recursive folder structure import - Auto-detect file format from extension - Replace all existing favorites on import Implements #8 --- Sources/ManagedFavsGenerator/AppError.swift | 15 ++ .../ManagedFavsGenerator/ContentView.swift | 13 ++ .../FavoritesViewModel.swift | 71 +++++++ .../ManagedFavsGenerator/FormatParser.swift | 178 ++++++++++++++++++ .../Services/ImportService.swift | 55 ++++++ .../Services/MockImportService.swift | 15 ++ 6 files changed, 347 insertions(+) create mode 100644 Sources/ManagedFavsGenerator/FormatParser.swift create mode 100644 Sources/ManagedFavsGenerator/Services/ImportService.swift create mode 100644 Sources/ManagedFavsGenerator/Services/MockImportService.swift diff --git a/Sources/ManagedFavsGenerator/AppError.swift b/Sources/ManagedFavsGenerator/AppError.swift index 90b998c..e752a50 100644 --- a/Sources/ManagedFavsGenerator/AppError.swift +++ b/Sources/ManagedFavsGenerator/AppError.swift @@ -6,6 +6,9 @@ enum AppError: LocalizedError { case clipboardAccessFailed case invalidURL(String) case emptyFavorites + case fileReadFailed(URL, underlyingError: Error) + case importInvalidFormat(String) + case importUnsupportedFormat(String) var errorDescription: String? { switch self { @@ -17,6 +20,12 @@ enum AppError: LocalizedError { return "Ungültige URL: \(url)" case .emptyFavorites: return "Keine Favoriten zum Exportieren vorhanden" + case .fileReadFailed(let url, let error): + return "Datei konnte nicht gelesen werden: \(url.lastPathComponent)\nFehler: \(error.localizedDescription)" + case .importInvalidFormat(let details): + return "Ungültiges Dateiformat: \(details)" + case .importUnsupportedFormat(let ext): + return "Nicht unterstütztes Dateiformat: .\(ext)" } } @@ -30,6 +39,12 @@ enum AppError: LocalizedError { return "Geben Sie eine gültige URL ein (z.B. https://example.com)" case .emptyFavorites: return "Fügen Sie mindestens einen Favoriten hinzu." + case .fileReadFailed: + return "Überprüfen Sie die Leserechte und versuchen Sie es erneut." + case .importInvalidFormat: + return "Stellen Sie sicher, dass die Datei eine gültige Microsoft Edge Managed Favorites Konfiguration ist." + case .importUnsupportedFormat: + return "Nur JSON (.json) und Plist (.plist) Dateien werden unterstützt." } } } diff --git a/Sources/ManagedFavsGenerator/ContentView.swift b/Sources/ManagedFavsGenerator/ContentView.swift index 654d704..0cd4128 100644 --- a/Sources/ManagedFavsGenerator/ContentView.swift +++ b/Sources/ManagedFavsGenerator/ContentView.swift @@ -87,6 +87,19 @@ struct ContentView: View { Divider() + // Import Configuration + Button { + Task { + await viewModel.importConfiguration(replaceAll: true) + } + } label: { + Label("Import", systemImage: "square.and.arrow.down.on.square") + } + .keyboardShortcut("i", modifiers: [.command]) + .help("Import JSON or Plist configuration (⌘I)") + + Divider() + // Copy JSON Button { let json = FormatGenerator.generateJSON( diff --git a/Sources/ManagedFavsGenerator/FavoritesViewModel.swift b/Sources/ManagedFavsGenerator/FavoritesViewModel.swift index 3aa36d7..044199b 100644 --- a/Sources/ManagedFavsGenerator/FavoritesViewModel.swift +++ b/Sources/ManagedFavsGenerator/FavoritesViewModel.swift @@ -23,6 +23,7 @@ class FavoritesViewModel { // MARK: - Services (Dependency Injection) private let clipboardService: ClipboardServiceProtocol private let fileService: FileServiceProtocol + private let importService: ImportServiceProtocol // MARK: - SwiftData Context private var modelContext: ModelContext? @@ -31,10 +32,12 @@ class FavoritesViewModel { init( clipboardService: ClipboardServiceProtocol = ClipboardService(), fileService: FileServiceProtocol = FileService(), + importService: ImportServiceProtocol = ImportService(), modelContext: ModelContext? = nil ) { self.clipboardService = clipboardService self.fileService = fileService + self.importService = importService self.modelContext = modelContext // Load persisted toplevelName from UserDefaults @@ -180,6 +183,74 @@ class FavoritesViewModel { } } + // MARK: - Import + + func importConfiguration(replaceAll: Bool = true) async { + do { + // Datei auswählen + guard let fileURL = try await importService.selectFileForImport() else { + logger.info("Import abgebrochen") + return + } + + // Datei parsen + let parsedConfig = try FormatParser.parse(fileURL: fileURL) + + logger.info("Import gestartet: \(parsedConfig.favorites.count) Items, replaceAll=\(replaceAll)") + + guard let modelContext = modelContext else { + logger.error("ModelContext nicht verfügbar") + return + } + + // Option 1: Alles ersetzen (Delete all existing) + if replaceAll { + // Delete all existing favorites + let fetchDescriptor = FetchDescriptor() + let existingFavorites = try modelContext.fetch(fetchDescriptor) + + for favorite in existingFavorites { + modelContext.delete(favorite) + } + + logger.info("Bestehende Favoriten gelöscht: \(existingFavorites.count)") + } + + // Import parsed favorites + importParsedFavorites(parsedConfig.favorites, parentID: nil, modelContext: modelContext) + + // Update toplevelName + self.toplevelName = parsedConfig.toplevelName + + // Save to database + try modelContext.save() + + logger.info("Import erfolgreich abgeschlossen: \(parsedConfig.favorites.count) Items importiert") + + } catch { + handleError(error) + } + } + + /// Rekursiv Favoriten importieren (unterstützt Ordner) + private func importParsedFavorites(_ parsedFavorites: [ParsedFavorite], parentID: UUID?, modelContext: ModelContext) { + for parsedFav in parsedFavorites { + let favorite = Favorite( + name: parsedFav.name, + url: parsedFav.url, + parentID: parentID, + order: parsedFav.order + ) + + modelContext.insert(favorite) + + // Wenn Ordner: Kinder rekursiv importieren + if let children = parsedFav.children { + importParsedFavorites(children, parentID: favorite.id, modelContext: modelContext) + } + } + } + // MARK: - Error Handling private func handleError(_ error: Error) { diff --git a/Sources/ManagedFavsGenerator/FormatParser.swift b/Sources/ManagedFavsGenerator/FormatParser.swift new file mode 100644 index 0000000..e5fc859 --- /dev/null +++ b/Sources/ManagedFavsGenerator/FormatParser.swift @@ -0,0 +1,178 @@ +import Foundation +import OSLog + +private let logger = Logger(subsystem: "ManagedFavsGenerator", category: "FormatParser") + +/// Struktur für geparste Daten (intern) +struct ParsedConfiguration { + var toplevelName: String + var favorites: [ParsedFavorite] +} + +struct ParsedFavorite { + var name: String + var url: String? + var children: [ParsedFavorite]? + var order: Int +} + +enum FormatParser { + + // MARK: - Main Entry Point + + /// Parse JSON or Plist file and return structured data + static func parse(fileURL: URL) throws -> ParsedConfiguration { + let data = try Data(contentsOf: fileURL) + + switch fileURL.pathExtension.lowercased() { + case "json": + return try parseJSON(data: data) + case "plist": + return try parsePlist(data: data) + default: + throw AppError.importUnsupportedFormat(fileURL.pathExtension) + } + } + + // MARK: - JSON Parser + + private static func parseJSON(data: Data) throws -> ParsedConfiguration { + guard let jsonArray = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + throw AppError.importInvalidFormat("JSON muss ein Array sein") + } + + guard !jsonArray.isEmpty else { + throw AppError.importInvalidFormat("JSON-Array ist leer") + } + + var toplevelName = "managedFavs" + var parsedFavorites: [ParsedFavorite] = [] + + for (index, item) in jsonArray.enumerated() { + // First item: toplevel_name + if index == 0, let name = item["toplevel_name"] as? String { + toplevelName = name + continue + } + + // Check for folder (has children) + if let children = item["children"] as? [[String: Any]], + let name = item["name"] as? String { + let parsedChildren = try parseJSONChildren(children) + parsedFavorites.append(ParsedFavorite( + name: name, + url: nil, + children: parsedChildren, + order: parsedFavorites.count + )) + } + // Regular favorite (name + url) + else if let name = item["name"] as? String, + let url = item["url"] as? String { + parsedFavorites.append(ParsedFavorite( + name: name, + url: url, + children: nil, + order: parsedFavorites.count + )) + } + } + + logger.info("JSON erfolgreich geparst: toplevel=\(toplevelName), items=\(parsedFavorites.count)") + + return ParsedConfiguration(toplevelName: toplevelName, favorites: parsedFavorites) + } + + private static func parseJSONChildren(_ children: [[String: Any]]) throws -> [ParsedFavorite] { + var parsedChildren: [ParsedFavorite] = [] + + for (index, child) in children.enumerated() { + guard let name = child["name"] as? String, + let url = child["url"] as? String else { + throw AppError.importInvalidFormat("Child muss 'name' und 'url' enthalten") + } + + parsedChildren.append(ParsedFavorite( + name: name, + url: url, + children: nil, + order: index + )) + } + + return parsedChildren + } + + // MARK: - Plist Parser + + private static func parsePlist(data: Data) throws -> ParsedConfiguration { + guard let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { + throw AppError.importInvalidFormat("Plist muss ein Dictionary sein") + } + + guard let managedFavorites = plist["ManagedFavorites"] as? [[String: Any]] else { + throw AppError.importInvalidFormat("Plist muss 'ManagedFavorites' Array enthalten") + } + + guard !managedFavorites.isEmpty else { + throw AppError.importInvalidFormat("ManagedFavorites Array ist leer") + } + + var toplevelName = "managedFavs" + var parsedFavorites: [ParsedFavorite] = [] + + for (index, item) in managedFavorites.enumerated() { + // First item: toplevel_name + if index == 0, let name = item["toplevel_name"] as? String { + toplevelName = name + continue + } + + // Check for folder (has children) + if let children = item["children"] as? [[String: Any]], + let name = item["name"] as? String { + let parsedChildren = try parsePlistChildren(children) + parsedFavorites.append(ParsedFavorite( + name: name, + url: nil, + children: parsedChildren, + order: parsedFavorites.count + )) + } + // Regular favorite (name + url) + else if let name = item["name"] as? String, + let url = item["url"] as? String { + parsedFavorites.append(ParsedFavorite( + name: name, + url: url, + children: nil, + order: parsedFavorites.count + )) + } + } + + logger.info("Plist erfolgreich geparst: toplevel=\(toplevelName), items=\(parsedFavorites.count)") + + return ParsedConfiguration(toplevelName: toplevelName, favorites: parsedFavorites) + } + + private static func parsePlistChildren(_ children: [[String: Any]]) throws -> [ParsedFavorite] { + var parsedChildren: [ParsedFavorite] = [] + + for (index, child) in children.enumerated() { + guard let name = child["name"] as? String, + let url = child["url"] as? String else { + throw AppError.importInvalidFormat("Child muss 'name' und 'url' enthalten") + } + + parsedChildren.append(ParsedFavorite( + name: name, + url: url, + children: nil, + order: index + )) + } + + return parsedChildren + } +} diff --git a/Sources/ManagedFavsGenerator/Services/ImportService.swift b/Sources/ManagedFavsGenerator/Services/ImportService.swift new file mode 100644 index 0000000..515f76b --- /dev/null +++ b/Sources/ManagedFavsGenerator/Services/ImportService.swift @@ -0,0 +1,55 @@ +import Foundation +import AppKit +import UniformTypeIdentifiers +import OSLog + +private let logger = Logger(subsystem: "ManagedFavsGenerator", category: "ImportService") + +/// Protocol für Import-Operationen (für Testbarkeit) +@MainActor +protocol ImportServiceProtocol { + func selectFileForImport() async throws -> URL? +} + +/// Service für Import-Operationen (Open/Load) +@MainActor +final class ImportService: ImportServiceProtocol { + + /// Zeigt Open Panel für Dateiauswahl + /// - Returns: URL der ausgewählten Datei oder nil bei Abbruch + func selectFileForImport() async throws -> URL? { + let openPanel = NSOpenPanel() + openPanel.allowedContentTypes = [.json, .propertyList] + openPanel.allowsMultipleSelection = false + openPanel.canChooseDirectories = false + openPanel.canChooseFiles = true + openPanel.title = "Import Configuration" + openPanel.message = "Wählen Sie eine JSON- oder Plist-Konfigurationsdatei" + openPanel.prompt = "Import" + + // App in den Vordergrund holen + NSApplication.shared.activate(ignoringOtherApps: true) + + // Finde das sichtbare Hauptfenster + let window = NSApplication.shared.windows.first(where: { + $0.isVisible && $0.canBecomeKey + }) + + // Panel als Sheet oder Modal anzeigen + let response: NSApplication.ModalResponse + if let window = window { + response = await openPanel.beginSheetModal(for: window) + } else { + response = await openPanel.begin() + } + + // Prüfe Benutzer-Aktion + guard response == .OK, let url = openPanel.url else { + logger.info("Import vom Benutzer abgebrochen") + return nil + } + + logger.info("Datei für Import ausgewählt: \(url.path)") + return url + } +} diff --git a/Sources/ManagedFavsGenerator/Services/MockImportService.swift b/Sources/ManagedFavsGenerator/Services/MockImportService.swift new file mode 100644 index 0000000..aa2e7c6 --- /dev/null +++ b/Sources/ManagedFavsGenerator/Services/MockImportService.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Mock ImportService für Unit Tests +@MainActor +final class MockImportService: ImportServiceProtocol { + var shouldReturnURL: URL? + var shouldThrowError: Error? + + func selectFileForImport() async throws -> URL? { + if let error = shouldThrowError { + throw error + } + return shouldReturnURL + } +} From db30c6772055abeca616cba73e815b8b23e44acc Mon Sep 17 00:00:00 2001 From: dernerl Date: Mon, 8 Dec 2025 13:56:58 +0100 Subject: [PATCH 2/4] feat: Add JSON copy/paste import and fix Plist fragment support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ImportJSONView with TextEditor for copy/paste JSON import - Split import into two methods: - importJSONString() for JSON copy/paste (⌘I) - importPlistFile() for Plist file selection (⌘⇧I) - Fix Plist parser to handle fragments without XML headers - Auto-wrap fragments in proper Plist structure - Support Intune export format (fragments) - Add parseJSONString() to FormatParser - Add Import JSON button to toolbar (⌘I) - Add Import Plist button to toolbar (⌘⇧I) - Refactor shared import logic into performImport() - Add sheet presentation for JSON import dialog Fixes Plist import issue with Intune exports Related to #8 --- .../ManagedFavsGenerator/ContentView.swift | 27 ++++-- .../FavoritesViewModel.swift | 84 +++++++++++++------ .../ManagedFavsGenerator/FormatParser.swift | 54 +++++++++++- .../ManagedFavsGenerator/ImportJSONView.swift | 76 +++++++++++++++++ 4 files changed, 205 insertions(+), 36 deletions(-) create mode 100644 Sources/ManagedFavsGenerator/ImportJSONView.swift diff --git a/Sources/ManagedFavsGenerator/ContentView.swift b/Sources/ManagedFavsGenerator/ContentView.swift index 0cd4128..1d7b430 100644 --- a/Sources/ManagedFavsGenerator/ContentView.swift +++ b/Sources/ManagedFavsGenerator/ContentView.swift @@ -8,6 +8,7 @@ struct ContentView: View { // Note: @Query is a SwiftData macro, not a GitHub user mention @Query(sort: \Favorite.createdAt) private var favorites: [Favorite] @State private var viewModel = FavoritesViewModel() + @State private var showImportJSON = false @Environment(\.openWindow) private var openWindow /// Root level items (no parent) @@ -87,16 +88,25 @@ struct ContentView: View { Divider() - // Import Configuration + // Import JSON (Copy/Paste) + Button { + showImportJSON = true + } label: { + Label("Import JSON", systemImage: "doc.text") + } + .keyboardShortcut("i", modifiers: [.command]) + .help("Import JSON via Copy/Paste (⌘I)") + + // Import Plist (File) Button { Task { - await viewModel.importConfiguration(replaceAll: true) + await viewModel.importPlistFile(replaceAll: true) } } label: { - Label("Import", systemImage: "square.and.arrow.down.on.square") + Label("Import Plist", systemImage: "doc.badge.arrow.up") } - .keyboardShortcut("i", modifiers: [.command]) - .help("Import JSON or Plist configuration (⌘I)") + .keyboardShortcut("i", modifiers: [.command, .shift]) + .help("Import Plist file (⌘⇧I)") Divider() @@ -152,6 +162,13 @@ struct ContentView: View { } message: { Text(viewModel.errorMessage ?? "Ein unerwarteter Fehler ist aufgetreten") } + .sheet(isPresented: $showImportJSON) { + ImportJSONView { jsonString in + Task { + await viewModel.importJSONString(jsonString, replaceAll: true) + } + } + } } // MARK: - Input Section diff --git a/Sources/ManagedFavsGenerator/FavoritesViewModel.swift b/Sources/ManagedFavsGenerator/FavoritesViewModel.swift index 044199b..b898fe6 100644 --- a/Sources/ManagedFavsGenerator/FavoritesViewModel.swift +++ b/Sources/ManagedFavsGenerator/FavoritesViewModel.swift @@ -185,7 +185,8 @@ class FavoritesViewModel { // MARK: - Import - func importConfiguration(replaceAll: Bool = true) async { + /// Import Plist file via file picker + func importPlistFile(replaceAll: Bool = true) async { do { // Datei auswählen guard let fileURL = try await importService.selectFileForImport() else { @@ -193,45 +194,74 @@ class FavoritesViewModel { return } + // Nur Plist erlauben + guard fileURL.pathExtension.lowercased() == "plist" else { + throw AppError.importUnsupportedFormat(fileURL.pathExtension) + } + // Datei parsen let parsedConfig = try FormatParser.parse(fileURL: fileURL) - logger.info("Import gestartet: \(parsedConfig.favorites.count) Items, replaceAll=\(replaceAll)") - - guard let modelContext = modelContext else { - logger.error("ModelContext nicht verfügbar") - return - } - - // Option 1: Alles ersetzen (Delete all existing) - if replaceAll { - // Delete all existing favorites - let fetchDescriptor = FetchDescriptor() - let existingFavorites = try modelContext.fetch(fetchDescriptor) - - for favorite in existingFavorites { - modelContext.delete(favorite) - } - - logger.info("Bestehende Favoriten gelöscht: \(existingFavorites.count)") - } + // Import durchführen + try await performImport(parsedConfig: parsedConfig, replaceAll: replaceAll) - // Import parsed favorites - importParsedFavorites(parsedConfig.favorites, parentID: nil, modelContext: modelContext) + logger.info("Plist Import erfolgreich: \(parsedConfig.favorites.count) Items") - // Update toplevelName - self.toplevelName = parsedConfig.toplevelName + } catch { + handleError(error) + } + } + + /// Import JSON from string (copy/paste) + func importJSONString(_ jsonString: String, replaceAll: Bool = true) async { + do { + // Parse JSON string + let parsedConfig = try FormatParser.parseJSONString(jsonString) - // Save to database - try modelContext.save() + // Import durchführen + try await performImport(parsedConfig: parsedConfig, replaceAll: replaceAll) - logger.info("Import erfolgreich abgeschlossen: \(parsedConfig.favorites.count) Items importiert") + logger.info("JSON Import erfolgreich: \(parsedConfig.favorites.count) Items") } catch { handleError(error) } } + /// Shared import logic + private func performImport(parsedConfig: ParsedConfiguration, replaceAll: Bool) async throws { + logger.info("Import gestartet: \(parsedConfig.favorites.count) Items, replaceAll=\(replaceAll)") + + guard let modelContext = modelContext else { + logger.error("ModelContext nicht verfügbar") + return + } + + // Option 1: Alles ersetzen (Delete all existing) + if replaceAll { + // Delete all existing favorites + let fetchDescriptor = FetchDescriptor() + let existingFavorites = try modelContext.fetch(fetchDescriptor) + + for favorite in existingFavorites { + modelContext.delete(favorite) + } + + logger.info("Bestehende Favoriten gelöscht: \(existingFavorites.count)") + } + + // Import parsed favorites + importParsedFavorites(parsedConfig.favorites, parentID: nil, modelContext: modelContext) + + // Update toplevelName + self.toplevelName = parsedConfig.toplevelName + + // Save to database + try modelContext.save() + + logger.info("Import erfolgreich abgeschlossen: \(parsedConfig.favorites.count) Items importiert") + } + /// Rekursiv Favoriten importieren (unterstützt Ordner) private func importParsedFavorites(_ parsedFavorites: [ParsedFavorite], parentID: UUID?, modelContext: ModelContext) { for parsedFav in parsedFavorites { diff --git a/Sources/ManagedFavsGenerator/FormatParser.swift b/Sources/ManagedFavsGenerator/FormatParser.swift index e5fc859..79e9a30 100644 --- a/Sources/ManagedFavsGenerator/FormatParser.swift +++ b/Sources/ManagedFavsGenerator/FormatParser.swift @@ -34,6 +34,14 @@ enum FormatParser { } } + /// Parse JSON from string (for copy/paste) + static func parseJSONString(_ jsonString: String) throws -> ParsedConfiguration { + guard let data = jsonString.data(using: .utf8) else { + throw AppError.importInvalidFormat("JSON-String konnte nicht in Data konvertiert werden") + } + return try parseJSON(data: data) + } + // MARK: - JSON Parser private static func parseJSON(data: Data) throws -> ParsedConfiguration { @@ -106,12 +114,50 @@ enum FormatParser { // MARK: - Plist Parser private static func parsePlist(data: Data) throws -> ParsedConfiguration { - guard let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { - throw AppError.importInvalidFormat("Plist muss ein Dictionary sein") + // Try to parse the plist, but if it's a fragment (no XML header), wrap it + var plistData = data + + // Check if data starts with valid plist header + if let dataString = String(data: data, encoding: .utf8), + !dataString.hasPrefix(" + + + + \(dataString) + + + """ + + guard let wrappedData = wrappedPlist.data(using: .utf8) else { + throw AppError.importInvalidFormat("Konnte Plist-Fragment nicht wrappen") + } + + plistData = wrappedData } - guard let managedFavorites = plist["ManagedFavorites"] as? [[String: Any]] else { - throw AppError.importInvalidFormat("Plist muss 'ManagedFavorites' Array enthalten") + let plistObject = try PropertyListSerialization.propertyList(from: plistData, format: nil) + + // Try to get ManagedFavorites array + // Option 1: Full plist with dictionary structure { "ManagedFavorites": [...] } + // Option 2: Fragment plist (direct array) - common in Intune exports + let managedFavorites: [[String: Any]] + + if let plistDict = plistObject as? [String: Any], + let favorites = plistDict["ManagedFavorites"] as? [[String: Any]] { + // Full plist format + managedFavorites = favorites + logger.info("Plist Format: Full dictionary with ManagedFavorites key") + } else if let favorites = plistObject as? [[String: Any]] { + // Fragment format (direct array) + managedFavorites = favorites + logger.info("Plist Format: Fragment (direct array)") + } else { + throw AppError.importInvalidFormat("Plist muss entweder ein Dictionary mit 'ManagedFavorites' Key oder ein direktes Array sein") } guard !managedFavorites.isEmpty else { diff --git a/Sources/ManagedFavsGenerator/ImportJSONView.swift b/Sources/ManagedFavsGenerator/ImportJSONView.swift new file mode 100644 index 0000000..cb045c8 --- /dev/null +++ b/Sources/ManagedFavsGenerator/ImportJSONView.swift @@ -0,0 +1,76 @@ +import SwiftUI + +struct ImportJSONView: View { + @Environment(\.dismiss) private var dismiss + @State private var jsonText: String = "" + let onImport: (String) -> Void + + var body: some View { + VStack(spacing: 20) { + // Header + VStack(spacing: 8) { + Image(systemName: "doc.text") + .font(.system(size: 48)) + .foregroundStyle(.blue) + + Text("Import JSON Configuration") + .font(.title2) + .fontWeight(.bold) + + Text("Paste your JSON configuration below") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.top) + + // Text Editor + VStack(alignment: .leading, spacing: 8) { + Text("JSON Content:") + .font(.headline) + + TextEditor(text: $jsonText) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 300) + .padding(8) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + + Text("Expected format: JSON array with managed favorites") + .font(.caption) + .foregroundStyle(.secondary) + } + + // Buttons + HStack(spacing: 12) { + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button { + onImport(jsonText) + dismiss() + } label: { + Label("Import", systemImage: "square.and.arrow.down") + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(jsonText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(.bottom) + } + .padding() + .frame(width: 600, height: 500) + } +} + +#Preview { + ImportJSONView { jsonString in + print("Importing: \(jsonString)") + } +} From aa6ba5bbee6e94af75a6bf804f150e2ce173b17b Mon Sep 17 00:00:00 2001 From: dernerl Date: Mon, 8 Dec 2025 15:04:21 +0100 Subject: [PATCH 3/4] fix: Reduce TextEditor height in JSON import dialog - Change TextEditor from minHeight: 300 to fixed height: 250 - Reduce dialog height from 500 to 480 - Ensures buttons are fully visible and not cut off --- Sources/ManagedFavsGenerator/ImportJSONView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ManagedFavsGenerator/ImportJSONView.swift b/Sources/ManagedFavsGenerator/ImportJSONView.swift index cb045c8..a865b2b 100644 --- a/Sources/ManagedFavsGenerator/ImportJSONView.swift +++ b/Sources/ManagedFavsGenerator/ImportJSONView.swift @@ -30,7 +30,7 @@ struct ImportJSONView: View { TextEditor(text: $jsonText) .font(.system(.body, design: .monospaced)) - .frame(minHeight: 300) + .frame(height: 250) .padding(8) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8)) .overlay( @@ -65,7 +65,7 @@ struct ImportJSONView: View { .padding(.bottom) } .padding() - .frame(width: 600, height: 500) + .frame(width: 600, height: 480) } } From e2eb56e6f38955d8b473df0b03b4b69f452022f8 Mon Sep 17 00:00:00 2001 From: dernerl Date: Mon, 8 Dec 2025 15:06:23 +0100 Subject: [PATCH 4/4] docs: Update CHANGELOG and README for v1.2.0 import feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive CHANGELOG entry for v1.2.0 - Document JSON copy/paste import (⌘I) - Document Plist file import (⌘⇧I) - Explain Plist fragment support for Intune exports - Add import section to README with use cases - List all technical changes and new components - Update numbering in How To Use section Closes #8 --- CHANGELOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 38 +++++++++++++++++++++++++++++++++++--- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b79ac..9d296b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2024-12-XX + +### Added +- **Import Configuration** - Import existing JSON and Plist configurations (#8) + - **JSON Import via Copy/Paste** (⌘I) + - New ImportJSONView with TextEditor + - Paste JSON directly into dialog + - Perfect for quick imports and snippets + - **Plist Import via File Selection** (⌘⇧I) + - NSOpenPanel for file selection + - Supports full Plist files and fragments + - Auto-wraps Intune export fragments (no XML header) + - Automatic format detection + - Replace all existing favorites on import + - Folder structure preservation (children arrays) + - Toplevel name extraction and update + - Two separate toolbar buttons with keyboard shortcuts + +### Fixed +- **Plist Fragment Support** - Handle Plist files without XML headers + - Common in Intune Configuration Profile exports + - Auto-wrap fragments in proper Plist structure + - Support both dictionary format and direct array format + +### Technical +- New `ImportService` for file selection with NSOpenPanel +- New `FormatParser` with dual parsing methods: + - `parse(fileURL:)` - For file-based imports (Plist) + - `parseJSONString(_:)` - For string-based imports (JSON) +- New `ImportJSONView` - SwiftUI sheet for JSON paste dialog +- Extended `AppError` with import-specific errors: + - `fileReadFailed` - File cannot be read + - `importInvalidFormat` - Invalid file structure + - `importUnsupportedFormat` - Unsupported file extension +- Split ViewModel import logic: + - `importJSONString()` - JSON copy/paste handler + - `importPlistFile()` - Plist file handler + - `performImport()` - Shared import logic +- `MockImportService` for unit testing +- Plist fragment detection and wrapping +- Recursive import for folder hierarchies + +### Changed +- Import workflow split into two distinct methods (JSON vs Plist) +- User experience optimized for different use cases + ## [1.1.0] - 2024-12-07 ### Added diff --git a/README.md b/README.md index 1f7f29b..62f6221 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,39 @@ Press **⌘N** or click the **Add Favorite** button in the toolbar: - **Name**: Display name (e.g., "Company Portal") - **URL**: Full URL including `https://` -### 2. **Generate Outputs** +**Add Folders** (⌘⇧N) to organize favorites hierarchically. + +### 2. **Import Existing Configuration** ⭐ NEW + +Import existing configurations from other sources or backups: + +#### **JSON Import (Copy/Paste)** - ⌘I +- Click **Import JSON** or press **⌘I** +- Dialog opens with text editor +- Paste your JSON configuration +- Click **Import** +- Perfect for quick imports, testing, or snippets + +#### **Plist Import (File Selection)** - ⌘⇧I +- Click **Import Plist** or press **⌘⇧I** +- Select `.plist` file from your system +- Supports full Plist files and Intune fragments +- Automatically handles files without XML headers + +**Features:** +- ✅ Replaces all existing favorites +- ✅ Preserves folder structure +- ✅ Extracts toplevel name +- ✅ Validates format before import + +**Use Cases:** +- Migrate existing Edge favorites configuration +- Share configurations between teams +- Backup and restore your favorites +- Import from other management tools +- Test configurations before deployment + +### 3. **Generate Outputs** The app automatically generates two formats as you add favorites: @@ -149,11 +181,11 @@ The app automatically generates two formats as you add favorites: - Press **⌘S** to export as file - Or click Copy to copy to clipboard -### 3. **Configure Toplevel Name** +### 4. **Configure Toplevel Name** The toplevel name (default: `managedFavs`) is the root key in your configuration. Change it in Settings (⌘,) if needed. -### 4. **Deploy to Your Organization** +### 5. **Deploy to Your Organization** See deployment guides below for Windows GPO, Intune Windows, or Intune macOS.