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.029';
3 4     4   270978 use strict;
  4         38  
  4         118  
4 4     4   21 use warnings;
  4         7  
  4         110  
5 4     4   19 use Cwd;
  4         7  
  4         261  
6 4     4   2358 use Getopt::Args;
  4         123492  
  4         32  
7 4     4   2948 use Log::Log4perl::Shortcuts 0.021 qw(:all);
  4         673320  
  4         800  
8 4     4   50 use Exporter qw(import);
  4         7  
  4         9087  
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 11176 my $args = optargs( @_ );
56              
57 7         5213 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         45 my $pd = $args->{parent_dir};
64              
65             # set log level as appropriate
66 7 50       35 if ($args->{verbose}) {
    100          
67 0         0 set_log_level('info');
68             } elsif ($args->{vverbose}) {
69 3         16 set_log_level('debug');
70             } else {
71 4         21 set_log_level('error');
72             }
73 7         72 logi('Log level set');
74              
75             # get and load the source file
76 7         13422 logi('Loading file');
77 7         11714 my $path = File::Spec->catfile($file); logd($path);
  7         40  
78 7 100       12268 if (! -e $path) {
79 1         8 logf("Aborting: Source file named '$path' does not exist.");
80             };
81 6 50   2   232 open (my $handle, "<:encoding(UTF-8)", $path) or logf("Could not open $path");;
  2         13  
  2         3  
  2         14  
82 6         2687 chomp(@lines = <$handle>);
83 6         270 close $handle;
84 6         38 logi('Source file loaded.');
85              
86             # pad data with a blank line to make it easier to process
87 6         11063 push @lines, '';
88              
89             # do the stuff we came here for
90 6         22 validate_src_file(); logd(\@notes);
  5         34  
