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:&quotedString])
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