1c3007fbb2785626ec2ae4ffd4be6aa03836b60d
[BackupPC.git] / lib / BackupPC / Search.pm
1 #!/usr/bin/perl
2 package BackupPC::Search;
3
4 use strict;
5 use BackupPC::CGI::Lib qw(:all);
6 use BackupPC::Attrib qw(:all);
7 use DBI;
8 use DateTime;
9 use vars qw(%In $MyURL);
10 use Time::HiRes qw/time/;
11 use XML::Writer;
12 use IO::File;
13 use Data::Dump qw(dump);
14
15 require Exporter;
16 our @ISA=qw(Exporter);
17 our @EXPORT=qw(unit);
18
19 my $on_page = 100;
20 my $pager_pages = 10;
21
22 my $dbh;
23
24 my $bpc = BackupPC::Lib->new || die;
25 $bpc->ConfigRead('_search_archive');
26 my %Conf = $bpc->Conf();
27
28 sub search_module {
29
30         my $search_module = $Conf{SearchModule} || die "search is disabled";
31         eval "use $search_module";
32         if ( $@ ) {
33                 warn "ERROR: $search_module: $!";
34         } else {
35                 #warn "# using $search_module for full-text search";
36         }
37
38         return $search_module->new( %Conf );
39 }
40
41 my $dbh;
42
43 sub get_dbh {
44         $dbh ||= DBI->connect($Conf{SearchDSN}, $Conf{SearchUser}, "", { RaiseError => 1, AutoCommit => 1 } );
45         return $dbh;
46 }
47
48 sub getShares {
49         my @ret;
50
51         my $dbh = get_dbh();
52         my $sth = $dbh->prepare(qq{
53                 SELECT
54                         shares.id       as id,
55                         hosts.name || ':' || shares.name as share
56                 FROM shares
57                 JOIN hosts on hostid = hosts.id
58                 ORDER BY share
59         } );
60         $sth->execute();
61         push @ret, { 'id' => '', 'share' => '-'};       # dummy any
62
63         while ( my $row = $sth->fetchrow_hashref() ) {
64                 if ( my $hide = $Conf{SearchHideShare} ) {
65                         next if $row->{share} =~ m/$hide/;
66                 }
67                 push @ret, $row;
68         }
69         return @ret;
70 }
71
72 sub epoch_to_iso {
73         my $t = shift || return;
74         my $iso = BackupPC::Lib::timeStamp(undef, $t);
75         $iso =~ s/\s/ /g;
76         return $iso;
77 }
78
79 sub dates_from_form($) {
80         my $param = shift || return;
81
82         sub mk_epoch_date($$) {
83                 my ($name,$suffix) = @_;
84
85                 my $yyyy = $param->{ $name . '_year_'  . $suffix}  || return undef;
86                 my $mm   = $param->{ $name . '_month_' . $suffix};
87                 my $dd   = $param->{ $name . '_day_'   . $suffix};
88
89                 $yyyy =~ s/\D//g;
90                 $mm   =~ s/\D//g;
91                 $dd   =~ s/\D//g;
92
93                 my $dt = new DateTime(
94                         year   => $yyyy,
95                         month  => $mm || 1,
96                         day    => $dd || 1,
97                         hour   => 0,
98                         minute => 0,
99                         second => 0,
100                 );
101                 if ( $suffix eq 'to' && ( ! $mm || ! $dd ) ) {
102                         $dt += DateTime::Duration->new( years  => 1 ) if ! $mm;
103                         $dt += DateTime::Duration->new( months => 1 ) if ! $dd;
104                         $dt -= DateTime::Duration->new( days   => 1 );
105                 }
106
107                 print STDERR "mk_epoch_date($name,$suffix) [$yyyy-$mm-$dd] = " . $dt->ymd . " " . $dt->hms . "\n";
108                 return $dt->epoch || 'NULL';
109         }
110
111         my @ret = (
112                 mk_epoch_date('search_backup', 'from'),
113                 mk_epoch_date('search_backup', 'to'),
114                 mk_epoch_date('search', 'from'),
115                 mk_epoch_date('search', 'to'),
116         );
117
118         return @ret;
119
120 }
121
122
123 sub getWhere($) {
124         my $param = shift || return;
125
126         my ($backup_from, $backup_to, $files_from, $files_to) = dates_from_form($param);
127
128         my @conditions;
129         push @conditions, qq{ backups.date >= $backup_from } if ($backup_from);
130         push @conditions, qq{ backups.date <= $backup_to } if ($backup_to);
131         push @conditions, qq{ files.date >= $files_from } if ($files_from);
132         push @conditions, qq{ files.date <= $files_to } if ($files_to);
133
134         print STDERR "backup: $backup_from - $backup_to files: $files_from - $files_to cond:" . join(" and ",@conditions);
135
136         push( @conditions, ' files.shareid = ' . $param->{'search_share'} ) if ($param->{'search_share'});
137         push (@conditions, " upper(files.path) LIKE upper('%".$param->{'search_filename'}."%')") if ($param->{'search_filename'});
138
139         push @conditions, join(' ' , 'burned is', $param->{burned} eq 'burned' ? '' : 'not', 'true') if $param->{burned};
140
141         return join(" and ", @conditions);
142 }
143
144 my $sort_def = {
145         search => {
146                 default => 'date_a',
147                 sql => {
148                         sname_d => 'shares.name DESC',
149                         sname_a => 'shares.name ASC',
150                         filepath_d => 'files.path DESC',
151                         filepath_a => 'files.path ASC',
152                         backupnum_d => 'files.backupnum DESC',
153                         backupnum_a => 'files.backupnum ASC',
154                         size_d => 'files.size DESC',
155                         size_a => 'files.size ASC',
156                         date_d => 'files.date DESC',
157                         date_a => 'files.date ASC',
158                 },
159         }, burn => {
160                 default => 'date_a',
161                 sql => {
162                         sname_d => 'host DESC, share DESC',
163                         sname_a => 'host ASC, share ASC',
164                         num_d => 'backupnum DESC',
165                         num_a => 'backupnum ASC',
166                         date_d => 'date DESC',
167                         date_a => 'date ASC',
168                         age_d => 'age DESC',
169                         age_a => 'age ASC',
170                         size_d => 'size DESC',
171                         size_a => 'size ASC',
172                         incsize_d => 'inc_size DESC',
173                         incsize_a => 'inc_size ASC',
174                 }
175         }
176 };
177
178 sub getSort($$$) {
179         my ($part,$type, $sort_order) = @_;
180
181         die "unknown part: $part" unless ($sort_def->{$part});
182         die "unknown type: $type" unless ($sort_def->{$part}->{$type});
183
184         $sort_order ||= $sort_def->{$part}->{'default'};
185
186         if (my $ret = $sort_def->{$part}->{$type}->{$sort_order}) {
187                 return $ret;
188         } else {
189                 # fallback to default sort order
190                 return $sort_def->{$part}->{$type}->{ $sort_def->{$part}->{'default'} };
191         }
192 }
193
194 sub getFiles($) {
195         my ($param) = @_;
196
197         my $offset = $param->{'offset'} || 0;
198         $offset *= $on_page;
199
200         my $dbh = get_dbh();
201
202         my $sql_cols = qq{
203                 files.id                        AS fid,
204                 hosts.name                      AS hname,
205                 shares.name                     AS sname,
206                 files.backupnum                 AS backupnum,
207                 files.path                      AS filepath,
208                 files.date                      AS date,
209                 files.type                      AS type,
210                 files.size                      AS size
211         };
212
213         my $sql_from = qq{
214                 FROM files 
215                         INNER JOIN shares       ON files.shareID=shares.ID
216                         INNER JOIN hosts        ON hosts.ID = shares.hostID
217                         INNER JOIN backups      ON backups.num = files.backupnum and backups.hostID = hosts.ID AND backups.shareID = files.shareID
218         };
219
220         my $sql_where;
221         my $where = getWhere($param);
222         $sql_where = " WHERE ". $where if ($where);
223
224         # do we have to add tables for burned media?
225         if ( $param->{burned} ) {
226                 $sql_from .= qq{
227                         LEFT OUTER JOIN backups_burned on backup_id = backups.id
228                 };
229         }
230
231         my $order = getSort('search', 'sql', $param->{'sort'});
232
233         # XXX LIMIT $on_page doesn't work since we don't get correct number of results
234         my $sql_order = qq{
235                 ORDER BY $order
236                 OFFSET ?
237         };
238
239         my $sql_results = qq{ select $sql_cols $sql_from $sql_where $sql_order };
240         my $sth = $dbh->prepare($sql_results);
241         my $rows = $sth->execute( $offset );
242
243         my @ret;
244
245         while (my $row = $sth->fetchrow_hashref()) {
246                 push @ret, $row;
247                 last if $#ret + 1 >= $on_page;
248         }
249      
250         $sth->finish();
251         return ($rows, \@ret);
252 }
253
254 sub getFilesHyperEstraier($) {
255         my ($param) = @_;
256
257         my $offset = $param->{'offset'} || 0;
258         $offset *= $on_page;
259
260         my $q = $param->{'search_filename'};
261         my $shareid = $param->{'search_share'};
262         my ($backup_from, $backup_to, $files_from, $files_to) = dates_from_form($param);
263
264         return search_module->search(
265                 $offset, $on_page, $param->{sort},
266                 $q, $shareid, $backup_from, $backup_to, $files_from, $files_to
267         );
268                 
269 }
270
271 sub getGzipName($$$)
272 {
273         my ($host, $share, $backupnum) = @_;
274         my $ret = $Conf{GzipSchema};
275         
276         $share =~ s/\//_/g;
277         $ret =~ s/\\h/$host/ge;
278         $ret =~ s/\\s/$share/ge;
279         $ret =~ s/\\n/$backupnum/ge;
280
281         $ret =~ s/__+/_/g;
282
283         return $ret;
284         
285 }
286
287 sub get_tgz_size_by_name($) {
288         my $name = shift;
289
290         my $tgz = $Conf{GzipTempDir}.'/'.$name;
291         my $size = -1;
292
293         $|=1;
294         if (-f "${tgz}.tar.gz") {
295                 $size = (stat("${tgz}.tar.gz"))[7];
296         } elsif (-d $tgz) {
297                 opendir(my $dir, $tgz) || die "can't opendir $tgz: $!";
298                 my @parts = grep { !/^\./ && !/md5/ && -f "$tgz/$_" } readdir($dir);
299                 $size = 0;
300                 foreach my $part (@parts) {
301                         my $currSize =  (stat("$tgz/$part"))[7]; 
302                         $size += (stat("$tgz/$part"))[7] || die "can't stat $tgz/$part: $!";
303                 }
304
305                 closedir $dir;
306         } else {
307                 return -1;
308         }
309
310         return $size;
311 }
312
313 sub getGzipSizeFromBackupID($) {
314         my ($backupID) = @_;
315         my $dbh = get_dbh();
316         my $sql = q{
317                                 SELECT hosts.name  as host,
318                                            shares.name as share,
319                                            backups.num as backupnum
320                                 FROM hosts, backups, shares
321                                 WHERE shares.id=backups.shareid AND
322                                           hosts.id =backups.hostid AND
323                                           backups.id = ?
324         };
325         my $sth = $dbh->prepare($sql);
326         $sth->execute($backupID);
327         my $row = $sth->fetchrow_hashref();
328
329         return get_tgz_size_by_name(
330                 getGzipName($row->{'host'}, $row->{share}, $row->{backupnum})
331         );
332 }
333
334 sub getGzipSize($$)
335 {
336         my ($hostID, $backupNum) = @_;
337         my $sql;
338         my $dbh = get_dbh();
339         
340         $sql = q{ 
341                                 SELECT hosts.name  as host,
342                                            shares.name as share,
343                                            backups.num as backupnum
344                                 FROM hosts, backups, shares
345                                 WHERE shares.id=backups.shareid AND
346                                           hosts.id =backups.hostid AND
347                                           hosts.id=? AND
348                                           backups.num=?
349                         };
350         my $sth = $dbh->prepare($sql);
351         $sth->execute($hostID, $backupNum);
352
353         my $row = $sth->fetchrow_hashref();
354
355         return get_tgz_size_by_name(
356                 getGzipName($row->{'host'}, $row->{share}, $row->{'backupnum'})
357         );
358 }
359
360 sub host_backup_nums {
361         my $host = shift;
362         my $sth = get_dbh->prepare(qq{
363                 select
364                         hosts.name as host, -- FIXME for debug
365                         backups.num as num,
366                         inc_size,
367                         size,
368                         inc_deleted
369                 from backups
370                 join hosts on hosts.id = hostid
371                 where hosts.name = ?
372         });
373         $sth->execute($host);
374         # and inc_size < 0 and size > 0 and not inc_deleted
375
376         my $all_backup_numbers;
377         # pre-seed with on disk backups
378         $all_backup_numbers->{ $_->{num} }++ foreach $bpc->BackupInfoRead($host);
379
380         while( my $row = $sth->fetchrow_hashref ) {
381 warn "# row ",dump $row;
382                 $all_backup_numbers->{ $row->{num} } =
383                 $row->{inc_deleted}  ? 0 :
384                 $row->{size}    == 0 ? 0 :
385                 $row->{inc_size}!= 0 ? 0 :
386                 $row->{size}     > 0 ? 1 :
387                 0;
388         }
389
390 warn "# host $host all_backup_numbers = ",dump($all_backup_numbers);
391         my @backup_nums = 
392                 sort { $a <=> $b }
393                 grep { $all_backup_numbers->{$_} }
394                 keys %$all_backup_numbers;
395
396         return @backup_nums;
397 }
398
399
400 sub getBackupsNotBurned($) {
401
402         my $param = shift;
403         my $dbh = get_dbh();
404
405         my $order = getSort('burn', 'sql', $param->{'sort'});
406
407 print STDERR "## sort=". ($param->{'sort'} || 'no sort param') . " burn sql order: $order\n";
408
409         my $sql = qq{
410                 SELECT
411                         p.id,
412                         p.filename,
413                         b.date,
414                         date_part('epoch',now()) - b.date as age,
415                         p.size,
416                         count(ap.*) as scheduled,
417                         count(ab.*) as burned
418                 FROM backup_parts p
419                 JOIN backups b          ON b.id = p.backup_id
420                 LEFT OUTER JOIN archive_parts  ap ON ap.backup_part_id = p.id
421                 LEFT OUTER JOIN archive_burned ab ON ab.archive_id = ap.archive_id
422                 GROUP BY p.id,filename,b.date,age,p.size,p.part_nr
423         };
424
425         $sql .= qq{
426                 HAVING count(ap.*) = 0
427         } unless $param->{scheduled};
428
429         $sql .= qq{
430                 ORDER BY b.date,p.part_nr
431         };
432         my $sth = $dbh->prepare( $sql );
433         my @ret;
434         $sth->execute();
435
436         while ( my $row = $sth->fetchrow_hashref() ) {
437                 $row->{'age'} = sprintf("%0.1f", ( $row->{'age'} / 86400 ) );
438                 #$row->{'age'} = sprintf("%0.1f", ( (time() - $row->{'date'}) / 86400 ) );
439                 push @ret, $row;
440         }
441       
442         return @ret;
443 }
444
445 sub displayBackupsGrid($) {
446
447         my $param = shift;
448
449         my $max_archive_size = $Conf{ArchiveMediaSize} || die "no ArchiveMediaSize";
450
451         my $retHTML .= qq|
452                 <form id="forma" method="POST" action="$MyURL?action=burn">
453
454 <script type="text/javascript">
455 var media_size = $max_archive_size ;
456 </script>
457
458         |;
459
460         { local $/ = undef; $retHTML .= <DATA> }
461
462         $retHTML .= q{
463                         <input type="hidden" value="burn" name="action">
464                         <input type="hidden" value="results" name="search_results">
465                         <table style="fview" border="0" cellspacing="0" cellpadding="2">
466                         <tr class="tableheader">
467                         <td class="tableheader">
468                                 <input type="checkbox" name="allFiles" id="allFiles" onClick="checkAll('allFiles');">
469                         </td>
470         } .
471                 sort_header($param, 'Filename', 'filename', 'left') .
472                 sort_header($param, 'Date', 'date', 'center') .
473                 sort_header($param, 'Age/days', 'age', 'center') .
474                 sort_header($param, 'Size', 'size', 'center') .
475         qq{
476                         <td align="center" title="scheduled">sc</td>
477                         <td align="center" title="burned">bu</td>
478                 </tr>
479         };
480
481         my @color = (' bgcolor="#e0e0e0"', '');
482
483         my $i = 1;
484 #       my $img_url = $Conf{CgiImageDirURL};
485
486         foreach my $backup ( getBackupsNotBurned($param) ) {
487
488                 $retHTML .= join(''
489                         ,'<tr',$color[$i++%2],'>'
490                         ,'<td class="fview">'
491                                 ,'<input type="checkbox" name="fcb',$backup->{id},'" value="',$backup->{id},'" onClick="sumiraj(this);">'
492                                 ,'<input type="hidden" id="fss',$backup->{id},'" value="',$backup->{size},'">'
493                         ,'</td>'
494                         ,'<td align="left">', $backup->{'filename'}, '</td>'
495                         ,'<td align="center">', epoch_to_iso( $backup->{'date'} ), '</td>'
496                         ,'<td align="center">', $backup->{'age'}, '</td>'
497                         ,'<td align="right">', unit($backup->{'size'}), '</td>'
498                         ,'<td align="center">', $backup->{scheduled}, '</td>'
499                         ,'<td align="center">', $backup->{burned}, '</td>'
500                         ,"</tr>\n"
501                 );
502         }
503
504         $retHTML .= "</table>";
505
506         $retHTML .= q{
507 <input type=submit name="scheduled" value="Show scheduled parts">
508         } unless $param->{scheduled};
509
510         $retHTML .= "</form>";
511       
512         return $retHTML;
513 }      
514
515 sub displayGrid($) {
516         my ($param) = @_;
517
518         my $offset = $param->{'offset'};
519         my $hilite = $param->{'search_filename'};
520
521         my $retHTML = "";
522  
523         my $start_t = time();
524
525         my ($results, $files);
526         if ($param->{'use_hest'} && length($hilite) > 0) {
527                 ($results, $files) = getFilesHyperEstraier($param);
528         } else {
529                 ($results, $files) = getFiles($param);
530         }
531
532         my $dur_t = time() - $start_t;
533         my $dur = sprintf("%0.4fs", $dur_t);
534
535         my ($from, $to) = (($offset * $on_page) + 1, ($offset * $on_page) + $on_page);
536
537         if ($results <= 0) {
538                 $retHTML .= qq{
539                         <p style="color: red;">No results found...</p>
540                 };
541                 return $retHTML;
542         } else {
543                 # DEBUG
544                 #use Data::Dumper;
545                 #$retHTML .= '<pre>' . Dumper($files) . '</pre>';
546         }
547
548
549         $retHTML .= qq{
550         <div>
551         Found <b>$results files</b> showing <b>$from - $to</b> (took $dur)
552         </div>
553         <table style="fview" width="100%" border="0" cellpadding="2" cellspacing="0">
554                 <tr class="fviewheader"> 
555                 <td></td>
556         };
557
558         sub sort_header($$$$) {
559                 my ($param, $display, $name, $align) = @_;
560
561                 my ($sort_what, $sort_direction) = split(/_/,$param->{'sort'},2);
562
563                 my $old_sort = $param->{'sort'};
564
565                 my $html = qq{<td align="$align"};
566                 my $arrow = '';
567
568                 if (lc($sort_what) eq lc($name)) {
569                         my $direction = lc($sort_direction);
570
571                         # swap direction or fallback to default
572                         $direction =~ tr/ad/da/;
573                         $direction = 'a' unless ($direction =~ /[ad]/);
574
575                         $param->{'sort'} = $name . '_' . $direction;
576                         $html .= ' style="border: 1px solid #808080;"';
577                 
578                         # add unicode arrow for direction
579                         $arrow .= '&nbsp;';
580                         $arrow .= $direction eq 'a'  ?  '&#9650;'
581                                 : $direction eq 'd'  ?  '&#9660;'
582                                 :                       ''
583                                 ;
584
585                 } else {
586                         $param->{'sort'} = $name . '_a';
587                 }
588
589                 $html .= '><a href="' . page_uri($param) . '">' . $display . '</a>' . $arrow . '</td>';
590                 $param->{'sort'} = $old_sort;
591
592                 return $html;
593         }
594
595         $retHTML .=
596                 sort_header($param, 'Share', 'sname', 'center') .
597                 sort_header($param, 'Type and Name', 'filepath', 'center') .
598                 sort_header($param, '#', 'backupnum', 'center') .
599                 sort_header($param, 'Size', 'size', 'center') .
600                 sort_header($param, 'Date', 'date', 'center');
601
602         $retHTML .= qq{
603                 <td align="center">Media</td>
604                 </tr>
605         };
606
607         my $file;
608
609         sub hilite_html($$) {
610                 my ($html, $search) = @_;
611                 $html =~ s#($search)#<b>$1</b>#gis;
612                 return $html;
613         }
614
615         sub restore_link($$$$$$) {
616                 my $type = shift;
617                 my $action = 'RestoreFile';
618                 $action = 'browse' if (lc($type) eq 'dir');
619                 return sprintf(qq{<a href="?action=%s&host=%s&num=%d&share=%s&dir=%s">%s</a>}, $action, @_);
620         }
621
622         my $sth_archived;
623         my %archived_cache;
624
625         sub check_archived($$$) {
626                 my ($host, $share, $num) = @_;
627
628                 if (my $html = $archived_cache{"$host $share $num"}) {
629                         return $html;
630                 }
631
632                 $sth_archived ||= $dbh->prepare(qq{
633                         select
634                                 archive.dvd_nr, note,
635                                 count(archive_burned.copy) as copies
636                         from archive
637                         inner join archive_burned on archive_burned.archive_id = archive.id
638                         inner join archive_backup_parts on archive.id = archive_backup_parts.archive_id
639                         inner join backups on backups.id = archive_backup_parts.backup_id
640                         inner join hosts on hosts.id = backups.hostid
641                         inner join shares on shares.id = backups.shareid
642                         where hosts.name = ? and shares.name = ? and backups.num = ?
643                         group by archive.dvd_nr, note
644                 });
645
646                 my @mediums;
647
648                 $sth_archived->execute($host, $share, $num);
649                 while (my $row = $sth_archived->fetchrow_hashref()) {
650                         push @mediums, '<abbr title="' .
651                                 $row->{'note'} .
652                                 ' [' . $row->{'copies'} . ']' .
653                                 '">' .$row->{'dvd_nr'} .
654                                 '</abbr>';
655                 }
656
657                 my $html = join(", ",@mediums);
658                 $archived_cache{"$host $share $num"} = $html;
659                 return $html;
660         }
661
662         my $i = $offset * $on_page;
663
664         foreach $file (@{ $files }) {
665                 $i++;
666
667                 my $typeStr  = BackupPC::Attrib::fileType2Text(undef, $file->{'type'});
668                 $retHTML .= qq{<tr class="fviewborder">};
669
670                 $retHTML .= qq{<td class="fviewborder">$i</td>};
671
672                 $retHTML .=
673                         qq{<td class="fviewborder" align="right">} . $file->{'hname'} . ':' . $file->{'sname'} . qq{</td>} .
674                         qq{<td class="fviewborder"><img src="$Conf{CgiImageDirURL}/icon-$typeStr.png" alt="$typeStr" align="middle">&nbsp;} . hilite_html( $file->{'filepath'}, $hilite ) . qq{</td>} .
675                         qq{<td class="fviewborder" align="center">} . restore_link( $typeStr, ${EscURI( $file->{'hname'} )}, $file->{'backupnum'}, ${EscURI( $file->{'sname'})}, ${EscURI( $file->{'filepath'} )}, $file->{'backupnum'} ) . qq{</td>} .
676                         qq{<td class="fviewborder" align="right">} . $file->{'size'} . qq{</td>} .
677                         qq{<td class="fviewborder">} . epoch_to_iso( $file->{'date'} ) . qq{</td>} .
678                         qq{<td class="fviewborder">} . check_archived( $file->{'hname'}, $file->{'sname'}, $file->{'backupnum'} ) . qq{</td>};
679
680                 $retHTML .= "</tr>";
681         }
682         $retHTML .= "</table>";
683
684         # all variables which has to be transfered
685         foreach my $n (qw/search_day_from search_month_from search_year_from search_day_to search_month_to search_year_to search_backup_day_from search_backup_month_from search_backup_year_from search_backup_day_to search_backup_month_to search_backup_year_to search_filename offset/) {
686                 $retHTML .= qq{<INPUT TYPE="hidden" NAME="$n" VALUE="$In{$n}">\n};
687         }
688
689         my $del = '';
690         my $max_page = int( $results / $on_page );
691         my $page = 0;
692
693         sub page_uri($) {
694                 my $param = shift || die "no param?";
695
696                 my $uri = $MyURL;
697                 my $del = '?';
698                 foreach my $k (keys %{ $param }) {
699                         if ($param->{$k}) {
700                                 $uri .= $del . $k . '=' . ${EscURI( $param->{$k} )};
701                                 $del = '&';
702                         }
703                 }
704                 return $uri;
705         }
706
707         sub page_link($$$) {
708                 my ($param,$page,$display) = @_;
709
710                 $param->{'offset'} = $page if (defined($page));
711
712                 my $html = '<a href = "' . page_uri($param) . '">' . $display . '</a>';
713         }
714
715         $retHTML .= '<div style="text-align: center;">';
716
717         if ($offset > 0) {
718                 $retHTML .= page_link($param, $offset - 1, '&lt;&lt;') . ' ';
719         }
720
721         while ($page <= $max_page) {
722                 if ($page == $offset) {
723                         $retHTML .= $del . '<b>' . ($page + 1) . '</b>';
724                 } else {
725                         $retHTML .= $del . page_link($param, $page, $page + 1);
726                 }
727
728                 if ($page < $offset - $pager_pages && $page != 0) {
729                         $retHTML .= " ... ";
730                         $page = $offset - $pager_pages;
731                         $del = '';
732                 } elsif ($page > $offset + $pager_pages && $page != $max_page) {
733                         $retHTML .= " ... ";
734                         $page = $max_page;
735                         $del = '';
736                 } else {
737                         $del = ' | ';
738                         $page++;
739                 }
740         }
741
742         if ($offset < $max_page) {
743                 $retHTML .= ' ' . page_link($param, $offset + 1, '&gt;&gt;');
744         }
745
746         $retHTML .= "</div>";
747
748         return $retHTML;
749 }
750
751 my @units = qw/b k M G/;
752 sub unit {
753         my $v = shift;
754
755         my $o = 0;
756
757         while ( ( $v / 10000 ) >= 1 ) {
758                 $o++;
759                 $v /= 1024;
760         }
761
762         if ( $v >= 1 ) {
763                 return sprintf("%d%s", $v, $units[$o]);
764         } elsif ( $v == 0 ) {
765                 return 0;
766         } else {
767                 return sprintf("%.1f%s", $v, $units[$o]);
768         }
769 }
770
771 1;
772
773 __DATA__
774
775
776 <style type="text/css">
777 <!--
778 DIV#fixedBox {
779         position: absolute;
780         top: 50em;
781         left: -24%;
782         padding: 0.5em;
783         width: 20%;
784         background-color: #E0F0E0;
785         border: 1px solid #00C000;
786 }
787
788 DIV#fixedBox, DIV#fixedBox INPUT, DIV#fixedBox TEXTAREA {
789         font-size: 10pt;
790 }
791
792 FORM>DIV#fixedBox {
793         position: fixed !important;
794         left: 0.5em !important;
795         top: auto !important;
796         bottom: 1em !important;
797         width: 15% !important;
798 }
799
800 DIV#fixedBox INPUT[type=text], DIV#fixedBox TEXTAREA {
801         border: 1px solid #00C000;
802 }
803
804 DIV#fixedBox #note {
805         display: block;
806         width: 100%;
807 }
808
809 DIV#fixedBox #submitBurner {
810         display: block;
811         width: 100%;
812         margin-top: 0.5em;
813         cursor: pointer;
814 }
815
816 * HTML {
817         overflow-y: hidden;
818 }
819
820 * HTML BODY {
821         overflow-y: auto;
822         height: 100%;
823         font-size: 100%;
824 }
825
826 * HTML DIV#fixedBox {
827         position: absolute;
828 }
829
830 #mContainer, #gradient, #mask, #progressIndicator {
831         display: block;
832         width: 100%;
833         font-size: 10pt;
834         font-weight: bold;
835         text-align: center;
836         vertical-align: middle;
837         padding: 1px;
838 }
839
840 #gradient, #mask, #progressIndicator {
841         left: 0;
842         border-width: 1px;
843         border-style: solid;
844         border-color: #000000;
845         color: #404040;
846         margin: 0.4em;
847         position: absolute;
848         margin-left: -1px;
849         margin-top: -1px;
850         margin-bottom: -1px;
851         overflow: hidden;
852 }
853
854 #mContainer {
855         display: block;
856         position: relative;
857         padding: 0px;
858         margin-top: 0.4em;
859         margin-bottom: 0.5em;
860 }
861
862 #gradient {
863         z-index: 1;
864         background-color: #FFFF00;
865 }
866
867 #mask {
868         z-index: 2;
869         background-color: #FFFFFF;
870 }
871
872 #progressIndicator {
873         z-index: 3;
874         background-color: transparent;
875 }
876
877 #volumes {
878         padding: 0.4em;
879         display: none;
880         width: 100%;
881         font-size: 80%;
882         color: #ff0000;
883         text-align: center;
884 }
885 -->
886 </style>
887 <script type="text/javascript">
888 <!--
889
890 var debug_div;
891
892 function debug(msg) {
893         return; // Disable debugging
894
895         if (! debug_div) debug_div = document.getElementById('debug');
896
897         // this will create debug div if it doesn't exist.
898         if (! debug_div) {
899                 debug_div = document.createElement('div');
900                 if (document.body) document.body.appendChild(debug_div);
901                 else debug_div = null;
902         }
903         if (debug_div) {
904                 debug_div.appendChild(document.createTextNode(msg));
905                 debug_div.appendChild(document.createElement("br"));
906         }
907 }
908
909
910 var element_id_cache = Array();
911
912 function element_id(name,element) {
913         if (! element_id_cache[name]) {
914                 element_id_cache[name] = self.document.getElementById(name);
915         }
916         return element_id_cache[name];
917 }
918
919 function checkAll(location) {
920         var f = element_id('forma') || null;
921         if (!f) return false;
922
923         var len = f.elements.length;
924         var check_all = element_id('allFiles');
925         var suma = check_all.checked ? (parseInt(f.elements['totalsize'].value) || 0) : 0;
926
927         for (var i = 0; i < len; i++) {
928                 var e = f.elements[i];
929                 if (e.name != 'all' && e.name.substr(0, 3) == 'fcb') {
930                         if (check_all.checked) {
931                                 if (e.checked) continue;
932                                 var el = element_id("fss" + e.name.substr(3));
933                                 var size = parseInt(el.value) || 0;
934                                 debug('suma: '+suma+' size: '+size);
935                                 if ((suma + size) < media_size) {
936                                         suma += size;
937                                         e.checked = true;
938                                 } else {
939                                         break;
940                                 }
941                         } else {
942                                 e.checked = false;
943                         }
944                 }
945         }
946         update_sum(suma);
947 }
948
949 function update_sum(suma, suma_disp) {
950         if (! suma_disp) suma_disp = suma;
951         suma_disp = Math.floor(suma_disp / 1024);
952         element_id('forma').elements['totalsize_kb'].value = suma_disp;
953         element_id('forma').elements['totalsize'].value = suma;
954         pbar_set(suma, media_size);
955         debug('total size: ' + suma);
956 }
957
958 function update_size(name, checked, suma) {
959         var size = parseInt( element_id("fss" + name).value);
960
961         if (checked) {
962                 suma += size;
963         } else {
964                 suma -= size;
965         }
966
967         debug('update_size('+name+','+checked+') suma: '+suma);
968 /* FIXME
969         if (volumes > 1) {
970                 if (checked) {
971                         element_id("volumes").innerHTML = "This will take "+volumes+" mediums!";
972                         element_id("volumes").style.display = 'block';
973                         suma = size;
974                         update_sum(suma);
975                 } else {
976                         suma -= size;
977                         element_id("volumes").style.display = 'none';
978                 }
979         }
980 */
981         return suma;
982 }
983
984 function sumiraj(e) {
985         var suma = parseInt(element_id('forma').elements['totalsize'].value) || 0;
986         var len = element_id('forma').elements.length;
987         if (e) {
988                 suma = update_size(e.name.substr(3), e.checked, suma);
989                 if (suma < 0) suma = 0;
990         } else {
991                 suma = 0;
992                 for (var i = 0; i < len; i++) {
993                         var fel = element_id('forma').elements[i];
994                         if (fel.name != 'all' && fel.checked && fel.name.substr(0,3) == 'fcb') {
995                                 suma = update_size(fel.name.substr(3), fel.checked, suma);
996                         } 
997                 }
998         }
999         update_sum(suma);
1000         return suma;
1001 }
1002
1003 /* progress bar */
1004
1005 var _pbar_width = null;
1006 var _pbar_warn = 10;    // change color in last 10%
1007
1008 function pbar_reset() {
1009         element_id("mask").style.left = "0px";
1010         _pbar_width = element_id("mContainer").offsetWidth - 2;
1011         element_id("mask").style.width = _pbar_width + "px";
1012         element_id("mask").style.display = "block";
1013         element_id("progressIndicator").style.zIndex  = 10;
1014         element_id("progressIndicator").innerHTML = "0";
1015 }
1016
1017 function dec2hex(d) {
1018         var hch = '0123456789ABCDEF';
1019         var a = d % 16;
1020         var q = (d - a) / 16;
1021         return hch.charAt(q) + hch.charAt(a);
1022 }
1023
1024 function pbar_set(amount, max) {
1025         debug('pbar_set('+amount+', '+max+')');
1026
1027         if (_pbar_width == null) {
1028                 var _mc = element_id("mContainer");
1029                 if (_pbar_width == null) _pbar_width = parseInt(_mc.offsetWidth ? (_mc.offsetWidth - 2) : 0) || null;
1030                 if (_pbar_width == null) _pbar_width = parseInt(_mc.clientWidth ? (_mc.clientWidth + 2) : 0) || null;
1031                 if (_pbar_width == null) _pbar_width = 0;
1032         }
1033
1034         var pcnt = Math.floor(amount * 100 / max);
1035         var p90 = 100 - _pbar_warn;
1036         var pcol = pcnt - p90;
1037         if (Math.round(pcnt) <= 100) {
1038                 if (pcol < 0) pcol = 0;
1039                 var e = element_id("submitBurner");
1040                 debug('enable_button');
1041                 e.disabled = false;
1042                 var a = e.getAttributeNode('disabled') || null;
1043                 if (a) e.removeAttributeNode(a);
1044         } else {
1045                 debug('disable button');
1046                 pcol = _pbar_warn;
1047                 var e = element_id("submitBurner");
1048                 if (!e.disabled) e.disabled = true;
1049         }
1050         var col_g = Math.floor((_pbar_warn - pcol) * 255 / _pbar_warn);
1051         var col = '#FF' + dec2hex(col_g) + '00';
1052
1053         //debug('pcol: '+pcol+' g:'+col_g+' _pbar_warn:'+ _pbar_warn + ' color: '+col);
1054         element_id("gradient").style.backgroundColor = col;
1055
1056         element_id("progressIndicator").innerHTML = pcnt + '%';
1057         //element_id("progressIndicator").innerHTML = amount;
1058
1059         element_id("mask").style.clip = 'rect(' + Array(
1060                 '0px',
1061                 element_id("mask").offsetWidth + 'px',
1062                 element_id("mask").offsetHeight + 'px',
1063                 Math.round(_pbar_width * amount / max) + 'px'
1064         ).join(' ') + ')';
1065 }
1066
1067 if (!self.body) self.body = new Object();
1068 self.onload = self.document.onload = self.body.onload = function() {
1069         //pbar_reset();
1070         sumiraj();
1071 };
1072
1073 // -->
1074 </script>
1075 <div id="fixedBox">
1076
1077 <input type="hidden" name="totalsize"/>
1078 Size: <input type="text" name="totalsize_kb" size="7" readonly="readonly" style="text-align:right;" value="0" /> kB
1079
1080 <div id="mContainer">
1081         <div id="gradient">&nbsp;</div>
1082         <div id="mask">&nbsp;</div>
1083         <div id="progressIndicator">0%</div>
1084 </div>
1085 <br/>
1086
1087 <div id="volumes">&nbsp;</div>
1088
1089 Note:
1090 <textarea name="note" cols="10" rows="5" id="note"></textarea>
1091
1092 <input type="submit" id="submitBurner" value="Burn selected" name="submitBurner" />
1093
1094 </div>
1095 <!--
1096 <div id="debug" style="float: right; width: 10em; border: 1px #ff0000 solid; background-color: #ffe0e0; -moz-opacity: 0.7;">
1097 no debug output yet
1098 </div>
1099 -->
1100