91 5         10831 generate_importable_files($pd);
92              
93             # print a success message
94 5 50       83 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       10757 logf('Source data file is empty.') if !$lines[0];
109              
110             # outer loop for parsing notes
111 6         12 my %fields; # keeps track of number of fields for each type of note
112 6         20 while (next_line()) {
113              
114             # ignore blank lines
115 30 50       181 next if ($cline =~ /^$|^\s+$/);
116              
117 30 100       121 if ($cline =~ /^#\s*(\S+)/) {
118 8         29 $ntype = $1;
119 8         32 logi("Found note type");
120 8         14017 logd($ntype);
121 8         14017 next;
122             }
123              
124 22         69 logi('Processing new note');
125             # get the note
126 22         43654 my $note = slurp_note();
127 22         69 logd($note);
128              
129 22         45123 logi('Checking number of note fields');
130             # validaate that notes of the same type have the same number of fields
131 22 100       44243 if (my $number_of_fields = $fields{$ntype}) {
132 13 100       42 if (scalar (@$note) != $number_of_fields) {
133 1         6 my $field_count = scalar(@$note);
134 1         23 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         48 $fields{$ntype} = scalar @$note;
146             }
147              
148 21         64 logi('Storing note');
149 21         42519 push @notes, {ntype => $ntype, note => $note};
150             }
151              
152             }
153              
154             sub slurp_note {
155 22     22 0 46 my @current_field;
156             my @note;
157 22         51 push @current_field, $cline;
158              
159             # loop over lines in the note
160 22         47 while (next_line()) {
161 134         387 logd($cline, 'cline');
162 134 100       274948 if ($cline =~ /^$|^\s+$/) {
163 71         190 my @all_fields = @current_field;
164 71 100       195 push (@note, \@all_fields) if @current_field;
165 71         129 @current_field = ();
166 71 100       349 if ($lline =~ /^$|^\s+$/) {
167 16         46 last;
168             }
169             } else {
170 63         200 push @current_field, $cline;
171             }
172             }
173 22         50 return \@note;
174             }
175              
176             sub next_line {
177 175 100   175 0 453 return 0 if !@lines; # last line in file was made blank
178 164         283 $lline = $cline;
179 164   100     469 $cline = (shift @lines || '');
180              
181             # do some cleanup
182 164         302 chomp $cline;
183 164         364 $cline =~ s/\t/ /g; # replace tabs with spaces
184              
185 164         360 ++$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 12 my $pd = shift; logi('Generating files for import');
  5         18  
192              
193 5         9481 my %filenames;
194              
195             # loop over notes
196 5         25 foreach my $note (@notes) {
197 45         159 logi('Looping over notes');
198              
199 45         101161 my $line = process_note($note->{note});
200              
201             # add our processed note to our data
202 45         179 my $filename = $note->{ntype} . '_notes_import.txt';
203 45         207 $filenames{$filename}{content} .= $line;
204             }
205              
206 5         25 logi('Writing notes out to file');
207 5         11708 foreach my $file ( keys %filenames ) {
208 9         198 my $dir = File::Spec->catfile($pd, 'anki_import_files');
209 9   33     422 mkdir $dir || logf("Could not make directory: $dir, $!");
210 9         64 logd($dir);
211 9         22136 my $out_path = File::Spec->catfile($dir, $file);
212 9 50       634 open (my $handle, ">>:encoding(UTF-8)", $out_path) or logf("Could not create file: $out_path");
213 9         909 chomp $filenames{$file}{content};
214 9         191 print $handle $filenames{$file}{content};
215 9         465 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 92 my $note = shift; logd($note, 'note_2b_processed');
  45         156  
223              
224 45         104933 my @fields = ();
225 45         87 my $new_autotags = 0; # flag raised if autotag line found
226              
227             # loop over note fields
228 45         104 foreach my $field (@$note) {
229 106         190 my $ws_mode = 0; # tracks if we are preserving whitespace
230 106         147 my $field_out = '';
231              
232             # loop over lines in field and process accordingly
233 106         201 my @lines = (''); # can't take a reference to nothing
234 106         196 foreach my $line (@$field) {
235 177         311 my $last_line = \$lines[-1]; # just to make it easier to type
236              
237             # detect autotags
238 177         529 logd($line);
239 177 50 33     404958 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     488 if ($line =~ /^\^\s*$/ && !$ws_mode) {
244 2         14 @autotags = split (/\s+/, $$last_line);
245 2         5 $new_autotags = 1;
246 2         6 next;
247             }
248              
249             # blanks lines not in non-whitespace mode
250 175 100 100     590 if ($line =~ /^`\s*$/ && !$ws_mode) {
251 29 100 66     116 if ($$last_line && $$last_line !~ /^
+$/) {
252 9         31 $$last_line .= '

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

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

";
274 10         31 next;
275             }
276              
277             # handle lines differently based on if we are preserving whitespace
278 126 100       267 if ($ws_mode) {
279             # escape characters in preserved text
280 23 100       89 if ($line =~ /^`\s*$/) {
281 2         6 $$last_line .= '
';
282 2         5 next;
283             }
284 21         99 $line =~ s/(?
285 21         51 $line =~ s/(?
286 21         46 $line =~ s/(?
287 21         87 $$last_line .= $line . "
";
288             } else {
289 103         267 push @lines, $line;
290             }
291             }
292 106 50       218 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         305 logd($field_out, 'field_out');
297              
298 106 100       248517 shift @lines if !$lines[0];
299 106         321 my $field = join ' ', @lines;
300              
301             # clean up dangling breaks
302 106         325 $field =~ s/
<\/div>/<\/div>/g;
303              
304             # handle formatting codes in text, preserve escaped characters
305              
306             # backticked characters
307 106         305 $field =~ s/(?$1<\/span>/gm;
308 106         227 $field =~ s/\\`/`/g;
309              
310             # bold
311 106         220 $field =~ s/(?$1<\/span>/gm;
312 106         183 $field =~ s/\\\*/*/g;
313              
314             # unordered lists
315 106         263 $field =~ s'(?
  • " . join ("
  • ", (split (/,\s*/, $1))) . "
  • <\/ul>"'gme;
      9         127  
    316 106         169 $field =~ s/\\%/%/g;
    317              
    318 106         294 $field =~ s/(
    )+$//;
    319 106         286 push @fields, $field;
    320              
    321             }
    322              
    323             # generate tag field
    324 45 100 100     135 if (@autotags && !$new_autotags) {
    325              
    326             # get tags from tag field
    327 3         27 my @note_tags = split (/\s+/, $fields[-1]); logd(\@note_tags, 'raw_note_tags');
      3         15  
    328 3         9618 my @new_tags = ();
    329              
    330             # add tags from tag field
    331 3         14 foreach my $note_tag (@note_tags) {
    332 1         2 my $in_autotags = grep { $_ eq $note_tag } @autotags;
      3         8  
    333 1 50       5 push @new_tags, $note_tag unless $in_autotags;
    334             }
    335              
    336             # add autotags
    337 3         5 foreach my $autotag (@autotags) {
    338 9         16 my $discard_autotag = grep { $_ eq $autotag } @note_tags;
      3         8  
    339 9 100       23 push @new_tags, $autotag if !$discard_autotag;
    340             }
    341              
    342             # add combined tags as a field
    343 3         12 logd(\@new_tags, 'new_tags');
    344 3         9499 my $new_tags = join (' ', @new_tags);
    345 3         10 $fields[-1] = $new_tags;
    346             }
    347 45         72 $new_autotags = 0;
    348              
    349 45         106 my $out = join ("\t", @fields);
    350              
    351             # create cloze fields
    352 45         66 my $cloze_count = 1;
    353             # TODO: should probably handle escaped braces just in case
    354 45         161 while ($out =~ /\{\{\{(.*?)}}}/) {
    355 2         19 $out =~ s/\{\{\{(.*?)}}}/{{c${cloze_count}::$1}}/s;
    356 2         9 $cloze_count++;
    357             }
    358 45         171 logd($out, 'out');
    359              
    360 45         107159 $out .= "\n";
    361             }
    362              
    363             1; # Magic true value
    364             # ABSTRACT: Anki note generation made easy.
    365              
    366             __END__