1/* HBChapterTitlesController.m $ 2 3 This file is part of the HandBrake source code. 4 Homepage: <http://handbrake.fr/>. 5 It may be used under the terms of the GNU General Public License. */ 6 7#import "HBChapterTitlesController.h" 8#import "HBPreferencesKeys.h" 9@import HandBrakeKit; 10 11@interface NSArray (HBCSVAdditions) 12 13+ (nullable NSArray<NSArray<NSString *> *> *)HB_arrayWithContentsOfCSVURL:(NSURL *)url; 14 15@end 16 17@implementation NSArray (HBCSVAdditions) 18 19// CSV parsing examples 20// CSV Record: 21// one,two,three 22// Fields: 23// <one> 24// <two> 25// <three> 26// CSV Record: 27// one, two, three 28// Fields: 29// <one> 30// < two> 31// < three> 32// CSV Record: 33// one,"2,345",three 34// Fields: 35// <one> 36// <2,345> 37// <three> 38// CSV record: 39// one,"John said, ""Hello there.""",three 40// Explanation: inside a quoted field, two double quotes in a row count 41// as an escaped double quote in the field data. 42// Fields: 43// <one> 44// <John said, "Hello there."> 45// <three> 46+ (nullable NSArray<NSArray<NSString *> *> *)HB_arrayWithContentsOfCSVURL:(NSURL *)url 47{ 48 NSString *str = [[NSString alloc] initWithContentsOfURL:url encoding:NSUTF8StringEncoding error:NULL]; 49 50 if (str == nil) 51 { 52 return nil; 53 } 54 55 NSMutableString *csvString = [str mutableCopy]; 56 [csvString replaceOccurrencesOfString:@"\r\n" withString:@"\n" options:NSLiteralSearch range:NSMakeRange(0, csvString.length)]; 57 [csvString replaceOccurrencesOfString:@"\r" withString:@"\n" options:NSLiteralSearch range:NSMakeRange(0, csvString.length)]; 58 59 if (!csvString) 60 { 61 return 0; 62 } 63 64 if ([csvString characterAtIndex:0] == 0xFEFF) 65 { 66 [csvString deleteCharactersInRange:NSMakeRange(0,1)]; 67 } 68 if ([csvString characterAtIndex:[csvString length]-1] != '\n') 69 { 70 [csvString appendFormat:@"%c",'\n']; 71 } 72 73 NSScanner *sc = [NSScanner scannerWithString:csvString]; 74 sc.charactersToBeSkipped = nil; 75 NSMutableArray *csvArray = [NSMutableArray array]; 76 [csvArray addObject:[NSMutableArray array]]; 77 NSCharacterSet *commaNewlineCS = [NSCharacterSet characterSetWithCharactersInString:@",\n"]; 78 79 while (sc.scanLocation < csvString.length) 80 { 81 if ([sc scanString:@"\"" intoString:NULL]) 82 { 83 // Quoted field 84 NSMutableString *field = [NSMutableString string]; 85 BOOL done = NO; 86 NSString *quotedString; 87 // Scan until we get to the end double quote or the EOF. 88 while (!done && sc.scanLocation < csvString.length) 89 { 90 if ([sc scanUpToString:@"\"" intoString:"edString]) 91 { 92 [field appendString:quotedString]; 93 } 94 if ([sc scanString:@"\"\"" intoString:NULL]) 95 { 96 // Escaped double quote inside the quoted string. 97 [field appendString:@"\""]; 98 } 99 else 100 { 101 done = YES; 102 } 103 } 104 if (sc.scanLocation < csvString.length) 105 { 106 ++sc.scanLocation; 107 BOOL nextIsNewline = [sc scanString:@"\n" intoString:NULL]; 108 BOOL nextIsComma = NO; 109 if (!nextIsNewline) 110 { 111 nextIsComma = [sc scanString:@"," intoString:NULL]; 112 } 113 if (nextIsNewline || nextIsComma) 114 { 115 [[csvArray lastObject] addObject:field]; 116 if (nextIsNewline && sc.scanLocation < csvString.length) 117 { 118 [csvArray addObject:[NSMutableArray array]]; 119 } 120 } 121 else 122 { 123 // Quoted fields must be immediately followed by a comma or newline. 124 return nil; 125 } 126 } 127 else 128 { 129 // No close quote found before EOF, so file is invalid CSV. 130 return nil; 131 } 132 } 133 else 134 { 135 NSString *field; 136 [sc scanUpToCharactersFromSet:commaNewlineCS intoString:&field]; 137 BOOL nextIsNewline = [sc scanString:@"\n" intoString:NULL]; 138 BOOL nextIsComma = NO; 139 if (!nextIsNewline) 140 { 141 nextIsComma = [sc scanString:@"," intoString:NULL]; 142 } 143 if (nextIsNewline || nextIsComma) 144 { 145 [[csvArray lastObject] addObject:field]; 146 if (nextIsNewline && sc.scanLocation < csvString.length) 147 { 148 [csvArray addObject:[NSMutableArray array]]; 149 } 150 } 151 } 152 } 153 return csvArray; 154} 155 156@end 157 158@interface HBChapterTitlesController () <NSTableViewDataSource, NSTableViewDelegate> 159 160@property (nonatomic, weak) IBOutlet NSTableView *table; 161@property (nonatomic, readwrite, strong) NSArray<HBChapter *> *chapterTitles; 162 163@end 164 165@implementation HBChapterTitlesController 166 167- (instancetype)init 168{ 169 self = [super initWithNibName:@"ChaptersTitles" bundle:nil]; 170 if (self) 171 { 172 _chapterTitles = [[NSMutableArray alloc] init]; 173 } 174 return self; 175} 176 177- (void)setJob:(HBJob *)job 178{ 179 _job = job; 180 self.chapterTitles = job.chapterTitles; 181} 182 183- (void)viewDidLoad 184{ 185 [super viewDidLoad]; 186 self.table.doubleAction = @selector(doubleClickAction:); 187} 188 189/** 190 * Method to edit the next chapter when the user presses Return. 191 * We queue the action on the runloop to avoid interfering 192 * with the chain of events that handles the edit. 193 */ 194- (void)controlTextDidEndEditing:(NSNotification *)notification 195{ 196 NSTableView *chapterTable = self.table; 197 NSInteger column = [self.table columnForView:[notification object]]; 198 NSInteger row = [self.table rowForView:[notification object]]; 199 NSInteger textMovement; 200 201 // Edit the cell in the next row, same column 202 row++; 203 textMovement = [[notification userInfo][@"NSTextMovement"] integerValue]; 204 if (textMovement == NSReturnTextMovement && row < chapterTable.numberOfRows) 205 { 206 NSArray *info = @[chapterTable, @(column), @(row)]; 207 // The delay is unimportant; editNextRow: won't be called until the responder 208 // chain finishes because the event loop containing the timer is on this thread 209 [self performSelector:@selector(editNextRow:) withObject:info afterDelay:0.0]; 210 } 211} 212 213- (void)editNextRow:(id)objects 214{ 215 NSTableView *chapterTable = objects[0]; 216 NSInteger column = [objects[1] integerValue]; 217 NSInteger row = [objects[2] integerValue]; 218 219 if (row >= 0 && row < chapterTable.numberOfRows) 220 { 221 [chapterTable selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; 222 [chapterTable editColumn:column row:row withEvent:nil select:YES]; 223 } 224} 225 226- (IBAction)doubleClickAction:(NSTableView *)sender 227{ 228 if (sender.clickedRow > -1) { 229 NSTableColumn *column = sender.tableColumns[sender.clickedColumn]; 230 if ([column.identifier isEqualToString:@"title"]) { 231 // edit the cell 232 [sender editColumn:sender.clickedColumn 233 row:sender.clickedRow 234 withEvent:nil 235 select:YES]; 236 } 237 } 238} 239 240#pragma mark - Chapter Files Import / Export 241 242- (BOOL)importChaptersFromURL:(NSURL *)URL error:(NSError **)outError 243{ 244 NSArray<NSArray<NSString *> *> *csvData = [NSArray HB_arrayWithContentsOfCSVURL:URL]; 245 if (csvData.count == self.chapterTitles.count) 246 { 247 NSUInteger i = 0; 248 for (NSArray<NSString *> *lineFields in csvData) 249 { 250 if (lineFields.count < 2 || [lineFields[0] integerValue] != i + 1) 251 { 252 if (NULL != outError) 253 { 254 *outError = [NSError errorWithDomain:@"HBError" code:0 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Invalid chapters CSV file", @"Chapters import -> invalid CSV description"), 255 NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString(@"The CSV file is not a valid chapters CSV file.", @"Chapters import -> invalid CSV recovery suggestion")}]; 256 } 257 return NO; 258 } 259 i++; 260 } 261 262 NSUInteger j = 0; 263 for (NSArray<NSString *> *lineFields in csvData) 264 { 265 [self.chapterTitles[j] setTitle:lineFields[1]]; 266 j++; 267 } 268 return YES; 269 } 270 271 if (NULL != outError) 272 { 273 *outError = [NSError errorWithDomain:@"HBError" code:0 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Incorrect line count", @"Chapters import -> invalid CSV line count description"), 274 NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString(@"The line count in the chapters CSV file does not match the number of chapters in the movie.", @"Chapters import -> invalid CSV line count recovery suggestion")}]; 275 } 276 277 return NO; 278} 279 280- (IBAction)browseForChapterFile:(id)sender 281{ 282 // We get the current file name and path from the destination field here 283 NSURL *sourceDirectory = [NSUserDefaults.standardUserDefaults URLForKey:HBLastDestinationDirectoryURL]; 284 285 // Open a panel to let the user choose the file 286 NSOpenPanel *panel = [NSOpenPanel openPanel]; 287 panel.allowedFileTypes = @[@"csv", @"txt"]; 288 panel.directoryURL = sourceDirectory; 289 290 [panel beginSheetModalForWindow:self.view.window completionHandler:^(NSInteger result) 291 { 292 if (result == NSModalResponseOK) 293 { 294 NSError *error; 295 if ([self importChaptersFromURL:panel.URL error:&error] == NO) 296 { 297 [self presentError:error]; 298 } 299 } 300 }]; 301} 302 303- (IBAction)browseForChapterFileSave:(id)sender 304{ 305 NSURL *destinationDirectory = [NSUserDefaults.standardUserDefaults URLForKey:HBLastDestinationDirectoryURL]; 306 307 NSSavePanel *panel = [NSSavePanel savePanel]; 308 panel.allowedFileTypes = @[@"csv"]; 309 panel.directoryURL = destinationDirectory; 310 panel.nameFieldStringValue = self.job.outputFileName.stringByDeletingPathExtension; 311 312 [panel beginSheetModalForWindow:self.view.window completionHandler:^(NSInteger result) 313 { 314 if (result == NSModalResponseOK) 315 { 316 NSError *saveError; 317 NSMutableString *csv = [NSMutableString string]; 318 319 NSInteger idx = 0; 320 for (HBChapter *chapter in self.chapterTitles) 321 { 322 // put each chapter title from the table into the array 323 [csv appendFormat:@"%ld,",idx + 1]; 324 idx++; 325 326 NSString *sanitizedTitle = [chapter.title stringByReplacingOccurrencesOfString:@"\"" withString:@"\"\""]; 327 328 // If the title contains any commas or quotes, add quotes 329 if ([sanitizedTitle containsString:@","] || [sanitizedTitle containsString:@"\""]) 330 { 331 [csv appendString:@"\""]; 332 [csv appendString:sanitizedTitle]; 333 [csv appendString:@"\""]; 334 } 335 else 336 { 337 [csv appendString:sanitizedTitle]; 338 } 339 [csv appendString:@"\n"]; 340 } 341 342 [csv deleteCharactersInRange:NSMakeRange(csv.length - 1, 1)]; 343 344 // try to write it to where the user wanted 345 if (![csv writeToURL:panel.URL 346 atomically:YES 347 encoding:NSUTF8StringEncoding 348 error:&saveError]) 349 { 350 [panel close]; 351 [[NSAlert alertWithError:saveError] runModal]; 352 } 353 } 354 }]; 355} 356 357@end 358