diff options
author | Yorhel <git@yorhel.nl> | 2019-09-23 11:01:17 +0200 |
---|---|---|
committer | Yorhel <git@yorhel.nl> | 2019-09-23 11:01:17 +0200 |
commit | 9ccafba60b8de37b0c2a202d22df5c3e25b78026 (patch) | |
tree | ae150b0c25282a646e1d71868022ad4a5789e60a | |
parent | 4542d952f782ee193dbf279cd8186cba0e9d87a4 (diff) |
v2rw: Convert doc pages + add framework for item fetching & display & revisions
This bumps the minimum Perl version to 5.26 in order to make use of
lexical subroutines - a feature I've been wanting for a while. This
should be the last version bump, 5.26 is the highest version in Ubuntu
LTS at the moment. Not that I use Ubuntu, but it's used by the Docker
container and it's a sensible reference.
I merged the 'maintabs' and 'hiddenmsg' features into the primary
framework_ function; It fits quite well there, removes a little bit
of boilerplate from the DB entry page code and reduces the reliance on
common "dbSomethingGet()" methods.
I was hoping I'd be able to reduce the boilerplate required for defining
revisions, but I don't think that's going to happen. What I did do was
reimplement the diffing to handle item and text diffs separately, with
sensible defaults for the old split/join/diff options. Diffing is now
performed on the raw structured data rather than on formatted HTML,
which, combined with the db_entry() functions, ought to be less brittle.
-rw-r--r-- | README.md | 2 | ||||
-rw-r--r-- | lib/VNDB/Handler/Docs.pm | 106 | ||||
-rw-r--r-- | lib/VNDB/Schema.pm | 2 | ||||
-rw-r--r-- | lib/VNWeb/Auth.pm | 1 | ||||
-rw-r--r-- | lib/VNWeb/DB.pm | 84 | ||||
-rw-r--r-- | lib/VNWeb/Docs/Lib.pm | 90 | ||||
-rw-r--r-- | lib/VNWeb/Docs/Page.pm | 67 | ||||
-rw-r--r-- | lib/VNWeb/HTML.pm | 330 | ||||
-rw-r--r-- | lib/VNWeb/Prelude.pm | 76 | ||||
-rw-r--r-- | lib/VNWeb/Validation.pm | 26 |
10 files changed, 669 insertions, 115 deletions
@@ -46,7 +46,7 @@ Global requirements: - Linux, or an OS that resembles Linux. Chances are VNDB won't run on Windows. - PostgreSQL 10 (older versions may work) -- Perl 5.24+ +- Perl 5.26+ - Elm 0.19 **Perl modules** (core modules are not listed): diff --git a/lib/VNDB/Handler/Docs.pm b/lib/VNDB/Handler/Docs.pm index d928a07c..4fabf1d3 100644 --- a/lib/VNDB/Handler/Docs.pm +++ b/lib/VNDB/Handler/Docs.pm @@ -7,116 +7,14 @@ use warnings; use TUWF ':html'; use VNDB::Func; use Text::MultiMarkdown 'markdown'; +use VNWeb::Docs::Lib; TUWF::register( - qr{d([1-9]\d*)(?:\.([1-9]\d*))?} => \&page, qr{d([1-9]\d*)(?:\.([1-9]\d*))?/edit} => \&edit, ); -sub _html { - my $content = shift; - - $content =~ s{^:MODERATORS:$}{ - my $l = tuwf->dbUserGet(results => 100, sort => 'id', notperm => tuwf->{default_perm}, what => 'extended'); - my $admin = 0; - $admin |= $_ for values %{ tuwf->{permissions} }; - '<dl>'.join('', map { - my $u = $_; - my $p = $u->{perm} >= $admin ? 'admin' : join ', ', sort map +($u->{perm} &~ tuwf->{default_perm}) & tuwf->{permissions}{$_} ? $_ : (), keys %{ tuwf->{permissions} }; - $p ? sprintf('<dt><a href="/u%d">%s</a></dt><dd>%s</dd>', $_->{id}, $_->{username}, $p) : () - } @$l).'</dl>'; - }me; - $content =~ s{^:SKINCONTRIB:$}{ - my %users; - push @{$users{ tuwf->{skins}{$_}[1] }}, [ $_, tuwf->{skins}{$_}[0] ] - for sort { tuwf->{skins}{$a}[0] cmp tuwf->{skins}{$b}[0] } keys %{ tuwf->{skins} }; - my $u = tuwf->dbUserGet(uid => [ keys %users ]); - '<dl>'.join('', map sprintf('<dt><a href="/u%d">%s</a></dt><dd>%s</dd>', - $_->{id}, $_->{username}, join(', ', map sprintf('<a href="?skin=%s">%s</a>', $_->[0], $_->[1]), @{$users{$_->{id}}}) - ), @$u).'</dl>'; - }me; - - my $html = markdown $content, { - strip_metadata => 1, - img_ids => 0, - disable_footnotes => 1, - disable_bibliography => 1, - }; - - # Number sections and turn them into links - my($sec, $subsec) = (0,0); - $html =~ s{<h([1-2])[^>]+>(.*?)</h\1>}{ - if($1 == 1) { - $sec++; - $subsec = 0; - qq{<h3><a href="#$sec" name="$sec">$sec. $2</a></h3>} - } elsif($1 == 2) { - $subsec++; - qq|<h4><a href="#$sec.$subsec" name="$sec.$subsec">$sec.$subsec. $2</a></h4>\n| - } - }ge; - - # Text::MultiMarkdown doesn't handle fenced code blocks properly. The - # following solution breaks inline code blocks, but I don't use those anyway. - $html =~ s/<code>/<pre>/g; - $html =~ s#</code>#</pre>#g; - - $html -} - - -sub page { - my($self, $id, $rev) = @_; - - my $method = $rev ? 'dbDocGetRev' : 'dbDocGet'; - my $d = $self->$method(id => $id, $rev ? ( rev => $rev ) : ())->[0]; - return $self->resNotFound if !$d->{id}; - - $self->htmlHeader(title => $d->{title}, noindex => $rev); - $self->htmlMainTabs(d => $d); - return if $self->htmlHiddenMessage('d', $d); - - if($rev) { - my $prev = $rev && $rev > 1 && $self->dbDocGetRev(id => $id, rev => $rev-1)->[0]; - $self->htmlRevision('d', $prev, $d, - [ title => 'Title', diff => 1 ], - [ content => 'Content', diff => qr/\s+/, short_diff => 1 ], - ); - } - - div class => 'mainbox'; - h1 $d->{title}; - div class => 'docs'; - ul class => 'index'; - li; b 'Guidelines'; end; - li; a href => '/d5', 'Editing Guidelines'; end; - li; a href => '/d2', 'Visual Novels'; end; - li; a href => '/d15', 'Special Games'; end; - li; a href => '/d3', 'Releases'; end; - li; a href => '/d4', 'Producers'; end; - li; a href => '/d16', 'Staff'; end; - li; a href => '/d12', 'Characters'; end; - li; a href => '/d10', 'Tags & Traits'; end; - li; a href => '/d13', 'Capturing Screenshots'; end; - li; b 'About VNDB'; end; - li; a href => '/d9', 'Discussion Board'; end; - li; a href => '/d6', 'FAQ'; end; - li; a href => '/d7', 'About Us'; end; - li; a href => '/d17', 'Privacy Policy & Licensing'; end; - li; a href => '/d11', 'Database API'; end; - li; a href => '/d14', 'Database Dumps'; end; - li; a href => '/d18', 'Database Querying'; end; - li; a href => '/d8', 'Development'; end; - end; - lit _html $d->{content}; - end; - end; - $self->htmlFooter; -} - - sub edit { my($self, $id, $rev) = @_; @@ -161,7 +59,7 @@ sub edit { div class => 'mainbox'; h1 'Preview'; div class => 'docs'; - lit _html $frm->{content}; + lit md2html $frm->{content}; end; end; } diff --git a/lib/VNDB/Schema.pm b/lib/VNDB/Schema.pm index 1094356c..b6e476b6 100644 --- a/lib/VNDB/Schema.pm +++ b/lib/VNDB/Schema.pm @@ -15,6 +15,7 @@ my $ROOT = $INC{'VNDB/Schema.pm'} =~ s{/lib/VNDB/Schema\.pm}{}r; # Reads schema.sql and returns a hashref with the following structure: # { # vn => { +# name => 'vn', # dbentry_type => 'v', # cols => [ # { @@ -40,6 +41,7 @@ sub schema { if(/^\s*CREATE\s+TABLE\s+([^ ]+)/) { die "Unexpected 'CREATE TABLE $1'\n" if $table; $table = $1; + $schema{$table}{name} = $table; $schema{$table}{dbentry_type} = $1 if /--.*\s+dbentry_type=(.)/; $schema{$table}{cols} = []; diff --git a/lib/VNWeb/Auth.pm b/lib/VNWeb/Auth.pm index 22a96de5..493db3f4 100644 --- a/lib/VNWeb/Auth.pm +++ b/lib/VNWeb/Auth.pm @@ -209,7 +209,6 @@ sub resetpass { my $id = tuwf->dbVali( select => sql_func(user_resetpass => \$mail, sql_fromhex sha1_hex lc $token) ); - warn $id; return $id ? ($id, $token) : (); } diff --git a/lib/VNWeb/DB.pm b/lib/VNWeb/DB.pm index 58a972a8..b839baf3 100644 --- a/lib/VNWeb/DB.pm +++ b/lib/VNWeb/DB.pm @@ -6,11 +6,13 @@ use TUWF; use SQL::Interp ':all'; use Carp 'carp'; use Exporter 'import'; +use VNDB::Schema; our @EXPORT = qw/ sql sql_join sql_comma sql_and sql_array sql_func sql_fromhex sql_tohex sql_fromtime sql_totime enrich enrich_merge enrich_flatten + db_entry /; @@ -177,4 +179,86 @@ sub enrich_flatten { } + +# Database entry API: Intended to provide a low-level read/write interface for +# versioned database entires. The same data structure is used for reading and +# updating entries, and should support easy diffing/comparison. +# Not very convenient for general querying & searching, those still need custom +# queries. + + +# Hash table, something like: +# { +# v => { +# prefix => 'vn', +# base => { .. 'vn_hist' schema } +# tables => { +# anime => { .. 'vn_anime_hist' schema } +# }, +# }, .. +# } +my $entry_types = do { + my $schema = VNDB::Schema::schema; + my %types = map +($_->{dbentry_type}, { prefix => $_->{name} }), grep $_->{dbentry_type}, values %$schema; + for my $t (values %$schema) { + my $n = $t->{name}; + my($type) = grep $n =~ s/^$_->{prefix}_//, values %types; + next if !$type; + $type->{base} = $t if $n eq 'hist'; + next if $n !~ s/_hist$//; + $type->{tables}{$n} = $t; + } + \%types; +}; + + +# Returns everything for a specific entry ID. The top-level hash also includes +# the following keys: +# +# id, chid, rev, maxrev, hidden, locked, entry_hidden, entry_locked +# +# (Ordering of arrays is unspecified) +# +# TODO: +# - Use non _hist tables if $maxrev == $rev (should be faster) +# - Combine the enrich_merge() calls into a single query. +sub db_entry { + my($type, $id, $rev) = @_; + my $t = $entry_types->{$type}||die; + + my $maxrev = tuwf->dbVali('SELECT MAX(rev) FROM changes WHERE type =', \$type, ' AND itemid =', \$id); + return undef if !$maxrev; + $rev ||= $maxrev; + my $entry = tuwf->dbRowi(q{ + SELECT itemid AS id, id AS chid, rev AS chrev, ihid AS hidden, ilock AS locked + FROM changes + WHERE}, { type => $type, itemid => $id, rev => $rev } + ); + return undef if !$entry->{id}; + $entry->{maxrev} = $maxrev; + + if($maxrev == $rev) { + $entry->{entry_hidden} = $entry->{hidden}; + $entry->{entry_locked} = $entry->{locked}; + } else { + my $base = $t->{base}{name} =~ s/_hist$//r; + enrich_merge id => "SELECT id, hidden AS entry_hidden, locked AS entry_locked FROM \"$base\" WHERE id IN", $entry; + } + + enrich_merge chid => sql( + SELECT => sql_comma(map "\"$_->{name}\"", $t->{base}{cols}->@*), + FROM => "\"$t->{base}{name}\"", + 'WHERE chid IN' + ), $entry; + + while(my($name, $tbl) = each $t->{tables}->%*) { + $entry->{$name} = tuwf->dbAlli( + SELECT => sql_comma(map "\"$_->{name}\"", grep $_->{name} ne 'chid', $tbl->{cols}->@*), + FROM => "\"$tbl->{name}\"", + WHERE => { chid => $entry->{chid} } + ); + } + $entry +} + 1; diff --git a/lib/VNWeb/Docs/Lib.pm b/lib/VNWeb/Docs/Lib.pm new file mode 100644 index 00000000..eed1afc0 --- /dev/null +++ b/lib/VNWeb/Docs/Lib.pm @@ -0,0 +1,90 @@ +package VNWeb::Docs::Lib; + +use VNWeb::Prelude; +use Text::MultiMarkdown 'markdown'; + +our @EXPORT = qw/md2html/; + + +# Lets you call TUWF::XML functions and returns a string, doesn't affect any existing TUWF::XML outputs. +# Nice idea for a TUWF::XML feature. +sub lexicalxml(&) { + my $f = shift; + my $buf = ''; + local $TUWF::XML::OBJ = TUWF::XML->new(write => sub { $buf .= shift }); + $f->(); + $buf +} + + +sub _moderators { + my $l = tuwf->dbAlli('SELECT id, username, perm FROM users WHERE (perm & ', \(auth->allPerms &~ auth->defaultPerms), ') > 0 ORDER BY id LIMIT 100'); + my @modperms = grep 0 == (auth->listPerms->{$_} & auth->defaultPerms), keys auth->listPerms->%*; + + lexicalxml { + dl_ sub { + for my $u (@$l) { + dt_ sub { a_ href => "/u$u->{id}", $u->{username} }; + dd_ auth->allPerms == ($u->{perm} & auth->allPerms) ? 'admin' + : join ', ', sort grep $u->{perm} & auth->listPerms->{$_}, @modperms; + } + } + } +} + + +sub _skincontrib { + my %users; + push $users{ tuwf->{skins}{$_}[1] }->@*, [ $_, tuwf->{skins}{$_}[0] ] + for sort { tuwf->{skins}{$a}[0] cmp tuwf->{skins}{$b}[0] } keys tuwf->{skins}->%*; + + my $u = tuwf->dbAlli('SELECT id, username FROM users WHERE id IN', [keys %users]); + + lexicalxml { + dl_ sub { + for my $u (@$u) { + dt_ sub { a_ href => "/u$u->{id}", $u->{username} }; + dd_ sub { + join_ ', ', sub { a_ href => "?skin=$_->[0]", $_->[1] }, $users{$u->{id}}->@* + } + } + } + } +} + + +sub md2html { + my $content = shift; + + $content =~ s{^:MODERATORS:$}{_moderators}me; + $content =~ s{^:SKINCONTRIB:$}{_skincontrib}me; + + my $html = markdown $content, { + strip_metadata => 1, + img_ids => 0, + disable_footnotes => 1, + disable_bibliography => 1, + }; + + # Number sections and turn them into links + my($sec, $subsec) = (0,0); + $html =~ s{<h([1-2])[^>]+>(.*?)</h\1>}{ + if($1 == 1) { + $sec++; + $subsec = 0; + qq{<h3><a href="#$sec" name="$sec">$sec. $2</a></h3>} + } elsif($1 == 2) { + $subsec++; + qq|<h4><a href="#$sec.$subsec" name="$sec.$subsec">$sec.$subsec. $2</a></h4>\n| + } + }ge; + + # Text::MultiMarkdown doesn't handle fenced code blocks properly. The + # following solution breaks inline code blocks, but I don't use those anyway. + $html =~ s/<code>/<pre>/g; + $html =~ s#</code>#</pre>#g; + + $html +} + +1; diff --git a/lib/VNWeb/Docs/Page.pm b/lib/VNWeb/Docs/Page.pm new file mode 100644 index 00000000..15d501a2 --- /dev/null +++ b/lib/VNWeb/Docs/Page.pm @@ -0,0 +1,67 @@ +package VNWeb::Docs::Page; + +use VNWeb::Prelude; +use VNWeb::Docs::Lib; + + +sub _index_ { + ul_ class => 'index', sub { + li_ sub { b_ 'Guidelines' }; + li_ sub { a_ href => '/d5', 'Editing Guidelines' }; + li_ sub { a_ href => '/d2', 'Visual Novels' }; + li_ sub { a_ href => '/d15', 'Special Games' }; + li_ sub { a_ href => '/d3', 'Releases' }; + li_ sub { a_ href => '/d4', 'Producers' }; + li_ sub { a_ href => '/d16', 'Staff' }; + li_ sub { a_ href => '/d12', 'Characters' }; + li_ sub { a_ href => '/d10', 'Tags & Traits' }; + li_ sub { a_ href => '/d13', 'Capturing Screenshots' }; + li_ sub { b_ 'About VNDB' }; + li_ sub { a_ href => '/d9', 'Discussion Board' }; + li_ sub { a_ href => '/d6', 'FAQ' }; + li_ sub { a_ href => '/d7', 'About Us' }; + li_ sub { a_ href => '/d17', 'Privacy Policy & Licensing' }; + li_ sub { a_ href => '/d11', 'Database API' }; + li_ sub { a_ href => '/d14', 'Database Dumps' }; + li_ sub { a_ href => '/d18', 'Database Querying' }; + li_ sub { a_ href => '/d8', 'Development' }; + } +} + + +sub _rev_ { + my $d = shift; + revision_ d => $d, sub {}, + [ title => 'Title' ], + [ content => 'Contents' ]; +} + + +# A little in-memory cache of the rendered HTML for the latest revision of each +# doc page. md2html() performance is "acceptable" for regular page loads but +# can still feel a little sluggish. +my %cache; # chid => html + + +TUWF::get qr{/$RE{drev}} => sub { + my $d = db_entry d => tuwf->capture('id'), tuwf->capture('rev'); + return tuwf->resNotFound if !$d; + + my $html = $cache{$d->{chid}} || md2html $d->{content}; + $cache{$d->{chid}} ||= $html if $d->{chrev} == $d->{maxrev}; + + framework_ title => $d->{title}, type => 'd', dbobj => $d, hiddenmsg => 1, + sub { + _rev_ $d if tuwf->capture('rev'); + div_ class => 'mainbox', sub { + h1_ $d->{title}; + div_ class => 'docs', sub { + _index_; + lit_ $html; + clearfloat_; + }; + }; + }; +}; + +1; diff --git a/lib/VNWeb/HTML.pm b/lib/VNWeb/HTML.pm index e28c7631..1e237a78 100644 --- a/lib/VNWeb/HTML.pm +++ b/lib/VNWeb/HTML.pm @@ -1,20 +1,32 @@ package VNWeb::HTML; -use v5.24; +use v5.26; use warnings; -use TUWF ':html5_', 'uri_escape'; +use utf8; +use Algorithm::Diff::XS 'sdiff', 'compact_diff'; +use Encode 'encode_utf8'; +use JSON::XS; +use TUWF ':html5_', 'uri_escape', 'html_escape', 'mkclass'; use Exporter 'import'; use JSON::XS; use VNDB::Config; +use VNDB::BBCode; use VNWeb::Auth; +use VNWeb::Validation; +use VNWeb::DB; +use VNDB::Func 'fmtdate'; our @EXPORT = qw/ clearfloat_ debug_ + join_ + user_ framework_ + revision_ /; + # Ugly hack to move rendering down below the float object. sub clearfloat_ { div_ class => 'clearfloat', '' } @@ -28,6 +40,29 @@ sub debug_ { } +# Similar to join($sep, map $f->(), @list), but works for HTML generation functions. +# join_ ', ', sub { a_ href => '#', $_ }, @list; +# join_ \&br_, \&txt_, @list; +sub join_($&@) { + my($sep, $f, @list) = @_; + for my $i (0..$#list) { + ref $sep ? $sep->() : txt_ $sep if $i > 0; + local $_ = $list[$i]; + $f->(); + } +} + + +# Display a user link. +sub user_ { + my($uid, $username) = @_; + return lit_ '[deleted]' if !$uid; + a_ href => "/u$uid", $username; +} + + + + sub _head_ { my $o = shift; my $skin = tuwf->reqGet('skin') || auth->pref('skin') || config->{skin_default}; @@ -179,12 +214,90 @@ sub _footer_ { } +sub _maintabs_ { + my $opt = shift; + my($t, $o, $sel) = @{$opt}{qw/type dbobj tab/}; + return if !$t || !$o; + return if $t eq 'g' && !auth->permTagmod; + + my $id = $t.$o->{id}; + + my sub t { + my($tabname, $url, $text) = @_; + li_ mkclass(tabselected => $tabname eq ($sel||'')), sub { + a_ href => $url, $text; + }; + }; + + ul_ class => 'maintabs', sub { + t hist => "/$id/hist", 'history' if $t =~ /[uvrpcsd]/; + + if($t =~ /[uvp]/) { + my $cnt = tuwf->dbVali(q{ + SELECT COUNT(*) + FROM threads_boards tb + JOIN threads t ON t.id = tb.tid + WHERE}, { 'tb.type' => $t, 'tb.iid' => $o->{id}, 't.hidden' => 0, 't.private' => 0 }); + t disc => "/t/$id", "discussions ($cnt)"; + }; + + # TODO: User lists + + t tagmod => "/$id/tagmod", 'modify tags' if $t eq 'v' && auth->permTag && !$o->{entry_hidden}; + t copy => "/$id/copy", 'copy' if $t =~ /[rc]/ && can_edit $t, $o; + t edit => "/$id/edit", 'edit' if can_edit $t, $o; + t del => "/$id/del", 'remove' if $t eq 'u' && auth && auth->uid == 2; + t releases => "/$id/releases", 'releases' if $t eq 'v'; + t rgraph => "/$id/rg", 'relations' if $t =~ /[vp]/ && $o->{rgraph}; # TODO: Check DB if this field isn't given? + t '' => "/$id", $id; + } +} + + +# Returns 1 if the page contents should be hidden. +sub _hidden_msg_ { + my $o = shift; + + die "Can't use hiddenmsg on an object that is missing 'entry_hidden'" if !exists $o->{dbobj}{entry_hidden}; + return 0 if !$o->{dbobj}{entry_hidden}; + + my $msg = tuwf->dbVali( + 'SELECT comments + FROM changes + WHERE', { type => $o->{type}, itemid => $o->{dbobj}{id} }, + 'ORDER BY id DESC LIMIT 1' + ); + my $board = $o->{type} =~ /[vp]/ ? $o->{type}.$o->{dbobj}{id} : 'db'; # TODO: Link to VN board for characters and releases? + div_ class => 'mainbox', sub { + h1_ $o->{title}; + div_ class => 'warning', sub { + h2_ 'Item deleted'; + p_ sub { + txt_ 'This item has been deleted from the database. You may file a request on the '; + a_ href => "/t/$board", "discussion board"; + txt_ ' if you believe that this entry should be restored.'; + br_; + br_; + lit_ bb2html $msg; + } + } + }; + !auth->permDbmod # dbmods can still see the page +} + + # Options: -# title => $title -# noindex => 1/0 -# feeds => 1/0 -# search => $query -# og => { opengraph metadata } +# title => $title +# noindex => 1/0 +# feeds => 1/0 +# search => $query +# og => { opengraph metadata } +# type => Database entry type (used for the main tabs & hidden message) +# dbobj => Database entry object (used for the main tabs & hidden message) +# Recognized object fields: id, entry_hidden, entry_locked +# tab => Current tab, or empty for the main tab +# hiddenmsg => 1/0, if true and dbobj is 'hidden', a message will be displayed +# and the content function will not be called. # sub { content } sub framework_ { my $cont = pop; @@ -197,11 +310,210 @@ sub framework_ { div_ id => 'header', sub { h1_ sub { a_ href => '/', 'the visual novel database' } }; div_ id => 'menulist', sub { _menu_ \%o }; div_ id => 'maincontent', sub { - $cont->(); - div_ id => 'footer', sub { _footer_ }; + _maintabs_ \%o; + $cont->() unless $o{hiddenmsg} && _hidden_msg_ \%o; + div_ id => 'footer', \&_footer_; } } } } + + + +sub _revision_header_ { + my($type, $obj) = @_; + b_ "Revision $obj->{chrev}"; + if(auth) { + lit_ ' ('; + a_ href => "/$type$obj->{id}.$obj->{chrev}/edit", $obj->{chrev} == $obj->{maxrev} ? 'edit' : 'revert to'; + if($obj->{rev_requester}) { + lit_ ' / '; + a_ href => "/t/u$obj->{rev_requester}/new?title=Regarding%20$type$obj->{id}.$obj->{chrev}", 'msg user'; + } + lit_ ')'; + } + br_; + lit_ 'By '; + user_ @{$obj}{'rev_requester', 'rev_username'}; + lit_ ' on '; + txt_ fmtdate $obj->{rev_added}, 'full'; +} + + + +sub _revision_fmtval_ { + my($opt, $val) = @_; + return i_ '[empty]' if !defined $val || !length $val; + return lit_ html_escape $val if !$opt->{fmt}; + return txt_ $val ? 'True' : 'False' if $opt->{fmt} eq 'bool'; + local $_ = $val; + $opt->{fmt}->(); +} + + +sub _revision_fmtcol_ { + my($opt, $i, $l) = @_; + + my $ctx = 100; # Number of characters of context in textual diffs + my sub sep_ { b_ class => 'standout', '<...>' }; # Context separator + + td_ class => 'tcval', sub { + join_ $opt->{join}||\&br_, sub { + my($ch, $old, $new, $diff) = @$_; + my $val = $_->[$i]; + + if($diff) { + my $lastchunk = int (($#$diff-2)/2); + for my $n (0..$lastchunk) { + my $a = join '', @{$old}[ $diff->[$n*2] .. $diff->[$n*2+2]-1 ]; + my $b = join '', @{$new}[ $diff->[$n*2+1] .. $diff->[$n*2+3]-1 ]; + + # Difference, highlight and display in full + if($n % 2) { + b_ class => $i == 1 ? 'diff_del' : 'diff_add', sub { lit_ html_escape $i == 1 ? $a : $b }; + # Short context, display in full + } elsif(length $a < $ctx*3) { + lit_ html_escape $a; + # Longer context, abbreviate + } elsif($n == 0) { + sep_; br_; lit_ html_escape substr $a, -$ctx; + } elsif($n == $lastchunk) { + lit_ html_escape substr $a, 0, $ctx; br_; sep_; + } else { + lit_ html_escape substr $a, 0, $ctx; + br_; br_; sep_; br_; br_; + lit_ html_escape substr $a, -$ctx; + } + } + + } elsif(@$l > 2 && $i == 2 && ($ch eq '+' || $ch eq 'c')) { + b_ class => 'diff_add', sub { _revision_fmtval_ $opt, $val } + } elsif(@$l > 2 && $i == 1 && ($ch eq '-' || $ch eq 'c')) { + b_ class => 'diff_del', sub { _revision_fmtval_ $opt, $val } + } elsif($ch eq 'c' || $ch eq 'u') { + _revision_fmtval_ $opt, $val; + } + }, @$l; + }; +} + + +sub _revision_diff_ { + my($type, $old, $new, $field, $name, %opt) = @_; + + # First do a diff on the raw field elements. + # (if the field is a scalar, it's considered a single element and the diff just tests equality) + my @old = ref $old->{$field} eq 'ARRAY' ? $old->{$field}->@* : ($old->{$field}); + my @new = ref $new->{$field} eq 'ARRAY' ? $new->{$field}->@* : ($new->{$field}); + + my $JS = JSON::XS->new->utf8->allow_nonref; + my $l = sdiff \@old, \@new, sub { $JS->encode($_[0]) }; + return if !grep $_->[0] ne 'u', @$l; + + # Now check if we should do a textual diff on the changed items. + for my $item (@$l) { + last if $opt{fmt}; + next if $item->[0] ne 'c' || ref $item->[1] || ref $item->[2]; + next if !defined $item->[1] || !defined $item->[2]; + next if length $item->[1] < 10 || length $item->[2] < 10; + + # Do a word-based diff if this is a large chunk of text, otherwise character-based. + my $split = length $item->[1] > 1024 ? qr/([ ,\n]+)/ : qr//; + $item->[1] = [split $split, $item->[1]]; + $item->[2] = [split $split, $item->[2]]; + $item->[3] = compact_diff $item->[1], $item->[2], \&encode_utf8; + } + + tr_ sub { + td_ $name; + _revision_fmtcol_ \%opt, 1, $l; + _revision_fmtcol_ \%opt, 2, $l; + } +} + + +sub _revision_cmp_ { + my($type, $old, $new, @fields) = @_; + + table_ class => 'stripe', sub { + thead_ sub { + tr_ sub { + td_ ' '; + td_ sub { _revision_header_ $type, $old }; + td_ sub { _revision_header_ $type, $new }; + }; + tr_ sub { + td_ ' '; + td_ colspan => 2, sub { + b_ "Edit summary for revision $new->{chrev}"; + br_; + br_; + lit_ bb2html $new->{rev_comments}||'-'; + }; + }; + }; + _revision_diff_ $type, $old, $new, @$_ for( + [ hidden => 'Hidden', fmt => 'bool' ], + [ locked => 'Locked', fmt => 'bool' ], + @fields, + ); + }; +} + + +# Revision info box. +# +# Arguments: $type, $object, \&enrich_for_diff, @fields +# +# The given $object is assumed to originate from VNWeb::DB::db_entry() and +# should have the 'id', 'hidden', 'locked', 'chrev' and 'maxrev' fields in +# addition to those specified in @fields. +# +# \&enrich_for_diff is a subroutine that is given an earlier revision returned +# by db_entry() and should enrich this object with information necessary for +# diffing. $object is assumed to have already been enriched in this way (it is +# assumed that a page will need to fetch and enrich such an $object for its own +# display purposes anyway). +# +# @fields is a list of arrayrefs with the following form: +# +# [ field_name, display_name, %options ] +# +# Options: +# fmt => 'bool'||sub {$_} - Formatting function for individual values. +# If not given, the field is rendered as plain text and changes are highlighted with a diff. +# join => sub{} - HTML to join multi-value fields, defaults to \&br_. +sub revision_ { + my($type, $new, $enrich, @fields) = @_; + + my $old = $new->{chrev} == 1 ? undef : db_entry $type, $new->{id}, $new->{chrev} - 1; + $enrich->($old) if $old; + + enrich_merge chid => sql( + 'SELECT c.id AS chid, c.comments as rev_comments,', sql_totime('c.added'), 'as rev_added + , c.requester as rev_requester, u.username as rev_username + FROM changes c LEFT JOIN users u ON u.id = c.requester + WHERE c.id IN'), + $new, $old||(); + + div_ class => 'mainbox revision', sub { + h1_ "Revision $new->{chrev}"; + + a_ class => 'prev', href => sprintf('/%s%d.%d', $type, $new->{id}, $new->{chrev}-1), '<- earlier revision' if $new->{chrev} > 1; + a_ class => 'next', href => sprintf('/%s%d.%d', $type, $new->{id}, $new->{chrev}+1), 'later revision ->' if $new->{chrev} < $new->{maxrev}; + p_ class => 'center', sub { a_ href => "/$type$new->{id}", $type.$new->{id} }; + + div_ class => 'rev', sub { + _revision_header_ $type, $new; + br_; + b_ 'Edit summary'; + br_; br_; + lit_ bb2html $new->{rev_comments}||'-'; + } if !$old; + + _revision_cmp_ $type, $old, $new, @fields if $old; + }; +} + 1; diff --git a/lib/VNWeb/Prelude.pm b/lib/VNWeb/Prelude.pm new file mode 100644 index 00000000..ecc5a606 --- /dev/null +++ b/lib/VNWeb/Prelude.pm @@ -0,0 +1,76 @@ +# Importing this module is equivalent to: +# +# use v5.26; +# use warnings; +# use utf8; +# +# use TUWF ':html5_', 'mkclass'; +# use Exporter 'import'; +# use Time::HiRes 'time'; +# +# use VNDBUtil; +# use VNDB::Types; +# use VNDB::Config; +# use VNWeb::Auth; +# use VNWeb::HTML; +# use VNWeb::DB; +# +# WARNING: This should not be used from the above modules. +package VNWeb::Prelude; + +use strict; +use warnings; +use feature ':5.26'; +use utf8; + +sub import { + my $c = caller; + + strict->import; + warnings->import; + feature->import(':5.26'); + utf8->import; + + die $@ if !eval <<" EOM;"; + package $c; + + use TUWF ':html5_', 'mkclass'; + use Exporter 'import'; + use Time::HiRes 'time'; + + use VNDBUtil; + use VNDB::Types; + use VNDB::Config; + use VNWeb::Auth; + use VNWeb::HTML; + use VNWeb::DB; + 1; + EOM; + + no strict 'refs'; + *{$c.'::RE'} = *RE; +} + + +# Regular expressions for use in path registration +my $num = qr{[1-9][0-9]{0,6}}; +my $id = qr{(?<id>$num)}; +my $rev = qr{(?:\.(?<rev>$num))}; +our %RE = ( + uid => qr{u$id}, + vid => qr{v$id}, + rid => qr{r$id}, + sid => qr{s$id}, + cid => qr{c$id}, + pid => qr{p$id}, + iid => qr{i$id}, + did => qr{d$id}, + vrev => qr{v$id$rev?}, + rrev => qr{r$id$rev?}, + prev => qr{p$id$rev?}, + srev => qr{s$id$rev?}, + crev => qr{c$id$rev?}, + drev => qr{d$id$rev?}, +); + +1; diff --git a/lib/VNWeb/Validation.pm b/lib/VNWeb/Validation.pm new file mode 100644 index 00000000..a014b11d --- /dev/null +++ b/lib/VNWeb/Validation.pm @@ -0,0 +1,26 @@ +package VNWeb::Validation; + +use v5.26; +use TUWF; +use VNWeb::Auth; +use Exporter 'import'; + +our @EXPORT = qw/ + can_edit +/; + + +# Returns whether the current user can edit the given database entry. +sub can_edit { + my($type, $entry) = @_; + + return auth->permUsermod || (auth && $entry->{id} == auth->uid) if $type eq 'u'; + return auth->permDbmod if $type eq 'd'; + + die "Can't do authorization test when entry_hidden/entry_locked fields aren't present" + if $entry->{id} && (!exists $entry->{entry_hidden} || !exists $entry->{entry_locked}); + + auth->permDbmod || (auth->permEdit && !($entry->{entry_hidden} || $entry->{entry_locked})); +} + +1; |