BRFA approved 2014-02-26 Wikipedia:Bots/Requests for approval/AnomieBOT III |
First supplemental BFRA approved 2017-05-22 Wikipedia:Bots/Requests for approval/AnomieBOT III 4 |
package tasks::BrokenRedirectDeleter;
=pod
=begin metadata
Bot: AnomieBOT III
Task: BrokenRedirectDeleter
BRFA: Wikipedia:Bots/Requests for approval/AnomieBOT III
Status: Approved 2014-02-26
+BRFA: Wikipedia:Bots/Requests for approval/AnomieBOT III 4
+Status: Approved 2017-05-22
Created: 2014-01-06
Cleans up broken redirects:
* Attempted interwiki redirects are replaced with {{tl|soft redirect}}
* Redirects broken due to a move of the target without leaving a redirect are
updated, if possible.
* Other broken redirects are deleted if the following are true:
** The redirect has only 1 revision OR was created more than 4 days ago
** The redirect is not in the User or User talk namespaces
** The target page has no log entries less than 12 hours ago
** The redirect has 10 or fewer incoming links
** The bot is not excluded, e.g. with {{tl|nobots}}
* When a broken redirect is deleted, any subpages, and the talk page (if any)
and its subpages will be deleted, unless:
** The page is a talk page with an existing subject page
** The page is tagged with {{tl|G8-exempt}}
** The page has more than 10 incoming links
** The bot is excluded, e.g. with {{tl|nobots}}
** The parent subpage wasn't deleted
* Skipped redirects will be reported to [[User:AnomieBOT III/Broken redirects]]
=end metadata
=cut
use utf8;
use strict;
use AnomieBOT::Task qw/ISO2timestamp/;
use Data::Dumper;
use Time::HiRes;
use URI::Escape;
use vars qw/@ISA/;
@ISA=qw/AnomieBOT::Task/;
my ($screwup, $ns_subpages, %ns, %rns);
my %ignore = (
'Wikipedia:Example of a broken redirect' => 1,
);
sub new {
my $class=shift;
my $self=$class->SUPER::new();
bless $self, $class;
return $self;
}
=pod
=for info
BRFA approved 2014-02-26<br />[[Wikipedia:Bots/Requests for approval/AnomieBOT III]]
=for info
First supplemental BFRA approved 2017-05-22<br />[[Wikipedia:Bots/Requests for approval/AnomieBOT III 4]]
=cut
sub approved {
return 500;
}
sub run {
my ($self, $api)=@_;
my $res;
$screwup='If this bot is malfunctioning, please report it at [[User:'.$api->user.'/shutoff/BrokenRedirectDeleter]]';
$api->task('BrokenRedirectDeleter', 0, 10, qw/d::IWNS d::Redirects/);
%ns = $api->namespace_map();
%rns = $api->namespace_reverse_map();
$ns_subpages = $api->cache->get( 'BrokenRedirectDeleter:ns_subpages' );
if ( !$ns_subpages ) {
my $res = $api->query(
meta => 'siteinfo',
siprop => 'namespaces',
);
if ( $res->{'code'} ne 'success' ) {
$api->warn( "Failed to fetch namespace info: " . $res->{'error'} . "\n" );
return 60;
}
$ns_subpages = {};
for my $ns (values %{$res->{'query'}{'namespaces'}}) {
$ns_subpages->{$ns->{'id'}} = exists($ns->{'subpages'});
}
$api->cache->set( 'BrokenRedirectDeleter:ns_subpages', $ns_subpages, 86400 );
}
my ($dbh);
eval {
($dbh) = $api->connectToReplica( 'enwiki', 'analytics' );
};
if ( $@ ) {
$api->warn( "Error connecting to replica: $@\n" );
return 300;
}
my $from = $self->{'dbfrom'} // 0;
my $report = $self->{'report'} // {};
if ( $from == 0 && %$report ) {
$res = $self->do_report( $api, $report );
return $res if $res;
$self->{'report'} = $report = {};
}
# Ensure bot is logged in.
$res = $api->login();
if ( $res->{'code'} ne 'success' ) {
$api->warn( "Not logged in (WTF?): " . $res->{'error'} . "\n" );
return 300;
}
# Spend a max of 5 minutes on this task before restarting
my $endtime=time()+300;
my $dbmax = (@{ $dbh->selectcol_arrayref('SELECT MAX(rd_from) FROM redirect') })[0];
while ( $from <= $dbmax ) {
return 0 if $api->halting;
# Load the list of redirects needing deletion
my @rows;
my $to = $from + 1e6;
$api->debug(1, "Selecting broken redirects $from-$to");
my $t0 = Time::HiRes::time();
eval {
@rows = @{ $dbh->selectall_arrayref( qq{
SELECT rd_from, p1.page_namespace, p1.page_title, rd_interwiki, rd_namespace, rd_title, rd_fragment, last_updated
FROM redirect
JOIN page AS p1 ON(rd_from = p1.page_id)
LEFT JOIN page AS p2 ON(rd_namespace=p2.page_namespace AND rd_title=p2.page_title)
JOIN heartbeat_p.heartbeat ON(shard = 's1')
WHERE rd_namespace >= 0
AND (p2.page_id IS NULL OR rd_interwiki != '' AND rd_interwiki IS NOT NULL)
AND rd_from > $from AND rd_from <= $to
}, { Slice => {} } ) };
};
if ( $@ ) {
$api->warn( "Error fetching page list from replica: $@\n" );
return 300;
}
my $t1 = Time::HiRes::time();
$api->log( 'DB query took ' . ($t1-$t0) . ' seconds' );
@rows = sort { $a->{'rd_from'} <=> $b->{'rd_from'} } @rows;
for my $row (@rows) {
return 0 if $api->halting;
$row->{'rd_interwiki'} //= '';
utf8::decode( $row->{'rd_interwiki'} ); # Data from database is binary
utf8::decode( $row->{'page_title'} ); # Data from database is binary
my $from = $row->{'page_title'};
$from = $rns{$row->{'page_namespace'}} . ':' . $from if $row->{'page_namespace'} != 0;
$from =~ s/_/ /g;
utf8::decode( $row->{'rd_title'} ); # Data from database is binary
my $to = $row->{'rd_title'};
$to = $rns{$row->{'rd_namespace'}} . ':' . $to if $row->{'rd_namespace'} != 0;
$to = $row->{'rd_interwiki'} . ':' . $to if $row->{'rd_interwiki'} ne '';
$to =~ s/_/ /g;
utf8::decode( $row->{'rd_fragment'} ); # Data from database is binary
my $to_f = $row->{'rd_fragment'} ? "$to#$row->{rd_fragment}" : $to;
if ( defined( $ignore{$from} ) ) {
#$api->log( "Ignoring $from, it's on the ignore list." );
goto done;
}
#$api->log( "Checking [[$from]] → [[$to_f]]" );
if ( $row->{'page_namespace'} == $ns{'User'} || $row->{'page_namespace'} == $ns{'User talk'} ) {
# Always skip User and User talk namespaces
push @{$report->{'user'}}, "[[:$from]] → [[:$to_f]]";
goto done;
} elsif ( $row->{'rd_interwiki'} ne '' ) {
my $tok = $api->edittoken( $from, EditRedir => 1 );
if ( $tok->{'code'} eq 'shutoff' ) {
$api->warn( "Task disabled: " . $tok->{'content'} . "\n" );
return 300;
}
if ( $tok->{'code'} eq 'botexcluded' ) {
$api->warn( "Bot excluded from $from: " . $tok->{'error'} . "\n" );
push @{$report->{'skip'}}, "[[:$from]] → [[:$to_f]] (bot excluded)";
goto done;
}
if ( $tok->{'code'} ne 'success' ) {
$api->warn( "Failed to get edit token for $from: " . $tok->{'error'} . "\n" );
push @{$report->{'fail'}}, "[[:$from]] → [[:$to_f]] (couldn't fetch edit token)";
goto failed;
}
if ( exists( $tok->{'missing'} ) ) {
#$api->log("$from no longer exists, skipping");
goto done;
}
if ( $tok->{'revisions'}[0]{'timestamp'} gt $row->{'last_updated'} ) {
$api->log("Excessive replag for $from, skipping ($tok->{'revisions'}[0]{'timestamp'} > $row->{'last_updated'})");
goto done;
}
my $txt = $tok->{'revisions'}[0]{'slots'}{'main'}{'*'};
$txt=~s/^\s*.*?\]\]/{{soft redirect|1=$to_f}}\n/;
my $summary = "Changing interwiki redirect to [[:$to_f]] into a soft redirect";
$api->log( "$summary in $from" );
my $r = $api->edit( $tok, $txt, "$summary. $screwup", 0, 1 );
if ( $r->{'code'} ne 'success' ) {
push @{$report->{'fail'}}, "[[:$from]] → [[:$to_f]] (edit failed)";
goto failed;
}
goto done;
} else {
# Make sure $to didn't get recreated
my $pg2 = undef;
if ( $to ne '' ) {
$res = $api->query( titles => $to, prop => 'imageinfo' );
if ( $res->{'code'} ne 'success' ) {
$api->warn( "Failed to get existence for $to: " . $res->{'error'} . "\n" );
push @{$report->{'fail'}}, "[[:$from]] → [[:$to_f]] (couldn't fetch target existence)";
goto failed;
}
$pg2 = (values %{$res->{'query'}{'pages'}})[0];
if ( exists( $pg2->{'pageid'} ) ) {
#$api->log("$to_f still exists, skipping");
goto done;
}
$res = $api->query( titles => $from, redirects => 1 );
if ( $res->{'code'} ne 'success' ) {
$api->warn( "Failed to resolve redirects $from: " . $res->{'error'} . "\n" );
push @{$report->{'fail'}}, "[[:$from]] → [[:$to_f]] (couldn't check redirect)";
goto failed;
}
my $pg3 = (values %{$res->{'query'}{'pages'}})[0];
if ( !exists( $pg3->{'missing'} ) && $pg3->{'title'} ne $to ) {
$api->log("$from is no longer a redirect to $to_f, skipping");
goto done;
}
}
my %q = (
titles => $from,
prop => 'info|revisions|imageinfo',
inprop => 'talkid',
rvprop => 'timestamp',
rvlimit => 2,
iiprop => 'canonicaltitle',
);
if ( $to ne '' ) {
%q = ( %q,
list => 'logevents',
letitle => $to,
lelimit => 1,
);
}
$res = $api->query( %q );
if ( $res->{'code'} ne 'success' ) {
$api->warn( "Failed to get info for $from and $to: " . $res->{'error'} . "\n" );
push @{$report->{'fail'}}, "[[:$from]] → [[:$to_f]] (couldn't fetch page info)";
goto failed;
}
my @le = @{$res->{'query'}{'logevents'} // []};
my $pg = (values %{$res->{'query'}{'pages'}})[0];
if ( exists( $pg->{'missing'} ) ) {
#$api->log("$from no longer exists, skipping");
goto done;
}
if ( !exists( $pg->{'redirect'} ) ) {
$api->log("$from is no longer a redirect, skipping");
goto done;
}
if ( ($pg->{'imagerepository'}//'') eq 'local' && $pg->{'imageinfo'}[0]{'canonicaltitle'} eq $pg->{'title'} ) {
#$api->log("$from has a local file version");
push @{$report->{'skip'}}, "[[:$from]] → [[:$to_f]] (page is a redirect, but a local file exists at the title)";
goto done;
}
if ( $pg2 && ($pg2->{'imagerepository'}//'') eq 'local' ) {
#$api->log("$to has a file");
push @{$report->{'skip'}}, "[[:$from]] → [[:$to_f]] (a file exists at the target title)";
goto done;
}
if ( $pg2 && ($pg2->{'imagerepository'}//'') ne '' ) {
#$api->log("$to has a file");
push @{$report->{'skip'}}, "[[:$from]] → [[:$to_f]] (a non-local file exists at the target title; while this doesn't actually work, people would rather fix it manually)";
goto done;
}
if ( @{$pg->{'revisions'}} > 1 && ISO2timestamp( $pg->{'revisions'}[0]{'timestamp'} ) > time() - 4*86400 ) {
#$api->log("$from has more that one revision and was edited recently");
push @{$report->{'wait'}}, "[[:$from]] → [[:$to_f]] (redirect has more than 1 revision and was edited less than 4 days ago)";
goto done;
}
if ( @le ) {
my $le = $le[0];
my ( $err, $ts, $newTo ) = $self->checkLogEntry( $api, $le );
if ( $err ) {
push @{$report->{'fail'}}, "[[:$from]] → [[:$to_f]] (couldn't check log entries)";
goto failed;
}
if ( ISO2timestamp( $ts ) > time() - 43200 ) {
#$api->log("$to (or something in the chain of moves after it) has recent log entries");
if ( $newTo && $newTo ne $to ) {
push @{$report->{'wait'}}, "[[:$from]] → [[:$to_f]] → [[:$newTo]] (intermediate or eventual target has recent log entries)";
} else {
push @{$report->{'wait'}}, "[[:$from]] → [[:$to_f]] (intermediate or eventual target has recent log entries)";
}
goto done;
}
if ( $newTo && $newTo ne $to ) {
my $newTo_f = $newTo;
$newTo_f .= $1 if $to_f =~ /(#.*)$/;
my $tok = $api->edittoken( $from, EditRedir => 1, NoExclusion => 1 );
if ( $tok->{'code'} eq 'shutoff' ) {
$api->warn( "Task disabled: " . $tok->{'content'} . "\n" );
return 300;
}
if ( $tok->{'code'} ne 'success' ) {
$api->warn( "Failed to get edit token for $from: " . $tok->{'error'} . "\n" );
push @{$report->{'fail'}}, "[[:$from]] → [[:$to_f]] → [[:$newTo]] (couldn't fetch edit token)";
goto failed;
}
my $re = $api->redirect_regex();
my $txt = $tok->{'revisions'}[0]{'slots'}{'main'}{'*'} // '';
unless ( $txt=~s/($re)\[\[[^]]+\]\]/$1\[\[$newTo_f\]\]/ ) {
$api->warn( "Failed to replace #REDIRECT in $from\n" );
push @{$report->{'fail'}}, "[[:$from]] → [[:$to_f]] → [[:$newTo]] (couldn't replace #REDIRECT)";
goto failed;
}
my $summary = "Redirecting [[:$from]] to [[:$newTo_f]] following a move-without-redirect of [[:$to]]";
$api->log( $summary );
my $r = $api->edit( $tok, $txt, "$summary. $screwup", 0, 1 );
if ( $r->{'code'} ne 'success' ) {
push @{$report->{'fail'}}, "[[:$from]] → [[:$to_f]] → [[:$newTo]] (edit failed)";
goto failed;
}
goto done;
}
}
my ($key, $reason);
($res, $key, $reason) = $self->do_delete( $api, $row->{'page_namespace'}, $row->{'page_title'}, "[[WP:CSD#G8|G8]]: Broken redirect to [[:$to_f]]", 1 );
if ( $res ) {
return $res if $res > 0;
push @{$report->{$key}}, "[[:$from]] → [[:$to_f]] ($reason)";
goto failed if $key eq 'failed';
goto done;
}
$self->do_delete( $api, $row->{'page_namespace'}+1, $row->{'page_title'}, "[[WP:CSD#G8|G8]]: Talk page of deleted page", 1 ) if exists($pg->{'talkid'});
}
done:
$self->{'dbfrom'} = $row->{'rd_from'};
failed:
$self->{'report'} = $report;
# If we've been at it long enough, let another task have a go.
return 0 if time()>=$endtime;
}
$self->{'dbfrom'} = $from = $to;
}
$self->{'dbfrom'} = 0;
my $wait = exists($report->{'fail'}) ? 60 : 21600;
$res = $self->do_report( $api, $report );
return $res if $res;
$self->{'report'} = {};
return $wait;
}
sub do_delete {
my ($self, $api, $ns, $title, $reason, $subpages) = @_;
$title =~ s/_/ /g;
my $page = $ns ? $rns{$ns} . ':' . $title : $title;
my $tok=$api->gettoken('csrf', Title => $page, EditRedir => 1, templates => { templates => 'Template:G8-exempt' } );
if ( $tok->{'code'} eq 'shutoff' ) {
$api->warn( "Task disabled: " . $tok->{'content'} . "\n" );
return (300, undef);
}
if ( $tok->{'code'} eq 'botexcluded' ) {
$api->warn( "Bot excluded from $page: " . $tok->{'error'} . "\n" );
return (-1, 'skip', 'bot excluded');
}
if ( $tok->{'code'} ne 'success' ) {
$api->warn( "Failed to get delete token for $page: " . $tok->{'error'} . "\n" );
return (-1, 'fail', "couldn't fetch delete token");
}
if ( exists( $tok->{'missing'} ) ) {
#$api->log("$page no longer exists, skipping");
return (0, 'ok', "no longer exists");
}
if ( @{$tok->{'templates'} // []} ) {
#$api->log("$page is G8-exempt");
return (-1, 'skip', "marked G8-exempt");
}
my $res = $api->query(
list => 'backlinks',
bltitle => $page,
bllimit => 10,
);
if ( exists( $res->{'query-continue'}{'backlinks'} ) ) {
#$api->log("$page has too many backlinks");
return (-1, 'skip', "too many incoming links");
}
$api->log( "Deleting $page: $reason" );
$res = $api->action( $tok,
action => 'delete',
title => $page,
reason => "$reason. $screwup",
);
if ( $res->{'code'} ne 'success' ) {
$api->warn( "Failed to delete $page: " . $res->{'error'} . "\n" );
return (-1, 'fail', 'delete failed');
}
if ( $subpages && $ns_subpages->{$ns} ) {
# Delete subpages of deleted page
my $iter = $api->iterator(
generator => 'allpages',
gapnamespace => $ns,
gapprefix => "$title/",
gaplimit => 'max',
prop => 'info',
inprop => 'subjectid',
);
my %skip = ();
ITER: while( my $p = $iter->next ) {
last unless $p->{'_ok_'};
my @parts = split( m!/!, $p->{'title'} );
for ( my $i = 0; $i < @parts; $i++ ) {
my $t = join( '/', @parts[0..$i] );
next ITER if exists( $skip{$t} );
}
if ( exists( $p->{'subjectid'} ) || @{$p->{'templates'} // []} ) {
$skip{$p->{'title'}} = 1;
next ITER;
}
$p->{'title'}=~s/^[^:]*:// if $ns;
my ($res, $key, $reason) = $self->do_delete( $api, $ns, $p->{'title'}, "[[WP:CSD#G8|G8]]: Subpage of a deleted page", 0 );
$skip{$p->{'title'}} = 1 if $res;
}
}
}
sub do_report {
my ($self, $api, $report) = @_;
my $txt;
$txt = "<noinclude>This page reports on redirects that AnomieBOT\'s BrokenRedirectDeleter will not clean up because they are in the User or User talk namespace. This page was last updated {{#time:Y-m-d H:i:s|{{REVISIONTIMESTAMP}}}} (UTC).";
if ( exists( $report->{'user'} ) ) {
$txt .= "</noinclude>\n";
$txt .= "== User space ==\nThese redirects are in the User or User talk namespaces, and will '''not''' be automatically cleaned up.\n<includeonly>{{collapse top|title=Userspace redirects}}</includeonly>\n";
$txt .= "* " . join( "\n* ", @{$report->{'user'}} ) . "\n<includeonly>{{collapse bottom}}</includeonly>";
} else {
$txt .= "\n\nThere are no unhandled redirects at this time.</noinclude>";
}
$txt =~ s/(?<=^\* )\[\[:(.*?)\]\]/{{no redirect|1=$1}}/gm;
$txt =~ s/\s*$/\n/;
my $title = "User:" . $api->user . "/Broken redirects/Userspace";
my $tok = $api->edittoken( $title, EditRedir => 1, NoExclusion => 1 );
if ( $tok->{'code'} eq 'shutoff' ) {
$api->warn( "Task disabled: " . $tok->{'content'} . "\n" );
return 300;
}
if ( $tok->{'code'} ne 'success' ) {
$api->warn( "Failed to get edit token for $title: " . $tok->{'error'} . "\n" );
return 60;
}
my $intxt = $tok->{'revisions'}[0]{'slots'}{'main'}{'*'} // '';
$intxt =~ s/\s*$/\n/;
if ( $txt ne $intxt ) {
$api->log( "Updating $title" );
my $r = $api->edit( $tok, $txt, "Updating broken redirects list", 0, 1 );
if ( $r->{'code'} ne 'success' ) {
return 60;
}
}
$txt = "This page reports on redirects that AnomieBOT\'s BrokenRedirectDeleter cannot clean up. This page was last updated {{#time:Y-m-d H:i:s|{{REVISIONTIMESTAMP}}}} (UTC).\n\n";
unless ( %$report ) {
$txt .= "There are no unhandled redirects at this time.";
}
if ( exists( $report->{'skip'} ) ) {
$txt .= "== Skipped ==\nThese redirects were skipped by the bot, and will '''not''' be automatically cleaned up as long as the indicated issues apply.\n";
$txt .= "* " . join( "\n* ", @{$report->{'skip'}} ) . "\n\n";
}
if ( exists( $report->{'user'} ) ) {
$txt =~ s/\s*$/\n/;
$txt .= "{{ {{FULLPAGENAME}}/Userspace }}\n";
}
if ( exists( $report->{'wait'} ) ) {
$txt .= "== Recently changed ==\nThese redirects were created recently, or their targets have recent log entries. The bot will process them after the appropriate waiting period.\n";
$txt .= "* " . join( "\n* ", @{$report->{'wait'}} ) . "\n\n";
}
if ( exists( $report->{'fail'} ) ) {
$txt .= "== Failed ==\nThe most recent attempt to edit or delete these redirects failed. The bot will retry on its next run.\n";
$txt .= "* " . join( "\n* ", @{$report->{'fail'}} ) . "\n\n";
}
$txt =~ s/(?<=^\* )\[\[:(.*?)\]\]/{{no redirect|1=$1}}/gm;
$txt =~ s/\s*$/\n/;
$title = "User:" . $api->user . "/Broken redirects";
$tok = $api->edittoken( $title, EditRedir => 1, NoExclusion => 1 );
if ( $tok->{'code'} eq 'shutoff' ) {
$api->warn( "Task disabled: " . $tok->{'content'} . "\n" );
return 300;
}
if ( $tok->{'code'} ne 'success' ) {
$api->warn( "Failed to get edit token for $title" . $tok->{'error'} . "\n" );
return 60;
}
$intxt = $tok->{'revisions'}[0]{'slots'}{'main'}{'*'} // '';
$intxt =~ s/\s*$/\n/;
if ( $txt ne $intxt ) {
$api->log( "Updating $title" );
my $r = $api->edit( $tok, $txt, "Updating broken redirects list", 0, 1 );
if ( $r->{'code'} ne 'success' ) {
return 60;
}
}
return 0;
}
sub checkLogEntry {
my ( $self, $api, $le ) = @_;
my $ts = $le->{'timestamp'};
return ( undef, $ts, undef ) unless ( $le->{'type'} eq 'move' && exists( $le->{'params'}{'suppressredirect'} ) && $le->{'params'}{'target_ns'} == $le->{'ns'} );
# Find the new target
my $target = $le->{'params'}{'target_title'};
# Bypass double redirects
my $res = $api->query( titles => $target, redirects => 1 );
if ( $res->{'code'} ne 'success' ) {
$api->warn( "Failed to retrieve info for $target: " . $res->{'error'} . "\n" );
return ( 'fail' );
}
my %map = ();
if ( exists( $res->{'query'}{'normalized'} ) ) {
$map{$_->{'from'}} = $_->{'to'} foreach @{$res->{'query'}{'normalized'}};
}
if ( exists($res->{'query'}{'redirects'} ) ) {
$map{$_->{'from'}} = $_->{'to'} foreach @{$res->{'query'}{'redirects'}};
}
$target = $map{$target} if exists( $map{$target} );
# Does the final target exist?
my %exists = ();
if ( exists( $res->{'query'}{'pages'} ) ) {
for my $p (values %{$res->{'query'}{'pages'}}) {
$exists{$p->{'title'}} = $p->{'ns'} if $p->{'pageid'}//0;
}
}
if ( exists( $exists{$target} ) ) {
return ( undef, $ts, ($exists{$target} == $le->{'ns'} ? $target : undef ) );
}
# No, check if it in turn was moved-without-redirect.
$res = $api->query( list => 'logevents', letitle => $target, lelimit => 1 );
if ( $res->{'code'} ne 'success' ) {
$api->warn( "Failed to retrieve log events for $target: " . $res->{'error'} . "\n" );
return ( 'fail' );
}
$target = undef; # If not, just return no target.
my @le = @{$res->{'query'}{'logevents'} // []};
if ( @le ) {
my ( $err, $ts2 );
( $err, $ts2, $target ) = $self->checkLogEntry( $api, $le[0] );
return ( $err ) if $err;
$ts = $ts2 if defined( $ts2 ) && $ts2 lt $ts;
}
return ( undef, $ts, $target );
}
1;