Bug 24152: Add the ability to purge pseudonymized tables
[koha-ffzg.git] / misc / cronjobs / cleanup_database.pl
1 #!/usr/bin/perl
2
3 # Copyright 2009 PTFS, Inc.
4 #
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20 use Modern::Perl;
21
22 use constant DEFAULT_ZEBRAQ_PURGEDAYS             => 30;
23 use constant DEFAULT_MAIL_PURGEDAYS               => 30;
24 use constant DEFAULT_IMPORT_PURGEDAYS             => 60;
25 use constant DEFAULT_LOGS_PURGEDAYS               => 180;
26 use constant DEFAULT_SEARCHHISTORY_PURGEDAYS      => 30;
27 use constant DEFAULT_SHARE_INVITATION_EXPIRY_DAYS => 14;
28 use constant DEFAULT_DEBARMENTS_PURGEDAYS         => 30;
29
30 BEGIN {
31     # find Koha's Perl modules
32     # test carefully before changing this
33     use FindBin;
34     eval { require "$FindBin::Bin/../kohalib.pl" };
35 }
36
37 use Koha::Script -cron;
38 use C4::Context;
39 use C4::Search;
40 use C4::Search::History;
41 use Getopt::Long;
42 use C4::Log;
43 use C4::Accounts;
44 use Koha::UploadedFiles;
45 use Koha::Old::Biblios;
46 use Koha::Old::Items;
47 use Koha::Old::Biblioitems;
48 use Koha::Old::Checkouts;
49 use Koha::Old::Holds;
50 use Koha::Old::Patrons;
51 use Koha::Item::Transfers;
52 use Koha::PseudonymizedTransactions;
53
54 sub usage {
55     print STDERR <<USAGE;
56 Usage: $0 [-h|--help] [--sessions] [--sessdays DAYS] [-v|--verbose] [--zebraqueue DAYS] [-m|--mail] [--merged] [--import DAYS] [--logs DAYS] [--searchhistory DAYS] [--restrictions DAYS] [--all-restrictions] [--fees DAYS] [--temp-uploads] [--temp-uploads-days DAYS] [--uploads-missing 0|1 ] [--statistics DAYS] [--deleted-catalog DAYS] [--deleted-patrons DAYS] [--old-issues DAYS] [--old-reserves DAYS] [--transfers DAYS]
57
58    -h --help          prints this help message, and exits, ignoring all
59                       other options
60    --sessions         purge the sessions table.  If you use this while users 
61                       are logged into Koha, they will have to reconnect.
62    --sessdays DAYS    purge only sessions older than DAYS days.
63    -v --verbose       will cause the script to give you a bit more information
64                       about the run.
65    --zebraqueue DAYS  purge completed zebraqueue entries older than DAYS days.
66                       Defaults to 30 days if no days specified.
67    -m --mail DAYS     purge items from the mail queue that are older than DAYS days.
68                       Defaults to 30 days if no days specified.
69    --merged           purged completed entries from need_merge_authorities.
70    --import DAYS      purge records from import tables older than DAYS days.
71                       Defaults to 60 days if no days specified.
72    --z3950            purge records from import tables that are the result
73                       of Z39.50 searches
74    --fees DAYS        purge entries accountlines older than DAYS days, where
75                       amountoutstanding is 0 or NULL.
76                       In the case of --fees, DAYS must be greater than
77                       or equal to 1.
78    --logs DAYS        purge entries from action_logs older than DAYS days.
79                       Defaults to 180 days if no days specified.
80    --searchhistory DAYS  purge entries from search_history older than DAYS days.
81                          Defaults to 30 days if no days specified
82    --list-invites  DAYS  purge (unaccepted) list share invites older than DAYS
83                          days.  Defaults to 14 days if no days specified.
84    --restrictions DAYS   purge patrons restrictions expired since more than DAYS days.
85                          Defaults to 30 days if no days specified.
86     --all-restrictions   purge all expired patrons restrictions.
87    --del-exp-selfreg  Delete expired self registration accounts
88    --del-unv-selfreg  DAYS  Delete unverified self registrations older than DAYS
89    --unique-holidays DAYS  Delete all unique holidays older than DAYS
90    --temp-uploads     Delete temporary uploads.
91    --temp-uploads-days DAYS Override the corresponding preference value.
92    --uploads-missing FLAG Delete upload records for missing files when FLAG is true, count them otherwise
93    --oauth-tokens     Delete expired OAuth2 tokens
94    --statistics DAYS       Purge statistics entries more than DAYS days old.
95                            This table is used to build reports, make sure you are aware of the consequences of this before using it!
96    --deleted-catalog  DAYS Purge catalog records deleted more then DAYS days ago
97                            (from tables deleteditems, deletedbiblioitems, deletedbiblio_metadata and deletedbiblio).
98    --deleted-patrons DAYS  Purge patrons deleted more than DAYS days ago.
99    --old-issues DAYS       Purge checkouts (old_issues) returned more than DAYS days ago.
100    --old-reserves DAYS     Purge reserves (old_reserves) more than DAYS old.
101    --transfers DAYS        Purge transfers completed more than DAYS day ago.
102    --pseudo-transactions DAYS   Purge the pseudonymized transactions that have been originally created more than DAYS days ago
103                                 DAYS is optional and can be replaced by:
104                                     --pseudo-transactions-from YYYY-MM-DD and/or --pseudo-transactions-to YYYY-MM-DD
105 USAGE
106     exit $_[0];
107 }
108
109 my $help;
110 my $sessions;
111 my $sess_days;
112 my $verbose;
113 my $zebraqueue_days;
114 my $mail;
115 my $purge_merged;
116 my $pImport;
117 my $pLogs;
118 my $pSearchhistory;
119 my $pZ3950;
120 my $pListShareInvites;
121 my $pDebarments;
122 my $allDebarments;
123 my $pExpSelfReg;
124 my $pUnvSelfReg;
125 my $fees_days;
126 my $special_holidays_days;
127 my $temp_uploads;
128 my $temp_uploads_days;
129 my $uploads_missing;
130 my $oauth_tokens;
131 my $pStatistics;
132 my $pDeletedCatalog;
133 my $pDeletedPatrons;
134 my $pOldIssues;
135 my $pOldReserves;
136 my $pTransfers;
137 my ( $pPseudoTransactions, $pPseudoTransactionsFrom, $pPseudoTransactionsTo );
138
139 GetOptions(
140     'h|help'            => \$help,
141     'sessions'          => \$sessions,
142     'sessdays:i'        => \$sess_days,
143     'v|verbose'         => \$verbose,
144     'm|mail:i'          => \$mail,
145     'zebraqueue:i'      => \$zebraqueue_days,
146     'merged'            => \$purge_merged,
147     'import:i'          => \$pImport,
148     'z3950'             => \$pZ3950,
149     'logs:i'            => \$pLogs,
150     'fees:i'            => \$fees_days,
151     'searchhistory:i'   => \$pSearchhistory,
152     'list-invites:i'    => \$pListShareInvites,
153     'restrictions:i'    => \$pDebarments,
154     'all-restrictions'  => \$allDebarments,
155     'del-exp-selfreg'   => \$pExpSelfReg,
156     'del-unv-selfreg'   => \$pUnvSelfReg,
157     'unique-holidays:i' => \$special_holidays_days,
158     'temp-uploads'      => \$temp_uploads,
159     'temp-uploads-days:i' => \$temp_uploads_days,
160     'uploads-missing:i' => \$uploads_missing,
161     'oauth-tokens'      => \$oauth_tokens,
162     'statistics:i'      => \$pStatistics,
163     'deleted-catalog:i' => \$pDeletedCatalog,
164     'deleted-patrons:i' => \$pDeletedPatrons,
165     'old-issues:i'      => \$pOldIssues,
166     'old-reserves:i'    => \$pOldReserves,
167     'transfers:i'       => \$pTransfers,
168     'pseudo-transactions:i'      => \$pPseudoTransactions,
169     'pseudo-transactions-from:s' => \$pPseudoTransactionsFrom,
170     'pseudo-transactions-to:s'   => \$pPseudoTransactionsTo,
171 ) || usage(1);
172
173 # Use default values
174 $sessions          = 1                                    if $sess_days                  && $sess_days > 0;
175 $pImport           = DEFAULT_IMPORT_PURGEDAYS             if defined($pImport)           && $pImport == 0;
176 $pLogs             = DEFAULT_LOGS_PURGEDAYS               if defined($pLogs)             && $pLogs == 0;
177 $zebraqueue_days   = DEFAULT_ZEBRAQ_PURGEDAYS             if defined($zebraqueue_days)   && $zebraqueue_days == 0;
178 $mail              = DEFAULT_MAIL_PURGEDAYS               if defined($mail)              && $mail == 0;
179 $pSearchhistory    = DEFAULT_SEARCHHISTORY_PURGEDAYS      if defined($pSearchhistory)    && $pSearchhistory == 0;
180 $pListShareInvites = DEFAULT_SHARE_INVITATION_EXPIRY_DAYS if defined($pListShareInvites) && $pListShareInvites == 0;
181 $pDebarments       = DEFAULT_DEBARMENTS_PURGEDAYS         if defined($pDebarments)       && $pDebarments == 0;
182
183 if ($help) {
184     usage(0);
185 }
186
187 unless ( $sessions
188     || $zebraqueue_days
189     || $mail
190     || $purge_merged
191     || $pImport
192     || $pLogs
193     || $fees_days
194     || $pSearchhistory
195     || $pZ3950
196     || $pListShareInvites
197     || $pDebarments
198     || $allDebarments
199     || $pExpSelfReg
200     || $pUnvSelfReg
201     || $special_holidays_days
202     || $temp_uploads
203     || defined $uploads_missing
204     || $oauth_tokens
205     || $pStatistics
206     || $pDeletedCatalog
207     || $pDeletedPatrons
208     || $pOldIssues
209     || $pOldReserves
210     || $pTransfers
211     || defined $pPseudoTransactions
212 ) {
213     print "You did not specify any cleanup work for the script to do.\n\n";
214     usage(1);
215 }
216
217 if ($pDebarments && $allDebarments) {
218     print "You can not specify both --restrictions and --all-restrictions.\n\n";
219     usage(1);
220 }
221
222 cronlogaction();
223
224 my $dbh = C4::Context->dbh();
225 my $sth;
226 my $sth2;
227 my $count;
228
229 if ( $sessions && !$sess_days ) {
230     if ($verbose) {
231         print "Session purge triggered.\n";
232         $sth = $dbh->prepare(q{ SELECT COUNT(*) FROM sessions });
233         $sth->execute() or die $dbh->errstr;
234         my @count_arr = $sth->fetchrow_array;
235         print "$count_arr[0] entries will be deleted.\n";
236     }
237     $sth = $dbh->prepare(q{ TRUNCATE sessions });
238     $sth->execute() or die $dbh->errstr;
239     if ($verbose) {
240         print "Done with session purge.\n";
241     }
242 }
243 elsif ( $sessions && $sess_days > 0 ) {
244     print "Session purge triggered with days>$sess_days.\n" if $verbose;
245     RemoveOldSessions();
246     print "Done with session purge with days>$sess_days.\n" if $verbose;
247 }
248
249 if ($zebraqueue_days) {
250     $count = 0;
251     print "Zebraqueue purge triggered for $zebraqueue_days days.\n" if $verbose;
252     $sth = $dbh->prepare(
253         q{
254             SELECT id,biblio_auth_number,server,time
255             FROM zebraqueue
256             WHERE done=1 AND time < date_sub(curdate(), INTERVAL ? DAY)
257         }
258     );
259     $sth->execute($zebraqueue_days) or die $dbh->errstr;
260     $sth2 = $dbh->prepare(q{ DELETE FROM zebraqueue WHERE id=? });
261     while ( my $record = $sth->fetchrow_hashref ) {
262         $sth2->execute( $record->{id} ) or die $dbh->errstr;
263         $count++;
264     }
265     print "$count records were deleted.\nDone with zebraqueue purge.\n" if $verbose;
266 }
267
268 if ($mail) {
269     print "Mail queue purge triggered for $mail days.\n" if $verbose;
270     $sth = $dbh->prepare(
271         q{
272             DELETE FROM message_queue
273             WHERE time_queued < date_sub(curdate(), INTERVAL ? DAY)
274         }
275     );
276     $sth->execute($mail) or die $dbh->errstr;
277     $count = $sth->rows;
278     $sth->finish;
279     print "$count messages were deleted from the mail queue.\nDone with message_queue purge.\n" if $verbose;
280 }
281
282 if ($purge_merged) {
283     print "Purging completed entries from need_merge_authorities.\n" if $verbose;
284     $sth = $dbh->prepare(q{ DELETE FROM need_merge_authorities WHERE done=1 });
285     $sth->execute() or die $dbh->errstr;
286     print "Done with purging need_merge_authorities.\n" if $verbose;
287 }
288
289 if ($pImport) {
290     print "Purging records from import tables.\n" if $verbose;
291     PurgeImportTables();
292     print "Done with purging import tables.\n" if $verbose;
293 }
294
295 if ($pZ3950) {
296     print "Purging Z39.50 records from import tables.\n" if $verbose;
297     PurgeZ3950();
298     print "Done with purging Z39.50 records from import tables.\n" if $verbose;
299 }
300
301 if ($pLogs) {
302     print "Purging records from action_logs.\n" if $verbose;
303     $sth = $dbh->prepare(
304         q{
305             DELETE FROM action_logs
306             WHERE timestamp < date_sub(curdate(), INTERVAL ? DAY)
307         }
308     );
309     $sth->execute($pLogs) or die $dbh->errstr;
310     print "Done with purging action_logs.\n" if $verbose;
311 }
312
313 if ($fees_days) {
314     print "Purging records from accountlines.\n" if $verbose;
315     purge_zero_balance_fees( $fees_days );
316     print "Done purging records from accountlines.\n" if $verbose;
317 }
318
319 if ($pSearchhistory) {
320     print "Purging records older than $pSearchhistory from search_history.\n" if $verbose;
321     C4::Search::History::delete({ interval => $pSearchhistory });
322     print "Done with purging search_history.\n" if $verbose;
323 }
324
325 if ($pListShareInvites) {
326     print "Purging unaccepted list share invites older than $pListShareInvites days.\n" if $verbose;
327     $sth = $dbh->prepare(
328         q{
329             DELETE FROM virtualshelfshares
330             WHERE invitekey IS NOT NULL
331             AND (sharedate + INTERVAL ? DAY) < NOW()
332         }
333     );
334     $sth->execute($pListShareInvites);
335     print "Done with purging unaccepted list share invites.\n" if $verbose;
336 }
337
338 if ($pDebarments) {
339     print "Expired patrons restrictions purge triggered for $pDebarments days.\n" if $verbose;
340     $count = PurgeDebarments($pDebarments);
341     print "$count restrictions were deleted.\nDone with restrictions purge.\n" if $verbose;
342 }
343
344 if($allDebarments) {
345     print "All expired patrons restrictions purge triggered.\n" if $verbose;
346     $count = PurgeDebarments(0);
347     print "$count restrictions were deleted.\nDone with all restrictions purge.\n" if $verbose;
348 }
349
350 # Handle unsubscribe requests from GDPR consent form, depends on UnsubscribeReflectionDelay preference
351 my $unsubscribed_patrons = Koha::Patrons->search_unsubscribed;
352 $count = $unsubscribed_patrons->count;
353 $unsubscribed_patrons->lock( { expire => 1, remove => 1 } );
354 say sprintf "Locked %d patrons", $count if $verbose;
355
356 # Anonymize patron data, depending on PatronAnonymizeDelay
357 my $anonymize_candidates = Koha::Patrons->search_anonymize_candidates( { locked => 1 } );
358 $count = $anonymize_candidates->count;
359 $anonymize_candidates->anonymize;
360 say sprintf "Anonymized %s patrons", $count if $verbose;
361
362 # Remove patron data, depending on PatronRemovalDelay (will raise an exception if problem encountered
363 my $anonymized_patrons = Koha::Patrons->search_anonymized;
364 $count = $anonymized_patrons->count;
365 $anonymized_patrons->delete( { move => 1 } );
366 if ($@) {
367     warn $@;
368 }
369 elsif ($verbose) {
370     say sprintf "Deleted %d patrons", $count;
371 }
372
373 if( $pExpSelfReg ) {
374     DeleteExpiredSelfRegs();
375 }
376 if( $pUnvSelfReg ) {
377     DeleteUnverifiedSelfRegs( $pUnvSelfReg );
378 }
379
380 if ($special_holidays_days) {
381     DeleteSpecialHolidays( abs($special_holidays_days) );
382 }
383
384 if( $temp_uploads ) {
385     # Delete temporary uploads, governed by a pref (unless you override)
386     print "Purging temporary uploads.\n" if $verbose;
387     Koha::UploadedFiles->delete_temporary({
388         defined($temp_uploads_days)
389             ? ( override_pref => $temp_uploads_days )
390             : ()
391     });
392     print "Done purging temporary uploads.\n" if $verbose;
393 }
394
395 if( defined $uploads_missing ) {
396     print "Looking for missing uploads\n" if $verbose;
397     my $keep = $uploads_missing == 1 ? 0 : 1;
398     my $count = Koha::UploadedFiles->delete_missing({ keep_record => $keep });
399     if( $keep ) {
400         print "Counted $count missing uploaded files\n";
401     } else {
402         print "Removed $count records for missing uploads\n";
403     }
404 }
405
406 if ($oauth_tokens) {
407     require Koha::OAuthAccessTokens;
408
409     my $count = int Koha::OAuthAccessTokens->search({ expires => { '<=', time } })->delete;
410     say "Removed $count expired OAuth2 tokens" if $verbose;
411 }
412
413 if ($pStatistics) {
414     print "Purging statistics older than $pStatistics days.\n" if $verbose;
415     Koha::Statistics->filter_by_last_update(
416         { timestamp_column_name => 'datetime', days => $pStatistics } )->delete;
417     print "Done with purging statistics.\n" if $verbose;
418 }
419
420 if ($pDeletedCatalog) {
421     print "Purging deleted catalog older than $pDeletedCatalog days.\n" if $verbose;
422     Koha::Old::Items      ->filter_by_last_update( { days => $pDeletedCatalog } )->delete;
423     Koha::Old::Biblioitems->filter_by_last_update( { days => $pDeletedCatalog } )->delete;
424     Koha::Old::Biblios    ->filter_by_last_update( { days => $pDeletedCatalog } )->delete;
425     print "Done with purging deleted catalog.\n" if $verbose;
426 }
427
428 if ($pDeletedPatrons) {
429     print "Purging deleted patrons older than $pDeletedPatrons days.\n" if $verbose;
430     Koha::Old::Patrons->filter_by_last_update(
431         { timestamp_column_name => 'updated_on', days => $pDeletedPatrons } )
432       ->delete;
433     print "Done with purging deleted patrons.\n" if $verbose;
434 }
435
436 if ($pOldIssues) {
437     print "Purging old checkouts older than $pOldIssues days.\n" if $verbose;
438     Koha::Old::Checkouts->filter_by_last_update( { days => $pOldIssues } )->delete;
439     print "Done with purging old issues.\n" if $verbose;
440 }
441
442 if ($pOldReserves) {
443     print "Purging old reserves older than $pOldReserves days.\n" if $verbose;
444     Koha::Old::Holds->filter_by_last_update( { days => $pOldReserves } )->delete;
445     print "Done with purging old reserves.\n" if $verbose;
446 }
447
448 if ($pTransfers) {
449     print "Purging arrived item transfers older than $pTransfers days.\n" if $verbose;
450     Koha::Item::Transfers->filter_by_last_update(
451         {
452             timestamp_column_name => 'datearrived',
453             days => $pTransfers,
454         }
455     )->delete;
456     print "Done with purging transfers.\n" if $verbose;
457 }
458
459 if (defined $pPseudoTransactions) {
460     print "Purging pseudonymized transactions older than $pPseudoTransactions days.\n" if $verbose;
461     Koha::PseudonymizedTransactions->filter_by_last_update(
462         {
463             timestamp_column_name => 'datetime',
464             ( $pPseudoTransactions     ? ( days => $pPseudoTransactions     ) : () ),
465             ( $pPseudoTransactionsFrom ? ( from => $pPseudoTransactionsFrom ) : () ),
466             ( $pPseudoTransactionsTo   ? ( to   => $pPseudoTransactionsTo   ) : () ),
467         }
468     )->delete;
469     print "Done with purging pseudonymized transactions.\n" if $verbose;
470 }
471
472 exit(0);
473
474 sub RemoveOldSessions {
475     my ( $id, $a_session, $limit, $lasttime );
476     $limit = time() - 24 * 3600 * $sess_days;
477
478     $sth = $dbh->prepare(q{ SELECT id, a_session FROM sessions });
479     $sth->execute or die $dbh->errstr;
480     $sth->bind_columns( \$id, \$a_session );
481     $sth2  = $dbh->prepare(q{ DELETE FROM sessions WHERE id=? });
482     $count = 0;
483
484     while ( $sth->fetch ) {
485         $lasttime = 0;
486         if ( $a_session =~ /lasttime:\s+'?(\d+)/ ) {
487             $lasttime = $1;
488         }
489         elsif ( $a_session =~ /(ATIME|CTIME):\s+'?(\d+)/ ) {
490             $lasttime = $2;
491         }
492         if ( $lasttime && $lasttime < $limit ) {
493             $sth2->execute($id) or die $dbh->errstr;
494             $count++;
495         }
496     }
497     if ($verbose) {
498         print "$count sessions were deleted.\n";
499     }
500 }
501
502 sub PurgeImportTables {
503
504     #First purge import_records
505     #Delete cascades to import_biblios, import_items and import_record_matches
506     $sth = $dbh->prepare(
507         q{
508             DELETE FROM import_records
509             WHERE upload_timestamp < date_sub(curdate(), INTERVAL ? DAY)
510         }
511     );
512     $sth->execute($pImport) or die $dbh->errstr;
513
514     # Now purge import_batches
515     # Timestamp cannot be used here without care, because records are added
516     # continuously to batches without updating timestamp (Z39.50 search).
517     # So we only delete older empty batches.
518     # This delete will therefore not have a cascading effect.
519     $sth = $dbh->prepare(
520         q{
521             DELETE ba
522             FROM import_batches ba
523             LEFT JOIN import_records re ON re.import_batch_id=ba.import_batch_id
524             WHERE re.import_record_id IS NULL AND
525             ba.upload_timestamp < date_sub(curdate(), INTERVAL ? DAY)
526         }
527     );
528     $sth->execute($pImport) or die $dbh->errstr;
529 }
530
531 sub PurgeZ3950 {
532     $sth = $dbh->prepare(
533         q{
534             DELETE FROM import_batches
535             WHERE batch_type = 'z3950'
536         }
537     );
538     $sth->execute() or die $dbh->errstr;
539 }
540
541 sub PurgeDebarments {
542     require Koha::Patron::Debarments;
543     my $days = shift;
544     $count = 0;
545     $sth   = $dbh->prepare(
546         q{
547             SELECT borrower_debarment_id
548             FROM borrower_debarments
549             WHERE expiration < date_sub(curdate(), INTERVAL ? DAY)
550         }
551     );
552     $sth->execute($days) or die $dbh->errstr;
553     while ( my ($borrower_debarment_id) = $sth->fetchrow_array ) {
554         Koha::Patron::Debarments::DelDebarment($borrower_debarment_id);
555         $count++;
556     }
557     return $count;
558 }
559
560 sub DeleteExpiredSelfRegs {
561     my $cnt= C4::Members::DeleteExpiredOpacRegistrations();
562     print "Removed $cnt expired self-registered borrowers\n" if $verbose;
563 }
564
565 sub DeleteUnverifiedSelfRegs {
566     my $cnt= C4::Members::DeleteUnverifiedOpacRegistrations( $_[0] );
567     print "Removed $cnt unverified self-registrations\n" if $verbose;
568 }
569
570 sub DeleteSpecialHolidays {
571     my ( $days ) = @_;
572
573     my $sth = $dbh->prepare(q{
574         DELETE FROM special_holidays
575         WHERE DATE( CONCAT( year, '-', month, '-', day ) ) < DATE_SUB( CAST(NOW() AS DATE), INTERVAL ? DAY );
576     });
577     my $count = $sth->execute( $days ) + 0;
578     print "Removed $count unique holidays\n" if $verbose;
579 }