#
# Known limitations:
#
-# - Poor performance in the best case: it takes forever to check
-# whether we're up-to-date (on fetch or push) or to fetch a few
-# revisions from a large wiki, because we use exclusively a
-# page-based synchronization. We could switch to a wiki-wide
-# synchronization when the synchronization involves few revisions
-# but the wiki is large.
+# - Several strategies are provided to fetch modifications from the
+# wiki, but no automatic heuristics is provided, the user has
+# to understand and chose which strategy is appropriate for him.
#
# - Git renames could be turned into MediaWiki renames (see TODO
# below)
#
-# - login/password support requires the user to write the password
-# cleartext in a file (see TODO below).
-#
# - No way to import "one page, and all pages included in it"
#
# - Multiple remote MediaWikis have not been very well tested.
use strict;
use MediaWiki::API;
use DateTime::Format::ISO8601;
-use FileHandle;
# By default, use UTF-8 to communicate with Git and the user
binmode STDERR, ":utf8";
$import_media = ($import_media eq "true");
my $wiki_login = run_git("config --get remote.". $remotename .".mwLogin");
-# TODO: ideally, this should be able to read from keyboard, but we're
-# inside a remote helper, so our stdin is connect to git, not to a
-# terminal.
+# Note: mwPassword is discourraged. Use the credential system instead.
my $wiki_passwd = run_git("config --get remote.". $remotename .".mwPassword");
my $wiki_domain = run_git("config --get remote.". $remotename .".mwDomain");
chomp($wiki_login);
chomp($shallow_import);
$shallow_import = ($shallow_import eq "true");
-# Cache for MediaWiki namespace ids.
-my %namespace_id;
+# Fetch (clone and pull) by revisions instead of by pages. This behavior
+# is more efficient when we have a wiki with lots of pages and we fetch
+# the revisions quite often so that they concern only few pages.
+# Possible values:
+# - by_rev: perform one query per new revision on the remote wiki
+# - by_page: query each tracked page for new revision
+my $fetch_strategy = run_git("config --get remote.$remotename.fetchStrategy");
+unless ($fetch_strategy) {
+ $fetch_strategy = run_git("config --get mediawiki.fetchStrategy");
+}
+chomp($fetch_strategy);
+unless ($fetch_strategy) {
+ $fetch_strategy = "by_page";
+}
# Dumb push: don't update notes and mediawiki ref to reflect the last push.
#
## Functions for listing pages on the remote wiki
sub get_mw_tracked_pages {
my $pages = shift;
- my @some_pages = @tracked_pages;
+ get_mw_page_list(\@tracked_pages, $pages);
+}
+
+sub get_mw_page_list {
+ my $page_list = shift;
+ my $pages = shift;
+ my @some_pages = @$page_list;
while (@some_pages) {
my $last = 50;
if ($#some_pages < $last) {
get_all_mediafiles(\%pages);
}
}
- return values(%pages);
+ return %pages;
}
# usage: $out = run_git("command args");
my $result = $mediawiki->api($query);
while (my ($id, $page) = each(%{$result->{query}->{pages}})) {
- my @titles;
+ my @media_titles;
if (defined($page->{links})) {
my @link_titles = map $_->{title}, @{$page->{links}};
- push(@titles, @link_titles);
+ push(@media_titles, @link_titles);
}
if (defined($page->{images})) {
my @image_titles = map $_->{title}, @{$page->{images}};
- push(@titles, @image_titles);
+ push(@media_titles, @image_titles);
}
- if (@titles) {
- get_mw_first_pages(\@titles, \%{$pages});
+ if (@media_titles) {
+ get_mw_page_list(\@media_titles, $pages);
}
}
sub get_mw_mediafile_for_page_revision {
# Name of the file on Wiki, with the prefix.
- my $mw_filename = shift;
+ my $filename = shift;
my $timestamp = shift;
my %mediafile;
- # Search if on MediaWiki exists a media file with given
- # timestamp. In that case download the file.
+ # Search if on a media file with given timestamp exists on
+ # MediaWiki. In that case download the file.
my $query = {
action => 'query',
prop => 'imageinfo',
- titles => $mw_filename,
+ titles => "File:" . $filename,
iistart => $timestamp,
iiend => $timestamp,
iiprop => 'timestamp|archivename|url',
};
my $result = $mediawiki->api($query);
- my ($fileid, $file) = each ( %{$result->{query}->{pages}} );
+ my ($fileid, $file) = each( %{$result->{query}->{pages}} );
# If not defined it means there is no revision of the file for
# given timestamp.
if (defined($file->{imageinfo})) {
- # Get real name of media file.
- my $filename;
- if (index($mw_filename, 'File:') == 0) {
- $filename = substr $mw_filename, 5;
- } else {
- $filename = substr $mw_filename, 6;
- }
$mediafile{title} = $filename;
my $fileinfo = pop(@{$file->{imageinfo}});
$mediafile{timestamp} = $fileinfo->{timestamp};
- # If this is an old version of the file, the file has to be
- # obtained from the archive. Otherwise it can be downloaded
- # by MediaWiki API download() function.
- if (defined($fileinfo->{archivename})) {
- $mediafile{content} = download_mw_mediafile_from_archive($fileinfo->{url});
- } else {
- $mediafile{content} = download_mw_mediafile($mw_filename);
- }
+ # Mediawiki::API's download function doesn't support https URLs
+ # and can't download old versions of files.
+ print STDERR "\tDownloading file $mediafile{title}, version $mediafile{timestamp}\n";
+ $mediafile{content} = download_mw_mediafile($fileinfo->{url});
}
return %mediafile;
}
-sub download_mw_mediafile_from_archive {
+sub download_mw_mediafile {
my $url = shift;
- my $file;
- my $ua = LWP::UserAgent->new;
- my $response = $ua->get($url);
- if ($response->code) {
- $file = $response->decoded_content;
+ my $response = $mediawiki->{ua}->get($url);
+ if ($response->code == 200) {
+ return $response->decoded_content;
} else {
- print STDERR "Error downloading a file from archive.\n";
- }
-
- return $file;
-}
-
-sub download_mw_mediafile {
- my $filename = shift;
-
- $mediawiki->{config}->{files_url} = $url;
-
- my $file_content = $mediawiki->download( { title => $filename } );
- if (!defined($file_content)) {
- print STDERR "\tFile \'$filename\' could not be downloaded.\n";
- exit 1;
- } elsif ($file_content eq "") {
- print STDERR "\tFile \'$filename\' does not exist on the wiki.\n";
+ print STDERR "Error downloading mediafile from :\n";
+ print STDERR "URL: $url\n";
+ print STDERR "Server response: " . $response->code . " " . $response->message . "\n";
exit 1;
- } else {
- return $file_content;
}
}
# Remember the timestamp corresponding to a revision id.
my %basetimestamps;
+# Get the last remote revision without taking in account which pages are
+# tracked or not. This function makes a single request to the wiki thus
+# avoid a loop onto all tracked pages. This is useful for the fetch-by-rev
+# option.
+sub get_last_global_remote_rev {
+ mw_connect_maybe();
+
+ my $query = {
+ action => 'query',
+ list => 'recentchanges',
+ prop => 'revisions',
+ rclimit => '1',
+ rcdir => 'older',
+ };
+ my $result = $mediawiki->api($query);
+ return $result->{query}->{recentchanges}[0]->{revid};
+}
+
+# Get the last remote revision concerning the tracked pages and the tracked
+# categories.
sub get_last_remote_revision {
mw_connect_maybe();
- my @pages = get_mw_pages();
+ my %pages_hash = get_mw_pages();
+ my @pages = values(%pages_hash);
my $max_rev_num = 0;
mw_connect_maybe();
- my @pages = get_mw_pages();
-
print STDERR "Searching revisions...\n";
my $last_local = get_last_local_revision();
my $fetch_from = $last_local + 1;
} else {
print STDERR ", fetching from here.\n";
}
+
+ my $n = 0;
+ if ($fetch_strategy eq "by_rev") {
+ print STDERR "Fetching & writing export data by revs...\n";
+ $n = mw_import_ref_by_revs($fetch_from);
+ } elsif ($fetch_strategy eq "by_page") {
+ print STDERR "Fetching & writing export data by pages...\n";
+ $n = mw_import_ref_by_pages($fetch_from);
+ } else {
+ print STDERR "fatal: invalid fetch strategy \"$fetch_strategy\".\n";
+ print STDERR "Check your configuration variables remote.$remotename.fetchStrategy and mediawiki.fetchStrategy\n";
+ exit 1;
+ }
+
+ if ($fetch_from == 1 && $n == 0) {
+ print STDERR "You appear to have cloned an empty MediaWiki.\n";
+ # Something has to be done remote-helper side. If nothing is done, an error is
+ # thrown saying that HEAD is refering to unknown object 0000000000000000000
+ # and the clone fails.
+ }
+}
+
+sub mw_import_ref_by_pages {
+
+ my $fetch_from = shift;
+ my %pages_hash = get_mw_pages();
+ my @pages = values(%pages_hash);
+
my ($n, @revisions) = fetch_mw_revisions(\@pages, $fetch_from);
- # Creation of the fast-import stream
- print STDERR "Fetching & writing export data...\n";
+ @revisions = sort {$a->{revid} <=> $b->{revid}} @revisions;
+ my @revision_ids = map $_->{revid}, @revisions;
+
+ return mw_import_revids($fetch_from, \@revision_ids, \%pages_hash);
+}
+
+sub mw_import_ref_by_revs {
+
+ my $fetch_from = shift;
+ my %pages_hash = get_mw_pages();
- $n = 0;
+ my $last_remote = get_last_global_remote_rev();
+ my @revision_ids = $fetch_from..$last_remote;
+ return mw_import_revids($fetch_from, \@revision_ids, \%pages_hash);
+}
+
+# Import revisions given in second argument (array of integers).
+# Only pages appearing in the third argument (hash indexed by page titles)
+# will be imported.
+sub mw_import_revids {
+ my $fetch_from = shift;
+ my $revision_ids = shift;
+ my $pages = shift;
+
+ my $n = 0;
+ my $n_actual = 0;
my $last_timestamp = 0; # Placeholer in case $rev->timestamp is undefined
- foreach my $pagerevid (sort {$a->{revid} <=> $b->{revid}} @revisions) {
+ foreach my $pagerevid (@$revision_ids) {
# fetch the content of the pages
my $query = {
action => 'query',
prop => 'revisions',
rvprop => 'content|timestamp|comment|user|ids',
- revids => $pagerevid->{revid},
+ revids => $pagerevid,
};
my $result = $mediawiki->api($query);
- my $rev = pop(@{$result->{query}->{pages}->{$pagerevid->{pageid}}->{revisions}});
+ if (!$result) {
+ die "Failed to retrieve modified page for revision $pagerevid";
+ }
+
+ if (!defined($result->{query}->{pages})) {
+ die "Invalid revision $pagerevid.";
+ }
+
+ my @result_pages = values(%{$result->{query}->{pages}});
+ my $result_page = $result_pages[0];
+ my $rev = $result_pages[0]->{revisions}->[0];
+ # Count page even if we skip it, since we display
+ # $n/$total and $total includes skipped pages.
$n++;
- my $page_title = $result->{query}->{pages}->{$pagerevid->{pageid}}->{title};
+ my $page_title = $result_page->{title};
+
+ if (!exists($pages->{$page_title})) {
+ print STDERR "$n/", scalar(@$revision_ids),
+ ": Skipping revision #$rev->{revid} of $page_title\n";
+ next;
+ }
+
+ $n_actual++;
+
my %commit;
$commit{author} = $rev->{user} || 'Anonymous';
$commit{comment} = $rev->{comment} || '*Empty MediaWiki Message*';
$commit{title} = mediawiki_smudge_filename($page_title);
- $commit{mw_revision} = $pagerevid->{revid};
+ $commit{mw_revision} = $rev->{revid};
$commit{content} = mediawiki_smudge($rev->{'*'});
if (!defined($rev->{timestamp})) {
$commit{date} = DateTime::Format::ISO8601->parse_datetime($last_timestamp);
# Differentiates classic pages and media files.
- my @prefix = split(":", $page_title);
-
+ my ($namespace, $filename) = $page_title =~ /^([^:]*):(.*)$/;
my %mediafile;
- if ($prefix[0] eq "File" || $prefix[0] eq "Image") {
- # The name of the file is the same as the media page.
- my $filename = $page_title;
+ if ($namespace && get_mw_namespace_id($namespace) == get_mw_namespace_id("File")) {
%mediafile = get_mw_mediafile_for_page_revision($filename, $rev->{timestamp});
}
# If this is a revision of the media page for new version
# of a file do one common commit for both file and media page.
# Else do commit only for that page.
- print STDERR "$n/", scalar(@revisions), ": Revision #$pagerevid->{revid} of $commit{title}\n";
- if (%mediafile) {
- print STDERR "\tDownloading file $mediafile{title}, version $mediafile{timestamp}\n";
- import_file_revision(\%commit, ($fetch_from == 1), $n, \%mediafile);
- } else {
- import_file_revision(\%commit, ($fetch_from == 1), $n);
- }
+ print STDERR "$n/", scalar(@$revision_ids), ": Revision #$rev->{revid} of $commit{title}\n";
+ import_file_revision(\%commit, ($fetch_from == 1), $n_actual, \%mediafile);
}
- if ($fetch_from == 1 && $n == 0) {
- print STDERR "You appear to have cloned an empty MediaWiki.\n";
- # Something has to be done remote-helper side. If nothing is done, an error is
- # thrown saying that HEAD is refering to unknown object 0000000000000000000
- # and the clone fails.
- }
+ return $n_actual;
}
sub error_non_fast_forward {
return %hashFile;
}
+# In memory cache for MediaWiki namespace ids.
+my %namespace_id;
+
+# Namespaces whose id is cached in the configuration file
+# (to avoid duplicates)
+my %cached_mw_namespace_id;
+
# Return MediaWiki id for a canonical namespace name.
# Ex.: "File", "Project".
-# Looks for the namespace id in the local configuration
-# variables, if it is not found asks MW API.
sub get_mw_namespace_id {
mw_connect_maybe();
my $name = shift;
if (!exists $namespace_id{$name}) {
# Look at configuration file, if the record for that namespace is
- # already stored. Namespaces are stored in form:
+ # already cached. Namespaces are stored in form:
# "Name_of_namespace:Id_namespace", ex.: "File:6".
my @temp = split(/[ \n]/, run_git("config --get-all remote."
- . $remotename .".namespaces"));
+ . $remotename .".namespaceCache"));
chomp(@temp);
foreach my $ns (@temp) {
- my ($n, $s) = split(/:/, $ns);
- $namespace_id{$n} = $s;
+ my ($n, $id) = split(/:/, $ns);
+ $namespace_id{$n} = $id;
+ $cached_mw_namespace_id{$n} = 1;
}
}
if (!exists $namespace_id{$name}) {
+ print STDERR "Namespace $name not found in cache, querying the wiki ...\n";
# NS not found => get namespace id from MW and store it in
# configuration file.
my $query = {
my $result = $mediawiki->api($query);
while (my ($id, $ns) = each(%{$result->{query}->{namespaces}})) {
- if (defined($ns->{canonical}) && ($ns->{canonical} eq $name)) {
- run_git("config --add remote.". $remotename
- .".namespaces ". $name .":". $ns->{id});
- $namespace_id{$name} = $ns->{id};
- }
+ if (defined($ns->{id}) && defined($ns->{canonical})) {
+ $namespace_id{$ns->{canonical}} = $ns->{id};
+ if ($ns->{'*'}) {
+ # alias (e.g. french Fichier: as alias for canonical File:)
+ $namespace_id{$ns->{'*'}} = $ns->{id};
+ }
+ }
}
}
- if (exists $namespace_id{$name}) {
- return $namespace_id{$name};
+ my $id = $namespace_id{$name};
+
+ if (defined $id) {
+ # Store explicitely requested namespaces on disk
+ if (!exists $cached_mw_namespace_id{$name}) {
+ run_git("config --add remote.". $remotename
+ .".namespaceCache \"". $name .":". $id ."\"");
+ $cached_mw_namespace_id{$name} = 1;
+ }
+ return $id;
} else {
die "No such namespace $name on MediaWiki.";
}