Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.

Expand Down
15 changes: 15 additions & 0 deletions Sources/ManagedFavsGenerator/AppError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)"
}
}

Expand All @@ -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."
}
}
}
30 changes: 30 additions & 0 deletions Sources/ManagedFavsGenerator/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -87,6 +88,28 @@ struct ContentView: View {

Divider()

// 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.importPlistFile(replaceAll: true)
}
} label: {
Label("Import Plist", systemImage: "doc.badge.arrow.up")
}
.keyboardShortcut("i", modifiers: [.command, .shift])
.help("Import Plist file (⌘⇧I)")

Divider()

// Copy JSON
Button {
let json = FormatGenerator.generateJSON(
Expand Down Expand Up @@ -139,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
Expand Down
101 changes: 101 additions & 0 deletions Sources/ManagedFavsGenerator/FavoritesViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand Down Expand Up @@ -180,6 +183,104 @@ class FavoritesViewModel {
}
}

// MARK: - Import

/// Import Plist file via file picker
func importPlistFile(replaceAll: Bool = true) async {
do {
// Datei auswählen
guard let fileURL = try await importService.selectFileForImport() else {
logger.info("Import abgebrochen")
return
}

// Nur Plist erlauben
guard fileURL.pathExtension.lowercased() == "plist" else {
throw AppError.importUnsupportedFormat(fileURL.pathExtension)
}

// Datei parsen
let parsedConfig = try FormatParser.parse(fileURL: fileURL)

// Import durchführen
try await performImport(parsedConfig: parsedConfig, replaceAll: replaceAll)

logger.info("Plist Import erfolgreich: \(parsedConfig.favorites.count) Items")

} 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)

// Import durchführen
try await performImport(parsedConfig: parsedConfig, replaceAll: replaceAll)

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<Favorite>()
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 {
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) {
Expand Down
Loading