File Coverage

lib/File/ValueFile/Simple/Writer.pm
Criterion Covered Total %
statement 76 325 23.3
branch 16 190 8.4
condition 3 84 3.5
subroutine 13 26 50.0
pod 15 15 100.0
total 123 640 19.2


line stmt bran cond sub pod time code
1             # Copyright (c) 2024-2025 Philipp Schafft
2              
3             # licensed under Artistic License 2.0 (see LICENSE file)
4              
5             # ABSTRACT: module for reading and writing ValueFile files
6              
7             package File::ValueFile::Simple::Writer;
8              
9 2     2   345170 use v5.10;
  2         9  
10 2     2   57 use strict;
  2         5  
  2         68  
11 2     2   10 use warnings;
  2         2  
  2         168  
12              
13 2     2   12 use parent qw(Data::Identifier::Interface::Userdata Data::Identifier::Interface::Subobjects);
  2         6  
  2         15  
14              
15 2     2   2965 use Carp;
  2         3  
  2         168  
16 2     2   538 use URI::Escape qw(uri_escape uri_escape_utf8);
  2         2281  
  2         274  
17 2     2   681 use Data::Identifier v0.03;
  2         145582  
  2         17  
18              
19 2     2   727 use File::ValueFile;
  2         4  
  2         122  
20              
21             use constant {
22 2         10640 FORMAT_ISE => '54bf8af4-b1d7-44da-af48-5278d11e8f32',
23             SF_ISE => 'e5da6a39-46d5-48a9-b174-5c26008e208e', # tagpool-source-format
24             TLv1_ISE => 'afdb46f2-e13f-4419-80d7-c4b956ed85fa', # tagpool-taglist-format-v1
25             F_M_L_ISE => 'f06c2226-b33e-48f2-9085-cd906a3dcee0', # tagpool-source-format-modern-limited
26             F_M_F_ISE => '1c71f5b1-216d-4a9b-81a1-54dc22d8a067', # tagpool-source-format-modern-full
27             DOT_REPEAT_ISE => '2ec67bbe-4698-4a0c-921d-1f0951923ee6',
28 2     2   13 };
  2         3  
