File indexing completed on 2024-11-17 04:01:00
0001 #!/usr/bin/perl 0002 # SPDX-FileCopyrightText: 2017-2020 Robert Krawitz <rlk@alum.mit.edu> 0003 # SPDX-License-Identifier: GPL-2.0-or-later 0004 0005 # Maintain KPhotoAlbum index.xml files 0006 0007 use strict; 0008 use warnings; 0009 0010 use XML::LibXML; 0011 use Getopt::Long; 0012 use Carp("cluck"); 0013 0014 my ($kpa_attributes); 0015 0016 my (%categories); 0017 # Map between category name and ID for compressed files 0018 my (%category_map); 0019 my (%blocklist); 0020 # This is stored as {category}{member}{groupname}, as members can only be 0021 # members of one group. Weird, huh? But this way, when we overlay the new 0022 my (%member_groups); 0023 # $group_memberss{category}{groupname}{member} = is_referenced 0024 my (%group_members); 0025 # $category_images{category}{member} = is_referenced 0026 my (%categories_used); 0027 # $orphans{category}{member} = 1 0028 my (%orphans); 0029 # $category_remappings{$category}{$item}{"old"|"new"} 0030 # If there's only one category 0, we can safely remap it if it shows 0031 # up in a member group. If there are more than one (indicated by a value 0032 # of -1 here), there's nothing we can do. 0033 my (%category_remappings); 0034 # $category_remappings{$id} = new_id 0035 my (%category_id_remap); 0036 0037 my ($compress_output); 0038 0039 # Order matters here; sort by date and then filename 0040 my (%images_seq); 0041 # But we also need fast access! 0042 my (%images); 0043 my (@image_list); 0044 my (@stacks_byimage); 0045 my (@stacks_byid); 0046 my (%stacks_remap); 0047 my (%stacks_to_remove); 0048 # Ordering within stacks does matter (particularly for the first image 0049 # on the stack). 0050 my (%stack_order); 0051 my ($max_stack_pass1) = 0; 0052 my ($opt_reject_new_images) = 0; 0053 my ($opt_keep_blocked_images) = 0; 0054 my ($opt_no_output) = 0; 0055 my ($opt_clean_unused_labels) = 0; 0056 my ($opt_replace_categories) = 0; 0057 my ($opt_force_compressed_output) = 0; 0058 my ($opt_force_uncompressed_output) = 0; 0059 my ($last_pass) = 1; 0060 my (%warned_idx0) = (); 0061 my ($opt_output_version) = 0; 0062 my ($output_version) = 0; 0063 0064 sub usage() { 0065 my ($usage) = << 'FINIS'; 0066 Usage: kpa-merge [options] file1 [file2] 0067 0068 If two files are provided, merge the two files and write the 0069 result to stdout. file1 is the up-to-date file containing 0070 categories you wish to merge into file2; the result is printed 0071 to stdout. 0072 0073 Keywords and other categories are combined, such that the 0074 result contains all information from both files. Stacks are 0075 also combined and merged where appropriate. 0076 0077 Image entries present in file1 are *not* copied to file2; a 0078 warning is printed. 0079 0080 If only one file is provided, it is processed in the same way. 0081 This form can be used to clean up an index.xml file. 0082 0083 kpa-merge currently handles version 7 and 8 files and by default 0084 writes the version of the first input file. 0085 0086 kpa-merge can write either compressed or uncompressed output; 0087 by default it uses the compression of the first input file. 0088 0089 Options: 0090 0091 -R|--reject-new-images Don't load new images from 0092 the first file 0093 -B|--keep-blocked-images Unblock blocked images in 0094 the second file 0095 -n|--no-output Don't actually write the 0096 result (for testing purposes) 0097 -r|--replace-categories Replace all categories from 0098 images in the second file 0099 with corresponding data from 0100 the first (rather than merging) 0101 -c|--clean-unused-labels Purge unused labels (useful 0102 for one file usage) 0103 -C|--compressed-output Force compressed output 0104 -N|--no-compressed-output Force uncompressed output 0105 -V|--version Force specified output 0106 version (7 or 8). 0107 FINIS 0108 print STDERR $usage; 0109 exit(1); 0110 } 0111 0112 ################################################################ 0113 ################################################################ 0114 # Load files ################################################### 0115 ################################################################ 0116 ################################################################ 0117 0118 0119 ################################################################ 0120 # Utilities 0121 ################################################################ 0122 0123 sub getAttributes($) { 0124 my ($node) = @_; 0125 return $node->findnodes("./@*"); 0126 } 0127 0128 sub getAttribute(\@$) { 0129 my ($attributes, $name) = @_; 0130 foreach my $attr (@$attributes) { 0131 if ($name eq $attr->nodeName) { 0132 return $attr; 0133 } 0134 } 0135 return undef; 0136 } 0137 0138 sub setAttribute(\@$$) { 0139 my ($attributes, $name, $value) = @_; 0140 my ($attr) = getAttribute(@$attributes, $name); 0141 if ($attr) { 0142 $attr->setValue($value); 0143 } else { 0144 $attr = XML::LibXML::Attr->new($name, $value); 0145 push @$attributes, $attr; 0146 } 0147 } 0148 0149 sub setDefaultAttribute(\@$$) { 0150 my ($attributes, $name, $value) = @_; 0151 if (! getAttribute(@$attributes, $name)) { 0152 my $attr = XML::LibXML::Attr->new($name, $value); 0153 push @$attributes, $attr; 0154 } 0155 } 0156 0157 sub isNode($$) { 0158 my ($node, $name) = @_; 0159 return ($node->nodeType() == 1 && lc $node->nodeName() eq $name); 0160 } 0161 0162 ################################################################ 0163 # Categories 0164 ################################################################ 0165 0166 sub loadCategory($$$) { 0167 my ($node, $pass, $compressed) = @_; 0168 my ($name) = $node->getAttribute("name"); 0169 $categories{"members"}{$name} = {} if (! defined $categories{"members"}{$name}); 0170 $orphans{$name} = {}; 0171 $category_remappings{$name} = {}; 0172 $category_map{$name} = []; 0173 $category_id_remap{$name} = {}; 0174 my ($category) = $categories{"members"}{$name}; 0175 if ($pass == $last_pass) { 0176 $$category{"attributes"} = getAttributes($node); 0177 } 0178 $$category{"members"} = {} if (! defined $$category{"members"}); 0179 my (@members); 0180 my $children = $node->childNodes(); 0181 my ($category_max_id) = 0; 0182 my %items_to_remap; 0183 foreach my $i (1..$children->size()) { 0184 my ($child) = $children->get_node($i); 0185 next if $node->nodeType() != 1 || !isNode($child, "value"); 0186 my ($value) = $child->getAttribute("value"); 0187 my ($id) = $child->getAttribute("id"); 0188 if ($id == 0) { 0189 print STDERR "Warning: $name:$value has id 0, will remap\n"; 0190 $items_to_remap{$value} = $id; 0191 } elsif (defined $category_map{$name}[$id]) { 0192 print STDERR "Warning: duplicate ID for $name:$id!\n"; 0193 $items_to_remap{$value} = $id; 0194 } 0195 $$category{"members"}{$value} = 1; 0196 $category_map{$name}[$id] = $value; 0197 if ($id > $category_max_id) { 0198 $category_max_id = $id; 0199 } 0200 } 0201 foreach my $remap (keys %items_to_remap) { 0202 $category_max_id++; 0203 print STDERR "Remapping $remap from $items_to_remap{$remap} to $category_max_id\n"; 0204 $category_map{$remap}{$category_max_id} = $category_max_id; 0205 # Uncompressed databases don't have any problem with remapping, 0206 # since items are stored uncompressed. 0207 if ($compressed) { 0208 my ($id) = $items_to_remap{$remap}; 0209 $category_remappings{$name}{$remap}{"old"} = $id; 0210 $category_remappings{$name}{$remap}{"new"} = $category_max_id; 0211 if (defined $category_id_remap{$id}) { 0212 print STDERR "*** Non-unique category remap for $id.\n"; 0213 print STDERR "*** Will remove member-group mappings for this id!"; 0214 $category_id_remap{$id} = -1; 0215 } else { 0216 $category_id_remap{$id} = $category_max_id; 0217 } 0218 } 0219 } 0220 } 0221 0222 sub loadCategories($$$) { 0223 my ($node, $pass, $compressed) = @_; 0224 my $children = $node->childNodes(); 0225 if ($pass == $last_pass) { 0226 $categories{"attributes"} = getAttributes($node); 0227 } 0228 $categories{"members"} = {} if (! defined $categories{"members"}); 0229 # Category maps (mapping between name and ID in a compressed file) 0230 # will differ for each file. 0231 %category_map = (); 0232 foreach my $i (1..$children->size()) { 0233 my ($child) = $children->get_node($i); 0234 next if $node->nodeType() != 1 || !isNode($child, "category"); 0235 loadCategory($child, $pass, $compressed); 0236 } 0237 } 0238 0239 ################################################################ 0240 # Images 0241 ################################################################ 0242 0243 # Image options and values for uncompressed files. 0244 0245 sub loadOptionValues($$$) { 0246 my ($node, $pass, $file) = @_; 0247 my ($name) = $node->getAttribute("name"); 0248 $images{$file}{"options"}{$name} = {} if ($opt_replace_categories || ! defined $images{$file}{"options"}{$name}); 0249 my $children = $node->childNodes(); 0250 foreach my $i (1..$children->size()) { 0251 my ($child) = $children->get_node($i); 0252 next if $node->nodeType() != 1 || !isNode($child, "value"); 0253 my ($val) = $child->getAttribute("value"); 0254 $images{$file}{"options"}{$name}{$val} = 1; 0255 $categories_used{$pass}{$name}{$val} = 1; 0256 } 0257 } 0258 0259 sub loadOptionTypes($$$) { 0260 my ($node, $pass, $file) = @_; 0261 my $children = $node->childNodes(); 0262 foreach my $i (1..$children->size()) { 0263 my ($child) = $children->get_node($i); 0264 next if $node->nodeType() != 1 || !isNode($child, "option"); 0265 $images{$file}{"options"} = {} if ($opt_replace_categories || ! defined $images{$file}{"options"}); 0266 loadOptionValues($child, $pass, $file); 0267 } 0268 } 0269 0270 sub loadUncompressedOptions($$$) { 0271 my ($node, $pass, $file) = @_; 0272 my $children = $node->childNodes(); 0273 foreach my $i (1..$children->size()) { 0274 my ($child) = $children->get_node($i); 0275 next if $node->nodeType() != 1 || !isNode($child, "options"); 0276 loadOptionTypes($child, $pass, $file); 0277 } 0278 } 0279 0280 # Compressed XML files are simpler to parse; there's only one node for each 0281 # category. 0282 0283 sub loadCompressedOptions($$$) { 0284 my ($node, $pass, $file) = @_; 0285 foreach my $category (sort keys %category_map) { 0286 my ($members) = $node->getAttribute($category); 0287 if (defined $members && $members ne '') { 0288 my ($map) = $category_map{$category}; 0289 my (@members); 0290 my (@old_members) = split(/,/, $members); 0291 foreach my $id (@old_members) { 0292 if (defined $category_id_remap{$id}) { 0293 if ($category_id_remap{$id} > 0) { 0294 push @members, $category_id_remap{$id}; 0295 } else { 0296 print STDERR "*** Cannot remap non-unique id $id on file $file\n"; 0297 } 0298 } elsif ($id <= 0) { 0299 print STDERR "*** Invalid option ID 0 found category '$category' in $file; omitting\n"; 0300 } else { 0301 push @members, $id; 0302 } 0303 } 0304 $images{$file}{"options"} = {} if ($opt_replace_categories || ! defined $images{$file}{"options"}); 0305 $images{$file}{"options"}{$category} = {} if (! defined $images{$file}{"options"}{$category}); 0306 map { 0307 $images{$file}{"options"}{$category}{$$map[$_]} = 1; 0308 $categories_used{$pass}{$category}{$$map[$_]} = 1; 0309 } @members; 0310 } 0311 } 0312 } 0313 0314 sub loadImage($$$) { 0315 my ($node, $pass, $compressed) = @_; 0316 my ($file) = $node->getAttribute("file"); 0317 my ($stack) = $node->getAttribute("stackId"); 0318 my ($stack_order) = $node->getAttribute("stackOrder"); 0319 my ($image_already_defined) = defined $images{$file}; 0320 $node->removeAttribute("stackId"); 0321 $node->removeAttribute("stackOrder"); 0322 if ($output_version == 7) { 0323 # Restore any attributes defaulted in version 8 0324 if (! $node->hasAttribute("angle")) { 0325 $node->setAttribute("angle", 0); 0326 } 0327 if (! $node->hasAttribute("endDate")) { 0328 $node->setAttribute("endDate", $node->getAttribute("startDate")); 0329 } 0330 if (! $node->hasAttribute("label")) { 0331 my ($label) = $file; 0332 $label =~ s,^.*/,,; 0333 $label =~ s/\.[^.]*$//; 0334 $node->setAttribute("label", $label); 0335 } 0336 } else { 0337 if ($node->hasAttribute("angle") && 0338 $node->getAttribute("angle") eq "0") { 0339 $node->removeAttribute("angle"); 0340 } 0341 if ($node->hasAttribute("endDate") && 0342 $node->getAttribute("endDate") eq $node->getAttribute("startDate")) { 0343 $node->removeAttribute("endDate"); 0344 } 0345 if ($node->hasAttribute("label")) { 0346 my ($label) = $file; 0347 $label =~ s,^.*/,,; 0348 $label =~ s/\.[^.]*$//; 0349 if ($node->getAttribute("label") eq $label) { 0350 $node->removeAttribute("label"); 0351 } 0352 } 0353 } 0354 0355 if (!defined $images{$file}) { 0356 # Always load images from the first file. We might or might not 0357 # keep images only found in the second file depending upon what 0358 # the user requested. 0359 if ($pass > 0) { 0360 if ($blocklist{$file}) { 0361 if ($opt_keep_blocked_images) { 0362 delete $blocklist{$file}; 0363 } else { 0364 warn "Skipping $file in destination blocklist\n"; 0365 return; 0366 } 0367 } elsif ($opt_reject_new_images) { 0368 warn "Skipping image $file after initial load\n"; 0369 return; 0370 } 0371 } 0372 $images{$file} = {}; 0373 $images{$file}{"attributes"} = getAttributes($node); 0374 } else { 0375 # We want to use the pass1 attributes where available. 0376 # But special case width and height; we want to use a value that's 0377 # not -1. 0378 my (@attributes) = $node->getAttributes(); 0379 my ($nattrs) = $images{$file}{"attributes"}; 0380 foreach my $attribute (@attributes) { 0381 my ($name) = $attribute->nodeName; 0382 my ($value) = $attribute->value; 0383 0384 if (($name eq "width" || $name eq "height")) { 0385 my ($attr1) = getAttribute(@$nattrs, $name); 0386 if ($value ne "-1" && (! $attr1 || $attr1->value eq "-1")) { 0387 warn "Fixing $name on $file (" . $attr1->value . " => $value)\n"; 0388 $attr1->setValue($value); 0389 } 0390 } else { 0391 setDefaultAttribute(@$nattrs, $name, $value); 0392 } 0393 } 0394 } 0395 if ($stack) { 0396 $stacks_byimage[$pass]{$file} = $stack; 0397 $stacks_byid[$pass]{$stack} = [] if (! defined $stacks_byid[$pass]{$stack}); 0398 if (defined $stacks_byid[$pass]{$stack}[$stack_order - 1]) { 0399 warn "Duplicate stack/order ($stack, $stack_order) found for $file and $stacks_byid[$pass]{$stack}[$stack_order - 1], appending.\n"; 0400 push @{$stacks_byid[$pass]{$stack}}, $file; 0401 } else { 0402 $stacks_byid[$pass]{$stack}[$stack_order - 1] = $file; 0403 } 0404 if ($pass == $last_pass && $stack > $max_stack_pass1) { 0405 $max_stack_pass1 = $stack; 0406 } 0407 } 0408 my ($start_date) = $node->getAttribute("startDate"); 0409 my ($sort_key) = "$start_date$file"; 0410 $images_seq{$file} = $sort_key; 0411 if ($opt_replace_categories) { 0412 $images{$file}{"options"} = {}; 0413 } 0414 if ($compressed) { 0415 loadCompressedOptions($node, $pass, $file); 0416 } else { 0417 loadUncompressedOptions($node, $pass, $file); 0418 } 0419 } 0420 0421 0422 sub loadImages($$$) { 0423 my ($node, $pass, $compressed) = @_; 0424 my $children = $node->childNodes(); 0425 $stacks_byimage[$pass] = {}; 0426 $stacks_byid[$pass] = {}; 0427 foreach my $i (1..$children->size()) { 0428 my ($child) = $children->get_node($i); 0429 next if $node->nodeType() != 1 || !isNode($child, "image"); 0430 loadImage($child, $pass, $compressed); 0431 } 0432 } 0433 0434 ################################################################ 0435 # Block list 0436 ################################################################ 0437 0438 sub loadBlocklist($$$) { 0439 my ($node, $pass, $compressed) = @_; 0440 my $children = $node->childNodes(); 0441 foreach my $i (1..$children->size()) { 0442 my ($child) = $children->get_node($i); 0443 next if $node->nodeType() != 1 || !isNode($child, "block"); 0444 $blocklist{$child->getAttribute("file")} = 1; 0445 } 0446 } 0447 0448 ################################################################ 0449 # Member groups 0450 ################################################################ 0451 0452 sub loadMemberGroup($$) { 0453 my ($node, $compressed) = @_; 0454 my ($category) = $node->getAttribute("category"); 0455 my ($groupname) = $node->getAttribute("group-name"); 0456 if (! defined $categories{"members"}{$category}{"members"}{$groupname}) { 0457 if (! defined $orphans{$category}) { 0458 $orphans{$category}{$groupname} = 1; 0459 } 0460 if ($compressed && $node->hasAttribute("members") && $node->getAttribute("members") ne "") { 0461 my $suffix = (! $node->getAttribute("members") =~ /,/) ? 'ren' : ''; 0462 printf STDERR "WARNING: Orphan group $category:$groupname has child$suffix %s!\n", $node->getAttribute("members"); 0463 } else { 0464 print STDERR "Removing orphaned member-group $category:$groupname\n"; 0465 return; 0466 } 0467 } 0468 $member_groups{$category} = {} if (! defined $member_groups{$category}); 0469 $group_members{$category} = {} if (! defined $group_members{$category}); 0470 $group_members{$category}{$groupname} = {} if (! defined $group_members{$category}{$groupname}); 0471 if ($compressed) { 0472 my ($members) = $node->getAttribute("members"); 0473 if ($members) { 0474 my ($map) = $category_map{$category}; 0475 my (@old_members) = grep { ! $_ == 0 } split(/,/, $members); 0476 my (@members); 0477 foreach my $id (@old_members) { 0478 if (defined $category_id_remap{$id}) { 0479 if ($category_id_remap{$id} > 0) { 0480 push @members, $category_id_remap{$id}; 0481 } else { 0482 print STDERR "*** Cannot remap non-unique id $id for member-group $category:$groupname\n"; 0483 } 0484 } else { 0485 push @members, $id; 0486 } 0487 } 0488 map { 0489 $member_groups{$category}{$$map[$_]} = $groupname; 0490 $group_members{$category}{$groupname}{$$map[$_]} = 1; 0491 } @members; 0492 } 0493 } else { 0494 my ($member) = $node->getAttribute("member"); 0495 $member_groups{$category}{$member} = $groupname; 0496 $group_members{$category}{$groupname}{$member} = 1; 0497 } 0498 } 0499 0500 sub loadMemberGroups($$$) { 0501 my ($node, $pass, $compressed) = @_; 0502 my $children = $node->childNodes(); 0503 foreach my $i (1..$children->size()) { 0504 my ($child) = $children->get_node($i); 0505 next if $node->nodeType() != 1 || !isNode($child, "member"); 0506 loadMemberGroup($child, $compressed); 0507 } 0508 } 0509 0510 ################################################################ 0511 # Top level file loader 0512 ################################################################ 0513 0514 sub load_file($$) { 0515 my ($file, $pass) = @_; 0516 print STDERR "Loading $file..."; 0517 my $doc = XML::LibXML->load_xml(location => $file); 0518 if (! $doc) { 0519 usage(); 0520 } 0521 0522 my $kpa = ${$doc->findnodes('KPhotoAlbum')}[0]; 0523 0524 if ($pass == 0) { 0525 $kpa_attributes = $kpa->findnodes("./@*"); 0526 } 0527 0528 if ($pass == 0) { 0529 if ($opt_output_version != 0 && 0530 $opt_output_version != 7 && 0531 $opt_output_version != 8) { 0532 print STDERR "Output version must be 7 or 8"; 0533 usage(); 0534 } 0535 } 0536 if ($kpa->getAttribute("version") != 7 && 0537 $kpa->getAttribute("version") != 8) { 0538 die "kpa-merge only works with version 7 and 8 files\n"; 0539 } 0540 if ($pass == 0) { 0541 if ($opt_output_version) { 0542 $output_version = $opt_output_version; 0543 } else { 0544 $output_version = $kpa->getAttribute("version"); 0545 } 0546 } 0547 # Always write a version 8 file right now. 0548 $kpa->setAttribute("version", $output_version); 0549 0550 my ($compressed) = int $kpa->getAttribute("compressed"); 0551 if ($pass == 0) { 0552 if ($opt_force_compressed_output) { 0553 $compress_output = 1; 0554 } elsif ($opt_force_uncompressed_output) { 0555 $compress_output = 0; 0556 } else { 0557 $compress_output = $compressed; 0558 } 0559 } 0560 0561 my $children = $kpa->childNodes(); 0562 0563 foreach my $i (1..$children->size()) { 0564 my ($topcn) = $children->get_node($i); 0565 if (isNode($topcn, "categories")) { 0566 print STDERR "categories..."; 0567 loadCategories($topcn, $pass, $compressed); 0568 } elsif (isNode($topcn, "images")) { 0569 print STDERR "images..."; 0570 loadImages($topcn, $pass, $compressed); 0571 } elsif (isNode($topcn, "blocklist")) { 0572 print STDERR "blocklist..."; 0573 loadBlocklist($topcn, $pass, $compressed); 0574 } elsif (isNode($topcn, "member-groups")) { 0575 print STDERR "member-groups..."; 0576 loadMemberGroups($topcn, $pass, $compressed); 0577 } elsif ($topcn->nodeType() == 1) { 0578 warn "Found unknown node " . $topcn->nodeName() . "\n"; 0579 } 0580 } 0581 if (keys %warned_idx0) { 0582 print STDERR "\n"; 0583 foreach my $k (sort keys %warned_idx0) { 0584 warn "Found $warned_idx0{$k} files with index 0 ($k $category_map{$k}[0])\n"; 0585 } 0586 } 0587 print STDERR "done.\n"; 0588 } 0589 0590 ################################################################ 0591 ################################################################ 0592 # Reconcile images ############################################# 0593 ################################################################ 0594 ################################################################ 0595 0596 # Reconcile stack IDs between the source and the merge files. 0597 # The merge file is considered to be authoritative. 0598 sub reconcile_stacks() { 0599 # We only need to look at stacks in the first file. If a stack exists 0600 # in the second file but not the first, it won't be disturbed by this, 0601 # as intended. 0602 print STDERR "image stacks..."; 0603 foreach my $file (sort keys %{$stacks_byimage[0]}) { 0604 if (! defined $stacks_byimage[1]{$file}) { 0605 my ($old_stack) = $stacks_byimage[0]{$file}; 0606 my ($by_id_0) = $stacks_byid[0]{$old_stack}; 0607 my ($found) = -1; 0608 foreach my $ofile (@$by_id_0) { 0609 # Gaps in stack indices 0610 next if (! defined $ofile); 0611 if (defined $stacks_byimage[1]{$ofile}) { 0612 if ($found == -1) { 0613 $found = $stacks_byimage[1]{$ofile}; 0614 } elsif ($found != $stacks_byimage[1]{$ofile}) { 0615 # If an image is in a different stack in one file 0616 # vs the other, there's not much we can do. 0617 warn "INCONSISTENT STACKS for $file ($found, $stacks_byimage[1]{$ofile})!\n"; 0618 } 0619 } 0620 } 0621 if ($found == -1) { 0622 my ($new_stack) = ++$max_stack_pass1; 0623 # Fix up all of the files in the renumbered stack 0624 map { $stacks_byimage[1]{$_} = $new_stack; } (grep { defined $_ } @$by_id_0); 0625 $stacks_byid[1]{$new_stack} = $stacks_byid[0]{$old_stack}; 0626 } else { 0627 $stacks_byimage[1]{$file} = $found; 0628 push @{$stacks_byid[1]{$found}}, $file; 0629 } 0630 } 0631 } 0632 # Now, set the stack order for each image 0633 my ($new_stackid) = 1; 0634 foreach my $stackid (sort keys %{$stacks_byid[1]}) { 0635 my ($stack) = $stacks_byid[1]{$stackid}; 0636 my ($order) = 1; 0637 foreach my $file (@$stack) { 0638 if (defined $file) { 0639 $stack_order{$file} = $order++; 0640 } 0641 } 0642 if ($order <= 2) { 0643 $stacks_to_remove{$stackid} = 1; 0644 } else { 0645 $stacks_remap{$stackid} = $new_stackid++; 0646 } 0647 } 0648 } 0649 0650 sub reconcile_images() { 0651 # Now, stitch the two image sequences together. 0652 print STDERR "image sequences..."; 0653 my (%invert_images) = reverse %images_seq; 0654 @image_list = map { $invert_images{$_}} sort keys %invert_images; 0655 print STDERR "done.\n"; 0656 } 0657 0658 # Find labels that are unreferenced by anything and purge them. This 0659 # may be an iterative process, since labels may be related to other 0660 # labels by way of member groups; removing a label may result in 0661 # another label losing all of its references. So we keep going until 0662 # we've found no further unreferenced labels. 0663 0664 sub clean_unused_labels_pass(\%) { 0665 my ($categories_in_use) = @_; 0666 my ($removed_something) = 0; 0667 0668 foreach my $category (sort keys %{$categories{"members"}}) { 0669 next if $category eq "Tokens"; 0670 print STDERR " Category $category...\n"; 0671 my ($members) = $categories{"members"}{$category}{"members"}; 0672 # "Member" here is the group name 0673 foreach my $member (sort keys %$members) { 0674 next if defined $$categories_in_use{$category}{$member}; 0675 if (! defined $group_members{$category}{$member} || 0676 ! scalar %{$group_members{$category}{$member}}) { 0677 # This is not used by any images and is not the name of a group. 0678 # Remove from categories 0679 print STDERR " Purging $member\n"; 0680 delete $$members{$member}; 0681 # Remove this group membership 0682 my ($group) = $member_groups{$category}{$member}; 0683 delete $member_groups{$category}{$member}; 0684 if (defined $group_members{$category}{$member}) { 0685 if (scalar %{$group_members{$category}{$member}} > 0) { 0686 print STDERR " WARNING: $member still has sub-members! Not deleting.\n"; 0687 } else { 0688 print STDERR " Deleting empty member-group $member\n"; 0689 delete $group_members{$category}{$member}; 0690 } 0691 } 0692 # And remove it from any group it's a member of. 0693 if (defined $group) { 0694 print STDERR " Removing $member from\n"; 0695 print STDERR " $group\n"; 0696 delete $group_members{$category}{$group}{$member}; 0697 # Prune any groups in which this was the last member, 0698 # which may allow us to do more work in the next pass. 0699 if (scalar %{$group_members{$category}{$group}} == 0) { 0700 print STDERR " Removed last member from $group\n"; 0701 delete $group_members{$category}{$group}; 0702 } 0703 } 0704 $removed_something = 1; 0705 } 0706 } 0707 } 0708 return $removed_something; 0709 } 0710 0711 sub clean_unused_labels() { 0712 print STDERR "\nCleaning unused labels...\n"; 0713 my %categories_in_use; 0714 0715 foreach my $category (keys %{$categories{"members"}}) { 0716 next if $category eq "Tokens"; 0717 $categories_in_use{$category} = (); 0718 map { $categories_in_use{$category}{$_} = 1; } keys %{$categories_used{$last_pass}{$category}}; 0719 if (! $opt_replace_categories && $last_pass > 0) { 0720 map { $categories_in_use{$category}{$_} = 1; } keys %{$categories_used{0}{$category}}; 0721 } 0722 } 0723 my ($pass) = 0; 0724 do { 0725 print STDERR " Pass $pass...\n"; 0726 $pass++; 0727 } while (clean_unused_labels_pass(%categories_in_use)); 0728 print STDERR "done.\n"; 0729 } 0730 0731 ################################################################ 0732 ################################################################ 0733 # Write new file ############################################### 0734 ################################################################ 0735 ################################################################ 0736 0737 # This code is a lot simpler; we don't have the same kinds of parse 0738 # issues or corner cases to worry about. 0739 0740 sub copy_attributes($$;$) { 0741 my ($node, $attributes, $omit_pre_existing_categories) = @_; 0742 foreach my $attribute (@$attributes) { 0743 if (! $omit_pre_existing_categories || 0744 ! defined $categories{"members"}{$attribute->nodeName}) { 0745 $node->setAttribute($attribute->nodeName, $attribute->value); 0746 } 0747 } 0748 } 0749 0750 sub addElement($$$) { 0751 my ($dom, $node, $element) = @_; 0752 my ($nnode) = $dom->createElement($element); 0753 $node->appendChild($nnode); 0754 return $nnode; 0755 } 0756 0757 sub build_categories($$) { 0758 my ($dom, $new_kpa) = @_; 0759 my ($new_categories) = addElement($dom, $new_kpa, 'Categories'); 0760 copy_attributes($new_categories, $categories{"attributes"}); 0761 my ($members) = $categories{"members"}; 0762 %category_map = (); 0763 0764 foreach my $cat (sort keys %$members) { 0765 my %cmap; 0766 my ($cnode) = addElement($dom, $new_categories, "Category"); 0767 my ($cat_data) = $$members{$cat}; 0768 copy_attributes($cnode, $$cat_data{"attributes"}); 0769 my ($count) = 1; 0770 foreach my $value (sort keys %{$$cat_data{"members"}}) { 0771 my ($vnode) = addElement($dom, $cnode, "value"); 0772 $cmap{$value} = $count; 0773 $vnode->setAttribute("value", $value); 0774 $vnode->setAttribute("id", $count++); 0775 } 0776 $category_map{$cat} = \%cmap; 0777 } 0778 } 0779 0780 sub build_image_options($$$$) { 0781 my ($dom, $onode, $options, $iname) = @_; 0782 foreach my $option (sort keys %$options) { 0783 my ($oonode) = addElement($dom, $onode, "option"); 0784 $oonode->setAttribute("name", $option); 0785 foreach my $value (sort keys %{$$options{$option}}) { 0786 my ($vnode) = addElement($dom, $oonode, "value"); 0787 $vnode->setAttribute("value", $value); 0788 } 0789 } 0790 } 0791 0792 sub build_images($$) { 0793 my ($dom, $new_kpa) = @_; 0794 my ($new_images) = addElement($dom, $new_kpa, 'images'); 0795 my ($compressed) = int $new_kpa->getAttribute("compressed"); 0796 foreach my $iname (@image_list) { 0797 my ($inode) = addElement($dom, $new_images, 'image'); 0798 my ($image) = $images{$iname}; 0799 copy_attributes($inode, $$image{"attributes"}, 1); 0800 if (defined $stacks_byimage[1]{$iname} && 0801 ! defined $stacks_to_remove{$stacks_byimage[1]{$iname}}) { 0802 $inode->setAttribute("stackId", 0803 $stacks_remap{$stacks_byimage[1]{$iname}}); 0804 $inode->setAttribute("stackOrder", $stack_order{$iname}); 0805 } 0806 if (defined $$image{"options"}) { 0807 if ($compressed) { 0808 foreach my $option (sort keys %{$$image{"options"}}) { 0809 my ($val) = join(",", sort {$a <=> $b} map { $category_map{$option}{$_} } keys %{$$image{"options"}{$option}}); 0810 $inode->setAttribute($option, $val); 0811 } 0812 } else { 0813 my ($onode) = addElement($dom, $inode, 'options'); 0814 build_image_options($dom, $onode, $$image{"options"}, $iname); 0815 } 0816 } 0817 } 0818 } 0819 0820 sub build_blocklist($$) { 0821 my ($dom, $new_kpa) = @_; 0822 my ($new_blocklist) = addElement($dom, $new_kpa, 'blocklist'); 0823 foreach my $file (sort keys %blocklist) { 0824 my ($bnode) = addElement($dom, $new_blocklist, "block"); 0825 $bnode->setAttribute("file", $file); 0826 } 0827 } 0828 0829 sub build_member_groups($$) { 0830 my ($dom, $new_kpa) = @_; 0831 my ($new_member_groups) = addElement($dom, $new_kpa, 'member-groups'); 0832 my ($compressed) = int $new_kpa->getAttribute("compressed"); 0833 0834 if ($compressed) { 0835 foreach my $cat (sort keys %group_members) { 0836 my ($groups) = $group_members{$cat}; 0837 foreach my $group (sort keys %$groups) { 0838 my ($val) = join(",", sort {$a <=> $b} map {$category_map{$cat}{$_}} keys %{$$groups{$group}}); 0839 my ($mnode) = addElement($dom, $new_member_groups, "member"); 0840 $mnode->setAttribute("category", $cat); 0841 $mnode->setAttribute("group-name", $group); 0842 $mnode->setAttribute("members", $val); 0843 } 0844 } 0845 } else { 0846 foreach my $cat (sort keys %member_groups) { 0847 my ($clist) = $member_groups{$cat}; 0848 foreach my $member (sort keys %$clist) { 0849 my ($groupname) = $$clist{$member}; 0850 my ($mnode) = addElement($dom, $new_member_groups, "member"); 0851 $mnode->setAttribute("category", $cat); 0852 $mnode->setAttribute("group-name", $groupname); 0853 $mnode->setAttribute("member", $member); 0854 } 0855 } 0856 } 0857 } 0858 0859 sub build_new_doc() { 0860 print STDERR "Building new document..."; 0861 my ($dom) = XML::LibXML::Document->new("1.0", "UTF-8"); 0862 my ($new_kpa) = $dom->createElement('KPhotoAlbum'); 0863 $dom->setDocumentElement($new_kpa); 0864 copy_attributes($new_kpa, $kpa_attributes); 0865 $new_kpa->setAttribute("compressed", $compress_output); 0866 print STDERR "categories..."; 0867 build_categories($dom, $new_kpa); 0868 print STDERR "images..."; 0869 build_images($dom, $new_kpa); 0870 print STDERR "blocklist..."; 0871 build_blocklist($dom, $new_kpa); 0872 print STDERR "member groups..."; 0873 build_member_groups($dom, $new_kpa); 0874 print STDERR "done.\n"; 0875 return $dom; 0876 } 0877 0878 ################################################################ 0879 ################################################################ 0880 # ...And the top level! ######################################## 0881 ################################################################ 0882 ################################################################ 0883 0884 my (%options) = ("R" => \$opt_reject_new_images, 0885 "reject-new-images" => \$opt_reject_new_images, 0886 "B" => \$opt_keep_blocked_images, 0887 "keep-blocked-images" => \$opt_keep_blocked_images, 0888 "n" => \$opt_no_output, 0889 "no-output" => \$opt_no_output, 0890 "N" => \$opt_force_uncompressed_output, 0891 "no-compressed-output"=> \$opt_force_uncompressed_output, 0892 "c" => \$opt_clean_unused_labels, 0893 "clean-unused-labels" => \$opt_clean_unused_labels, 0894 "C" => \$opt_force_compressed_output, 0895 "compressed-output" => \$opt_force_compressed_output, 0896 "r" => \$opt_replace_categories, 0897 "replace-categories" => \$opt_replace_categories, 0898 "V:i" => \$opt_output_version, 0899 "version:i" => \$opt_output_version, 0900 ); 0901 0902 Getopt::Long::Configure("bundling", "require_order"); 0903 if (!Getopt::Long::GetOptions(%options)) { 0904 usage(); 0905 } 0906 0907 my ($src, $merge); 0908 if ($#ARGV == 1) { 0909 $src = $ARGV[1]; 0910 $merge = $ARGV[0]; 0911 $last_pass = 1; 0912 } elsif ($#ARGV == 0) { 0913 $src = $ARGV[0]; 0914 $last_pass = 0; 0915 } else { 0916 usage(); 0917 } 0918 0919 load_file($src, 0); 0920 load_file($merge, 1) if ($merge); 0921 0922 print STDERR "Reconciling "; 0923 clean_unused_labels() if ($opt_clean_unused_labels); 0924 0925 reconcile_stacks(); 0926 0927 reconcile_images(); 0928 0929 if (! $opt_no_output) { 0930 my ($doc) = build_new_doc(); 0931 0932 print STDERR "Writing..."; 0933 $doc->toFH(\*STDOUT, 1); 0934 print STDERR "done.\n"; 0935 }