| 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'(? | ||||
| 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__ |