Skip to content

Commit 0e758c4

Browse files
committed
feat(json-editor): add deep merge utilities and export StubLoader
Add deepMerge(), mergeJsonFile(), and mergeJsonData() methods to JsonEditor for recursive JSON merging with source-wins semantics. Used by install commands that need idempotent translation file merging instead of full overwrite. Export StubLoader from barrel file so consuming plugins can load stubs without importing from src/.
1 parent 9422a7a commit 0e758c4

3 files changed

Lines changed: 440 additions & 0 deletions

File tree

lib/magic_cli.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export 'src/helpers/platform_helper.dart';
1717
export 'src/helpers/xml_editor.dart';
1818

1919
// Stubs
20+
export 'src/stubs/stub_loader.dart';
2021

2122
// Commands
2223
export 'src/commands/install_command.dart';

lib/src/helpers/json_editor.dart

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ import 'dart:io';
1515
/// // Merge a single key (idempotent overwrite)
1616
/// JsonEditor.mergeKey('/path/to/manifest.json', 'gcm_sender_id', '482941778795');
1717
///
18+
/// // Deep-merge two maps (nested translation files, configs, etc.)
19+
/// final merged = JsonEditor.deepMerge(existing, incoming);
20+
///
21+
/// // Merge an incoming JSON file into an existing one (idempotent)
22+
/// JsonEditor.mergeJsonFile('/app/assets/lang/en.json', '/stubs/en.json');
23+
///
1824
/// // Write a full map
1925
/// JsonEditor.writeJson('/path/to/output.json', {'key': 'value'});
2026
///
@@ -102,6 +108,133 @@ class JsonEditor {
102108
writeJson(path, data);
103109
}
104110

111+
/// Deep-merge [source] into [target], returning a new map.
112+
///
113+
/// When both [target] and [source] contain the same key and both values
114+
/// are `Map<String, dynamic>`, the merge recurses into that key. Otherwise
115+
/// the [source] value wins (overwrite semantics).
116+
///
117+
/// Neither [target] nor [source] are mutated — a fresh map is returned.
118+
///
119+
/// ### Example
120+
///
121+
/// ```dart
122+
/// final target = {'auth': {'login': 'Login', 'logout': 'Logout'}};
123+
/// final source = {'auth': {'login': 'Sign In', 'register': 'Sign Up'}};
124+
/// final result = JsonEditor.deepMerge(target, source);
125+
/// // {'auth': {'login': 'Sign In', 'logout': 'Logout', 'register': 'Sign Up'}}
126+
/// ```
127+
///
128+
/// @param target The base map (existing data).
129+
/// @param source The incoming map whose values take precedence.
130+
/// @return A new [Map<String, dynamic>] containing the merged result.
131+
static Map<String, dynamic> deepMerge(
132+
Map<String, dynamic> target,
133+
Map<String, dynamic> source,
134+
) {
135+
final Map<String, dynamic> result = Map<String, dynamic>.from(target);
136+
137+
for (final entry in source.entries) {
138+
final existing = result[entry.key];
139+
140+
// Both sides are maps → recurse.
141+
if (existing is Map<String, dynamic> &&
142+
entry.value is Map<String, dynamic>) {
143+
result[entry.key] = deepMerge(
144+
existing,
145+
entry.value as Map<String, dynamic>,
146+
);
147+
} else {
148+
// Source wins — overwrite or add.
149+
result[entry.key] = entry.value;
150+
}
151+
}
152+
153+
return result;
154+
}
155+
156+
/// Merge an incoming JSON file into an existing target JSON file.
157+
///
158+
/// If the [targetPath] file does not exist, the [sourcePath] content is
159+
/// written as-is. If the target already exists, a recursive deep-merge
160+
/// is performed — existing keys the user may have customised are preserved
161+
/// while new keys from [sourcePath] are added.
162+
///
163+
/// When [force] is `true`, the [sourcePath] content overwrites [targetPath]
164+
/// entirely — no merge is performed.
165+
///
166+
/// ### Example
167+
///
168+
/// ```dart
169+
/// // Merge plugin translations into host app (idempotent)
170+
/// JsonEditor.mergeJsonFile(
171+
/// '/app/assets/lang/en.json',
172+
/// '/stubs/en.json',
173+
/// );
174+
///
175+
/// // Force-overwrite with stub content
176+
/// JsonEditor.mergeJsonFile(
177+
/// '/app/assets/lang/en.json',
178+
/// '/stubs/en.json',
179+
/// force: true,
180+
/// );
181+
/// ```
182+
///
183+
/// @param targetPath The destination JSON file (host app's file).
184+
/// @param sourcePath The incoming JSON file (stub / plugin file).
185+
/// @param force When `true`, skip merge and overwrite entirely.
186+
///
187+
/// @throws [FileSystemException] if [sourcePath] does not exist.
188+
/// @throws [FormatException] if either file contains invalid JSON.
189+
static void mergeJsonFile(
190+
String targetPath,
191+
String sourcePath, {
192+
bool force = false,
193+
}) {
194+
final sourceData = readJson(sourcePath);
195+
final targetFile = File(targetPath);
196+
197+
// 1. Force mode or target missing → write source as-is.
198+
if (force || !targetFile.existsSync()) {
199+
writeJson(targetPath, sourceData);
200+
return;
201+
}
202+
203+
// 2. Deep-merge source into existing target.
204+
final targetData = readJson(targetPath);
205+
final merged = deepMerge(targetData, sourceData);
206+
207+
writeJson(targetPath, merged);
208+
}
209+
210+
/// Merge an in-memory [sourceData] map into an existing JSON file.
211+
///
212+
/// Convenience variant of [mergeJsonFile] when the source content is
213+
/// already loaded (e.g. from a stub string rather than a file on disk).
214+
///
215+
/// @param targetPath The destination JSON file.
216+
/// @param sourceData The incoming map to merge.
217+
/// @param force When `true`, skip merge and overwrite entirely.
218+
static void mergeJsonData(
219+
String targetPath,
220+
Map<String, dynamic> sourceData, {
221+
bool force = false,
222+
}) {
223+
final targetFile = File(targetPath);
224+
225+
// 1. Force mode or target missing → write source as-is.
226+
if (force || !targetFile.existsSync()) {
227+
writeJson(targetPath, sourceData);
228+
return;
229+
}
230+
231+
// 2. Deep-merge source into existing target.
232+
final targetData = readJson(targetPath);
233+
final merged = deepMerge(targetData, sourceData);
234+
235+
writeJson(targetPath, merged);
236+
}
237+
105238
// -------------------------------------------------------------------------
106239
// Inspection
107240
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)