File indexing completed on 2024-04-28 15:39:51

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 }