4 die "bresto is not enabled" if (not $bresto_enable);
8 Bweb - A Bacula web interface
9 Bacula® - The Network Backup Solution
11 Copyright (C) 2000-2010 Free Software Foundation Europe e.V.
13 The main author of Bweb is Eric Bollengier.
14 The main author of Bacula is Kern Sibbald, with contributions from
15 many others, a complete list can be found in the file AUTHORS.
16 This program is Free Software; you can redistribute it and/or
17 modify it under the terms of version three of the GNU Affero General Public
18 License as published by the Free Software Foundation and included
21 This program is distributed in the hope that it will be useful, but
22 WITHOUT ANY WARRANTY; without even the implied warranty of
23 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
24 Affero General Public License for more details.
26 You should have received a copy of the GNU Affero General Public License
27 along with this program; if not, write to the Free Software
28 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
31 Bacula® is a registered trademark of Kern Sibbald.
32 The licensor of Bacula is the Free Software Foundation Europe
33 (FSFE), Fiduciary Program, Sumatrastrasse 25, 8006 Zürich,
34 Switzerland, email:ftf@fsfeurope.org.
46 return $self->get_pathid('');
49 # change the current directory
52 my ($self, $pathid) = @_;
53 $self->{cwdid} = $pathid;
56 # return the current PWD
60 return $self->get_path($self->{cwdid});
63 # get the Path from a PathId
66 my ($self, $pathid) = @_;
67 $self->debug("Call with pathid = $pathid");
68 my $query = "SELECT Path FROM Path WHERE PathId = ?";
69 my $sth = $self->dbh_prepare($query);
70 $sth->execute($pathid);
71 my $result = $sth->fetchrow_arrayref();
76 # we are working with these jobids
79 my ($self, @jobids) = @_;
80 $self->{curjobids} = join(',', @jobids);
81 # $self->update_brestore_table(@jobids);
84 # get the PathId from a Path
87 my ($self, $dir) = @_;
89 "SELECT PathId FROM Path WHERE Path = ?";
90 my $sth = $self->dbh_prepare($query);
92 my $result = $sth->fetchrow_arrayref();
100 my ($self, $offset, $limit) = @_;
101 $self->{limit} = $limit || 100;
102 $self->{offset} = $offset || 0;
107 my ($self, $pattern) = @_;
108 $self->{pattern} = $pattern;
111 # fill brestore_xxx tables for speedup
115 my $b = $self->get_bconsole();
116 $b->send_one_cmd(".bvfs_update" . $self->{bvfs_user});
119 sub update_brestore_table
121 my ($self, @jobs) = @_;
122 my $jobs = join(",", sort {$a <=> $b} @jobs);
123 my $b = $self->get_bconsole();
124 $b->send_one_cmd(".bvfs_update jobid=$jobs" . $self->{bvfs_user});
127 # list all files in a directory, accross curjobids
132 return undef unless ($self->{curjobids});
134 my $pathid = $self->{cwdid};
135 my $jobclause = $self->{curjobids};
138 if ($self->{pattern}) {
139 $filter = " pattern=\"$self->{pattern}\"";
141 my $b = $self->get_bconsole();
142 my $ret = $b->send_one_cmd(".bvfs_lsfiles jobid=$jobclause " .
143 "pathid=$pathid " . $self->{bvfs_user} .
144 "limit=$self->{limit} offset=$self->{offset} " .
148 # PathId, FilenameId, fileid, jobid, lstat, Name
150 foreach my $line (@{$ret})
152 next unless ($line =~ /^\d+\t\d+/);
154 my @row = split("\t", $line, 6);
159 my $jobid = $row[3] || 0;
160 # We have to clean up this dirname ... we only want it's 'basename'
161 my @return_array = ($fnid, $fid,$name,$lstat,$jobid);
162 push @return_list,(\@return_array);
164 #FilenameId, listfiles.id, Name, File.LStat, File.JobId
166 return \@return_list;
169 # list all directories in a directory, accross curjobids
170 # return ($dirid,$dir_basename,$lstat,$jobid)
175 return undef unless ($self->{curjobids});
177 my $pathid = $self->{cwdid};
178 my $jobclause = $self->{curjobids};
181 if ($self->{pattern}) {
182 $filter = " pattern=\"$self->{pattern}\" ";
184 my $b = $self->get_bconsole();
185 my $ret = $b->send_one_cmd(".bvfs_lsdir jobid=$jobclause pathid=$pathid " .
187 "limit=$self->{limit} offset=$self->{offset} " .
191 # PathId, 0, fileid, jobid, lstat, path
194 foreach my $line (@{$ret})
196 next unless ($line =~ /^\d+\t\d+/);
198 my @row = split("\t", $line, 6);
202 my $jobid = $row[3] || 0;
203 next if ($self->{skipdot} && $dir =~ /^\.+$/);
204 # We have to clean up this dirname ... we only want it's 'basename'
205 my @return_array = ($dirid,$dir,'', $lstat,$jobid);
206 push @return_list,(\@return_array);
210 return \@return_list;
213 # TODO : we want be able to restore files from a bad ended backup
214 # we have JobStatus IN ('T', 'A', 'E') and we must
216 # Data acces subs from here. Interaction with SGBD and caching
218 # This sub retrieves the list of jobs corresponding to the jobs selected in the
219 # GUI and stores them in @CurrentJobIds.
220 # date must be quoted
221 sub set_job_ids_for_date
223 my ($self, $client, $date)=@_;
225 if (!$client or !$date) {
228 my $filter = $self->get_client_filter();
229 # The algorithm : for a client, we get all the backups for each
230 # fileset, in reverse order Then, for each fileset, we store the 'good'
231 # incrementals and differentials until we have found a full so it goes
232 # like this : store all incrementals until we have found a differential
233 # or a full, then find the full
235 SELECT JobId, FileSet, Level, JobStatus
237 JOIN FileSet USING (FileSetId)
238 JOIN Client USING (ClientId) $filter
239 WHERE EndTime <= $date
240 AND Client.Name = '$client'
242 AND JobStatus IN ('T')
243 ORDER BY FileSet, JobTDate DESC";
246 my $result = $self->dbh_selectall_arrayref($query);
248 foreach my $refrow (@$result)
250 my $jobid = $refrow->[0];
251 my $fileset = $refrow->[1];
252 my $level = $refrow->[2];
254 defined $progress{$fileset} or $progress{$fileset}='U'; # U for unknown
256 next if $progress{$fileset} eq 'F'; # It's over for this fileset...
260 next unless ($progress{$fileset} eq 'U' or $progress{$fileset} eq 'I');
261 push @CurrentJobIds,($jobid);
263 elsif ($level eq 'D')
265 next if $progress{$fileset} eq 'D'; # We allready have a differential
266 push @CurrentJobIds,($jobid);
268 elsif ($level eq 'F')
270 push @CurrentJobIds,($jobid);
273 my $status = $refrow->[3] ;
274 if ($status eq 'T') { # good end of job
275 $progress{$fileset} = $level;
279 return @CurrentJobIds;
282 sub dbh_selectrow_arrayref
284 my ($self, $query) = @_;
285 $self->debug($query, up => 1);
286 return $self->{dbh}->selectrow_arrayref($query);
289 # Returns list of versions of a file that could be restored
290 # returns an array of
291 # (jobid,fileindex,mtime,size,inchanger,md5,volname,fileid,LinkFI)
292 # there will be only one jobid in the array of jobids...
293 sub get_all_file_versions
295 my ($self,$pathid,$fileid,$client,$see_all,$see_copies)=@_;
297 defined $see_all or $see_all=0;
300 $backup_type=" copies ";
303 my $bc = $self->get_bconsole();
304 my $res = $bc->send_one_cmd(".bvfs_versions fnid=$fileid pathid=$pathid " .
305 "client=\"$client\" jobid=1 $backup_type" .
309 # (pathid,fileid,jobid,fid,mtime,size,inchanger,md5,volname,LinkFI );
310 # PathId, FilenameId, fileid, jobid, lstat, Md5, VolName, VolInchanger
311 foreach my $row (@$res)
313 next unless $row =~ /^\d+\t\d+/;
314 my ($pathid, $fid, $fileid, $jobid, $lstat, $md5, $volname, $inchanger)
317 my @attribs = parse_lstat($lstat);
318 my $mtime = array_attrib('st_mtime',\@attribs);
319 my $size = array_attrib('st_size',\@attribs);
320 my $LinkFI = array_attrib('LinkFI',\@attribs);
323 my @list = ($pathid,$fileid,$jobid, $fid, $mtime, $size, $inchanger,
324 $md5, $volname, $LinkFI);
325 push @versions, (\@list);
328 # We have the list of all versions of this file.
329 # We'll sort it by mtime desc, size, md5, inchanger desc, FileId
330 # the rest of the algorithm will be simpler
331 # ('FILE:',filename,jobid,fileindex,mtime,size,inchanger,md5,volname)
332 @versions = sort { $b->[4] <=> $a->[4]
333 || $a->[5] <=> $b->[5]
334 || $a->[7] cmp $a->[7]
335 || $b->[6] <=> $a->[6]} @versions;
338 my %allready_seen_by_mtime;
339 my %allready_seen_by_md5;
340 # Now we should create a new array with only the interesting records
341 foreach my $ref (@versions)
345 # The file has a md5. We compare his md5 to other known md5...
346 # We take size into account. It may happen that 2 files
347 # have the same md5sum and are different. size is a supplementary
350 # If we allready have a (better) version
351 next if ( (not $see_all)
352 and $allready_seen_by_md5{$ref->[7] .'-'. $ref->[5]});
354 # we never met this one before...
355 $allready_seen_by_md5{$ref->[7] .'-'. $ref->[5]}=1;
357 # Even if it has a md5, we should also work with mtimes
358 # We allready have a (better) version
359 next if ( (not $see_all)
360 and $allready_seen_by_mtime{$ref->[4] .'-'. $ref->[5]});
361 $allready_seen_by_mtime{$ref->[4] .'-'. $ref->[5] . '-' . $ref->[7]}=1;
363 # We reached there. The file hasn't been seen.
364 push @good_versions,($ref);
367 # To be nice with the user, we re-sort good_versions by
368 # inchanger desc, mtime desc
369 @good_versions = sort { $b->[4] <=> $a->[4]
370 || $b->[2] <=> $a->[2]} @good_versions;
372 return \@good_versions;
375 my %attrib_name_id = ( 'st_dev' => 0,'st_ino' => 1,'st_mode' => 2,
376 'st_nlink' => 3,'st_uid' => 4,'st_gid' => 5,
377 'st_rdev' => 6,'st_size' => 7,'st_blksize' => 8,
378 'st_blocks' => 9,'st_atime' => 10,'st_mtime' => 11,
379 'st_ctime' => 12,'LinkFI' => 13,'st_flags' => 14,
380 'data_stream' => 15);;
383 my ($attrib,$ref_attrib)=@_;
384 return $ref_attrib->[$attrib_name_id{$attrib}];
388 { # $file = [filenameid,listfiles.id,listfiles.Name, File.LStat, File.JobId]
390 my ($file, $attrib)=@_;
392 if (defined $attrib_name_id{$attrib}) {
394 my @d = split(' ', $file->[3]) ; # TODO : cache this
396 return from_base64($d[$attrib_name_id{$attrib}]);
398 } elsif ($attrib eq 'jobid') {
402 } elsif ($attrib eq 'name') {
407 die "Attribute not known : $attrib.\n";
413 my ($lstat,$attrib)=@_;
414 if ($lstat and defined $attrib_name_id{$attrib})
416 my @d = split(' ', $lstat) ; # TODO : cache this
417 return from_base64($d[$attrib_name_id{$attrib}]);
424 # Base 64 functions, directly from recover.pl.
426 # Karl Hakimian <hakimian@aha.com>
427 # This section is also under GPL v2 or later.
434 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
435 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
436 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
437 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
438 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/'
440 @base64_map = (0) x 128;
442 for (my $i=0; $i<64; $i++) {
443 $base64_map[ord($base64_digits[$i])] = $i;
458 if (substr($where, 0, 1) eq '-') {
460 $where = substr($where, 1);
463 while ($where ne '') {
465 my $d = substr($where, 0, 1);
466 $val += $base64_map[ord(substr($where, 0, 1))];
467 $where = substr($where, 1);
475 my @attribs = split(' ',$lstat);
476 foreach my $element (@attribs)
478 $element = from_base64($element);
484 # get jobids that the current user can view (ACL)
487 my ($self, @jobid) = @_;
488 my $filter = $self->get_client_filter();
490 my $jobids = $self->dbh_join(@jobid);
493 FROM Job JOIN Client USING (ClientId) $filter
494 WHERE Jobid IN ($jobids)";
495 my $res = $self->dbh_selectall_arrayref($q);
496 @jobid = map { $_->[0] } @$res;
501 ################################################################
506 use POSIX qw/strftime/;
509 my $conf = new Bweb::Config(config_file => $Bweb::config_file);
513 if (CGI::param("skipdot")) {
517 my $bvfs = new Bvfs(info => $conf, skipdot => $skipdot);
518 my $user = $bvfs->{loginname};
519 if ($bvfs->{loginname}) {
520 $bvfs->{bvfs_user} = " username=\"$bvfs->{loginname}\" ";
522 $bvfs->{bvfs_user} = "";
526 my $action = CGI::param('action') || '';
528 my $args = $bvfs->get_form('pathid', 'filenameid', 'fileid', 'qdate',
529 'limit', 'offset', 'client');
531 if ($action eq 'batch') {
532 $bvfs->update_cache();
536 my $pattern = CGI::param('pattern') || '';
537 if ($pattern =~ /^([\w\d,:\.\-% ]+)$/) {
538 $bvfs->set_pattern($1);
543 && scalar(%{$conf->{subconf}}) # we have non empty subconf
544 && !$conf->{current_conf})
548 # All these functions are returning JSON compatible data
549 # for javascript parsing
551 if ($action eq 'list_client') { # list all client [ ['c1'],['c2']..]
552 print CGI::header('application/x-javascript');
555 print "[['Choose a Director first']]\n";
559 my $filter = $bvfs->get_client_filter();
560 my $q = "SELECT Name FROM Client $filter";
561 my $ret = $bvfs->dbh_selectall_arrayref($q);
564 print join(',', map { "['$_->[0]']" } @$ret);
568 } elsif ($action eq 'list_job') {
569 # list jobs for a client [[jobid,endtime,'desc'],..]
571 print CGI::header('application/x-javascript');
573 my $filter = $bvfs->get_client_filter();
575 SELECT Job.JobId,Job.EndTime, FileSet.FileSet, Job.Level, Job.JobStatus
576 FROM Job JOIN FileSet USING (FileSetId) JOIN Client USING (ClientId) $filter
577 WHERE Client.Name = '$args->{client}'
579 AND JobStatus IN ('f', 'T')
580 ORDER BY EndTime desc";
581 my $result = $bvfs->dbh_selectall_arrayref($query);
585 print join(',', map {
586 "[$_->[0], '$_->[1]', '$_->[1] $_->[2] $_->[3] ($_->[4]) $_->[0]']"
592 } elsif ($action eq 'list_storage') {
593 print CGI::header('application/x-javascript');
595 my $bconsole = $bvfs->get_bconsole();
596 my @lst = $bconsole->list_storage();
598 print join(',', map { "[ '$_' ]" } @lst);
603 sub fill_table_for_restore
607 # in "force" mode, we need the FileId to compute media list
608 my $FileId = CGI::param('force')?",FileId":"";
610 my $fileid = join(',', grep { /^\d+$/ } CGI::param('fileid'));
611 # can get dirid=("10,11", 10, 11)
612 my $dirid = join(',', grep { /^\d+$/ }
613 map { split(/,/) } CGI::param('dirid')) ;
614 my $findex = join(',', grep { /^\d+$/ }
615 map { split(/,|\//) } CGI::param('findex')) ;
616 my $jobid = join(',', grep { /^\d+$/ }
617 map { split(/,/) } CGI::param('jobid')) ;
618 my $inclause = join(',', @jobid);
620 my $b = $bvfs->get_bconsole();
621 my $ret = $b->send_one_cmd(".bvfs_restore path=b2$$ fileid=$fileid " .
622 "dirid=$dirid hardlink=$findex jobid=$jobid"
623 . $bvfs->{bvfs_user});
624 if (grep (/OK/, @$ret)) {
630 sub get_media_list_with_dir
634 SELECT DISTINCT VolumeName, Enabled, InChanger
636 ( -- Get all media from this job
637 SELECT MIN(FirstIndex) AS FirstIndex, MAX(LastIndex) AS LastIndex,
638 VolumeName, Enabled, Inchanger
639 FROM JobMedia JOIN Media USING (MediaId)
640 WHERE JobId IN (SELECT DISTINCT JobId FROM $table)
641 GROUP BY VolumeName,Enabled,InChanger
643 WHERE $table.FileIndex >= allmedia.FirstIndex
644 AND $table.FileIndex <= allmedia.LastIndex
646 my $lst = $bvfs->dbh_selectall_arrayref($q);
652 my ($jobid, $fileid) = @_;
654 SELECT DISTINCT VolumeName, Enabled, InChanger
656 ( -- Get all media from this job
657 SELECT MIN(FirstIndex) AS FirstIndex, MAX(LastIndex) AS LastIndex,
658 VolumeName, Enabled, Inchanger
659 FROM JobMedia JOIN Media USING (MediaId)
660 WHERE JobId IN ($jobid)
661 GROUP BY VolumeName,Enabled,InChanger
663 WHERE File.FileId IN ($fileid)
664 AND File.FileIndex >= allmedia.FirstIndex
665 AND File.FileIndex <= allmedia.LastIndex
667 my $lst = $bvfs->dbh_selectall_arrayref($q);
671 # get jobid param and apply user filter
672 my @jobid = $bvfs->get_jobids(grep { /^\d+(,\d+)*$/ } CGI::param('jobid'));
674 # get jobid from date arg
675 if (!scalar(@jobid) and $args->{qdate} and $args->{client}) {
676 @jobid = $bvfs->set_job_ids_for_date($args->{client}, $args->{qdate});
679 $bvfs->set_curjobids(@jobid);
680 $bvfs->set_limits($args->{offset}, $args->{limit});
682 if (!scalar(@jobid)) {
686 if (CGI::param('init')) { # used when choosing a job
687 $bvfs->update_brestore_table(@jobid);
690 my $pathid = CGI::param('node') || CGI::param('pathid') || '';
691 my $path = CGI::param('path');
693 if ($pathid =~ /^(\d+)$/) {
696 $pathid = $bvfs->get_pathid($path);
698 $pathid = $bvfs->get_root();
700 $bvfs->ch_dir($pathid);
702 #print STDERR "pathid=$pathid\n";
704 if ($action eq 'restore') {
706 # TODO: pouvoir choisir le replace et le jobname
707 my $arg = $bvfs->get_form(qw/client storage regexwhere where comment dir/);
709 if (!$arg->{client}) {
710 print "ERROR: missing client\n";
714 my $table = fill_table_for_restore(@jobid);
716 print "ERROR: can create restore table\n";
720 # TODO: remove it after a while
721 if ($bvfs->get_db_field('Comment') ne 'Comment') {
722 delete $arg->{comment};
725 my $bconsole = $bvfs->get_bconsole();
726 # TODO: pouvoir choisir le replace et le jobname
727 my $jobid = $bconsole->run(client => $arg->{client},
728 storage => $arg->{storage},
729 where => $arg->{where},
730 regexwhere=> $arg->{regexwhere},
732 comment => $arg->{comment},
735 $bvfs->dbh_do("DROP TABLE $table");
738 print CGI::header('text/html');
739 $bvfs->display_begin();
740 $bvfs->error("Can't start your job:<br/>" . $bconsole->before());
741 $bvfs->display_end();
749 $dir=";dir=$arg->{dir}";
752 print CGI::redirect("bweb.pl?action=dsp_cur_job;jobid=$jobid$dir") ;
773 $str =~ s/([\x22\x5c\n\r\t\f\b])/$esc{$1}/g;
775 $str =~ s/([\x00-\x08\x0b\x0e-\x1f])/'\\u00' . unpack('H2', $1)/eg;
779 print CGI::header('application/x-javascript');
782 if ($action eq 'list_files_dirs') {
783 # fileid, filenameid, pathid, jobid, name, size, mtime, LinkFI
784 my $jids = join(",", @jobid);
786 my $files = $bvfs->ls_dirs();
787 # return ($dirid,$dir_basename,$lstat,$jobid)
789 map { my @p=Bvfs::parse_lstat($_->[3]);
795 '"' . escape_quote($_->[1]) . '"', # name
796 "'" . $p[7] . "'", # size
797 "'" . strftime('%Y-%m-%d %H:%m:%S', localtime($p[11]||0)) . "'",
802 print "," if (@$files);
804 $files = $bvfs->ls_files();
806 map { my @p=Bvfs::parse_lstat($_->[3]);
812 '"' . escape_quote($_->[2]) . '"', # name
814 "'" . strftime('%Y-%m-%d %H:%m:%S', localtime($p[11])) . "'",
820 } elsif ($action eq 'list_files') {
821 print "[[0,0,0,0,'.',4096,'1970-01-01 00:00:00'],";
822 my $files = $bvfs->ls_files();
823 # [ 1, 2, 3, "Bill", 10, '2007-01-01 00:00:00'],
824 # File.FilenameId, listfiles.id, listfiles.Name, File.LStat, File.JobId,LinkFI
827 map { my @p=Bvfs::parse_lstat($_->[3]);
833 '"' . escape_quote($_->[2]) . '"', # name
835 "'" . strftime('%Y-%m-%d %H:%m:%S', localtime($p[11])) . "'",
841 } elsif ($action eq 'list_dirs') {
844 my $dirs = $bvfs->ls_dirs();
846 # return ($dirid,$dir_basename,$lstat,$jobid)
849 map { "{ 'jobid': '$bvfs->{curjobids}', 'id': '$_->[0]'," .
850 "'text': '" . escape_quote($_->[1]) . "', 'cls':'folder'}" }
854 } elsif ($action eq 'list_versions') {
856 my $vafv = CGI::param('vafv') || 'false'; # view all file versions
857 $vafv = ($vafv eq 'false')?0:1;
859 my $vcopies = CGI::param('vcopies') || 'false'; # view copies file versions
860 $vcopies = ($vcopies eq 'false')?0:1;
863 # 0 1 2 3 4 5 6 7 8 9
864 #(pathid,fileid,jobid,fid,mtime,size,inchanger,md5,volname,LinkFI );
865 my $files = $bvfs->get_all_file_versions($args->{pathid}, $args->{filenameid}, $args->{client}, $vafv, $vcopies);
867 map { "[ $_->[1], $_->[3], $_->[0], $_->[2], '$_->[8]', $_->[6], '$_->[7]', $_->[5],'" . strftime('%Y-%m-%d %H:%m:%S', localtime($_->[4])) . "',$_->[9]]" }
871 # this action is used when the restore box appear, we can display
872 # the media list that will be needed for restore
873 } elsif ($action eq 'get_media') {
874 my ($jobid, $fileid, $table);
877 # in this mode, we compute the result to get all needed media
878 # print STDERR "force=", CGI::param('force'), "\n";
879 if (CGI::param('force')) {
880 $table = fill_table_for_restore(@jobid);
884 # mysql is very slow without this index...
885 if ($bvfs->dbh_is_mysql()) {
886 $bvfs->dbh_do("CREATE INDEX idx_$table ON $table (JobId)");
888 $lst = get_media_list_with_dir($table);
890 $jobid = join(',', @jobid);
891 $fileid = join(',', grep { /^\d+(,\d+)*$/ } CGI::param('fileid'));
892 $lst = get_media_list($jobid, $fileid);
897 print join(',', map { "['$_->[0]',$_->[1],$_->[2]]" } @$lst);
902 my $b = $bvfs->get_bconsole();
903 $b->send_one_cmd(".bvfs_cleanup path=b2$$");
911 SELECT path || name AS name,pathid,filenameid,fileid,jobid
912 FROM File JOIN FileName USING (FilenameId) JOIN Path USING (PathId);
914 SELECT 'drop table ' || tablename || ';'
915 FROM pg_tables WHERE tablename ~ '^b[0-9]';