File Coverage

blib/lib/Anki/Import.pm
Criterion Covered Total %
statement 176 182 96.7
branch 50 60 83.3
condition 19 26 73.0
subroutine 13 13 100.0
pod 1 6 16.6
total 259 287 90.2


line stmt bran cond sub pod time code
1             package Anki::Import ;
2             $Anki::Import::VERSION = '0.028';
3 4     4   275307 use strict;
  4         36  
  4         114  
4 4     4   20 use warnings;
  4         6  
  4         107  
5 4     4   24 use Cwd;
  4         7  
  4         245  
6 4     4   2295 use Getopt::Args;
  4         122965  
  4         35  
7 4     4   2926 use Log::Log4perl::Shortcuts 0.021 qw(:all);
  4         674530  
  4         808  
8 4     4   57 use Exporter qw(import);
  4         8  
  4         9042  
9             our @EXPORT = qw(anki_import);
10              
11             # change log config to test for development for fine-tuned control over log output
12             set_log_config('anki-import.cfg');
13             #set_log_config('test.cfg', __PACKAGE__);
14              
15             # set up variables
16             my @lines; # lines from source file
17             my $line_count = 0; # count processed lines to give more helpful error msg
18             my $cline = ''; # current line getting processed
19             my $lline = ''; # last (previous) line processed
20             my $ntype = 'Basic'; # default note type
21             my @notes = (); # array for storing notes
22             my @autotags = (); # for storing automated tags
23              
24             # argument processing
25             arg file => (
26             isa => 'Str',
27             required => 1,
28             comment => 'the name of the source file'
29             );
30             arg parent_dir => (
31             isa => 'Str',
32             default => cwd,
33             comment => 'optional directory to save output files, defaults to current directory',
34             );
35             opt quiet => (
36             isa => 'Bool',
37             alias => 'q',
38             default => 1,
39             comment => 'On by default. Use --quiet to override this setting to suppress'
40             . ' the success message after a successful execution of the command.'
41             );
42             opt verbose => (
43             isa => 'Bool',
44             alias => 'v',
45             comment => 'provide details on progress of Anki::Import'
46             );
47             opt vverbose => (
48             isa => 'Bool',
49             alias => 'V',
50             comment => 'verbose information plus debug info'
51             );
52              
53             # start here
54             sub anki_import {
55 8     8 1 11767 my $args = optargs( @_ );
56              
57 7         5313 my $file = $args->{file};
58 7 50       22 if (!$file) {
59 0         0 logf('Aborting: No file passed to Anki::Import.');
60             }
61              
62             # set parent directory
63 7         43 my $pd = $args->{parent_dir};
64              
65             # set log level as appropriate
66 7 50       36 if ($args->{verbose}) {
    100          
67 0         0 set_log_level('info');
68             } elsif ($args->{vverbose}) {
69 3         18 set_log_level('debug');
70             } else {
71 4         19 set_log_level('error');
72             }
73 7         82 logi('Log level set');
74              
75             # get and load the source file
76 7         13919 logi('Loading file');
77 7         11651 my $path = File::Spec->catfile($file); logd($path);
  7         42  
78 7 100       12348 if (! -e $path) {
79 1         10 logf("Aborting: Source file named '$path' does not exist.");
80             };
81 6 50   2   292 open (my $handle, "<:encoding(UTF-8)", $path) or logf("Could not open $path");;
  2         17  
  2         4  
  2         17  
82 6         3033 chomp(@lines = <$handle>);
83 6         295 close $handle;
84 6         34 logi('Source file loaded.');
85              
86             # pad data with a blank line to make it easier to process
87 6         11152 push @lines, '';
88              
89             # do the stuff we came here for
90 6         24 validate_src_file(); logd(\@notes);
  5         26  
91 5         10784 generate_importable_files($pd);
92              
93             # print a success message
94 5 50       92 unless ($args->{'quiet'}) {
95 0         0 set_log_level('info');
96 0         0 logi("Success! Your import files are in the $pd"
97             . '/anki_import_files directory');
98             }
99              
100             # fin
101             }
102              
103             # functions for first pass parsing of source data
104             sub validate_src_file {
105 6     6 0 25 logi('Validating source file');
106              
107             # throw error if file is empty
108 6 50       10718 logf('Source data file is empty.') if !$lines[0];
109              
110             # outer loop for parsing notes
111 6         29 my %fields; # keeps track of number of fields for each type of note
112 6         22 while (next_line()) {
113              
114             # ignore blank lines
115 30 50       206 next if ($cline =~ /^$|^\s+$/);
116              
117 30 100       119 if ($cline =~ /^#\s*(\S+)/) {
118 8         33 $ntype = $1;
119 8         29 logi("Found note type");
120 8         14065 logd($ntype);
121 8         14115 next;
122             }
123              
124 22         80 logi('Processing new note');
125             # get the note
126 22         43946 my $note = slurp_note();
127 22         80 logd($note);
128              
129 22         45210 logi('Checking number of note fields');
130             # validaate that notes of the same type have the same number of fields
131 22 100       44356 if (my $number_of_fields = $fields{$ntype}) {
132 13 100       54 if (scalar (@$note) != $number_of_fields) {
133 1         4 my $field_count = scalar(@$note);
134 1         16 logf("A(n) $ntype note ending on line $line_count"
135             . " has $field_count fields, a different amount than previous '$ntype' note types."
136             . " Notes of the same note type must have the same number of fields. One common reason"
137             . " for this error is that you did not indicate that you wanted to leave a field blank. To leave a field blank,"
138             . " place a single '`' (backtick) on the line by itself in the source file. You may also"
139             . " have failed to separate notes with two or more blank lines."
140             . " Check your source file to ensure it is properly formatted.\n\n\tRefer to the"
141             . " Anki::Import documentation for more help with formatting your source file."
142             );
143             }
144             } else {
145 9         47 $fields{$ntype} = scalar @$note;
146             }
147              
148 21         73 logi('Storing note');
149 21         42728 push @notes, {ntype => $ntype, note => $note};
150             }
151              
152             }
153              
154             sub slurp_note {
155 22     22 0 51 my @current_field;
156             my @note;
157 22         59 push @current_field, $cline;
158              
159             # loop over lines in the note
160 22         53 while (next_line()) {
161 134         434 logd($cline, 'cline');
162 134 100       274416 if ($cline =~ /^$|^\s+$/) {
163 71         211 my @all_fields = @current_field;
164 71 100       204 push (@note, \@all_fields) if @current_field;
165 71         123 @current_field = ();
166 71 100       373 if ($lline =~ /^$|^\s+$/) {
167 16         38 last;
168             }
169             } else {
170 63         197 push @current_field, $cline;
171             }
172             }
173 22         53 return \@note;
174             }
175              
176             sub next_line {
177 175 100   175 0 484 return 0 if !@lines; # last line in file was made blank
178 164         269 $lline = $cline;
179 164   100     595 $cline = (shift @lines || '');
180              
181             # do some cleanup
182 164         300 chomp $cline;
183 164         385 $cline =~ s/\t/ /g; # replace tabs with spaces
184              
185 164         392 ++$line_count;
186             }
187              
188             # functions for second pass parsing and formatting of source data
189             # and creation of import files
190             sub generate_importable_files {
191 5     5 0 18 my $pd = shift; logi('Generating files for import');
  5         24  
192              
193 5         10087 my %filenames;
194              
195             # loop over notes
196 5         31 foreach my $note (@notes) {
197 45         156 logi('Looping over notes');
198              
199 45         102255 my $line = process_note($note->{note});
200              
201             # add our processed note to our data
202 45         152 my $filename = $note->{ntype} . '_notes_import.txt';
203 45         223 $filenames{$filename}{content} .= $line;
204             }
205              
206 5         22 logi('Writing notes out to file');
207 5         11865 foreach my $file ( keys %filenames ) {
208 9         233 my $dir = File::Spec->catfile($pd, 'anki_import_files');
209 9   33     474 mkdir $dir || logf("Could not make directory: $dir, $!");
210 9         66 logd($dir);
211 9         22083 my $out_path = File::Spec->catfile($dir, $file);
212 9 50       4305 open (my $handle, ">>:encoding(UTF-8)", $out_path) or logf("Could not create file: $out_path");
213 9         908 chomp $filenames{$file}{content};
214 9         209 print $handle $filenames{$file}{content};
215 9         478 close $handle;
216             }
217             }
218              
219             # the meat of the matter
220             # TODO: break up into shorter functions for readability
221             sub process_note {
222 45     45 0 109 my $note = shift; logd($note, 'note_2b_processed');
  45         163  
223              
224 45         104723 my @fields = ();
225 45         96 my $new_autotags = 0; # flag raised if autotag line found
226              
227             # loop over note fields
228 45         89 foreach my $field (@$note) {
229 106         162 my $ws_mode = 0; # tracks if we are preserving whitespace
230 106         160 my $field_out = '';
231              
232             # loop over lines in field and process accordingly
233 106         214 my @lines = (''); # can't take a reference to nothing
234 106         218 foreach my $line (@$field) {
235 177         344 my $last_line = \$lines[-1]; # just to make it easier to type
236              
237             # detect autotags
238 177         534 logd($line);
239 177 50 33     406161 if ($line =~ /^\+\s*$/ && !$ws_mode) {
240 0         0 push @autotags, split (/\s+/, $$last_line);
241 0         0 $new_autotags = 1;
242             }
243 177 100 66     489 if ($line =~ /^\^\s*$/ && !$ws_mode) {
244 2         15 @autotags = split (/\s+/, $$last_line);
245 2         7 $new_autotags = 1;
246 2         5 next;
247             }
248              
249             # blanks lines not in non-whitespace mode
250 175 100 100     699 if ($line =~ /^`\s*$/ && !$ws_mode) {
251 29 100 66     115 if ($$last_line && $$last_line !~ /^
+$/) {
252 9         28 $$last_line .= '

';
253             }
254 29         68 next;
255             }
256              
257             # enter whitespace mode and adding appropriate HTML
258 146 100 100     470 if ($line =~ /^`{3,3}$/ && !$ws_mode) {
259 10         26 $ws_mode = 1;
260              
261             # add a couple of blank lines to previous line
262 10 100       30 if ($$last_line) {
263 9         28 $$last_line .= '

';
264             }
265              
266 10         30 $$last_line .= '
';
267 10         28 next;
268             }
269              
270             # exit whitespace mode, close out HTML, add blank lines
271 136 100 66     363 if ($line =~ /^`{3,3}$/ && $ws_mode) {
272 10         26 $ws_mode = 0;
273 10         47 $$last_line .= "

";
274 10         25 next;
275             }
276              
277             # handle lines differently based on if we are preserving whitespace
278 126 100       283 if ($ws_mode) {
279             # escape characters in preserved text
280 23 100       116 if ($line =~ /^`\s*$/) {
281 2         5 $$last_line .= '
';
282 2         6 next;
283             }
284 21         92 $line =~ s/(?
285 21         47 $line =~ s/(?
286 21         36 $line =~ s/(?
287 21         97 $$last_line .= $line . "
";
288             } else {
289 103         237 push @lines, $line;
290             }
291             }
292 106 50       216 logf('A set of backticks (```) is unmatched or you failed to backtick a'
293             . ' blank line inside of a backtick set. Please correct the source'
294             . ' file and try again. Run "perldoc Anki::Import" for more help.') if $ws_mode;
295              
296 106         285 logd($field_out, 'field_out');
297              
298 106 100       250428 shift @lines if !$lines[0];
299 106         333 my $field = join ' ', @lines;
300              
301             # clean up dangling breaks
302 106         343 $field =~ s/
<\/div>/<\/div>/g;
303              
304             # handle formatting codes in text, preserve escaped characters
305              
306             # backticked characters
307 106         321 $field =~ s/(?$1<\/span>/gm;
308 106         222 $field =~ s/\\`/`/g;
309              
310             # bold
311 106         239 $field =~ s/(?$1<\/span>/gm;
312 106         201 $field =~ s/\\\*/*/g;
313              
314             # unordered lists
315 106         266 $field =~ s'(?
  • " . join ("
  • ", (split (/,\s*/, $1))) . "
  • <\/ul>"'gme;
      9         116  
    316 106         193 $field =~ s/\\%/%/g;
    317              
    318 106         287 $field =~ s/(
    )+$//;
    319 106         294 push @fields, $field;
    320              
    321             }
    322              
    323             # generate tag field
    324 45 100 100     172 if (@autotags && !$new_autotags) {
    325              
    326             # get tags from tag field
    327 3         17 my @note_tags = split (/\s+/, $fields[-1]); logd(\@note_tags, 'raw_note_tags');
      3         13  
    328 3         9198 my @new_tags = ();
    329              
    330             # add tags from tag field
    331 3         9 foreach my $note_tag (@note_tags) {
    332 1         3 my $in_autotags = grep { $_ eq $note_tag } @autotags;
      3         7  
    333 1 50       6 push @new_tags, $note_tag unless $in_autotags;
    334             }
    335              
    336             # add autotags
    337 3         7 foreach my $autotag (@autotags) {
    338 9         17 my $discard_autotag = grep { $_ eq $autotag } @note_tags;
      3         6  
    339 9 100       26 push @new_tags, $autotag if !$discard_autotag;
    340             }
    341              
    342             # add combined tags as a field
    343 3         17 logd(\@new_tags, 'new_tags');
    344 3         9608 my $new_tags = join (' ', @new_tags);
    345 3         9 $fields[-1] = $new_tags;
    346             }
    347 45         80 $new_autotags = 0;
    348              
    349 45         120 my $out = join ("\t", @fields);
    350              
    351             # create cloze fields
    352 45         80 my $cloze_count = 1;
    353             # TODO: should probably handle escaped braces just in case
    354 45         169 while ($out =~ /\{\{\{(.*?)}}}/) {
    355 2         20 $out =~ s/\{\{\{(.*?)}}}/{{c${cloze_count}::$1}}/s;
    356 2         10 $cloze_count++;
    357             }
    358 45         175 logd($out, 'out');
    359              
    360 45         106664 $out .= "\n";
    361             }
    362              
    363             1; # Magic true value
    364             # ABSTRACT: Anki note generation made easy.
    365              
    366             __END__