diff options
author | Yorhel <git@yorhel.nl> | 2019-10-16 10:31:24 +0200 |
---|---|---|
committer | Yorhel <git@yorhel.nl> | 2019-10-16 15:29:38 +0200 |
commit | 678f511619708ba893cb2414eead90cdae685708 (patch) | |
tree | 2c79c111805f38454e07d96645f3fdc31fe75860 | |
parent | 1fb8a234cf5a455af6d78c893320b21de8347bc4 (diff) |
v2rw: Convert staff adding/editing form
This is the first major editing form to be converted, so I'm expecting a
little breakage. A good chunk of this code has been copied from v3.
In terms of the UI there has been a small change: aliases that are still
referenced do not have the 'remove' link and instead have a flag that
shows that they are still referenced. This ought to be a bit friendlier
than throwing an error message after the user has submitted the form.
Some other things I'd like to improve in this form:
- BBCode preview
- Pasting in external links and letting the form figure out the Pixiv
ID, etc.
- Or perhaps even: Integrate AniDB/Wikidata search/autocompletion.
-rw-r--r-- | data/js/main.js | 3 | ||||
-rw-r--r-- | data/js/staffalias.js | 80 | ||||
-rw-r--r-- | data/style.css | 24 | ||||
-rw-r--r-- | elm/Lib/Html.elm | 44 | ||||
-rw-r--r-- | elm/Lib/Util.elm | 32 | ||||
-rw-r--r-- | elm/StaffEdit/Main.elm | 227 | ||||
-rw-r--r-- | elm/StaffEdit/New.elm | 12 | ||||
-rw-r--r-- | lib/VNDB/BBCode.pm | 51 | ||||
-rw-r--r-- | lib/VNDB/DB/Misc.pm | 1 | ||||
-rw-r--r-- | lib/VNDB/DB/Staff.pm | 42 | ||||
-rw-r--r-- | lib/VNDB/Handler/Staff.pm | 120 | ||||
-rw-r--r-- | lib/VNDB/Util/CommonHTML.pm | 54 | ||||
-rw-r--r-- | lib/VNDB/Util/Misc.pm | 49 | ||||
-rw-r--r-- | lib/VNWeb/DB.pm | 9 | ||||
-rw-r--r-- | lib/VNWeb/Elm.pm | 3 | ||||
-rw-r--r-- | lib/VNWeb/HTML.pm | 78 | ||||
-rw-r--r-- | lib/VNWeb/Staff/Edit.pm | 103 | ||||
-rw-r--r-- | lib/VNWeb/Validation.pm | 36 |
18 files changed, 596 insertions, 372 deletions
diff --git a/data/js/main.js b/data/js/main.js index 09d62d12..3d4248b9 100644 --- a/data/js/main.js +++ b/data/js/main.js @@ -48,6 +48,3 @@ VARS = /*VARS*/; // Character editing (/c+/edit) //include chartraits.js //include charvns.js - -// Staff editing (/s+/edit) -//include staffalias.js diff --git a/data/js/staffalias.js b/data/js/staffalias.js deleted file mode 100644 index 7e6abe0c..00000000 --- a/data/js/staffalias.js +++ /dev/null @@ -1,80 +0,0 @@ -function salLoad () { - byId('alias_tbl').appendChild(tag('tr', {id:'alias_new'}, - tag('td', null), - tag('td', {colspan:3}, tag('a', {href:'#', onclick:salFormAdd}, 'Add alias')))); - - salAdd(byId('primary').value||0, byId('name').value, byId('original').value); - var aliases = jsonParse(byId('aliases').value) || []; - for(var i = 0; i < aliases.length; i++) { - salAdd(aliases[i].aid, aliases[i].name, aliases[i].orig); - } - - byName(byId('maincontent'), 'form')[0].onsubmit = salSerialize; -} - -function salAdd(aid, name, original) { - var tbl = byId('alias_tbl'); - var first = tbl.rows.length <= 1; - tbl.insertBefore(tag('tr', first ? {id:'primary_name'} : null, - tag('td', {'class':'tc_id' }, - tag('input', {type:'radio', name:'primary_id', value:aid, checked:first, onchange:salPrimary})), - tag('td', {'class':'tc_name' }, tag('input', {type:'text', 'class':'text', value:name})), - tag('td', {'class':'tc_original' }, tag('input', {type:'text', 'class':'text', value:original})), - tag('td', {'class':'tc_add' }, !first ? - tag('a', {href:'#', onclick:salDel}, 'remove') : null) - ), byId('alias_new')); -} - -function salPrimary() { - var prev = byId('primary_name') - prev.removeAttribute('id'); - byClass(prev, 'td', 'tc_add')[0].appendChild(tag('a', {href:'#', onclick:salDel}, 'remove')); - var tr = this; - while (tr && tr.nodeName.toLowerCase() != 'tr') - tr = tr.parentNode; - tr.setAttribute('id', 'primary_name'); - var td = byClass(tr, 'td', 'tc_add')[0]; - while (td.firstChild) - td.removeChild(td.firstChild); - - return salSerialize(); -} - -function salSerialize() { - var tbl = byName(byId('alias_tbl'), 'tr'); - var a = []; - for (var i = 0; i < tbl.length; ++i) { - if(tbl[i].id == 'alias_new') - continue; - var id = byName(byClass(tbl[i], 'td', 'tc_id')[0], 'input')[0].value; - var name = byName(byClass(tbl[i], 'td', 'tc_name')[0], 'input')[0].value; - var orig = byName(byClass(tbl[i], 'td', 'tc_original')[0], 'input')[0].value; - if(tbl[i].id == 'primary_name') { - byId('name').value = name; - byId('original').value = orig; - byId('primary').value = id; - } else - a.push({ aid:Number(id), name:name, orig:orig }); - } - byId('aliases').value = JSON.stringify(a); - return true; -} - -function salDel() { - var tr = this; - while (tr && tr.nodeName.toLowerCase() != 'tr') - tr = tr.parentNode; - var tbl = byId('alias_tbl'); - tbl.removeChild(tr); - salSerialize(); - return false; -} - -function salFormAdd() { - salAdd(0, '', ''); - byName(byClass(byId('alias_new').previousSibling, 'td', 'tc_name')[0], 'input')[0].focus(); - return false; -} - -if(byId('jt_box_staffe_geninfo')) - salLoad(); diff --git a/data/style.css b/data/style.css index 9ad103a4..2655c85b 100644 --- a/data/style.css +++ b/data/style.css @@ -145,7 +145,7 @@ fieldset.submit input[type=submit] { width: 150px; } fieldset.submit input[type=checkbox] { margin: 0 5px 0 15px; } fieldset.submit h2 { font-size: 13px!important; } fieldset.submit textarea { margin: 0 20px 5px 20px; } -td.label, td.label label { width: 110px; } +td.label, td.label label { width: 130px; } td.label label { display: block; } td.field label { margin: 0 5px 0 5px; } table.formtable { margin: 0 20px 20px 20px; } @@ -711,27 +711,27 @@ div.charsum_list .charsum_bubble { #jt_box_vn_staff h2 { margin: 0 0 3px 0px; } #jt_box_vn_cast td, #jt_box_vn_staff td, -#jt_box_staffe_geninfo table#names td { padding: 1px 2px; vertical-align: middle; } -#jt_box_staffe_geninfo table#names tr#alias_new td { padding-top: 8px } #jt_box_vn_cast td.tc_role, #jt_box_vn_cast td.tc_role select, #jt_box_vn_staff td.tc_role, #jt_box_vn_staff td.tc_role select { width: 120px } -#jt_box_vn_cast td.tc_staff, -#jt_box_vn_staff td.tc_staff, -#jt_box_staffe_geninfo td.tc_name, -#jt_box_staffe_geninfo td.tc_original { width: 200px } -#jt_box_vn_cast td.tc_staff input, -#jt_box_vn_staff td.tc_staff input, -#jt_box_staffe_geninfo td.tc_name input, -#jt_box_staffe_geninfo td.tc_original input { width: 200px } +#jt_box_vn_cast td.tc_staff, +#jt_box_vn_staff td.tc_staff, +.staffedit td.tc_name, +.staffedit td.tc_original { width: 200px } +#jt_box_vn_cast td.tc_staff input, +#jt_box_vn_staff td.tc_staff input, +.staffedit td.tc_name input, +.staffedit td.tc_original input { width: 200px } #jt_box_vn_cast td.tc_note, #jt_box_vn_cast td.tc_note input, #jt_box_vn_staff td.tc_note, #jt_box_vn_staff td.tc_note input { width: 250px } #jt_box_vn_cast td.tc_add, #jt_box_vn_staff td.tc_add, -#jt_box_staffe_geninfo td.tc_add { width: 40px; text-align: left } +.staffedit td.tc_add { width: 40px; text-align: left } +.staffedit table.names td { padding: 1px 2px; vertical-align: middle; } +.staffedit table.names tr.alias_new td { padding-top: 8px } /***** Documentation pages *****/ diff --git a/elm/Lib/Html.elm b/elm/Lib/Html.elm index d2588799..fe67da32 100644 --- a/elm/Lib/Html.elm +++ b/elm/Lib/Html.elm @@ -82,6 +82,8 @@ inputTextArea : String -> String -> (String -> m) -> List (Attribute m) -> Html inputTextArea nam val onch attrs = textarea ( [ tabindex 10 , onInput onch + , rows 4 + , cols 50 ] ++ attrs ++ (if nam == "" then [] else [ id nam, name nam ]) @@ -99,12 +101,33 @@ inputCheck nam val onch = input ( ) [] +inputRadio : String -> Bool -> (Bool -> m) -> Html m +inputRadio nam val onch = input ( + [ type_ "radio" + , tabindex 10 + , onCheck onch + , checked val + ] + ++ (if nam == "" then [] else [ name nam ]) + ) [] + + +-- Same as an inputText, but formats/parses an integer as Q### +inputWikidata : String -> Int -> (Int -> m) -> Html m +inputWikidata nam val onch = + inputText nam + (if val == 0 then "" else "Q" ++ String.fromInt val) + (\v -> onch <| if v == "" then 0 else Maybe.withDefault val <| String.toInt <| if String.startsWith "Q" v then String.dropLeft 1 v else v) + [ pattern "^Q?[1-9][0-9]{0,8}$" ] + + -- Generate a form field (table row) with a label. The `label` string can be: -- --- "none" -> To generate a full-width field (colspan=2) --- "" -> Empty label --- "Some string" -> Text label --- "input::String" -> Label that refers to the named input +-- "none" -> To generate a full-width field (colspan=2) +-- "" -> Empty label +-- "Some string" -> Text label +-- "Some string#eng" -> Text label with (English please!) message +-- "input::String" -> Label that refers to the named input (also supports #eng) -- -- (Yeah, stringly typed arguments; I wish Elm had typeclasses) formField : String -> List (Html m) -> Html m @@ -113,10 +136,13 @@ formField lbl cont = [ if lbl == "none" then text "" else - td [ class "label" ] - [ case String.split "::" lbl of - [name, txt] -> label [ for name ] [ text txt ] - txt -> text <| String.concat txt - ] + let + (nlbl, eng) = if String.endsWith "#eng" lbl then (String.dropRight 4 lbl, True) else (lbl, False) + genlbl str = text str :: if eng then [ br [] [], b [ class "standout" ] [ text "English please!" ] ] else [] + in + td [ class "label" ] <| + case String.split "::" nlbl of + [name, txt] -> [ label [ for name ] (genlbl txt) ] + txt -> genlbl (String.concat txt) , td (class "field" :: if lbl == "none" then [ colspan 2 ] else []) cont ] diff --git a/elm/Lib/Util.elm b/elm/Lib/Util.elm new file mode 100644 index 00000000..186cf365 --- /dev/null +++ b/elm/Lib/Util.elm @@ -0,0 +1,32 @@ +module Lib.Util exposing (..) + +import Dict + +-- Delete an element from a List +delidx : Int -> List a -> List a +delidx n l = List.take n l ++ List.drop (n+1) l + + +-- Modify an element in a List +modidx : Int -> (a -> a) -> List a -> List a +modidx n f = List.indexedMap (\i e -> if i == n then f e else e) + + +isJust : Maybe a -> Bool +isJust m = case m of + Just _ -> True + _ -> False + + +-- Returns true if the list contains duplicates +hasDuplicates : List comparable -> Bool +hasDuplicates l = + let + step e acc = + case acc of + Nothing -> Nothing + Just m -> if Dict.member e m then Nothing else Just (Dict.insert e True m) + in + case List.foldr step (Just Dict.empty) l of + Nothing -> True + Just _ -> False diff --git a/elm/StaffEdit/Main.elm b/elm/StaffEdit/Main.elm new file mode 100644 index 00000000..f9d1f3da --- /dev/null +++ b/elm/StaffEdit/Main.elm @@ -0,0 +1,227 @@ +module StaffEdit.Main exposing (Model, Msg, main, new, view, update) + +import Html exposing (..) +import Html.Events exposing (..) +import Html.Attributes exposing (..) +import Browser +import Browser.Navigation exposing (load) +import Lib.Util exposing (..) +import Lib.Html exposing (..) +import Lib.Api as Api +import Lib.Editsum as Editsum +import Gen.StaffEdit as GSE +import Gen.Types as GT +import Gen.Api as GApi + + +main : Program GSE.Recv Model Msg +main = Browser.element + { init = \e -> (init e, Cmd.none) + , view = view + , update = update + , subscriptions = always Sub.none + } + + +type alias Model = + { state : Api.State + , editsum : Editsum.Model + , alias : List GSE.RecvAlias + , aliasDup : Bool + , aid : Int + , desc : String + , gender : String + , lang : String + , l_site : String + , l_wikidata : Int + , l_twitter : String + , l_anidb : Maybe Int + , l_pixiv : Int + , id : Maybe Int + } + + +init : GSE.Recv -> Model +init d = + { state = Api.Normal + , editsum = { authmod = d.authmod, editsum = d.editsum, locked = d.locked, hidden = d.hidden } + , alias = d.alias + , aliasDup = False + , aid = d.aid + , desc = d.desc + , gender = d.gender + , lang = d.lang + , l_site = d.l_site + , l_wikidata = d.l_wikidata + , l_twitter = d.l_twitter + , l_anidb = d.l_anidb + , l_pixiv = d.l_pixiv + , id = Just d.id + } + + +new : Model +new = + { state = Api.Normal + , editsum = Editsum.new + , alias = [ { aid = -1, name = "", original = "", inuse = False } ] + , aliasDup = False + , aid = -1 + , desc = "" + , gender = "unknown" + , lang = "ja" + , l_site = "" + , l_wikidata = 0 + , l_twitter = "" + , l_anidb = Nothing + , l_pixiv = 0 + , id = Nothing + } + + +encode : Model -> GSE.Send +encode model = + { editsum = model.editsum.editsum + , hidden = model.editsum.hidden + , locked = model.editsum.locked + , aid = model.aid + , alias = List.map (\e -> { aid = e.aid, name = e.name, original = e.original }) model.alias + , desc = model.desc + , gender = model.gender + , lang = model.lang + , l_site = model.l_site + , l_wikidata = model.l_wikidata + , l_twitter = model.l_twitter + , l_anidb = model.l_anidb + , l_pixiv = model.l_pixiv + } + + +newAid : Model -> Int +newAid model = + let id = Maybe.withDefault 0 <| List.minimum <| List.map .aid model.alias + in if id >= 0 then -1 else id - 1 + + +type Msg + = Editsum Editsum.Msg + | Submit + | Submitted GApi.Response + | Lang String + | Gender String + | Website String + | LWikidata Int + | LTwitter String + | LAnidb String + | LPixiv String + | Desc String + | AliasDel Int + | AliasName Int String + | AliasOrig Int String + | AliasMain Int Bool + | AliasAdd + + +validate : Model -> Model +validate model = { model | aliasDup = hasDuplicates <| List.map (\e -> (e.name, e.original)) model.alias } + + +update : Msg -> Model -> (Model, Cmd Msg) +update msg model = + case msg of + Editsum m -> ({ model | editsum = Editsum.update m model.editsum }, Cmd.none) + Lang s -> ({ model | lang = s }, Cmd.none) + Gender s -> ({ model | gender = s }, Cmd.none) + Website s -> ({ model | l_site = s }, Cmd.none) + LWikidata n-> ({ model | l_wikidata= n }, Cmd.none) + LTwitter s -> ({ model | l_twitter = s }, Cmd.none) + LAnidb s -> ({ model | l_anidb = if s == "" then Nothing else String.toInt s }, Cmd.none) + LPixiv s -> ({ model | l_pixiv = Maybe.withDefault model.l_pixiv (String.toInt s) }, Cmd.none) + Desc s -> ({ model | desc = s }, Cmd.none) + + AliasDel i -> (validate { model | alias = delidx i model.alias }, Cmd.none) + AliasName i s -> (validate { model | alias = modidx i (\e -> { e | name = s }) model.alias }, Cmd.none) + AliasOrig i s -> (validate { model | alias = modidx i (\e -> { e | original = s }) model.alias }, Cmd.none) + AliasMain n _ -> ({ model | aid = n }, Cmd.none) + AliasAdd -> ({ model | alias = model.alias ++ [{ aid = newAid model, name = "", original = "", inuse = False }] }, Cmd.none) + + Submit -> + let + path = + case model.id of + Just id -> "/s" ++ String.fromInt id ++ "/edit" + Nothing -> "/s/add" + body = GSE.encode (encode model) + in ({ model | state = Api.Loading }, Api.post path body Submitted) + + Submitted (GApi.Changed id rev) -> (model, load <| "/s" ++ String.fromInt id ++ "." ++ String.fromInt rev) + Submitted r -> ({ model | state = Api.Error r }, Cmd.none) + + +isValid : Model -> Bool +isValid model = not model.aliasDup + + +view : Model -> Html Msg +view model = + let + nameEntry n e = + tr [] + [ td [ class "tc_id" ] [ inputRadio "main" (e.aid == model.aid) (AliasMain e.aid) ] + , td [ class "tc_name" ] [ inputText "" e.name (AliasName n) GSE.valAliasName ] + , td [ class "tc_original" ] [ inputText "" e.original (AliasOrig n) GSE.valAliasOriginal ] + , td [ class "tc_add" ] + [ if model.aid == e.aid then b [ class "grayedout" ] [ text " primary" ] + else if e.inuse then b [ class "grayedout" ] [ text " referenced" ] + else a [ onClick (AliasDel n) ] [ text " remove" ] + ] + ] + + names = + table [ class "names" ] <| + [ thead [] + [ tr [] + [ td [ class "tc_id" ] [] + , td [ class "tc_name" ] [ text "Name (romaji)" ] + , td [ class "tc_original" ] [ text "Original" ] + , td [] [] + ] + ] + ] ++ List.indexedMap nameEntry model.alias ++ + [ tr [ class "alias_new" ] + [ td [] [] + , td [ colspan 3 ] + [ if not model.aliasDup then text "" + else b [ class "standout" ] [ text "The list contains duplicate aliases.", br_ 1 ] + , a [ onClick AliasAdd ] [ text "Add alias" ] + ] + ] + ] + + in + Html.form [ onSubmit Submit ] + [ div [ class "mainbox staffedit" ] + [ h1 [] [ text "General info" ] + , table [ class "formtable" ] + [ formField "Names" [ names, br_ 1 ] + , formField "desc::Biography#eng" [ inputTextArea "desc" model.desc Desc GSE.valDesc ] + , formField "gender::Gender" [ inputSelect "gender" model.gender Gender [] + [ ("unknown", "Unknown or N/A") + , ("f", "Female") + , ("m", "Male") + ] ] + , formField "lang::Primary Language" [ inputSelect "lang" model.lang Lang [] GT.languages ] + , formField "l_site::Official page" [ inputText "l_site" model.l_site Website (style "width" "400px" :: GSE.valL_Site) ] + , formField "l_wikidata::Wikidata ID" [ inputWikidata "l_wikidata" model.l_wikidata LWikidata ] + , formField "l_twitter::Twitter username" [ inputText "l_twitter" model.l_twitter LTwitter GSE.valL_Twitter ] + , formField "l_anidb::AniDB Creator ID" [ inputText "l_anidb" (Maybe.withDefault "" (Maybe.map String.fromInt model.l_anidb)) LAnidb GSE.valL_Anidb ] + , formField "l_pixiv::Pixiv ID" [ inputText "l_pixiv" (if model.l_pixiv == 0 then "" else String.fromInt model.l_pixiv) LPixiv GSE.valL_Pixiv ] + ] + ] + , div [ class "mainbox" ] + [ fieldset [ class "submit" ] + [ Html.map Editsum (Editsum.view model.editsum) + , submitButton "Submit" model.state (isValid model) False + ] + ] + ] diff --git a/elm/StaffEdit/New.elm b/elm/StaffEdit/New.elm new file mode 100644 index 00000000..64e58517 --- /dev/null +++ b/elm/StaffEdit/New.elm @@ -0,0 +1,12 @@ +module StaffEdit.New exposing (main) + +import Browser +import StaffEdit.Main as Main + +main : Program () Main.Model Main.Msg +main = Browser.element + { init = always (Main.new, Cmd.none) + , view = Main.view + , update = Main.update + , subscriptions = always Sub.none + } diff --git a/lib/VNDB/BBCode.pm b/lib/VNDB/BBCode.pm index c1de1f7f..6ef72cdc 100644 --- a/lib/VNDB/BBCode.pm +++ b/lib/VNDB/BBCode.pm @@ -1,11 +1,11 @@ package VNDB::BBCode; -use strict; +use v5.26; use warnings; use Exporter 'import'; use TUWF::XML 'xml_escape'; -our @EXPORT = qw/bb2html bb2text/; +our @EXPORT = qw/bb2html bb2text bb_subst_links/; # Supported BBCode: # [spoiler] .. [/spoiler] @@ -249,4 +249,51 @@ sub bb2text { } +# Turn (most) 'dblink's into [url=..] links. This function relies on TUWF to do +# the database querying, so can't be used from Multi. +# Doesn't handle: +# - d+, t+, r+ and u+ links +# - item revisions +sub bb_subst_links { + my $msg = shift; + + # Parse a message and create an index of links to resolve + my %lookup; + parse $msg, sub { + my($code, $tag) = @_; + $lookup{$1}{$2} = 1 if $tag eq 'dblink' && $code =~ /^(.)(\d+)/; + 1; + }; + return $msg unless %lookup; + + # Now resolve the links + state $types = { # Query must return 'id' and 'name' columns, list of IDs will be appended to it. + v => 'SELECT id, title AS name FROM vn WHERE id IN', + c => 'SELECT id, name FROM chars WHERE id IN', + p => 'SELECT id, name FROM producers WHERE id IN', + g => 'SELECT id, name FROM tags WHERE id IN', + i => 'SELECT id, name FROM traits WHERE id IN', + s => 'SELECT s.id, sa.name FROM staff_alias sa JOIN staff s ON s.aid = sa.id WHERE s.id IN', + }; + my %links; + for my $type (keys %$types) { + next if !$lookup{$type}; + my $lst = $TUWF::OBJ->dbAlli($types->{$type}, [keys %{$lookup{$type}}]); + $links{$type . $_->{id}} = $_->{name} for @$lst; + } + return $msg unless %links; + + # Now substitute + my $result = ''; + parse $msg, sub { + my($code, $tag) = @_; + $result .= $tag eq 'dblink' && $links{$code} + ? sprintf '[url=/%s]%s[/url]', $code, $links{$code} + : $code; + 1; + }; + return $result; +} + + 1; diff --git a/lib/VNDB/DB/Misc.pm b/lib/VNDB/DB/Misc.pm index 27494380..cd290d61 100644 --- a/lib/VNDB/DB/Misc.pm +++ b/lib/VNDB/DB/Misc.pm @@ -40,7 +40,6 @@ sub dbItemEdit { $self->dbProducerRevisionInsert(\%o) if $type eq 'p'; $self->dbReleaseRevisionInsert( \%o) if $type eq 'r'; $self->dbCharRevisionInsert( \%o) if $type eq 'c'; - $self->dbStaffRevisionInsert( \%o) if $type eq 's'; return $self->dbRow('SELECT * FROM edit_!s_commit()', $type); } diff --git a/lib/VNDB/DB/Staff.pm b/lib/VNDB/DB/Staff.pm index b8995d04..87dbef43 100644 --- a/lib/VNDB/DB/Staff.pm +++ b/lib/VNDB/DB/Staff.pm @@ -5,7 +5,7 @@ use strict; use warnings; use Exporter 'import'; -our @EXPORT = qw|dbStaffGet dbStaffGetRev dbStaffRevisionInsert dbStaffAliasIds|; +our @EXPORT = qw|dbStaffGet dbStaffGetRev|; # options: results, page, id, aid, search, exact, truename, role, gender # what: extended changes roles aliases @@ -153,44 +153,4 @@ sub _enrich { return wantarray ? ($r, $np) : $r; } - -# Updates the edit_* tables, used from dbItemEdit() -# Arguments: { columns in staff_rev and staff_alias}, -sub dbStaffRevisionInsert { - my($self, $o) = @_; - - $self->dbExec('DELETE FROM edit_staff_alias'); - if($o->{aid}) { - $self->dbExec(q| - INSERT INTO edit_staff_alias (aid, name, original) VALUES (?, ?, ?)|, - $o->{aid}, $o->{name}, $o->{original}); - } else { - $o->{aid} = $self->dbRow(q| - INSERT INTO edit_staff_alias (name, original) VALUES (?, ?) RETURNING aid|, - $o->{name}, $o->{original})->{aid}; - } - - my %staff = map exists($o->{$_}) ? (qq|"$_" = ?|, $o->{$_}) : (), - qw|aid gender lang desc l_wp l_site l_twitter l_anidb l_wikidata l_pixiv|; - $self->dbExec('UPDATE edit_staff !H', \%staff) if %staff; - for my $a (@{$o->{aliases}}) { - if($a->{aid}) { - $self->dbExec('INSERT INTO edit_staff_alias (aid, name, original) VALUES (!l)', [ @{$a}{qw|aid name orig|} ]); - } else { - $self->dbExec('INSERT INTO edit_staff_alias (name, original) VALUES (?, ?)', $a->{name}, $a->{orig}); - } - } -} - - -# returns alias IDs that are and were related to the given staff ID -sub dbStaffAliasIds { - my($self, $sid) = @_; - return $self->dbAll(q| - SELECT DISTINCT sa.aid - FROM changes c - JOIN staff_alias_hist sa ON sa.chid = c.id - WHERE c.type = 's' AND c.itemid = ?|, $sid); -} - 1; diff --git a/lib/VNDB/Handler/Staff.pm b/lib/VNDB/Handler/Staff.pm index bcdbb08f..1eb2f927 100644 --- a/lib/VNDB/Handler/Staff.pm +++ b/lib/VNDB/Handler/Staff.pm @@ -10,8 +10,6 @@ use List::Util qw(first); TUWF::register( qr{s([1-9]\d*)(?:\.([1-9]\d*))?} => \&page, - qr{s(?:([1-9]\d*)(?:\.([1-9]\d*))?/edit|/new)} - => \&edit, qr{s/([a-z0]|all)} => \&list, qr{xml/staff\.xml} => \&staffxml, ); @@ -178,124 +176,6 @@ sub _cast { } -sub edit { - my($self, $sid, $rev) = @_; - - my $s = $sid && $self->dbStaffGetRev(id => $sid, what => 'extended aliases roles', $rev ? (rev => $rev) : ())->[0]; - return $self->resNotFound if $sid && !$s->{id}; - $rev = undef if !$s || $s->{lastrev}; - - return $self->htmlDenied if !$self->authCan('edit') - || $sid && (($s->{locked} || $s->{hidden}) && !$self->authCan('dbmod')); - - my %b4 = !$sid ? () : ( - (map { $_ => $s->{$_} } qw|name original gender lang desc l_site l_wikidata l_twitter l_anidb l_pixiv ihid ilock|), - primary => $s->{aid}, - aliases => [ - map +{ aid => $_->{aid}, name => $_->{name}, orig => $_->{original} }, - sort { $a->{name} cmp $b->{name} || $a->{original} cmp $b->{original} } @{$s->{aliases}} - ], - ); - my $frm; - - if ($self->reqMethod eq 'POST') { - return if !$self->authCheckCode; - $frm = $self->formValidate ( - { post => 'name', maxlength => 200 }, - { post => 'original', required => 0, maxlength => 200, default => '' }, - { post => 'primary', required => 0, template => 'id', default => 0 }, - { post => 'desc', required => 0, maxlength => 5000, default => '' }, - { post => 'gender', required => 0, default => 'unknown', enum => [qw|unknown m f|] }, - { post => 'lang', enum => [ keys %LANGUAGE ] }, - { post => 'l_site', required => 0, template => 'weburl', maxlength => 250, default => '' }, - { post => 'l_wikidata', required => 0, template => 'wikidata' }, - { post => 'l_twitter', required => 0, maxlength => 16, default => '', regex => [ qr/^\S+$/, 'Invalid twitter username' ] }, - { post => 'l_anidb', required => 0, template => 'id', default => undef }, - { post => 'l_pixiv', required => 0, default => 0, template => 'uint' }, - { post => 'aliases', template => 'json', json_sort => ['name','orig'], json_fields => [ - { field => 'name', required => 1, maxlength => 200 }, - { field => 'orig', required => 0, maxlength => 200, default => '' }, - { field => 'aid', required => 0, template => 'id', default => 0 }, - ]}, - { post => 'editsum', template => 'editsum' }, - { post => 'ihid', required => 0 }, - { post => 'ilock', required => 0 }, - ); - - if(!$frm->{_err}) { - my %old_aliases = $sid ? ( map +($_->{aid} => 1), @{$self->dbStaffAliasIds($sid)} ) : (); - $frm->{primary} = 0 unless exists $old_aliases{$frm->{primary}}; - - # reset aid to zero for newly added aliases. - $_->{aid} *= $old_aliases{$_->{aid}} ? 1 : 0 for(@{$frm->{aliases}}); - - # Make sure no aliases that have been linked to a VN are removed. - my %new_aliases = map +($_, 1), grep $_, $frm->{primary}, map $_->{aid}, @{$frm->{aliases}}; - $frm->{_err} = [ "Can't remove an alias that is still linked to a VN." ] - if grep !$new_aliases{$_->{aid}}, @{$s->{roles}}, @{$self->{cast}}; - } - - if(!$frm->{_err}) { - $frm->{ihid} = $frm->{ihid} ?1:0; - $frm->{ilock} = $frm->{ilock}?1:0; - $frm->{aid} = $frm->{primary} if $sid; - $frm->{desc} = $self->bbSubstLinks($frm->{desc}); - return $self->resRedirect("/s$sid", 'post') if $sid && !form_compare(\%b4, $frm); - - my $nrev = $self->dbItemEdit(s => $sid ? ($s->{id}, $s->{rev}) : (undef, undef), %$frm); - return $self->resRedirect("/s$nrev->{itemid}.$nrev->{rev}", 'post'); - } - } - - $frm->{$_} //= $b4{$_} for keys %b4; - $frm->{editsum} //= sprintf 'Reverted to revision s%d.%d', $sid, $rev if $rev; - $frm->{lang} = 'ja' if !$sid && !defined $frm->{lang}; - - my $title = $s ? "Edit $s->{name}" : 'Add staff member'; - $self->htmlHeader(title => $title, noindex => 1); - $self->htmlMainTabs('s', $s, 'edit') if $s; - $self->htmlEditMessage('s', $s, $title); - $self->htmlForm({ frm => $frm, action => $s ? "/s$sid/edit" : '/s/new', editsum => 1 }, - staffe_geninfo => [ 'General info', - [ hidden => short => 'name' ], - [ hidden => short => 'original' ], - [ hidden => short => 'primary' ], - [ json => short => 'aliases' ], - $sid && @{$s->{aliases}} ? - [ static => content => 'You may choose a different primary name.' ] : (), - [ static => label => 'Names', content => sub { - table id => 'names'; - thead; Tr; - td class => 'tc_id'; end; - td class => 'tc_name', 'Name (romaji)'; - td class => 'tc_original', 'Original'; td; end; - end; end; - tbody id => 'alias_tbl'; - # filled with javascript - end; - end; - }], - [ static => content => '<br />' ], - [ text => name => 'Staff note<br /><b class="standout">English please!</b>', short => 'desc', rows => 4 ], - [ select => name => 'Gender',short => 'gender', options => [ - map [ $_, $GENDER{$_} ], qw(unknown m f) ] ], - [ select => name => 'Primary language', short => 'lang', - options => [ map [ $_, "$LANGUAGE{$_} ($_)" ], sort { $LANGUAGE{$a} cmp $LANGUAGE{$b} } keys %LANGUAGE ] ], - [ input => name => 'Official page', short => 'l_site' ], - [ input => short => 'l_wikidata',name => 'Wikidata ID', - value => $frm->{l_wikidata} ? "Q$frm->{l_wikidata}" : '', - post => qq{ (<a href="$self->{url_static}/f/wikidata.png">How to find this</a>)} - ], - [ input => name => 'Twitter username', short => 'l_twitter' ], - [ input => name => 'AniDB creator ID', short => 'l_anidb' ], - [ input => name => 'Pixiv ID', short => 'l_pixiv' ], - [ static => content => '<br />' ], - ]); - - $self->htmlFooter; -} - - sub list { my ($self, $char) = @_; diff --git a/lib/VNDB/Util/CommonHTML.pm b/lib/VNDB/Util/CommonHTML.pm index 81309325..b56a7c74 100644 --- a/lib/VNDB/Util/CommonHTML.pm +++ b/lib/VNDB/Util/CommonHTML.pm @@ -268,59 +268,9 @@ sub revdiff { # Generates a generic message to show as the header of the edit forms -# Arguments: v/r/p, obj +# Arguments: v/r/p, obj, title, copy sub htmlEditMessage { - my($self, $type, $obj, $title, $copy) = @_; - my $typename = {v => 'visual novel', r => 'release', p => 'producer', c => 'character', s => 'person'}->{$type}; - my $guidelines = {v => 2, r => 3, p => 4, c => 12, 's' => 16}->{$type}; - - div class => 'mainbox'; - h1 $title; - if($copy) { - div class => 'warning'; - h2 'You\'re not editing an entry!'; - p; - txt 'You\'re about to insert a new entry into the database with information based on '; - a href => "/$type$obj->{id}", $obj->{title}||$obj->{name}; - txt '.'; - br; - txt 'Hit the \'edit\' tab on the right-top if you intended to edit the entry instead of creating a new one.'; - end; - end; - } - div class => 'notice'; - h2 'Before editing:'; - ul; - li; - txt "Read the "; - a href=> "/d$guidelines", 'guidelines'; - txt '!'; - end; - if($obj) { - li; - txt 'Check for any existing discussions on the '; - a href => $type =~ /[cs]/ ? '/t/db' : $type eq 'r' ? "/t/v$obj->{vn}[0]{vid}" : "/t/$type$obj->{id}", 'discussion board'; - end; - li; - txt 'Browse the '; - a href => "/$type$obj->{id}/hist", 'edit history'; - txt ' for any recent changes related to what you want to change.'; - end; - } elsif($type ne 'r') { - li; - a href => "/$type/all", 'Search the database'; - txt " to see if we already have information about this $typename."; - end; - } - end; - end; - if($obj && !$obj->{lastrev}) { - div class => 'warning'; - h2 'Reverting'; - p "You are editing an old revision of this $typename. If you save it, all changes made after this revision will be reverted!"; - end; - } - end 'div'; + shift; VNWeb::HTML::editmsg_(@_); } diff --git a/lib/VNDB/Util/Misc.pm b/lib/VNDB/Util/Misc.pm index 9cfb8210..394003ae 100644 --- a/lib/VNDB/Util/Misc.pm +++ b/lib/VNDB/Util/Misc.pm @@ -7,7 +7,7 @@ use Exporter 'import'; use TUWF ':html'; use VNDB::Func; use VNDB::Types; -use VNDB::BBCode (); +use VNDB::BBCode; our @EXPORT = qw|filFetchDB filCompat bbSubstLinks entryLinks|; @@ -114,54 +114,11 @@ sub filCompat { sub bbSubstLinks { - my ($self, $msg) = @_; - - # Parse a message and create an index of links to resolve - my %lookup; - VNDB::BBCode::parse $msg, sub { - my($code, $tag) = @_; - $lookup{$1}{$2} = 1 if $tag eq 'dblink' && $code =~ /^(.)(\d+)/; - 1; - }; - return $msg unless %lookup; - - # Now resolve the links - my %links; - my @opt = (results => 50); - - if ($lookup{v}) { - $links{"v$_->{id}"} = $_->{title} for (@{$self->dbVNGet(id => [keys %{$lookup{v}}], @opt)}); - } - if ($lookup{c}) { - $links{"c$_->{id}"} = $_->{name} for (@{$self->dbCharGet(id => [keys %{$lookup{c}}], @opt)}); - } - if ($lookup{p}) { - $links{"p$_->{id}"} = $_->{name} for (@{$self->dbProducerGet(id => [keys %{$lookup{p}}], @opt)}); - } - if ($lookup{g}) { - $links{"g$_->{id}"} = $_->{name} for (@{$self->dbTagGet(id => [keys %{$lookup{g}}], @opt)}); - } - if ($lookup{i}) { - $links{"i$_->{id}"} = $_->{name} for (@{$self->dbTraitGet(id => [keys %{$lookup{i}}], @opt)}); - } - if ($lookup{s}) { - $links{"s$_->{id}"} = $_->{name} for (@{$self->dbStaffGet(id => [keys %{$lookup{s}}], @opt)}); - } - return $msg unless %links; - - # Now substitute - my $result = ''; - VNDB::BBCode::parse $msg, sub { - my($code, $tag) = @_; - $result .= $tag eq 'dblink' && $links{$code} - ? sprintf '[url=/%s]%s[/url]', $code, $links{$code} - : $code; - 1; - }; - return $result; + shift; bb_subst_links @_; } + # Returns an arrayref of links, each link being [$title, $url, $price] sub entryLinks { my($self, $type, $obj) = @_; diff --git a/lib/VNWeb/DB.pm b/lib/VNWeb/DB.pm index 27d04b64..1583e731 100644 --- a/lib/VNWeb/DB.pm +++ b/lib/VNWeb/DB.pm @@ -161,7 +161,7 @@ sub _enrich { return if !keys %ids; # Fetch the data - $sql = ref $sql eq 'CODE' ? $sql->([keys %ids]) : sql $sql, [keys %ids]; + $sql = ref $sql eq 'CODE' ? do { local $_ = [keys %ids]; sql $sql->($_) } : sql $sql, [keys %ids]; my $data = tuwf->dbAlli($sql); # And merge @@ -237,7 +237,7 @@ my $entry_types = do { # 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 +# id, chid, chrev, maxrev, hidden, locked, entry_hidden, entry_locked # # (Ordering of arrays is unspecified) # @@ -318,10 +318,11 @@ sub db_edit { while(my($name, $tbl) = each $t->{tables}->%*) { my $base = $tbl->{name} =~ s/_hist$//r; - my @cols = sql_comma(map sql_identifier($_->{name}), $tbl->{cols}->$@); + my @colnames = grep $_ ne 'chid', map $_->{name}, $tbl->{cols}->@*; + my @cols = sql_comma(map sql_identifier($_), @colnames); my @rows = map { my $d = $_; - sql '(', sql_comma(map \$d, $tbl->{cols}->@*), ')' + sql '(', sql_comma(map \$d->{$_}, @colnames), ')' } $data->{$name}->@*; tuwf->dbExeci("DELETE FROM edit_${base}"); diff --git a/lib/VNWeb/Elm.pm b/lib/VNWeb/Elm.pm index e83faf25..1cdfcb88 100644 --- a/lib/VNWeb/Elm.pm +++ b/lib/VNWeb/Elm.pm @@ -234,6 +234,9 @@ sub write_types { $data .= def skins => 'List (String, String)' => list map tuple(string $_, string tuwf->{skins}{$_}[0]), sort { tuwf->{skins}{$a}[0] cmp tuwf->{skins}{$b}[0] } keys tuwf->{skins}->%*; + $data .= def languages => 'List (String, String)' => + list map tuple(string $_, string $LANGUAGE{$_}), + sort { $LANGUAGE{$a} cmp $LANGUAGE{$b} } keys %LANGUAGE; write_module Types => $data; } diff --git a/lib/VNWeb/HTML.pm b/lib/VNWeb/HTML.pm index f11019ca..2223c4cf 100644 --- a/lib/VNWeb/HTML.pm +++ b/lib/VNWeb/HTML.pm @@ -9,6 +9,7 @@ use JSON::XS; use TUWF ':html5_', 'uri_escape', 'html_escape', 'mkclass'; use Exporter 'import'; use POSIX 'ceil'; +use Carp 'croak'; use JSON::XS; use VNDB::Config; use VNDB::BBCode; @@ -28,6 +29,7 @@ our @EXPORT = qw/ paginate_ sortable_ searchbox_ + editmsg_ /; @@ -336,6 +338,15 @@ sub _maintabs_ { } +# Attempt to figure out the board id from a database entry ($type, $dbobj) combination +sub _board_id { + my($type, $obj) = @_; + $type =~ /[vp]/ ? $type.$obj->{id} : + $type eq 'r' && $obj->{vn}->@* ? 'v'.$obj->{vn}[0]{vid} : + $type eq 'c' && $obj->{vns}->@* ? 'v'.$obj->{vns}[0]{vid} : 'db'; +} + + # Returns 1 if the page contents should be hidden. sub _hidden_msg_ { my $o = shift; @@ -349,14 +360,13 @@ sub _hidden_msg_ { 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"; + a_ href => '/t/'._board_id($o->{type}, $o->{dbobj}), "discussion board"; txt_ ' if you believe that this entry should be restored.'; br_; br_; @@ -669,4 +679,68 @@ sub searchbox_ { }; } + +# Generate the initial mainbox when adding or editing a database entry, with a +# friendly message pointing to the guidelines and stuff. +# Args: $type ('v','r', etc), $obj (from db_entry(), or undef for new page), $page_title, $is_this_a_copy? +sub editmsg_ { + my($type, $obj, $title, $copy) = @_; + my $typename = {v => 'visual novel', r => 'release', p => 'producer', c => 'character', s => 'person'}->{$type}; + my $guidelines = {v => 2, r => 3, p => 4, c => 12, s => 16 }->{$type}; + croak "Unknown type: $type" if !$typename; + + div_ class => 'mainbox', sub { + h1_ sub { + txt_ $title; + debug_ $obj if $obj; + }; + if($copy) { + div_ class => 'warning', sub { + h2_ "You're not editing an entry!"; + p_ sub {; + txt_ "You're about to insert a new entry into the database with information based on "; + a_ href => "/$type$obj->{id}", "$type$obj->{id}"; + txt_ '.'; + br_; + txt_ "Hit the 'edit' tab on the right-top if you intended to edit the entry instead of creating a new one."; + } + } + } + # 'lastrev' is for compatibility with VNDB::* + if($obj && ($obj->{maxrev} ? $obj->{maxrev} != $obj->{chrev} : !$obj->{lastrev})) { + div_ class => 'warning', sub { + h2_ 'Reverting'; + p_ "You are editing an old revision of this $typename. If you save it, all changes made after this revision will be reverted!"; + } + } + div_ class => 'notice', sub { + h2_ 'Before editing:'; + ul_ sub { + li_ sub { + txt_ 'Read the '; + a_ href=> "/d$guidelines", 'guidelines'; + txt_ '!'; + }; + if($obj) { + li_ sub { + txt_ 'Check for any existing discussions on the '; + a_ href => '/t/'._board_id($type, $obj), 'discussion board'; + }; + # TODO: Include a list of the most recent edits in this page. + li_ sub { + txt_ 'Browse the '; + a_ href => "/$type$obj->{id}/hist", 'edit history'; + txt_ ' for any recent changes related to what you want to change.'; + }; + } elsif($type ne 'r') { + li_ sub { + a_ href => "/$type/all", 'Search the database'; + txt_ " to see if we already have information about this $typename."; + } + } + } + }; + } +} + 1; diff --git a/lib/VNWeb/Staff/Edit.pm b/lib/VNWeb/Staff/Edit.pm new file mode 100644 index 00000000..b3c69db7 --- /dev/null +++ b/lib/VNWeb/Staff/Edit.pm @@ -0,0 +1,103 @@ +package VNWeb::Staff::Edit; + +use VNWeb::Prelude; + + +my $FORM = { + aid => { int => 1, range => [ -1000, 1<<40 ] }, # X + alias => { maxlength => 100, sort_keys => 'aid', aoh => { + aid => { int => 1, range => [ -1000, 1<<40 ] }, # X, negative IDs are for new aliases + name => { maxlength => 200 }, + original => { maxlength => 200, required => 0, default => '' }, + inuse => { anybool => 1, _when => 'out' }, + } }, + desc => { required => 0, default => '', maxlength => 5000 }, + gender => { required => 0, default => 'unknown', enum => [qw[unknown m f]] }, + lang => { language => 1 }, + l_site => { required => 0, default => '', weburl => 1 }, + l_wikidata => { required => 0, default => 0, id => 1 }, + l_twitter => { required => 0, default => '', regex => qr/^\S+$/, maxlength => 16 }, + l_anidb => { required => 0, id => 1, default => undef }, + l_pixiv => { required => 0, id => 1, default => 0 }, + hidden => { anybool => 1 }, + locked => { anybool => 1 }, + + id => { _when => 'out', id => 1 }, + authmod => { _when => 'out', anybool => 1 }, + editsum => { _when => 'in out', editsum => 1 }, +}; + +my $FORM_OUT = form_compile out => $FORM; +my $FORM_IN = form_compile in => $FORM; +my $FORM_CMP = form_compile cmp => $FORM; + +elm_form StaffEdit => $FORM_OUT, $FORM_IN; + + +TUWF::get qr{/$RE{srev}/edit} => sub { + my $e = db_entry s => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound; + return tuwf->resDenied if !can_edit s => $e; + + $e->{authmod} = auth->permDbmod; + $e->{editsum} = $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision s$e->{id}.$e->{chrev}"; + + enrich_merge aid => sub { + 'SELECT aid, EXISTS(SELECT 1 FROM vn_staff WHERE aid = x.aid UNION ALL SELECT 1 FROM vn_seiyuu WHERE aid = x.aid) AS inuse + FROM unnest(', sql_array(@$_), '::int[]) AS x(aid)' + }, $e->{alias}; + + my $name = (grep $_->{aid} == $e->{aid}, @{$e->{alias}})[0]{name}; + framework_ title => "Edit $name", type => 's', dbobj => $e, tab => 'edit', + sub { + editmsg_ s => $e, "Edit $name"; + elm_ 'StaffEdit.Main' => $FORM_OUT, $e; + }; +}; + + +TUWF::get qr{/s/new}, sub { + return tuwf->resDenied if !can_edit s => undef; + framework_ title => 'Add staff member', + sub { + editmsg_ s => undef, 'Add staff member'; + elm_ 'StaffEdit.New'; + }; +}; + + +json_api qr{/(?:$RE{sid}/edit|s/add)}, $FORM_IN, sub { + my $data = shift; + my $new = !tuwf->capture('id'); + my $e = $new ? { id => 0 } : db_entry s => tuwf->capture('id') or return tuwf->resNotFound; + return elm_Unauth if !can_edit s => $e; + + if(!auth->permDbmod) { + $data->{hidden} = $e->{hidden}||0; + $data->{locked} = $e->{locked}||0; + } + $data->{l_wp} = $e->{l_wp}||''; + $data->{desc} = bb_subst_links $data->{desc}; + + # The form validation only checks for duplicate aid's, but the name+original should also be unique. + my %names; + die "Duplicate aliases" if grep $names{"$_->{name}\x00$_->{original}"}++, $data->{alias}->@*; + + # For positive alias IDs: Make sure they exist and are owned by this entry. + validate_dbid + sql('SELECT aid FROM staff_alias WHERE id =', \$e->{id}, 'AND aid IN'), + grep $_>=0, map $_->{aid}, $data->{alias}->@*; + + # For negative alias IDs: Assign a new ID. + for my $alias (grep $_->{aid} < 0, $data->{alias}->@*) { + my $new = tuwf->dbVali(select => sql_func nextval => \'staff_alias_aid_seq'); + $data->{aid} = $new if $alias->{aid} == $data->{aid}; + $alias->{aid} = $new; + } + # We rely on Postgres to throw an error if we attempt to delete an alias that is still being referenced. + + return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e; + my($id,undef,$rev) = db_edit s => $e->{id}, $data; + elm_Changed $id, $rev; +}; + +1; diff --git a/lib/VNWeb/Validation.pm b/lib/VNWeb/Validation.pm index eb29a52c..ee3bc386 100644 --- a/lib/VNWeb/Validation.pm +++ b/lib/VNWeb/Validation.pm @@ -3,14 +3,18 @@ package VNWeb::Validation; use v5.26; use TUWF; use PWLookup; +use VNDB::Types; use VNDB::Config; use VNWeb::Auth; +use VNWeb::DB; +use Carp 'croak'; use Exporter 'import'; our @EXPORT = qw/ is_insecurepass form_compile form_changed + validate_dbid can_edit /; @@ -22,6 +26,20 @@ TUWF::set custom_validations => { upage => { uint => 1, min => 1, required => 0, default => 1 }, # pagination without a maximum username => { regex => qr/^(?!-*[a-z][0-9]+-*$)[a-z0-9-]*$/, minlength => 2, maxlength => 15 }, password => { length => [ 4, 500 ] }, + language => { enum => \%LANGUAGE }, + # Sort an array by the listed hash keys, using string comparison on each key + sort_keys => sub { + my @keys = ref $_[0] eq 'ARRAY' ? @{$_[0]} : $_[0]; + +{ type => 'array', sort => sub { + for(@keys) { + my $c = defined($_[0]{$_}) cmp defined($_[1]{$_}) || (defined($_[0]{$_}) && $_[0]{$_} cmp $_[1]{$_}); + return $c if $c; + } + 0 + } } + }, + # Sorted and unique array-of-hashes (default order is sort_keys on the sorted keys...) + aoh => sub { +{ type => 'array', unique => 1, sort_keys => [sort keys %{$_[0]}], values => { type => 'hash', keys => $_[0] } } }, }; @@ -88,6 +106,24 @@ sub form_changed { } +# Validate identifiers against an SQL query. The query must end with a 'id IN' +# clause, where the @ids array is appended. The query must return exactly 1 +# column, the id of each entry. This function throws an error if an id is +# missing from the query. For example, to test for non-hidden VNs: +# +# validate_dbid 'SELECT id FROM vn WHERE NOT hidden AND id IN', 2,3,5,7,...; +# +# If any of those ids is hidden or not in the database, an error is thrown. +sub validate_dbid { + my($sql, @ids) = @_; + return if !@ids; + $sql = ref $sql eq 'CODE' ? do { local $_ = \@ids; sql $sql->(\@ids) } : sql $sql, \@ids; + my %dbids = map +((values %$_)[0],1), @{ tuwf->dbAlli($sql) }; + my @missing = grep !$dbids{$_}, @ids; + croak "Invalid database IDs: ".join(',', @missing) if @missing; +} + + # Returns whether the current user can edit the given database entry. sub can_edit { my($type, $entry) = @_; |