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