Merge branch 'mm/config-xdg'
[gitweb.git] / contrib / mw-to-git / git-remote-mediawiki
index 76b78bc27f7bce42075028606ac97e43dcc06bfa..accd70a94c82b425039b55afdf7b4b8761e7faba 100755 (executable)
 #
 # 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.
@@ -33,7 +27,6 @@
 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";
@@ -76,9 +69,7 @@ chomp($import_media);
 $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);
@@ -90,8 +81,20 @@ my $shallow_import = run_git("config --get --bool remote.". $remotename .".shall
 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.
 #
@@ -267,7 +270,13 @@ sub mw_connect_maybe {
 ## 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) {
@@ -377,7 +386,7 @@ sub get_mw_pages {
                        get_all_mediafiles(\%pages);
                }
        }
-       return values(%pages);
+       return %pages;
 }
 
 # usage: $out = run_git("command args");
@@ -443,17 +452,17 @@ sub get_linked_mediafiles {
                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);
                        }
                }
 
@@ -463,16 +472,16 @@ sub get_linked_mediafiles {
 
 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',
@@ -480,62 +489,33 @@ sub get_mw_mediafile_for_page_revision {
        };
        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;
        }
 }
 
@@ -560,10 +540,31 @@ sub get_last_local_revision {
 # 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;
 
@@ -829,8 +830,6 @@ sub mw_import_ref {
 
        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;
@@ -839,35 +838,106 @@ sub mw_import_ref {
        } 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})) {
@@ -878,32 +948,19 @@ sub mw_import_ref {
                $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 {
@@ -1201,28 +1258,35 @@ sub get_allowed_file_extensions {
        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 = {
@@ -1233,16 +1297,26 @@ sub get_mw_namespace_id {
                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.";
        }