/* HBChapterTitlesController.m $ This file is part of the HandBrake source code. Homepage: . It may be used under the terms of the GNU General Public License. */ #import "HBChapterTitlesController.h" @import HandBrakeKit.HBChapter; @import HandBrakeKit.HBJob; @interface NSArray (HBCSVAdditions) + (nullable NSArray *> *)HB_arrayWithContentsOfCSVURL:(NSURL *)url; @end @implementation NSArray (HBCSVAdditions) // CSV parsing examples // CSV Record: // one,two,three // Fields: // // // // CSV Record: // one, two, three // Fields: // // < two> // < three> // CSV Record: // one,"2,345",three // Fields: // // <2,345> // // CSV record: // one,"John said, ""Hello there.""",three // Explanation: inside a quoted field, two double quotes in a row count // as an escaped double quote in the field data. // Fields: // // // + (nullable NSArray *> *)HB_arrayWithContentsOfCSVURL:(NSURL *)url; { NSString *str = [[NSString alloc] initWithContentsOfURL:url encoding:NSUTF8StringEncoding error:NULL]; if (str == nil) { return nil; } NSMutableString *csvString = [str mutableCopy]; [csvString replaceOccurrencesOfString:@"\r\n" withString:@"\n" options:NSLiteralSearch range:NSMakeRange(0, csvString.length)]; [csvString replaceOccurrencesOfString:@"\r" withString:@"\n" options:NSLiteralSearch range:NSMakeRange(0, csvString.length)]; if (!csvString) { return 0; } if ([csvString characterAtIndex:0] == 0xFEFF) { [csvString deleteCharactersInRange:NSMakeRange(0,1)]; } if ([csvString characterAtIndex:[csvString length]-1] != '\n') { [csvString appendFormat:@"%c",'\n']; } NSScanner *sc = [NSScanner scannerWithString:csvString]; sc.charactersToBeSkipped = nil; NSMutableArray *csvArray = [NSMutableArray array]; [csvArray addObject:[NSMutableArray array]]; NSCharacterSet *commaNewlineCS = [NSCharacterSet characterSetWithCharactersInString:@",\n"]; while (sc.scanLocation < csvString.length) { if ([sc scanString:@"\"" intoString:NULL]) { // Quoted field NSMutableString *field = [NSMutableString string]; BOOL done = NO; NSString *quotedString; // Scan until we get to the end double quote or the EOF. while (!done && sc.scanLocation < csvString.length) { if ([sc scanUpToString:@"\"" intoString:"edString]) { [field appendString:quotedString]; } if ([sc scanString:@"\"\"" intoString:NULL]) { // Escaped double quote inside the quoted string. [field appendString:@"\""]; } else { done = YES; } } if (sc.scanLocation < csvString.length) { ++sc.scanLocation; BOOL nextIsNewline = [sc scanString:@"\n" intoString:NULL]; BOOL nextIsComma = NO; if (!nextIsNewline) { nextIsComma = [sc scanString:@"," intoString:NULL]; } if (nextIsNewline || nextIsComma) { [[csvArray lastObject] addObject:field]; if (nextIsNewline && sc.scanLocation < csvString.length) { [csvArray addObject:[NSMutableArray array]]; } } else { // Quoted fields must be immediately followed by a comma or newline. return nil; } } else { // No close quote found before EOF, so file is invalid CSV. return nil; } } else { NSString *field; [sc scanUpToCharactersFromSet:commaNewlineCS intoString:&field]; BOOL nextIsNewline = [sc scanString:@"\n" intoString:NULL]; BOOL nextIsComma = NO; if (!nextIsNewline) { nextIsComma = [sc scanString:@"," intoString:NULL]; } if (nextIsNewline || nextIsComma) { [[csvArray lastObject] addObject:field]; if (nextIsNewline && sc.scanLocation < csvString.length) { [csvArray addObject:[NSMutableArray array]]; } } } } return csvArray; } @end @interface HBChapterTitlesController () @property (weak) IBOutlet NSTableView *table; @property (nonatomic, readwrite, strong) NSArray *chapterTitles; @end @implementation HBChapterTitlesController - (instancetype)init { self = [super initWithNibName:@"ChaptersTitles" bundle:nil]; if (self) { _chapterTitles = [[NSMutableArray alloc] init]; } return self; } - (void)setJob:(HBJob *)job { _job = job; self.chapterTitles = job.chapterTitles; } /** * Method to edit the next chapter when the user presses Return. * We queue the action on the runloop to avoid interfering * with the chain of events that handles the edit. */ - (void)controlTextDidEndEditing:(NSNotification *)notification { NSTableView *chapterTable = self.table; NSInteger column = 2; NSInteger row = [self.table rowForView:[notification object]]; NSInteger textMovement; // Edit the cell in the next row, same column row++; textMovement = [[notification userInfo][@"NSTextMovement"] integerValue]; if (textMovement == NSReturnTextMovement && row < chapterTable.numberOfRows) { NSArray *info = @[chapterTable, @(column), @(row)]; // The delay is unimportant; editNextRow: won't be called until the responder // chain finishes because the event loop containing the timer is on this thread [self performSelector:@selector(editNextRow:) withObject:info afterDelay:0.0]; } } - (void)editNextRow:(id)objects { NSTableView *chapterTable = objects[0]; NSInteger column = [objects[1] integerValue]; NSInteger row = [objects[2] integerValue]; if (row >= 0 && row < chapterTable.numberOfRows) { [chapterTable selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; [chapterTable editColumn:column row:row withEvent:nil select:YES]; } } #pragma mark - Chapter Files Import / Export - (BOOL)importChaptersFromURL:(NSURL *)URL error:(NSError **)outError { NSArray *> *csvData = [NSArray HB_arrayWithContentsOfCSVURL:URL]; if (csvData.count == self.chapterTitles.count) { NSUInteger i = 0; for (NSArray *lineFields in csvData) { if (lineFields.count < 2 || [lineFields[0] integerValue] != i + 1) { if (NULL != outError) { *outError = [NSError errorWithDomain:@"HBError" code:0 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Invalid chapters CSV file", nil), NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString(@"The CSV file is not a valid chapters CSV file.", nil)}]; } return NO; } i++; } NSUInteger j = 0; for (NSArray *lineFields in csvData) { [self.chapterTitles[j] setTitle:lineFields[1]]; j++; } return YES; } if (NULL != outError) { *outError = [NSError errorWithDomain:@"HBError" code:0 userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Incorrect line count", nil), NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString(@"The line count in the chapters CSV file does not match the number of chapters in the movie.", nil)}]; } return NO; } - (IBAction)browseForChapterFile:(id)sender { // We get the current file name and path from the destination field here NSURL *sourceDirectory = [[NSUserDefaults standardUserDefaults] URLForKey:@"HBLastDestinationDirectory"]; // Open a panel to let the user choose the file NSOpenPanel *panel = [NSOpenPanel openPanel]; panel.allowedFileTypes = @[@"csv", @"txt"]; panel.directoryURL = sourceDirectory; [panel beginSheetModalForWindow:self.view.window completionHandler:^(NSInteger result) { if (result == NSFileHandlingPanelOKButton) { NSError *error; if ([self importChaptersFromURL:panel.URL error:&error] == NO) { [self presentError:error]; } } }]; } - (IBAction)browseForChapterFileSave:(id)sender { NSURL *destinationDirectory = [[NSUserDefaults standardUserDefaults] URLForKey:@"HBLastDestinationDirectory"]; NSSavePanel *panel = [NSSavePanel savePanel]; panel.allowedFileTypes = @[@"csv"]; panel.directoryURL = destinationDirectory; panel.nameFieldStringValue = self.job.destURL.lastPathComponent.stringByDeletingPathExtension; [panel beginSheetModalForWindow:self.view.window completionHandler:^(NSInteger result) { if (result == NSFileHandlingPanelOKButton) { NSError *saveError; NSMutableString *csv = [NSMutableString string]; NSInteger idx = 0; for (HBChapter *chapter in self.chapterTitles) { // put each chapter title from the table into the array [csv appendFormat:@"%ld,",idx + 1]; idx++; NSString *sanatizedTitle = [chapter.title stringByReplacingOccurrencesOfString:@"\"" withString:@"\"\""]; // If the title contains any commas or quotes, add quotes if ([sanatizedTitle containsString:@","] || [sanatizedTitle containsString:@"\""]) { [csv appendString:@"\""]; [csv appendString:sanatizedTitle]; [csv appendString:@"\""]; } else { [csv appendString:sanatizedTitle]; } [csv appendString:@"\n"]; } [csv deleteCharactersInRange:NSMakeRange(csv.length - 1, 1)]; // try to write it to where the user wanted if (![csv writeToURL:panel.URL atomically:YES encoding:NSUTF8StringEncoding error:&saveError]) { [panel close]; [[NSAlert alertWithError:saveError] runModal]; } } }]; } @end