File Coverage

blib/lib/CGI/Wiki/Store/Mediawiki.pm
Criterion Covered Total %
statement 10 12 83.3
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 14 16 87.5


line stmt bran cond sub pod time code
1             package CGI::Wiki::Store::Mediawiki;
2              
3 1     1   28695 use warnings;
  1         2  
  1         29  
4 1     1   4 use strict;
  1         1  
  1         32  
5              
6 1     1   5 use vars qw(@ISA);
  1         5  
  1         39  
7              
8 1     1   357 use CGI::Wiki::Store::Database;
  0            
  0            
9             use Carp qw/carp croak confess/;
10             use Time::Piece::Adaptive;
11             use Time::Seconds;
12              
13             @ISA = qw(CGI::Wiki::Store::Database);
14              
15              
16              
17             =head1 NAME
18              
19             CGI::Wiki::Store::Mediawiki - Mediawiki (MySQL) storage backend for CGI::Wiki
20              
21             =head1 VERSION
22              
23             Version 0.02
24              
25             =cut
26              
27             our $VERSION = '0.02';
28              
29             =head1 REQUIRES
30              
31             Subclasses CGI::Wiki::Store::Database.
32              
33             =head1 SYNOPSIS
34              
35             Implementation of L which reads and writes to a
36             Mediawiki 1.6 database running in MySQL.
37              
38             All date and time values are returned as L objects.
39             This should be transparent for most uses.
40              
41             See L for more.
42              
43             =cut
44              
45              
46              
47             ###
48             ### Globals
49             ###
50             our $timestamp_fmt = "%Y%m%d%H%M%S";
51              
52              
53              
54             # Internal method to return the data source string required by DBI.
55             sub _dsn {
56             my ($self, $dbname, $dbhost) = @_;
57             my $dsn = "dbi:mysql:$dbname";
58             $dsn .= ";host=$dbhost" if $dbhost;
59             return $dsn;
60             }
61              
62              
63              
64             =head1 METHODS
65              
66             =head2 check_and_write_node
67              
68             $store->check_and_write_node (node => $node,
69             checksum => $checksum,
70             %other_args);
71              
72             Locks the node, verifies the checksum, calls
73             C with all supplied arguments, unlocks the
74             node. Returns 1 on successful writing, 0 if checksum doesn't match,
75             croaks on error.
76              
77             Note: Uses MySQL's user level locking, so any locks are released when
78             the database handle disconnects. Doing it like this because I can't seem
79             to get it to work properly with transactions.
80              
81             =cut
82              
83             sub check_and_write_node
84             {
85             my ($self, %args) = @_;
86             my ($node, $checksum) = @args{qw(node checksum)};
87             $self->_lock_node ($node) or croak "Can't lock node";
88             my $ok = $self->verify_checksum ($node, $checksum);
89             unless ($ok)
90             {
91             $self->_unlock_node ($node) or carp "Can't unlock node";
92             return 0;
93             }
94             eval {$self->write_node_post_locking (%args)};
95             my $saverr = $@;
96             $self->_unlock_node ($node) or carp "Can't unlock node";
97             croak $saverr if $saverr;
98             return 1;
99             }
100              
101              
102              
103             =head2 new
104              
105             Like the C function from C, but also requires a
106             `wikiname' argument.
107              
108             =cut
109              
110             sub new {
111             my ($class, %args) = @_;
112             my $self = {};
113             bless $self, $class;
114              
115             # wikiname is required
116             croak "missing required `wikiname' argument" unless $args{wikiname};
117             $self->{wikiname} = $args{wikiname};
118              
119             # Set defaults for these arguments.
120             if (exists $args{convert_spaces}) {
121             $self->{convert_spaces} = $args{convert_spaces};
122             } else {
123             $self->{convert_spaces} = 1;
124             }
125              
126             $self->{default_date_format} = $args{default_date_format}
127             if $args{default_date_format};
128              
129             if (exists $args{ignore_case}) {
130             $self->{ignore_case} = $args{ignore_case};
131             } else {
132             $self->{ignore_case} = 1;
133             }
134              
135             # Call the parent initializer.
136             return $self->_init (%args);
137             }
138              
139             # Returns 1 if we can get a lock, 0 if we can't, croaks on error.
140             sub _lock_node
141             {
142             my ($self, $node) = @_;
143             my $dbh = $self->{_dbh};
144             $node = $dbh->quote ($node);
145             my $sql = "SELECT GET_LOCK($node, 10)";
146             my $sth = $dbh->prepare($sql);
147             $sth->execute or croak $dbh->errstr;
148             my $locked = $sth->fetchrow_array;
149             $sth->finish;
150             return $locked;
151             }
152              
153             # Returns 1 if we can unlock, 0 if we can't, croaks on error.
154             sub _unlock_node {
155             my ($self, $node) = @_;
156             my $dbh = $self->{_dbh};
157             $node = $dbh->quote($node);
158             my $sql = "SELECT RELEASE_LOCK($node)";
159             my $sth = $dbh->prepare($sql);
160             $sth->execute or croak $dbh->errstr;
161             my $unlocked = $sth->fetchrow_array;
162             $sth->finish;
163             return $unlocked;
164             }
165              
166              
167              
168             our @namespaces = qw{Talk User User_talk Project Project_talk Image Image_talk
169             MediaWiki MediaWiki_talk Template Template_talk Help
170             Help_talk Category Category_talk};
171            
172             # $store->__namespace_to_num ($node_name);
173             #
174             # Translate a node name containing a `:' into a Mediawiki namespace number.
175             sub __namespace_to_num
176             {
177             my ($self, $name) = @_;
178             $name =~ s/ /_/g if $self->{convert_spaces};
179             return 0, $name unless $name =~ /^([^:]+):(.*)$/;
180             return -2, $2 if $1 eq 'Media';
181             return -1, $2 if $1 eq 'Special';
182             return 4, $2 if $1 eq $self->{wikiname};
183             for (0 .. $#namespaces)
184             {
185             return $_ + 1, $2 if $1 eq $namespaces[$_];
186             }
187             return 0, $name;
188             }
189              
190              
191              
192             # $store->__num_to_namespace ($namespace_code, $node_name);
193             #
194             # Translate a Mediawiki namespace number into a node name containing a `:'.
195             sub __num_to_namespace
196             {
197             my ($self, $num, $name) = @_;
198             $name =~ s/_/ /g if $self->{convert_spaces};
199             return $name unless $num;
200             return "Media:$name" if $num == -2;
201             return "Special:$name" if $num == -1;
202             return $self->{wikiname} . ":$name" if $num == 4;
203             die "no such namespace $num"
204             unless $num > 0 && $num < @namespaces;
205             return "$namespaces[$num - 1]:$name";
206             }
207              
208              
209              
210             # turn the CGI::Wiki metadata fields of a search into a metadata hash
211             # substructure.
212             my @metadata_fields = qw{comment edit_type patrolled username};
213             sub _make_metadata
214             {
215             my $data = shift;
216             my %metadata;
217             @metadata{@metadata_fields} = map { [$_] } @$data{@metadata_fields};
218             $data->{metadata} = \%metadata;
219             }
220              
221              
222              
223             sub _make_date
224             {
225             my ($self, $date) = @_;
226             my $newdate;
227             my @strptime_args = ($date ? $date : "19700101000000", $timestamp_fmt);
228             push @strptime_args, stringify => $self->{default_date_format}
229             if $self->{default_date_format};
230             eval {
231             $newdate = Time::Piece::Adaptive->strptime (@strptime_args);
232             };
233             croak "bad timestamp (`$date').\n", $@ if $@;
234             return $newdate;
235             }
236              
237              
238              
239             # Returns hash or scalar depending on calling context.
240             sub _retrieve_node_data
241             {
242             my ($self, %args) = @_;
243             croak "Need name or version to lookup node"
244             unless $args{name} || $args{version};
245             my $dbh = $self->dbh;
246             my $sql;
247             my %data;
248             my @outfields = qw{content last_modified};
249             my $infields;
250             my $ignore_case = defined $args{ignore_case}
251             ? $args{ignore_case} : $self->{ignore_case};
252             if ($args{version})
253             {
254             $infields = "old_text";
255             if (wantarray)
256             {
257             push @outfields, qw{ns name};
258             $infields .= ", old_timestamp, old_namespace, old_title";
259             unless ($args{nometadata})
260             {
261             push @outfields, qw{edit_type username comment};
262             $infields .= ", old_minor_edit, old_user_text, old_comment";
263             }
264             }
265             $data{version} = $args{version} if $args{version};
266             $sql = "SELECT $infields"
267             . " FROM text"
268             . " WHERE old_id="
269             . $dbh->quote ($args{version});
270             }
271             else
272             {
273             my ($ns, $name) = $self->__namespace_to_num ($args{name});
274             $infields = "cur_text";
275             if (wantarray)
276             {
277             push @outfields, qw{version};
278             $infields .= ", page_touched, page_latest";
279             if ($ignore_case)
280             {
281             push @outfields, qw{ns name};
282             $infields .= ", page_namespace, page_title";
283             }
284             unless ($args{nometadata})
285             {
286             push @outfields, qw{edit_type username comment};
287             $infields .= ", cur_minor_edit, cur_user_text, cur_comment";
288             }
289             }
290             $sql = "SELECT $infields"
291             . " FROM cur, page"
292             . " WHERE page_namespace = $ns"
293             . " AND "
294             . $self->_get_cmp_sql ("page_title",
295             $self->charset_encode ($name),
296             $args{ignore_case})
297             . " AND cur_namespace = page_namespace"
298             . " AND cur_title = page_title";
299             }
300             my @results = $self->charset_decode ($dbh->selectrow_array ($sql));
301             return @results ? $results[0] : "" unless wantarray;
302             # @results = ("", 0, "") unless @results;
303             @data{@outfields} = @results;
304             if ($args{version} || $ignore_case)
305             {
306             $data{name} = $self->__num_to_namespace ($data{ns}, $data{name});
307             }
308             else
309             {
310             $data{name} = $args{name};
311             }
312             $data{edit_type} = $data{edit_type} ? "Minor tidying" : "Normal edit"
313             if defined $data{edit_type};
314             $data{last_modified} = $self->_make_date ($data{last_modified});
315             _make_metadata \%data unless $args{nometadata};
316             return %data;
317             }
318              
319              
320              
321             # $store->_retrieve_node_content (name => $node_name,
322             # version => $node_version);
323             # Params: 'name' is compulsory, 'version' is optional and defaults to latest.
324             # Returns a hash of data for C - content, version, last modified,
325             # or scalar, depending on context.
326             sub _retrieve_node_content
327             {
328             return _retrieve_node_data @_, nometadata => 1;
329             }
330              
331              
332              
333             =head2 list_all_nodes
334              
335             Like the parent function, but accepts limit & offset arguments.
336              
337             =cut
338              
339             sub list_all_nodes
340             {
341             my ($self, %args) = @_;
342             my $dbh = $self->dbh;
343              
344             my $fields;
345             if (wantarray)
346             {
347             $fields = "page_namespace, page_title";
348             }
349             else
350             {
351             $fields = "COUNT(*)";
352             }
353              
354             my $sql = "SELECT $fields FROM page";
355             my $limoffsql = _get_lim_off_sql (%args);
356             $sql .= " " . $limoffsql if $limoffsql;
357              
358             my $nodes = $dbh->selectall_arrayref ($sql);
359              
360             print STDERR "executing $sql\n"; # if $self->{debug};
361             return $nodes->[0]->[0] unless wantarray;
362              
363             return map {
364             $self->__num_to_namespace ($_->[0], $self->charset_decode ($_->[1]))
365             } @$nodes;
366             }
367              
368              
369              
370             =head2 list_recent_changes
371              
372             Like the parent method, but the C argument may be used in conjunction
373             with the others (C, C, and C are still mutually
374             exclusive). A new, $args{between_secs} argument is also processed. Its
375             contents should be two unix timestamps.
376              
377             =cut
378              
379             sub list_recent_changes
380             {
381             my $self = shift;
382             my %args = @_;
383              
384             my $exclusive = 0;
385             foreach my $option (qw{days since between_days between_secs})
386             {
387             $exclusive++ if $args{$option};
388             }
389             croak "between_days, days, between_secs, & since options are "
390             . "mutually exclusive"
391             if $exclusive > 1;
392              
393             $args{between_days} = [delete $args{days}, 0]
394             if $args{days};
395              
396             if ($args{between_days})
397             {
398             croak "two arguments required for between_days"
399             unless @{$args{between_days}} == 2;
400              
401             my $now = gmtime;
402             $args{between_secs} = [map {$now - $_ * ONE_DAY}
403             @{$args{between_days}}];
404             delete $args{between_days};
405             }
406              
407             $args{between_secs} = [delete $args{since}, gmtime]
408             if $args{since};
409              
410             if ($args{between_secs})
411             {
412             croak "two arguments required for between_secs"
413             unless @{$args{between_secs}} == 2;
414             $args{between_secs} = [map {scalar gmtime $_}
415             sort { $a <=> $b }
416             @{$args{between_secs}}];
417             }
418              
419             $args{limit} = delete $args{last_n_changes}
420             if $args{last_n_changes};
421              
422             return $self->_find_recent_changes_by_criteria (%args);
423             }
424              
425              
426              
427             sub _get_metadata_sql
428             {
429             my ($self, $is, $table_prefix, $metadata, %args) = @_;
430             my $sql;
431              
432             my $cmp;
433             if ($is)
434             {
435             $cmp = "=";
436             }
437             else
438             {
439             $cmp = "!=";
440             }
441              
442             foreach my $key (keys %$metadata)
443             {
444             if ($key eq "edit_type")
445             {
446             if ($metadata->{$key} eq "Minor tidying")
447             {
448             $sql .= " AND " . $table_prefix . "minor_edit $cmp 1"
449             }
450             elsif ($metadata->{$key} eq "Normal edit")
451             {
452             $sql .= " AND " . $table_prefix . "minor_edit $cmp 0"
453             }
454             else
455             {
456             confess "unrecognized edit_type: `" . $metadata->{$key} . "'";
457             }
458             }
459             elsif ($key eq "username")
460             {
461             $sql .= " AND " . ($is ? "" : "NOT ")
462             . $self->_get_cmp_sql ($table_prefix . "user_text",
463             $self->charset_encode ($metadata->{$key}),
464             $args{ignore_case});
465             }
466             elsif ($key eq "patrolled")
467             {
468             $sql .= " AND rc_patrolled $cmp " . $metadata->{$key};
469             }
470             else
471             {
472             confess "unimplemented metadata key: `$key'";
473             }
474             }
475              
476             return $sql;
477             }
478              
479              
480              
481             sub _get_lim_off_sql
482             {
483             my (%args) = @_;
484              
485             if (exists $args{limit})
486             {
487             croak "Bad argument limit=`$args{limit}'"
488             unless defined $args{limit} && $args{limit} =~ /^\d+$/;
489             }
490             if (exists $args{offset})
491             {
492             croak "Bad argument offset=`$args{offset}'"
493             unless defined $args{offset} && $args{offset} =~ /^\d+$/;
494              
495             # This number is big.
496             $args{limit} = 18446744073709551615 unless defined $args{limit};
497             }
498              
499             return (defined $args{limit} ? "LIMIT $args{limit}" : "")
500             . ($args{offset} ? " OFFSET $args{offset}" : "");
501             }
502              
503              
504              
505             sub _find_recent_changes_by_criteria
506             {
507             my ($self, %args) = @_;
508             my ($since, $between_days, $include_all_changes,
509             $metadata_is, $metadata_isnt, $metadata_was, $metadata_wasnt) =
510             @args{qw(since between_days include_all_changes
511             metadata_is metadata_isnt metadata_was metadata_wasnt)};
512             my $dbh = $self->dbh;
513             my $sql;
514             my $infields;
515             my @outfields;
516             my $ignore_case = exists $args{ignore_case}
517             ? $args{ignore_case} : $self->{ignore_case};
518              
519             my ($ns, $name) = $self->__namespace_to_num ($args{name})
520             if $args{name};
521              
522             my ($tables, $table_prefix);
523              
524             # Don't know the rationale for this complex algorithm to determine which
525             # table to use, but I copied it from CGI::Wiki::Store::Database. It works
526             # out such that, in order, include_all_changes == 1 will always force
527             # the view including history. metadata_is and metadata_isnt will always be
528             # processed, history or no, but if either is set then metadata_was and
529             # metadata_wasnt are ignored. If neither metadata_is and metadata_isnt are
530             # set, and either metadata_was or metadata_wasnt are set, then the view
531             # including history is selected, regardless of the value of
532             # include_all_changes.
533             #
534             # It seems to me like it would be easier to just accept two metadata
535             # arguments and let include_all_changes switch tables, but I am
536             # implementing this anyway for backwards compatibility.
537             if ($include_all_changes || (!($metadata_is || $metadata_isnt)
538             && ($metadata_was || $metadata_wasnt)))
539             {
540             $include_all_changes = 1;
541             $tables = "text LEFT JOIN recentchanges ON rc_this_oldid = old_id";
542             $table_prefix = "old_";
543             $metadata_is = $metadata_was unless $metadata_is;
544             $metadata_isnt = $metadata_wasnt unless $metadata_isnt;
545             }
546             else
547             {
548             $tables = "cur INNER JOIN page ON page_namespace = cur_namespace"
549             . " AND page_title = cur_title"
550             . " LEFT JOIN recentchanges ON rc_this_oldid = page_latest";
551             $table_prefix = "cur_";
552             }
553              
554              
555             if (wantarray)
556             {
557             if ($include_all_changes)
558             {
559             $infields = "old_id, rc_new, ";
560             }
561             else
562             {
563             $infields = "page_latest, cur_is_new, ";
564             }
565              
566             $infields .= join ", ", map {$table_prefix . $_}
567             qw{user_text comment timestamp
568             minor_edit};
569             @outfields = qw{version is_new username comment last_modified
570             edit_type};
571              
572             $infields .= ", rc_patrolled";
573             push @outfields, 'patrolled';
574              
575             unless ($args{name} && !$ignore_case)
576             {
577             $infields .= ", " . join ", ", map {$table_prefix . $_}
578             qw{namespace title};
579             push @outfields, qw{ns name};
580             }
581             }
582             else
583             {
584             $infields = "COUNT(*)";
585             }
586              
587             $sql = "SELECT $infields"
588             . " FROM $tables";
589              
590             $sql .= " WHERE 1 = 1";
591              
592             $sql .= " AND " . $table_prefix . "namespace = $ns"
593             . " AND "
594             . $self->_get_cmp_sql ($table_prefix . "title",
595             $self->charset_encode ($name),
596             $args{ignore_case})
597             if $args{name};
598              
599             if ($args{between_secs})
600             {
601             # This function assumes that it was called via recent_changes, which
602             # sorts the @{$args{between_secs}} array.
603             my ($s, $f) = map {defined $_ ? ($_->strftime ($timestamp_fmt)) : $_}
604             @{$args{between_secs}};
605             $sql .= " AND " . $table_prefix . "timestamp >= $s"
606             if $s;
607             $sql .= " AND " . $table_prefix . "timestamp <= $f"
608             if $f;
609             }
610              
611             $sql .= $self->_get_metadata_sql (1, $table_prefix, $metadata_is, %args)
612             if $metadata_is;
613             $sql .= $self->_get_metadata_sql (0, $table_prefix, $metadata_isnt, %args)
614             if $metadata_isnt;
615              
616             $sql .= " ORDER BY " . $table_prefix . "timestamp DESC";
617              
618             my $limoffsql = _get_lim_off_sql (%args);
619             $sql .= " " . $limoffsql if $limoffsql;
620              
621             print STDERR "executing $sql\n"; # if $self->{debug};
622             my $nodes = $dbh->selectall_arrayref ($sql);
623              
624             return $nodes->[0]->[0] unless wantarray;
625              
626             my @newnodes;
627             foreach my $i (0 .. (@$nodes - 1))
628             {
629             my %node;
630             @node{@outfields} = @{$nodes->[$i]};
631             if ($args{name} && !$ignore_case)
632             {
633             $node{name} = $args{name};
634             }
635             else
636             {
637             $node{name} =
638             $self->__num_to_namespace ($node{ns},
639             $self->charset_decode ($node{name}));
640             }
641             $node{edit_type} = $node{edit_type} ? "Minor tidying" : "Normal edit";
642             $node{last_modified} = $self->_make_date ($node{last_modified});
643             _make_metadata \%node;
644             push @newnodes, \%node;
645             }
646             return @newnodes;
647             }
648              
649              
650              
651             # $self->_get_cmp_sql (FIELD, TEXT, IGNORE_CASE)
652             # Return text that would return TRUE in a DB query's WHERE clause, if
653             # the contents of FIELD matches TEXT, honoring first IGNORE_CASE, then
654             # defaulting to $self->{ignore_case} when IGNORE_CASE is undefined.
655             sub _get_cmp_sql
656             {
657             my ($self, $field, $name, $ignore_case) = @_;
658             $ignore_case = $self->{ignore_case} unless defined $ignore_case;
659             my $dbh = $self->{_dbh};
660             return "NOT STRCMP($field, "
661             . ($ignore_case ? "" : "BINARY ")
662             . $dbh->quote ($name) . ")";
663             }
664              
665              
666             # $store->_get_relative_version ($node_name, $node_version, $direction);
667             # Return the version number of the previous or next node, as specified.
668             sub _get_relative_version
669             {
670             my ($self) = shift;
671              
672             my ($direction, $node, $version) = @_[0 .. 2];
673             my %args = @_[3 .. $#_] if @_ > 3;
674              
675             my ($ns, $name) = $self->__namespace_to_num ($node);
676             my $dbh = $self->dbh;
677             my $sql = "SELECT old_id FROM text"
678             . " WHERE old_namespace = $ns"
679             . " AND "
680             . $self->_get_cmp_sql ("old_title",
681             $self->charset_encode ($name),
682             $args{ignore_case})
683             . " AND old_id $direction $version"
684             . " ORDER BY old_id";
685             $sql .= " DESC" if $direction eq '<';
686             $sql .= " LIMIT 1";
687              
688             print STDERR "executing $sql\n"; # if $self->{debug};
689             my $ver = $dbh->selectrow_arrayref ($sql);
690             return $ver->[0];
691             }
692              
693              
694              
695             =head2 get_previous_version
696              
697             $store->get_previous_version ($node_name, $node_version, %other_args);
698              
699             Given a version number, returns the previous version for the given node.
700             This function is necessary because mediawiki gives every revision of every
701             page a version number which is unique across all pages.
702              
703             Techincally, node name shouldn't be necessary here, but it allows for a faster
704             search and you probably have it. Not requiring it would be an easy hack.
705              
706             =cut
707              
708             sub get_previous_version
709             {
710             my $self = shift;
711             return $self->_get_relative_version ('<', @_);
712             }
713              
714              
715              
716             =head2 get_next_version
717              
718             $store->get_next_version ($node_name, $node_version, %other_args);
719              
720             Given a version number, returns the next version for the given node.
721             This function is necessary because mediawiki gives every revision of every
722             page a version number which is unique across all pages.
723              
724             Techincally, node name shouldn't be necessary here, but it allows for a faster
725             search and you probably have it. Not requiring it would be an easy hack.
726              
727             =cut
728              
729             sub get_next_version
730             {
731             my $self = shift;
732             return $self->_get_relative_version ('>', @_);
733             }
734              
735              
736              
737             =head2 get_current_version
738              
739             $store->get_current_version ($node);
740             $store->get_current_version (name => $node, %other_args);
741              
742             Given a node, returns the current (most recent) version, or undef, if the node
743             does not exist.
744              
745             =cut
746              
747             sub get_current_version
748             {
749             my $self = shift;
750             my %args;
751              
752             if (@_ == 1)
753             {
754             $args{name} = $_[0];
755             }
756             else
757             {
758             %args = @_;
759             }
760              
761             my ($ns, $name) = $self->__namespace_to_num ($args{name});
762             my $dbh = $self->dbh;
763              
764             my $sql = "SELECT page_latest FROM page"
765             . " WHERE page_namespace = $ns"
766             . " AND "
767             . $self->_get_cmp_sql ("page_title",
768             $self->charset_encode ($name),
769             $args{ignore_case});
770             print STDERR "executing $sql\n"; # if $self->{debug};
771             my $ver = $dbh->selectrow_arrayref ($sql);
772             return $ver ? $ver->[0] : undef;
773             }
774              
775              
776              
777             sub _get_timestamp
778             {
779             my $self = shift;
780             # I don't care about no steenkin' timezones (yet).
781             my $time = shift || localtime; # Overloaded by Time::Piece::Adaptive.
782             # Make it into an object for strftime
783             $time = localtime $time unless ref $time;
784             return $time->strftime ($timestamp_fmt); # global
785             }
786              
787              
788              
789             =head2 write_node_post_locking
790              
791             Like the parent function, but works with the mediawiki DB.
792              
793             =cut
794              
795             sub write_node_post_locking
796             {
797             my ($self, %args) = @_;
798             my ($node, $content,
799             $links_to_ref, $metadata) = @args{qw(node content links_to
800             metadata)};
801             my $dbh = $self->dbh;
802              
803             croak "write_node_post_locking requires edit_type, and remote_ip metadata"
804             unless $metadata && $metadata->{edit_type};
805              
806             my $timestamp = $self->_get_timestamp ();
807             my @links_to = @{$links_to_ref || []}; # default to empty array
808              
809             my ($ns, $name) = $self->__namespace_to_num ($node);
810             my $sql;
811              
812             my $userid;
813             my $username;
814             if ($metadata->{username})
815             {
816             $sql = "SELECT user_id, user_name FROM user"
817             . " WHERE "
818             . $self->_get_cmp_sql ("user_name",
819             $self->charset_encode ($metadata->{username}),
820             $args{ignore_case});
821             print STDERR "executing $sql\n"; # if $self->{debug};
822             my $rec = $dbh->selectrow_arrayref ($sql)
823             or croak "unable to retrieve user `$username': " . $dbh->errstr;
824             $userid = $rec->[0];
825             $username = $rec->[1];
826             }
827             else
828             {
829             $username = $metadata->{remote_ip};
830             $userid = 0;
831             }
832              
833             # First, remember the previous version number.
834             my $old_old_id = $self->get_current_version ($node);
835              
836             # Always insert into the history table.
837             $sql = "INSERT INTO "
838             . "text (old_namespace, old_title, old_text, old_comment, "
839             . "old_user, old_user_text, old_timestamp, old_minor_edit, "
840             . "old_flags, inverse_timestamp)"
841             . " VALUES ($ns, "
842             . $dbh->quote ($self->charset_encode ($name)) . ", "
843             . $dbh->quote ($self->charset_encode ($content)) . ", "
844             . $dbh->quote ($self->charset_encode ($metadata->{comment})) . ", "
845             . "$userid, "
846             . $dbh->quote ($username)
847             . ", "
848             . $dbh->quote ($timestamp) . ", "
849             . ($metadata->{edit_type} eq 'Minor tidying' ? "1" : "0")
850             . ", 'utf-8', " . (99999999999999 - $timestamp) . ")";
851             print STDERR "executing $sql\n"; # if $self->{debug};
852             $dbh->do ($sql) or croak "Error updating database: " . $dbh->errstr;
853             my $new_old_id = $dbh->last_insert_id (undef, undef, undef, undef)
854             or croak "Error retrieving last insert id: " . $dbh->errstr;
855              
856             # Either inserting a new page or updating an old one.
857             my ($cur_id, $page_id);
858             if ($old_old_id)
859             {
860             # update cur & page tables
861             $sql = "SELECT cur_id FROM cur"
862             . " WHERE cur_namespace = $ns"
863             . " AND "
864             . $self->_get_cmp_sql ("cur_title",
865             $self->charset_encode ($name),
866             $args{ignore_case});
867             print STDERR "executing $sql\n"; # if $self->{debug};
868             $cur_id = $dbh->selectrow_arrayref ($sql)->[0]
869             or croak "Error retrieving cur id: " . $dbh->errstr;
870              
871             $sql = "UPDATE cur SET cur_text = "
872             . $dbh->quote ($self->charset_encode ($content)) . ", "
873             . "cur_comment = "
874             . $dbh->quote ($self->charset_encode ($metadata->{comment}))
875             . ", "
876             . "cur_user = $userid, "
877             . "cur_user_text = "
878             . $dbh->quote ($username) . ", "
879             . "cur_timestamp = " . $dbh->quote ($timestamp)
880             . ", "
881             . "cur_is_redirect = 0, "
882             . "cur_minor_edit = "
883             . ($metadata->{edit_type} eq 'Minor tidying' ? "1" : "0") . ", "
884             . "cur_is_new = 0, "
885             . "cur_touched = " . $dbh->quote ($timestamp)
886             . ", "
887             . "inverse_timestamp = "
888             . $dbh->quote (99999999999999 - $timestamp)
889             . " WHERE cur_id = $cur_id";
890             print STDERR "executing $sql\n"; # if $self->{debug};
891             $dbh->do ($sql) or croak "Error updating database: " . $dbh->errstr;
892              
893             $sql = "SELECT page_id FROM page"
894             . " WHERE page_namespace = $ns"
895             . " AND "
896             . $self->_get_cmp_sql ("page_title",
897             $self->charset_encode ($name),
898             $args{ignore_case});
899             print STDERR "executing $sql\n"; # if $self->{debug};
900             $page_id = $dbh->selectrow_arrayref ($sql)->[0]
901             or croak "Error retrieving page id: " . $dbh->errstr;
902              
903             $sql = "UPDATE page SET page_touched = " . $dbh->quote ($timestamp)
904             . ", "
905             . "page_is_redirect = 0, "
906             . "page_is_new = 0, "
907             . "page_latest = $new_old_id, "
908             . "page_len = "
909             . length ($self->charset_encode ($content))
910             . " WHERE page_id = $page_id";
911             print STDERR "executing $sql\n"; # if $self->{debug};
912             $dbh->do ($sql) or croak "Error updating database: " . $dbh->errstr;
913             }
914             else
915             {
916             $sql = "INSERT INTO cur (cur_namespace, cur_title, cur_text, "
917             . "cur_comment, cur_user, cur_user_text, "
918             . "cur_timestamp, cur_counter, cur_is_redirect, "
919             . "cur_is_new, cur_random, cur_minor_edit, "
920             . "cur_touched, inverse_timestamp)"
921             . " VALUES ($ns, "
922             . $dbh->quote ($self->charset_encode ($name)) . ", "
923             . $dbh->quote ($self->charset_encode ($content)) . ", "
924             . $dbh->quote ($self->charset_encode ($metadata->{comment}))
925             . ", $userid, "
926             . $dbh->quote ($username) . ", "
927             . $dbh->quote ($timestamp)
928             . ", 0, 0, 1, 0, "
929             . ($metadata->{edit_type} eq 'Minor tidying' ? "1" : "0") . ", "
930             . $dbh->quote ($timestamp) . ", "
931             . $dbh->quote (99999999999999 - $timestamp) . ")";
932             print STDERR "executing $sql\n"; # if $self->{debug};
933             $dbh->do ($sql) or croak "Error updating database: " . $dbh->errstr;
934              
935             $cur_id = $dbh->last_insert_id (undef, undef, undef, undef)
936             or croak "Error retrieving last insert id: " . $dbh->errstr;
937              
938             $sql = "INSERT INTO page (page_namespace, page_title, page_touched, "
939             . "page_counter, page_is_redirect, "
940             . "page_is_new, page_random, page_latest, "
941             . "page_len)"
942             . " VALUES ($ns, "
943             . $dbh->quote ($self->charset_encode ($name)) . ", "
944             . $dbh->quote ($timestamp)
945             . ", 0, 0, 1, 0, $new_old_id, "
946             . length ($self->charset_encode ($content)) . ")";
947             print STDERR "executing $sql\n"; # if $self->{debug};
948             $dbh->do ($sql) or croak "Error updating database: " . $dbh->errstr;
949              
950             $page_id = $dbh->last_insert_id (undef, undef, undef, undef)
951             or croak "Error retrieving last insert id: " . $dbh->errstr;
952              
953             # Fix broken links that are repaired by this insert.
954             $sql = "SELECT bl_from FROM brokenlinks"
955             . " WHERE "
956             . $self->_get_cmp_sql ("bl_to", $self->charset_encode ($node),
957             $args{ignore_case});
958             print STDERR "executing $sql\n"; # if $self->{debug};
959             my $pbls = $dbh->selectall_arrayref ($sql)
960             or croak $dbh->errstr;
961             if (@$pbls)
962             {
963             $sql = "DELETE FROM brokenlinks"
964             . " WHERE "
965             . $self->_get_cmp_sql ("bl_to",
966             $self->charset_encode ($node),
967             $args{ignore_case});
968             print STDERR "executing $sql\n"; # if $self->{debug};
969             $dbh->do ($sql)
970             or croak $dbh->errstr;
971              
972             # Assuming this is already in pagelinks.
973             $sql = "INSERT INTO links (l_from, l_to) VALUES (?, $page_id)";
974             my $sth = $dbh->prepare ($sql) or croak $dbh->errstr;
975             foreach (@$pbls)
976             {
977             $sth->execute ($_) or croak $dbh->errstr;
978             }
979             $sth->finish;
980             }
981             }
982              
983             # Always insert into the recent changes table.
984             $sql = "INSERT INTO "
985             . "recentchanges (rc_timestamp, rc_cur_time, rc_user, "
986             . "rc_user_text, rc_namespace, rc_title, "
987             . "rc_comment, rc_minor, rc_bot, rc_new, "
988             . "rc_cur_id, rc_this_oldid, rc_last_oldid, "
989             . "rc_type, rc_moved_to_ns, rc_patrolled, rc_ip)"
990             . " VALUES ("
991             . $dbh->quote ($timestamp) . ", "
992             . $dbh->quote ($timestamp)
993             . ", $userid, "
994             . $dbh->quote ($username)
995             . ", $ns, "
996             . $dbh->quote ($self->charset_encode ($name)) . ", "
997             . $dbh->quote ($self->charset_encode ($metadata->{comment})) . ", "
998             . ($metadata->{edit_type} eq 'Minor tidying' ? 1 : 0)
999             . ", 0, "
1000             . (defined $old_old_id ? 0 : 1)
1001             . ", $cur_id, $new_old_id, "
1002             . (defined $old_old_id ? $old_old_id : 0)
1003             . ", 0, $ns, 0, "
1004             . $dbh->quote ($metadata->{remote_ip})
1005             . ")";
1006             print STDERR "executing $sql\n"; # if $self->{debug};
1007             $dbh->do ($sql) or croak "Error updating database: " . $dbh->errstr;
1008              
1009             # Add to backlinks if possible
1010             $dbh->do ("DELETE FROM links "
1011             . " WHERE l_from = $page_id")
1012             or croak $dbh->errstr;
1013             $dbh->do ("DELETE FROM brokenlinks "
1014             . " WHERE bl_from = $page_id")
1015             or croak $dbh->errstr;
1016             $dbh->do ("DELETE FROM pagelinks "
1017             . " WHERE pl_from = $page_id")
1018             or croak $dbh->errstr;
1019              
1020             my $lastlink;
1021             my @locallinks;
1022             foreach (@links_to)
1023             {
1024             next if $lastlink && $_ eq $lastlink;
1025             $lastlink = $_;
1026             push @locallinks, $_ unless m(^\w+://) || /^\s*$/;
1027             }
1028              
1029             if (@locallinks)
1030             {
1031             my ($s, $n);
1032             $sql = "INSERT INTO links (l_from, l_to)"
1033             . " SELECT $page_id, page_id FROM page"
1034             . " WHERE "
1035             . join (" OR ",
1036             map {($s, $n) = $self->__namespace_to_num ($_);
1037             "(page_namespace = $s AND "
1038             . $self->_get_cmp_sql ("page_title",
1039             $self->charset_encode ($n),
1040             $args{ignore_case}) . ")"}
1041             @locallinks);
1042             print STDERR "executing $sql\n"; # if $self->{debug};
1043             $dbh->do ($sql) or croak $dbh->errstr;
1044             }
1045              
1046             # Insert into the pagelinks table, which records everything, and the
1047             # brokenlinks table, which records linked pages that don't exist.
1048             $sql = "INSERT INTO pagelinks (pl_from, pl_namespace, pl_title)"
1049             . " VALUES ($page_id, ?, ?)";
1050             my $st1 = $dbh->prepare ($sql) or croak $dbh->errstr;
1051             $sql = "SELECT page_id FROM page"
1052             . " WHERE page_namespace = ? AND page_title = ?";
1053             my $st2 = $dbh->prepare ($sql) or croak $dbh->errstr;
1054             $sql = "INSERT INTO brokenlinks (bl_from, bl_to)"
1055             . " VALUES ($page_id, ?)";
1056             my $st3 = $dbh->prepare ($sql) or croak $dbh->errstr;
1057             foreach my $link (sort @locallinks)
1058             {
1059             my $en = ($self->charset_encode ($link))[0];
1060             my ($s, $n) = $self->__namespace_to_num ($en);
1061             print STDERR "link=$link;en=$en;s=$s;n=$n\n";
1062              
1063             $st1->execute ($s, $n);
1064             $st2->execute ($s, $n);
1065             my ($tid) = $st2->fetchrow_array;
1066             $st3->execute ($en) unless $tid;
1067             }
1068             $st1->finish;
1069             $st2->finish;
1070             $st3->finish;
1071              
1072             # And also store any metadata. Note that any entries already in the
1073             # metadata table refer to old versions, so we don't need to delete them.
1074             foreach my $type (keys %$metadata)
1075             {
1076             croak "unknown metadata key `$type'"
1077             unless grep qr/^\Q$type\E$/, (qw{comment edit_type formatter
1078             username remote_ip});
1079             }
1080              
1081             # Finally call post_write on any plugins.
1082             my @plugins = @{$args{plugins} || [ ]};
1083             foreach my $plugin (@plugins) {
1084             if ($plugin->can ("post_write"))
1085             {
1086             $plugin->post_write (node => $node,
1087             version => $new_old_id,
1088             content => $content,
1089             metadata => $metadata);
1090             }
1091             }
1092              
1093             return 1;
1094             }
1095              
1096              
1097              
1098             =head2 node_exists
1099              
1100             $store->node_exists ($node);
1101             $store->node_exists (name => $node, %other_args);
1102              
1103             Like the parent function of the same name, but much faster. Really just
1104             a wrapper for get_current_version, returns the current version number when
1105             it exists and undef otherwise.
1106              
1107             =cut
1108              
1109             sub node_exists
1110             {
1111             my $self = shift;
1112             return $self->get_current_version (@_);
1113             }
1114              
1115              
1116              
1117             =head2 list_backlinks
1118              
1119             # List all nodes that link to the Home Page.
1120             my @links = $store->list_backlinks (node => "Home Page");
1121              
1122             =cut
1123              
1124             sub list_backlinks
1125             {
1126             my ($self, %args) = @_;
1127             my $node = $args{node};
1128             croak "Must supply a node name" unless $node;
1129              
1130             my ($ns, $name) = $self->__namespace_to_num ($node);
1131             my $dbh = $self->dbh;
1132              
1133             my $fields = "DISTINCT page_namespace, page_title";
1134             $fields = "COUNT($fields)" unless wantarray;
1135              
1136             my $sql = "SELECT $fields"
1137             . " FROM page p, pagelinks pl"
1138             . " WHERE pl_namespace = $ns"
1139             . " AND "
1140             . $self->_get_cmp_sql ("pl_title",
1141             $self->charset_encode ($name),
1142             $args{ignore_case})
1143             . " AND page_id = pl_from";
1144              
1145             my $limoffsql = _get_lim_off_sql (%args);
1146             $sql .= " " . $limoffsql if $limoffsql;
1147              
1148             print STDERR "executing $sql\n"; # if $self->{debug};
1149             my $sth = $dbh->prepare ($sql);
1150             $sth->execute or croak $dbh->errstr;
1151              
1152             return ($sth->fetchrow_array)[0] unless wantarray;
1153              
1154             my @backlinks;
1155             while (my ($ns_from, $from) = $sth->fetchrow_array)
1156             {
1157             push @backlinks,
1158             $self->__num_to_namespace ($ns_from,
1159             $self->charset_decode ($from));
1160             }
1161             return @backlinks;
1162             }
1163              
1164              
1165              
1166             =head2 list_dangling_links
1167              
1168             # List all nodes that have been linked to from other nodes but don't
1169             # yet exist.
1170             my @links = $store->list_dangling_links;
1171              
1172             Each node is returned once only, regardless of how many other nodes
1173             link to it. Nodes are be returned unsorted.
1174              
1175             =cut
1176              
1177             sub list_dangling_links
1178             {
1179             my $self = shift;
1180             my $dbh = $self->dbh;
1181             my $sql = "SELECT DISTINCT bl_to FROM brokenlinks";
1182             my $sth = $dbh->prepare ($sql);
1183             print STDERR "executing $sql\n"; # if $self->{debug};
1184             $sth->execute or croak $dbh->errstr;
1185             my @links;
1186             while (my ($link) = $self->charset_decode ($sth->fetchrow_array))
1187             {
1188             push @links, $link;
1189             }
1190             return @links;
1191             }
1192              
1193              
1194              
1195             =head2 list_dangling_links_w_count
1196              
1197             # List all nodes that have been linked to from other nodes but don't
1198             # yet exist, with a reference count.
1199             foreach my $link ($store->list_dangling_links_w_count)
1200             {
1201             print "Missing `", $link->[0], "' has ", $link->[1], " references.\n";
1202             }
1203              
1204             Nodes are returned sorted primarily by the reference count, greatest first, and
1205             secondarily in alphabetical order.
1206              
1207             =cut
1208              
1209             sub list_dangling_links_w_count
1210             {
1211             my ($self, %args) = @_;
1212             my $dbh = $self->dbh;
1213             my ($fields, $tail);
1214              
1215             if (wantarray)
1216             {
1217             $fields = "bl_to, COUNT(*)";
1218             $tail = "GROUP BY bl_to ORDER BY COUNT(*) DESC, bl_to";
1219             }
1220             else
1221             {
1222             $fields = "COUNT(DISTINCT bl_to)";
1223             }
1224              
1225             my $limoffsql = _get_lim_off_sql (%args);
1226             $tail .= ($tail ? " " : "") . $limoffsql if $limoffsql;
1227              
1228             my $sql = "SELECT $fields FROM brokenlinks";
1229             $sql .= " " . $tail if $tail;
1230              
1231             print STDERR "executing $sql\n"; # if $self->{debug};
1232             my $sth = $dbh->prepare ($sql);
1233             $sth->execute or croak $dbh->errstr;
1234              
1235             return ($sth->fetchrow_array)[0] unless wantarray;
1236              
1237             my @links;
1238             while (my @row = $sth->fetchrow_array)
1239             {
1240             push @links, [$self->charset_decode ($row[0]), $row[1]];
1241             }
1242             return @links;
1243             }
1244              
1245              
1246              
1247             =head2 validate_user
1248              
1249             my $username = $store->validate_user ($username, $password, %other_args);
1250              
1251             Given a username and a password, return the username if it exists and password
1252             is correct, or undef, otherwise.
1253              
1254             The returned username may be different from the one passed in when
1255             $args{ignore_case} is set.
1256              
1257             =cut
1258              
1259             use Digest::MD5 qw(md5_hex);
1260             sub validate_user
1261             {
1262             my ($self, $username, $password, %args) = @_;
1263             my $dbh = $self->{_dbh};
1264            
1265             my $sql = "SELECT user_id, user_password, user_name FROM user"
1266             . " WHERE "
1267             . $self->_get_cmp_sql ("user_name",
1268             $self->charset_encode ($username),
1269             $args{ignore_case});
1270              
1271             print STDERR "executing $sql\n"; # if $self->{debug};
1272             my $userinfo = $dbh->selectall_arrayref ($sql)
1273             or croak "Error retrieving user info: " . $dbh->errstr;
1274              
1275             # Check that one and only one user was found.
1276             return undef unless @$userinfo; # failed login
1277             die "multiple users found matching `$username'"
1278             unless @$userinfo == 1; # Corrupt database.
1279              
1280             # Check the password.
1281             $userinfo = $userinfo->[0];
1282             my $ep = md5_hex ($userinfo->[0] . "-" . md5_hex ($password));
1283             return undef unless $ep eq $userinfo->[1];
1284              
1285             # Return the real username, in case case is being ignored.
1286             return $userinfo->[2];
1287             }
1288              
1289              
1290              
1291             =head2 create_new_user
1292              
1293             my $errmsg = $store->create_new_user (name => $username, password => $p);
1294              
1295             Create a new user. C and C are required arguments.
1296             Optional arguments are C & C.
1297              
1298             Returns a potentially empty list of error messages.
1299              
1300             =cut
1301              
1302             sub create_new_user
1303             {
1304             my ($self, %args) = @_;
1305             my @errors;
1306              
1307             croak "name & password are required arguments"
1308             unless $args{name} && $args{password};
1309              
1310             my $dbh = $self->{_dbh};
1311              
1312             # Verify that the user does not exist.
1313             my $sql = "SELECT user_name FROM user"
1314             . " WHERE "
1315             . $self->_get_cmp_sql ("user_name",
1316             $self->charset_encode ($args{name}),
1317             $args{ignore_case});
1318             print STDERR "executing $sql\n"; # if $self->{debug};
1319             my $userinfo = $dbh->selectall_arrayref ($sql)
1320             or croak "Error retrieving user info: " . $dbh->errstr;
1321              
1322             # Check that one and only one user was found.
1323             if (@$userinfo)
1324             {
1325             push @errors, "User `" . $userinfo->[0]->[0] . "' already exists.";
1326             return @errors;
1327             }
1328              
1329             # Insert the new entry.
1330             my (@fields, @values);
1331             for my $field (qw{name real_name email})
1332             {
1333             if (exists $args{$field})
1334             {
1335             push @fields, "user_$field";
1336             push @values, $dbh->quote ($self->charset_encode ($args{$field}));
1337             }
1338             }
1339             $sql = "INSERT INTO user (" . join (", ", @fields)
1340             . ") VALUES (" . join (", ", @values) . ")";
1341             print STDERR "executing $sql\n"; # if $self->{debug};
1342             $dbh->do ($sql) or croak "Error updating database: " . $dbh->errstr;
1343              
1344             # Get the new user ID and update the password.
1345             my $new_uid = $dbh->last_insert_id (undef, undef, undef, undef)
1346             or croak "Error retrieving last insert id: " . $dbh->errstr;
1347              
1348             # Encode the password.
1349             my $ep = md5_hex ($new_uid . "-" . md5_hex ($args{password}));
1350              
1351             # Update the password.
1352             $sql = "UPDATE user SET user_password = " . $dbh->quote ($ep)
1353             . " WHERE user_id = $new_uid";
1354             print STDERR "executing $sql\n"; # if $self->{debug};
1355             $dbh->do ($sql) or croak "Error updating database: " . $dbh->errstr;
1356              
1357             return @errors;
1358             }
1359              
1360              
1361              
1362             =head1 SEE ALSO
1363              
1364             =over 4
1365              
1366             =item L
1367              
1368             =item L
1369              
1370             =item L
1371              
1372             =item L
1373              
1374             =back
1375              
1376             =head1 AUTHOR
1377              
1378             Derek Price, C<< >>
1379              
1380             =head1 BUGS
1381              
1382             Please report any bugs or feature requests to
1383             C, or through the web interface at
1384             L.
1385             I will be notified, and then you'll automatically be notified of progress on
1386             your bug as I make changes.
1387              
1388             =head1 SUPPORT
1389              
1390             You can find documentation for this module with the perldoc command.
1391              
1392             perldoc CGI::Wiki::Store::Mediawiki
1393              
1394             You can also look for information at:
1395              
1396             =over 4
1397              
1398             =item * AnnoCPAN: Annotated CPAN documentation
1399              
1400             L
1401              
1402             =item * CPAN Ratings
1403              
1404             L
1405              
1406             =item * RT: CPAN's request tracker
1407              
1408             L
1409              
1410             =item * Search CPAN
1411              
1412             L
1413              
1414             =back
1415              
1416             =head1 COPYRIGHT & LICENSE
1417              
1418             Copyright 2006 Derek Price, all rights reserved.
1419              
1420             This program is free software; you can redistribute it and/or modify it
1421             under the same terms as Perl itself.
1422              
1423             =cut
1424              
1425             1; # End of CGI::Wiki::Store::Mediawiki