Provide file promises from the mapping list. Perform various file sanitization in...
[enjoyable.git] / NJMappingsController.m
1 //
2 // NJMappingsController.m
3 // Enjoy
4 //
5 // Created by Sam McCall on 4/05/09.
6 //
7
8 #import "NJMappingsController.h"
9
10 #import "NJMapping.h"
11 #import "NJMappingsController.h"
12 #import "NJOutput.h"
13 #import "NJOutputController.h"
14 #import "NJEvents.h"
15
16 #define PB_ROW @"com.yukkurigames.Enjoyable.MappingRow"
17
18 @implementation NJMappingsController {
19 NSMutableArray *_mappings;
20 NJMapping *manualMapping;
21 NSString *draggingName;
22 }
23
24 - (id)init {
25 if ((self = [super init])) {
26 _mappings = [[NSMutableArray alloc] init];
27 _currentMapping = [[NJMapping alloc] initWithName:@"(default)"];
28 manualMapping = _currentMapping;
29 [_mappings addObject:_currentMapping];
30 }
31 return self;
32 }
33
34 - (void)awakeFromNib {
35 [tableView registerForDraggedTypes:@[PB_ROW]];
36 [tableView setDraggingSourceOperationMask:NSDragOperationCopy forLocal:NO];
37 }
38
39 - (NJMapping *)objectForKeyedSubscript:(NSString *)name {
40 for (NJMapping *mapping in _mappings)
41 if ([name isEqualToString:mapping.name])
42 return mapping;
43 return nil;
44 }
45
46 - (NJMapping *)objectAtIndexedSubscript:(NSUInteger)idx {
47 return idx < _mappings.count ? _mappings[idx] : nil;
48 }
49
50 - (void)mappingsChanged {
51 [self save];
52 [tableView reloadData];
53 popoverActivate.title = _currentMapping.name;
54 [self updateInterfaceForCurrentMapping];
55 [NSNotificationCenter.defaultCenter
56 postNotificationName:NJEventMappingListChanged
57 object:_mappings];
58 }
59
60 - (NSUInteger)countByEnumeratingWithState:(NSFastEnumerationState *)state
61 objects:(__unsafe_unretained id [])buffer
62 count:(NSUInteger)len {
63 return [_mappings countByEnumeratingWithState:state
64 objects:buffer
65 count:len];
66 }
67
68 - (void)activateMappingForProcess:(NSString *)processName {
69 if ([manualMapping.name.lowercaseString isEqualToString:@"@application"]) {
70 manualMapping.name = processName;
71 [self mappingsChanged];
72 } else {
73 NJMapping *oldMapping = manualMapping;
74 NJMapping *newMapping = self[processName];
75 if (!newMapping)
76 newMapping = oldMapping;
77 if (newMapping != _currentMapping)
78 [self activateMapping:newMapping];
79 manualMapping = oldMapping;
80 }
81 }
82
83 - (void)updateInterfaceForCurrentMapping {
84 NSUInteger selected = [_mappings indexOfObject:_currentMapping];
85 [removeButton setEnabled:selected != 0];
86 [moveDown setEnabled:selected && selected != _mappings.count - 1];
87 [moveUp setEnabled:selected > 1];
88 popoverActivate.title = _currentMapping.name;
89 [tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:selected] byExtendingSelection:NO];
90 [NSUserDefaults.standardUserDefaults setInteger:selected forKey:@"selected"];
91 }
92
93 - (void)activateMapping:(NJMapping *)mapping {
94 if (!mapping)
95 mapping = manualMapping;
96 if (mapping == _currentMapping)
97 return;
98 NSLog(@"Switching to mapping %@.", mapping.name);
99 manualMapping = mapping;
100 _currentMapping = mapping;
101 [self updateInterfaceForCurrentMapping];
102 [outputController loadCurrent];
103 [NSNotificationCenter.defaultCenter postNotificationName:NJEventMappingChanged
104 object:_currentMapping];
105 }
106
107 - (IBAction)addPressed:(id)sender {
108 NJMapping *newMapping = [[NJMapping alloc] initWithName:@"Untitled"];
109 [_mappings addObject:newMapping];
110 [self activateMapping:newMapping];
111 [self mappingsChanged];
112 [tableView editColumn:0 row:_mappings.count - 1 withEvent:nil select:YES];
113 }
114
115 - (IBAction)removePressed:(id)sender {
116 if (tableView.selectedRow == 0)
117 return;
118
119 [_mappings removeObjectAtIndex:tableView.selectedRow];
120 [self activateMapping:_mappings[0]];
121 [self mappingsChanged];
122 }
123
124 -(void)tableViewSelectionDidChange:(NSNotification *)notify {
125 [self activateMapping:self[tableView.selectedRow]];
126 }
127
128 - (id)tableView:(NSTableView *)view objectValueForTableColumn:(NSTableColumn *)column row:(NSInteger)index {
129 return self[index].name;
130 }
131
132 - (void)tableView:(NSTableView *)view
133 setObjectValue:(NSString *)obj
134 forTableColumn:(NSTableColumn *)col
135 row:(NSInteger)index {
136 self[index].name = obj;
137 [self mappingsChanged];
138 }
139
140 - (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView {
141 return _mappings.count;
142 }
143
144 - (BOOL)tableView:(NSTableView *)view shouldEditTableColumn:(NSTableColumn *)column row:(NSInteger)index {
145 return YES;
146 }
147
148 - (void)save {
149 NSLog(@"Saving mappings to defaults.");
150 NSMutableArray *ary = [[NSMutableArray alloc] initWithCapacity:_mappings.count];
151 for (NJMapping *mapping in _mappings)
152 [ary addObject:[mapping serialize]];
153 [NSUserDefaults.standardUserDefaults setObject:ary forKey:@"mappings"];
154 }
155
156 - (void)load {
157 NSUInteger selected = [NSUserDefaults.standardUserDefaults integerForKey:@"selected"];
158 NSArray *mappings = [NSUserDefaults.standardUserDefaults arrayForKey:@"mappings"];
159 [self loadAllFrom:mappings andActivate:selected];
160 }
161
162 - (void)loadAllFrom:(NSArray *)storedMappings andActivate:(NSUInteger)selected {
163 NSMutableArray* newMappings = [[NSMutableArray alloc] initWithCapacity:storedMappings.count];
164
165 // have to do two passes in case mapping1 refers to mapping2 via a NJOutputMapping
166 for (NSDictionary *storedMapping in storedMappings) {
167 NJMapping *mapping = [[NJMapping alloc] initWithName:storedMapping[@"name"]];
168 [newMappings addObject:mapping];
169 }
170
171 for (unsigned i = 0; i < storedMappings.count; ++i) {
172 NSDictionary *entries = storedMappings[i][@"entries"];
173 NJMapping *mapping = newMappings[i];
174 for (id key in entries) {
175 NJOutput *output = [NJOutput outputDeserialize:entries[key]
176 withMappings:newMappings];
177 if (output)
178 mapping.entries[key] = output;
179 }
180 }
181
182 if (newMappings.count) {
183 _mappings = newMappings;
184 if (selected >= newMappings.count)
185 selected = 0;
186 [self activateMapping:_mappings[selected]];
187 [self mappingsChanged];
188 }
189 }
190
191 - (NJMapping *)mappingWithURL:(NSURL *)url error:(NSError **)error {
192 NSInputStream *stream = [NSInputStream inputStreamWithURL:url];
193 [stream open];
194 NSDictionary *serialization = !*error
195 ? [NSJSONSerialization JSONObjectWithStream:stream options:0 error:error]
196 : nil;
197 [stream close];
198
199 if (!([serialization isKindOfClass:NSDictionary.class]
200 && [serialization[@"name"] isKindOfClass:NSString.class]
201 && [serialization[@"entries"] isKindOfClass:NSDictionary.class])) {
202 *error = [NSError errorWithDomain:@"Enjoyable"
203 code:0
204 description:@"This isn't a valid mapping file."];
205 return nil;
206 }
207
208 NSDictionary *entries = serialization[@"entries"];
209 NJMapping *mapping = [[NJMapping alloc] initWithName:serialization[@"name"]];
210 for (id key in entries) {
211 NSDictionary *value = entries[key];
212 if ([key isKindOfClass:NSString.class]) {
213 NJOutput *output = [NJOutput outputDeserialize:value
214 withMappings:_mappings];
215 if (output)
216 mapping.entries[key] = output;
217 }
218 }
219 return mapping;
220 }
221
222 - (void)importPressed:(id)sender {
223 NSOpenPanel *panel = [NSOpenPanel openPanel];
224 panel.allowedFileTypes = @[ @"enjoyable", @"json", @"txt" ];
225 NSWindow *window = NSApplication.sharedApplication.keyWindow;
226 [panel beginSheetModalForWindow:window
227 completionHandler:^(NSInteger result) {
228 if (result != NSFileHandlingPanelOKButton)
229 return;
230
231 [panel close];
232 NSError *error;
233 NJMapping *mapping = [self mappingWithURL:panel.URL error:&error];
234
235 if (!error) {
236 BOOL conflict = NO;
237 NJMapping *mergeInto = self[mapping.name];
238 for (id key in mapping.entries) {
239 if (mergeInto.entries[key]
240 && ![mergeInto.entries[key] isEqual:mapping.entries[key]]) {
241 conflict = YES;
242 break;
243 }
244 }
245
246 if (conflict) {
247 NSAlert *conflictAlert = [[NSAlert alloc] init];
248 conflictAlert.messageText = @"Replace existing mappings?";
249 conflictAlert.informativeText =
250 [NSString stringWithFormat:
251 @"This file contains inputs you've already mapped in \"%@\". Do you "
252 @"want to merge them and replace your existing mappings, or import this "
253 @"as a separate mapping?", mapping.name];
254 [conflictAlert addButtonWithTitle:@"Merge"];
255 [conflictAlert addButtonWithTitle:@"Cancel"];
256 [conflictAlert addButtonWithTitle:@"New Mapping"];
257 NSInteger res = [conflictAlert runModal];
258 if (res == NSAlertSecondButtonReturn)
259 return;
260 else if (res == NSAlertThirdButtonReturn)
261 mergeInto = nil;
262 }
263
264 if (mergeInto) {
265 [mergeInto.entries addEntriesFromDictionary:mapping.entries];
266 mapping = mergeInto;
267 } else {
268 [_mappings addObject:mapping];
269 }
270
271 [self activateMapping:mapping];
272 [self mappingsChanged];
273
274 if (conflict && !mergeInto) {
275 [tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:_mappings.count - 1] byExtendingSelection:NO];
276 [tableView editColumn:0 row:_mappings.count - 1 withEvent:nil select:YES];
277 }
278 }
279
280 if (error) {
281 [window presentError:error
282 modalForWindow:window
283 delegate:nil
284 didPresentSelector:nil
285 contextInfo:nil];
286 }
287 }];
288
289 }
290
291 - (void)exportPressed:(id)sender {
292 NSSavePanel *panel = [NSSavePanel savePanel];
293 panel.allowedFileTypes = @[ @"enjoyable" ];
294 NJMapping *mapping = _currentMapping;
295 panel.nameFieldStringValue = mapping.name;
296 NSWindow *window = NSApplication.sharedApplication.keyWindow;
297 [panel beginSheetModalForWindow:window
298 completionHandler:^(NSInteger result) {
299 if (result != NSFileHandlingPanelOKButton)
300 return;
301 [panel close];
302 NSError *error;
303 [mapping writeToURL:panel.URL error:&error];
304 if (error) {
305 [window presentError:error
306 modalForWindow:window
307 delegate:nil
308 didPresentSelector:nil
309 contextInfo:nil];
310 }
311 }];
312 }
313
314 - (IBAction)mappingPressed:(id)sender {
315 [popover showRelativeToRect:popoverActivate.bounds
316 ofView:popoverActivate
317 preferredEdge:NSMinXEdge];
318 }
319
320 - (void)popoverWillShow:(NSNotification *)notification {
321 popoverActivate.state = NSOnState;
322 }
323
324 - (void)popoverWillClose:(NSNotification *)notification {
325 popoverActivate.state = NSOffState;
326 }
327
328 - (IBAction)moveUpPressed:(id)sender {
329 NSUInteger idx = [_mappings indexOfObject:_currentMapping];
330 if (idx > 1 && idx != NSNotFound) {
331 [_mappings exchangeObjectAtIndex:idx withObjectAtIndex:idx - 1];
332 [self mappingsChanged];
333 }
334 }
335
336 - (IBAction)moveDownPressed:(id)sender {
337 NSUInteger idx = [_mappings indexOfObject:_currentMapping];
338 if (idx < _mappings.count - 1) {
339 [_mappings exchangeObjectAtIndex:idx withObjectAtIndex:idx + 1];
340 [self mappingsChanged];
341 }
342 }
343
344 - (BOOL)tableView:(NSTableView *)tableView
345 acceptDrop:(id <NSDraggingInfo>)info
346 row:(NSInteger)row
347 dropOperation:(NSTableViewDropOperation)dropOperation {
348 NSPasteboard *pboard = [info draggingPasteboard];
349 if ([pboard.types containsObject:PB_ROW]) {
350 NSString *value = [pboard stringForType:PB_ROW];
351 NSUInteger srcRow = [value intValue];
352 [_mappings moveObjectAtIndex:srcRow toIndex:row];
353 [self mappingsChanged];
354 return YES;
355 } else {
356 return NO;
357 }
358 }
359
360 - (NSDragOperation)tableView:(NSTableView *)tableView_
361 validateDrop:(id <NSDraggingInfo>)info
362 proposedRow:(NSInteger)row
363 proposedDropOperation:(NSTableViewDropOperation)dropOperation {
364 NSPasteboard *pboard = [info draggingPasteboard];
365 if ([pboard.types containsObject:PB_ROW]) {
366 [tableView_ setDropRow:MAX(1, row) dropOperation:NSTableViewDropAbove];
367 return NSDragOperationGeneric;
368 } else {
369 return NSDragOperationNone;
370 }
371 }
372
373 - (NSArray *)tableView:(NSTableView *)tableView_
374 namesOfPromisedFilesDroppedAtDestination:(NSURL *)dropDestination
375 forDraggedRowsWithIndexes:(NSIndexSet *)indexSet {
376 NJMapping *toSave = self[indexSet.firstIndex];
377 NSString *filename = [[toSave.name stringByFixingPathComponent]
378 stringByAppendingPathExtension:@"enjoyable"];
379 NSURL *dst = [dropDestination URLByAppendingPathComponent:filename];
380 dst = [NSFileManager.defaultManager generateUniqueURLWithBase:dst];
381 NSError *error;
382 if (![toSave writeToURL:dst error:&error]) {
383 [tableView_ presentError:error];
384 return @[];
385 } else {
386 return @[dst.lastPathComponent];
387 }
388 }
389
390 - (BOOL)tableView:(NSTableView *)tableView_
391 writeRowsWithIndexes:(NSIndexSet *)rowIndexes
392 toPasteboard:(NSPasteboard *)pboard {
393 if (rowIndexes.count == 1 && rowIndexes.firstIndex != 0) {
394 [pboard declareTypes:@[PB_ROW, NSFilesPromisePboardType] owner:nil];
395 [pboard setString:@(rowIndexes.firstIndex).stringValue forType:PB_ROW];
396 [pboard setPropertyList:@[@"enjoyable"] forType:NSFilesPromisePboardType];
397 return YES;
398 } else {
399 return NO;
400 }
401 }
402
403 @end