29              
30             my %_default_style = (
31             generator_hint => 'auto',
32             tag_relation => 'tag-relation',
33             degenerate_generator_hint => undef,
34             tag_ise_no_ise_retry => undef,
35             tag_ise_no_ise_one => 1,
36             tag_ise_no_ise_no_uriid => 1,
37             comment_column => 168,
38             );
39              
40             my %_generator_comments = (
41             '97b7f241-e1c5-4f02-ae3c-8e31e501e1dc' => 'gregorian-date',
42             '7da02209-0cf5-4bd2-9ce9-50bd0b7d76e3' => 'owner',
43             '5714e8d8-913a-4193-9de0-eb6ba9e13e1f' => 'favourite',
44             '283511f7-1c0d-44d4-abd8-6ed1e1fc5d66' => 'like',
45             'fd7eb1c9-2840-467c-a77b-afa6f925f0c3' => 'dislike',
46             'f2bd1194-d658-4741-b1b1-b4cfd0d6ddea' => 'todo',
47             'bd471aca-8b37-483c-8569-a84444b34cfc' => 'background',
48             'd511f370-0e49-42d5-ad18-bf280dc97e08' => 'body',
49             'bd1a1966-2e71-43cc-a7ce-f7a4547df450' => 'character',
50             'f616e3e2-7779-4172-ae26-865b979b4d0c' => 'artist',
51             'ee60545c-5415-474d-abe9-6de941045460' => 'fertilization',
52             '2b85ca08-921c-4683-a102-f18748c88fda' => 'birth',
53             '3e1c709e-32bf-4943-a9fa-8c25cb37dc92' => 'death',
54             '8b864ce6-e034-432d-8803-c95cd6da53b6' => 'tagcombiner',
55             );
56              
57             my %_old_style_relation = (
58             '7f265548-81dc-4280-9550-1bd0aa4bf748' => 'tag-type',
59             'e48cd5c6-83d7-411e-9640-cb370f3502fc' => 'tag-implies',
60             'd926eb95-6984-415f-8892-233c13491931' => 'tag-links',
61             );
62              
63             our $VERSION = v0.10;
64              
65              
66              
67             sub new {
68 1     1 1 902 my ($pkg, $out, %opts) = @_;
69 1         2 my $fh;
70 1         3 my $self = bless \%opts;
71 1         2 my $_is_utf8;
72             my %features;
73              
74 1 50       5 if (ref $out) {
75 1         2 $fh = $out;
76             } else {
77 0 0       0 open($fh, '>', $out) or croak $!;
78             }
79              
80             {
81 1         2 my $style = delete $opts{style};
  1         2  
82 1         17 $self->{style} = {%_default_style};
83 1 50       5 $self->style(%{$style}) if defined $style;
  0         0  
84             }
85              
86 1         2 $self->{fh} = $fh;
87 1         2 $self->{features} = \%features;
88 1         3 $self->{escape} = \&uri_escape; # set here, so we can write the markers.
89              
90 1 50       4 if (defined $opts{format}) {
91 1         6 my $format = $self->{format} = Data::Identifier->new(from => $opts{format});
92              
93 1         66 $self->_write_marker(required => 'ValueFile', FORMAT_ISE, $format);
94 1   33     28 $_is_utf8 ||= File::ValueFile->_is_utf8($format);
95             }
96              
97 1         42 foreach my $type (qw(required copy optional)) {
98 3   50     10 my $list = $opts{$type.'_feature'} // next;
99 0 0       0 $list = [$list] unless ref($list) eq 'ARRAY';
100 0         0 foreach my $entry (@{$list}) {
  0         0  
101 0         0 my $feature = Data::Identifier->new(from => $entry);
102              
103 0   0     0 $self->{dot_repreat} ||= $feature->eq(DOT_REPEAT_ISE);
104              
105 0         0 $self->_write_marker($type, 'Feature', $feature);
106 0   0     0 $_is_utf8 ||= File::ValueFile->_is_utf8($feature);
107 0         0 $features{$feature->ise} = $feature;
108             }
109             }
110              
111 1   50     7 $opts{utf8} //= 'auto';
112              
113 1 50       3 if ($opts{utf8} eq 'auto') {
114 1         3 $opts{utf8} = $_is_utf8;
115             }
116              
117 1 50       4 $self->{escape} = $opts{utf8} ? \&uri_escape_utf8 : \&uri_escape;
118              
119 1         9 return $self;
120             }
121              
122              
123             sub format {
124 0     0 1 0 my ($self, %opts) = @_;
125              
126 0 0       0 if (defined $self->{format}) {
127 0 0       0 return $self->{format}->as($opts{as}, so => $self) if defined $opts{as};
128 0         0 return $self->{format};
129             }
130              
131 0 0       0 return $opts{default} if exists $opts{default};
132 0         0 croak 'No value for format';
133             }
134              
135              
136             sub features {
137 0     0 1 0 my ($self, %opts) = @_;
138 0         0 return values %{$self->{features}};
  0         0  
139             }
140              
141              
142             sub style {
143 0     0 1 0 my ($self, @args) = @_;
144              
145 0 0       0 if (scalar(@args) == 1) {
    0          
146 0         0 return $self->{style}{$args[0]};
147             } elsif ((scalar(@args) & 1) == 0) {
148 0         0 my %merge = @args;
149 0         0 foreach my $key (keys %merge) {
150 0 0       0 croak 'Invalid style: '.$key unless exists $_default_style{$key};
151 0         0 $self->{style}{$key} = $merge{$key};
152             }
153 0         0 return undef;
154             }
155              
156 0         0 croak 'Invalid call (bad arguments?)';
157             }
158              
159             sub _escape {
160 4     4   8 my ($self, $in) = @_;
161              
162 4 50       11 return '!null' if !defined $in;
163 4 100       11 return '!empty' if $in eq '';
164              
165 3         13 return $self->{escape}->($in);
166             }
167              
168             sub _write_marker {
169 1     1   4 my ($self, $type, @line) = @_;
170              
171 1         5 $self->{last_line} = undef;
172              
173 1 50       3 if ($type eq 'required') {
    0          
    0          
174 1         145 $self->{fh}->print('!!');
175             } elsif ($type eq 'copy') {
176 0         0 $self->{fh}->print('!&');
177             } elsif ($type eq 'optional') {
178 0         0 $self->{fh}->print('!?');
179             } else {
180 0         0 croak 'Bug: Bad marker: '.$type;
181             }
182              
183 1 100       11388 @line = map {$self->_escape($_)} map {ref($_) ? $_->ise : $_} @line;
  3         86  
  3         33  
184              
185 1         14 local $, = ' ';
186 1         8 $self->{fh}->say(@line);
187             }
188              
189              
190             sub write {
191 1     1 1 5 my ($self, @line) = @_;
192              
193 1 50       4 unless (scalar @line) {
194 0         0 $self->{fh}->say('');
195 0         0 return;
196             }
197              
198 1 50       4 @line = map {ref($_) ? $_->ise : $_} @line;
  1         5  
199              
200 1 50       5 if ($self->{dot_repreat}) {
201 0         0 my $line = [@line];
202 0 0       0 if (defined(my $last_line = $self->{last_line})) {
203 0         0 my $x = 0;
204 0         0 foreach my $e (@line) {
205 0 0       0 if (defined $e) {
    0          
206 0 0 0     0 if (defined($last_line->[$x]) && $e eq $last_line->[$x]) {
    0          
207 0         0 $e = '.';
208             } elsif ($e =~ /^\.+$/) {
209 0         0 $e .= '.';
210             }
211             } elsif (!defined($last_line->[$x])) {
212 0         0 $e = '.';
213             }
214 0         0 $x++;
215             }
216             } else {
217 0         0 foreach my $e (@line) {
218 0 0       0 if (defined $e) {
219 0 0       0 if ($e =~ /^\.+$/) {
220 0         0 $e .= '.';
221             }
222             }
223             }
224             }
225 0         0 $self->{last_line} = $line;
226             }
227              
228 1         2 @line = map {$self->_escape($_)} @line;
  1         4  
229              
230             {
231 1         2 my $l = length($line[0]);
  1         3  
232 1 50       26 $line[0] .= ' ' x (19 - $l) if $l < 19;
233             }
234              
235 1         3 local $, = ' ';
236 1 50       5 if ($self->{no_eol}) {
237 0 0       0 if (defined(my $width = $self->{style}{comment_column})) {
238 0         0 my $line = join($,, @line);
239 0         0 my $l = length($line);
240 0         0 $width--;
241 0 0       0 $line .= ' ' x ($width - $l) if $width > $l;
242 0         0 @line = ($line);
243             }
244 0         0 $self->{fh}->print(@line);
245             } else {
246 1         6 $self->{fh}->say(@line);
247             }
248             }
249              
250              
251             sub write_with_comment {
252 0     0 1   my ($self, @line) = @_;
253 0           my $comment = pop(@line);
254 0   0       my $valid_comment = defined($comment) && length($comment);
255              
256 0 0 0       croak 'Unsupported comment: Bad characters' if $valid_comment && $comment =~ /[\x00-\x1F]/;
257              
258 0 0         if (scalar(@line)) {
259 0           local $self->{no_eol} = $valid_comment;
260 0           $self->write(@line);
261 0 0         $self->{fh}->print(' ') if $valid_comment;
262             }
263              
264 0 0         if ($valid_comment) {
265 0           $self->{fh}->say('# ', $comment);
266             }
267             }
268              
269              
270             sub write_blank {
271 0     0 1   my ($self) = @_;
272 0           return $self->write;
273             }
274              
275              
276             sub write_comment {
277 0     0 1   my ($self, @comment) = @_;
278              
279 0           foreach my $comment_line (map {split /[\r\n]/} grep {defined} @comment) {
  0            
  0            
280 0 0         croak 'Unsupported comment: Bad characters' if $comment_line =~ /[\x00-\x1F]/;
281 0           $self->{fh}->say('# ', $comment_line);
282             }
283             }
284              
285              
286             sub write_hash {
287 0     0 1   my ($self, $hash) = @_;
288              
289 0           foreach my $key (keys %{$hash}) {
  0            
290 0           my $value = $hash->{$key};
291              
292 0 0         $value = [$value] unless ref($value) eq 'ARRAY';
293              
294 0           foreach my $entry (@{$value}) {
  0            
295 0           $self->write($key => $entry);
296             }
297             }
298             }
299              
300              
301             sub write_taglist {
302 0     0 1   my ($self, @list) = @_;
303 0           my $format = $self->format;
304 0           my $mode;
305              
306 0 0         if ($format->eq(SF_ISE)) { # tagpool-source-format
    0          
307 0 0         if (defined $self->{features}{F_M_L_ISE()}) { # tagpool-source-format-modern-limited
    0          
308 0           $mode = 'tag-ise';
309             } elsif (defined $self->{features}{F_M_F_ISE()}) { # tagpool-source-format-modern-full
310 0           $mode = 'full';
311             } else {
312 0           $mode = 'tag';
313             }
314             } elsif ($format->eq(TLv1_ISE)) { # tagpool-taglist-format-v1
315 0           $mode = 'tag';
316             } else {
317 0           croak 'Unsupported format';
318             }
319              
320 0 0         if ($mode eq 'full') {
321 0           foreach my $sublist (@list) {
322 0 0         $sublist = [$sublist] unless ref($sublist) eq 'ARRAY';
323 0           foreach my $id (@{$sublist}) {
  0            
324 0           $self->write_tag_ise($id);
325             }
326             }
327             } else {
328 0           foreach my $sublist (@list) {
329 0 0         $sublist = [$sublist] unless ref($sublist) eq 'ARRAY';
330 0           foreach my $id (@{$sublist}) {
  0            
331 0           $self->write($mode, Data::Identifier->new(from => $id)->uuid);
332             }
333             }
334             }
335             }
336              
337              
338             sub write_tag_ise {
339 0     0 1   my ($self, @ids) = @_;
340 0           my $displayname;
341 0           my %collected = (uuid => {}, oid => {}, uri => {});
342              
343 0 0         @ids = map {ref($_) eq 'ARRAY' ? @{$_} : $_} @ids;
  0            
  0            
344              
345 0           foreach my $id (@ids) {
346 0           my $found_for_id;
347              
348 0 0         $id = Data::Identifier->new(ise => $id) unless ref $id;
349              
350 0   0       $displayname //= $id->displayname(default => undef, no_defaults => 1);
351              
352 0           foreach my $key (qw(uuid oid uri)) {
353 0           my $func = $id->can($key);
354 0           my $value = $id->$func(default => undef, no_defaults => 1, as => $key);
355 0 0         if (defined $value) {
356 0           $collected{$key}{$value} = undef;
357 0           $found_for_id = 1;
358             }
359             }
360              
361 0 0         unless ($found_for_id) {
362 0 0         if (defined(my $retry = $self->{style}{tag_ise_no_ise_retry})) {
363 0 0 0       my @list = map {$_ eq 'all' || $_ eq 'ise' ? qw(uuid oid uri) : ($_)} ref($retry) ? @{$retry} : split(/\s*,\s*|\s+/, $retry);
  0 0          
  0            
364              
365 0           foreach my $key (@list) {
366 0   0       my $func = $id->can($key) // croak 'Bad value for retry: '.$key;
367 0           my $value = $id->$func(default => undef, as => $key);
368 0 0         if (defined $value) {
369 0 0 0       next if $value =~ m#^https://uriid\.org/# && $self->{style}{tag_ise_no_ise_no_uriid};
370 0           $collected{$key}{$value} = undef;
371 0           $found_for_id = 1;
372 0 0         last if $self->{style}{tag_ise_no_ise_one};
373             }
374             }
375              
376 0 0         croak 'No ISE found (after retry) for one of the ids' unless $found_for_id;
377             } else {
378 0 0         croak 'No ISE found for one of the ids' unless $found_for_id;
379             }
380             }
381             }
382              
383 0 0         croak 'No ISEs found' unless scalar(keys(%{$collected{uuid}})) + scalar(keys(%{$collected{oid}})) + scalar(keys(%{$collected{uri}}));
  0            
  0            
  0            
384              
385 0           local $self->{style}{comment_column} = undef;
386 0           $self->write_with_comment('tag-ise', keys(%{$collected{uuid}}), keys(%{$collected{oid}}), keys(%{$collected{uri}}), $displayname);
  0            
  0            
  0            
387             }
388              
389              
390             sub write_tag_relation {
391 0     0 1   my ($self, @args) = @_;
392 0           my ($tag, $relation, $related, $context, $filter);
393 0           my %opts;
394 0           my $comment;
395              
396 0 0 0       if (scalar(@args) == 1) {
    0          
    0          
397 0           my $ref = ref($args[0]);
398 0 0         if ($ref eq 'HASH') {
    0          
399 0           return $self->write_tag_relation(%{$args[0]});
  0            
400             } elsif ($ref eq 'ARRAY') {
401 0           return $self->write_tag_relation(@{$args[0]});
  0            
402             } else {
403 0           $tag = $args[0]->tag(default => undef, no_defaults => 1);
404 0           $relation = $args[0]->relation(default => undef, no_defaults => 1);
405 0           $related = $args[0]->related(default => undef, no_defaults => 1);
406 0           $context = $args[0]->context(default => undef, no_defaults => 1);
407 0           $filter = $args[0]->filter(default => undef, no_defaults => 1);
408             }
409             } elsif (scalar(@args) == 3 || scalar(@args) == 5) {
410 0           ($tag, $relation, $related, $context, $filter) = @args;
411             } elsif ((scalar(@args) % 2) == 0) {
412 0           %opts = @args;
413              
414 0           $tag = $opts{tag};
415 0           $relation = $opts{relation};
416 0           $related = $opts{related};
417 0           $context = $opts{context};
418 0           $filter = $opts{filter};
419             } else {
420 0           croak 'Invalid argument configuration';
421             }
422              
423 0 0         croak 'No tag given' unless defined $tag;
424 0 0         croak 'No relation given' unless defined $relation;
425 0 0         croak 'No related given' unless defined $related;
426              
427             # Ensure types and well formatting:
428 0           foreach my $ent ($tag, $relation, $related, $context, $filter) {
429 0 0         next unless defined $ent;
430 0 0         $ent = Data::Identifier->new(ise => $ent) unless ref $ent;
431             }
432              
433             {
434 0           my $displayname_relation = $relation->displayname(default => undef, no_defaults => 1);
  0            
435 0           my $displayname_related = $related->displayname(default => undef, no_defaults => 1);
436              
437 0 0 0       if (defined($displayname_relation) && defined($displayname_related)) {
    0          
    0          
438 0           $comment = sprintf('%s: %s', $displayname_relation, $displayname_related);
439             } elsif (defined($displayname_relation)) {
440 0           $comment = $displayname_relation;
441             } elsif (defined($displayname_related)) {
442 0           $comment = '?: '.$displayname_related;
443             }
444             }
445              
446 0 0 0       if ($self->{style}{tag_relation} eq 'mixed' && !defined($context) && !defined($filter) && defined(my $old_style = $_old_style_relation{$relation->ise})) {
      0        
      0        
447 0           $self->write_with_comment($old_style, $tag, $related, $comment);
448 0           return;
449             }
450              
451 0           $self->write_with_comment('tag-relation', $tag, $relation, $related, $context, $filter, $comment);
452             }
453              
454              
455             sub write_tag_metadata {
456 0     0 1   my ($self, @args) = @_;
457 0           my ($tag, $relation, $context, $type, $encoding, $data_raw);
458 0           my %opts;
459 0           my $comment;
460              
461 0 0         if (scalar(@args) == 1) {
    0          
    0          
    0          
462 0           my $ref = ref($args[0]);
463 0 0         if ($ref eq 'HASH') {
    0          
464 0           return $self->write_tag_metadata(%{$args[0]});
  0            
465             } elsif ($ref eq 'ARRAY') {
466 0           return $self->write_tag_metadata(@{$args[0]});
  0            
467             } else {
468 0           $tag = $args[0]->tag(default => undef, no_defaults => 1);
469 0           $relation = $args[0]->relation(default => undef, no_defaults => 1);
470 0           $context = $args[0]->context(default => undef, no_defaults => 1);
471 0           $type = $args[0]->type(default => undef, no_defaults => 1);
472 0           $encoding = $args[0]->encoding(default => undef, no_defaults => 1);
473 0           $data_raw = $args[0]->data_raw(default => undef, no_defaults => 1);
474             }
475             } elsif (scalar(@args) == 3) {
476 0           ($tag, $relation, $data_raw) = @args;
477             } elsif (scalar(@args) == 5) {
478 0           ($tag, $relation, $type, $encoding, $data_raw) = @args;
479             } elsif ((scalar(@args) % 2) == 0) {
480 0           %opts = @args;
481              
482 0           $tag = $opts{tag};
483 0           $relation = $opts{relation};
484 0           $context = $opts{context};
485 0           $type = $opts{type};
486 0           $encoding = $opts{encoding};
487 0           $data_raw = $opts{data_raw};
488             } else {
489 0           croak 'Invalid argument configuration';
490             }
491              
492 0 0         croak 'No tag given' unless defined $tag;
493 0 0         croak 'No relation given' unless defined $relation;
494 0 0         croak 'No data_raw given' unless defined $data_raw;
495              
496             # Ensure types and well formatting:
497 0           foreach my $ent ($tag, $relation, $context, $type, $encoding) {
498 0 0         next unless defined $ent;
499 0 0         $ent = Data::Identifier->new(ise => $ent) unless ref $ent;
500             }
501              
502 0           $comment = $relation->displayname(default => undef, no_defaults => 1);
503 0 0 0       if (defined($comment) && defined($type) && defined(my $type_displayname = $type->displayname(default => undef, no_defaults => 1))) {
      0        
504 0           $comment .= '('.$type_displayname.')';
505             }
506              
507 0           $self->write_with_comment('tag-metadata', $tag, $relation, $context, $type, $encoding, $data_raw, $comment);
508             }
509              
510              
511             sub write_tag_generator_hint {
512 0     0 1   my ($self, $tag, $generator, $hint, $comment) = @_;
513              
514 0   0       $generator //= Data::Identifier->new(from => $tag)->generator(default => undef);
515 0   0       $hint //= Data::Identifier->new(from => $tag)->request(default => undef);
516              
517 0 0         if (ref($hint) eq 'ARRAY') {
518 0           $hint = join('--', sort map {$_->uuid} @{$hint});
  0            
  0            
519             }
520              
521 0 0 0       if ((!defined($generator) || !defined($hint)) && ($self->{style}{degenerate_generator_hint} // '') eq 'auto') {
      0        
      0        
522 0           $self->write_tag_ise($tag);
523 0           return;
524             }
525              
526 0 0         croak 'No generator given' unless defined $generator;
527 0 0         croak 'No hint given' unless defined $hint;
528              
529 0 0 0       if (
      0        
530             $self->{style}{generator_hint} eq 'auto' &&
531             (
532             defined($self->{features}{F_M_L_ISE()}) || # tagpool-source-format-modern-limited
533             defined($self->{features}{F_M_F_ISE()}) # tagpool-source-format-modern-full
534             )
535             ) {
536 0           state $generator_request = Data::Identifier->new(uuid => 'ab573786-73bc-4f5c-9b03-24ef8a70ae45')->register;
537 0           state $generated_by = Data::Identifier->new(uuid => '8efbc13b-47e5-4d92-a960-bd9a2efa9ccb')->register;
538              
539 0           $self->write_tag_metadata($tag, $generator_request, $hint);
540 0           $self->write_tag_relation($tag, $generated_by, Data::Identifier->new(from => $generator));
541             } else {
542 0 0         if (defined $generator) {
543 0 0 0       $comment //= $_generator_comments{ref($generator) ? $generator->ise : $generator};
544             }
545              
546 0 0         if (defined $comment) {
547 0           $self->write_with_comment('tag-generator-hint', $tag, $generator, $hint, $comment);
548             } else {
549 0           $self->write('tag-generator-hint', $tag, $generator, $hint);
550             }
551             }
552             }
553              
554              
555             sub write_tagname {
556 0     0 1   my ($self, $tag, $tagname) = @_;
557              
558 0 0 0       return unless defined($tagname) && length($tagname);
559              
560 0 0 0       if (
561             defined($self->{features}{F_M_L_ISE()}) || # tagpool-source-format-modern-limited
562             defined($self->{features}{F_M_F_ISE()}) # tagpool-source-format-modern-full
563             ) {
564 0           state $wk_asi = Data::Identifier->new(uuid => 'ddd60c5c-2934-404f-8f2d-fcb4da88b633')->register;
565 0           state $wk_tagname = Data::Identifier->new(uuid => 'bfae7574-3dae-425d-89b1-9c087c140c23')->register;
566              
567              
568 0           $self->write_tag_metadata($tag, $wk_asi, $wk_tagname, undef, $tagname);
569             } else {
570 0           $self->write('tag', $tag, $tagname);
571             }
572             }
573              
574             1;
575              
576             __END__