summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/style.css39
-rw-r--r--elm/Lib/Api.elm1
-rw-r--r--elm/Lib/DropDown.elm2
-rw-r--r--elm/Lib/Html.elm3
-rw-r--r--elm/Report.elm9
-rw-r--r--elm/Reviews/Edit.elm9
-rw-r--r--elm/Subscribe.elm99
-rw-r--r--elm/TagEdit.elm237
-rw-r--r--elm/Tagmod.elm1
-rw-r--r--elm/TraitEdit.elm209
-rw-r--r--elm/VNEdit.elm35
-rw-r--r--lib/VNDB/DB/Tags.pm55
-rw-r--r--lib/VNDB/DB/Traits.pm29
-rw-r--r--lib/VNDB/Handler/Misc.pm12
-rw-r--r--lib/VNDB/Handler/Producers.pm58
-rw-r--r--lib/VNDB/Handler/Tags.pm315
-rw-r--r--lib/VNDB/Handler/Traits.pm292
-rw-r--r--lib/VNDB/Handler/VNPage.pm8
-rw-r--r--lib/VNDB/Util/Auth.pm50
-rw-r--r--lib/VNDB/Util/BrowseHTML.pm2
-rw-r--r--lib/VNDB/Util/FormHTML.pm282
-rw-r--r--lib/VNDB/Util/LayoutHTML.pm1
-rw-r--r--lib/VNDB/Util/Misc.pm9
-rw-r--r--lib/VNDB/Util/ValidateTemplates.pm96
-rw-r--r--lib/VNWeb/Auth.pm11
-rw-r--r--lib/VNWeb/Discussions/Edit.pm3
-rw-r--r--lib/VNWeb/Discussions/PostEdit.pm3
-rw-r--r--lib/VNWeb/Discussions/Thread.pm7
-rw-r--r--lib/VNWeb/Elm.pm7
-rw-r--r--lib/VNWeb/HTML.pm57
-rw-r--r--lib/VNWeb/Images/Vote.pm12
-rw-r--r--lib/VNWeb/Misc/Feeds.pm1
-rw-r--r--lib/VNWeb/Misc/HomePage.pm3
-rw-r--r--lib/VNWeb/Misc/Redirects.pm42
-rw-r--r--lib/VNWeb/Producers/List.pm62
-rw-r--r--lib/VNWeb/Reviews/Edit.pm14
-rw-r--r--lib/VNWeb/Reviews/Elm.pm5
-rw-r--r--lib/VNWeb/Reviews/Lib.pm4
-rw-r--r--lib/VNWeb/Reviews/List.pm2
-rw-r--r--lib/VNWeb/Reviews/Page.pm22
-rw-r--r--lib/VNWeb/Reviews/VNTab.pm5
-rw-r--r--lib/VNWeb/TT/Elm.pm (renamed from lib/VNWeb/Traits/Elm.pm)22
-rw-r--r--lib/VNWeb/TT/Index.pm133
-rw-r--r--lib/VNWeb/TT/Lib.pm23
-rw-r--r--lib/VNWeb/TT/List.pm105
-rw-r--r--lib/VNWeb/TT/TagEdit.pm155
-rw-r--r--lib/VNWeb/TT/TagLinks.pm (renamed from lib/VNWeb/Tags/Links.pm)4
-rw-r--r--lib/VNWeb/TT/TraitEdit.pm140
-rw-r--r--lib/VNWeb/Tags/Elm.pm24
-rw-r--r--lib/VNWeb/Tags/Lib.pm16
-rw-r--r--lib/VNWeb/User/Notifications.pm88
-rw-r--r--lib/VNWeb/VN/Page.pm4
-rw-r--r--lib/VNWeb/VN/Tagmod.pm1
-rw-r--r--lib/VNWeb/Validation.pm8
-rw-r--r--sql/func.sql287
-rw-r--r--sql/perms.sql1
-rw-r--r--sql/schema.sql29
-rw-r--r--sql/tableattrs.sql5
-rw-r--r--sql/triggers.sql89
-rw-r--r--util/updates/2020-09-20-reviews-locked.sql1
-rw-r--r--util/updates/2020-10-08-extra-notifications.sql45
-rw-r--r--util/updates/2020-10-13-notifications-subapply.sql3
-rw-r--r--util/updates/2020-10-15-reviews-anonymous-votes.sql4
63 files changed, 1764 insertions, 1536 deletions
diff --git a/data/style.css b/data/style.css
index 5e1301c4..95358b13 100644
--- a/data/style.css
+++ b/data/style.css
@@ -306,11 +306,14 @@ div.maintabs { display: flex; justify-content: space-between; position:
div.maintabs.right { justify-content: flex-end }
div.maintabs.left { justify-content: flex-start }
div.maintabs > ul { margin: 0; padding: 0; list-style-type: none }
-div.maintabs > ul li { display: inline-block; margin: 0 0 0 10px }
-div.maintabs > ul li:nth-child(1) { margin-left: 0!important }
-div.maintabs > ul li a { display: inline-block; box-sizing: border-box; height: 21px; padding: 1px 7px 0 7px; border: 1px solid $border$; border-bottom: none; background-color: $tabbg$; color: $grayedout$; }
-div.maintabs > ul li.tabselected a,
-div.maintabs > ul li a:hover { background: $_blendbg$; color: $maintext$; height: 22px }
+div.maintabs > ul > li { display: inline-block; margin: 0 0 0 10px }
+div.maintabs > ul > li:nth-child(1) { margin-left: 0!important }
+div.maintabs > ul > li > a,
+div.maintabs > ul > li > div > a { display: inline-block; box-sizing: border-box; height: 21px; padding: 1px 7px 0 7px; border: 1px solid $border$; border-bottom: none; background-color: $tabbg$; color: $grayedout$; }
+div.maintabs > ul > li.tabselected > a,
+div.maintabs > ul > li.tabselected > div > a,
+div.maintabs > ul > li > div > a:hover,
+div.maintabs > ul > li > a:hover { background: $_blendbg$; color: $maintext$; height: 22px }
div.maintabs.browsetabs > ul li a { color: $maintext$ }
div.maintabs.browsetabs > ul li { margin-left: 5px }
div.maintabs.bottom { margin-top: 10px; /* WHY!? */ margin-bottom: -10px }
@@ -514,16 +517,15 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
}
.reviews { display: flex; justify-content: center; flex-wrap: wrap }
-.reviewbox { margin: 10px }
+.reviewbox { margin: 10px 12px 30px 12px; flex: 1 1; flex-basis: 450px }
.reviewbox > div:nth-child(2) > span:first-child { float: right; color: $grayedout$; font-style: normal; margin: -5px 0 0 0; visibility: hidden }
.reviewbox > div:nth-child(2):hover > span:first-child,
.reviewbox > div:nth-child(2):active > span:first-child { visibility: visible }
.reviewbox .review_spoil input:checked ~ span { display: none }
.reviewbox .review_spoil input:not(:checked) ~ div { display: none }
-.reviewbox > div { width: 500px }
.reviewbox > div:first-child { display: flex; justify-content: space-between; background: $secbg$; font-weight: bold }
.reviewbox > div:first-child > span:first-child { font-weight: bold }
-.reviewbox > div:nth-child(2) { box-sizing: border-box; padding: 5px 0 }
+.reviewbox > div:nth-child(2) { box-sizing: border-box; padding: 10px; background: $boxbg$ }
.reviewbox > div:last-child { display: flex; justify-content: space-between; border-top: 1px solid $border$ }
.reviewbox .myvote { font-weight: bold; text-decoration: underline }
@@ -599,8 +601,7 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
/***** Producer list ******/
-div.producerbrowse { padding-bottom: 10px }
-.producerbrowse ul { float: left; margin-top: -5px; margin-left: 3%; width: 28%; }
+.producerbrowse ul { -webkit-column-width: 250px; -moz-column-width: 250px; column-width: 250px; margin-bottom: 10px }
.producerbrowse ul li { list-style-type: none; }
.producerbrowse ul li abbr { margin-right: 5px; margin-top: 1px; }
@@ -631,7 +632,7 @@ div.producerbrowse { padding-bottom: 10px }
.reviewlist td.tc2 { width: 110px; }
.reviewlist td.tc3 { width: 50px; text-align: right }
.reviewlist td.tc4 { width: 50px }
-.reviewlist td.tc6 { width: 80px }
+.reviewlist td.tc6 { width: 140px }
.reviewlist td.tc7 { width: 30px; text-align: right }
.reviewlist td.tc8 { width: 250px; text-align: right }
@@ -859,6 +860,16 @@ div.votelist td.tc2 { width: 50px; text-align: right; padding-right: 10px }
.browse.notifies .unread td { font-weight: bold }
.browse.notifies tfoot td { padding: 0 0 0 25px }
+/***** Subscription tab thiny (HTML::_maintabs_subscribe_() + elm/Subscribe) ****/
+
+#subscribe .inactive { color: transparent; text-shadow: 0 0 $grayedout$ }
+#subscribe .active { color: transparent; text-shadow: 0 0 $maintext$ }
+
+#subscribe > div > a { height: 21px!important /* override :hover change */ }
+#subscribe > div > div { position: absolute; width: 1px }
+#subscribe > div > div > div { box-sizing: border-box; padding: 10px; width: 500px; border: 1px solid $border$; background: $secbg$; position: relative; bottom: 0; left: -470px; z-index: 100 }
+#subscribe p, #subscribe h4, #subscribe label { display: block; margin-bottom: 3px }
+
/***** User list *****/
.browse.userlist .tc3,
@@ -900,9 +911,11 @@ div.uposts td.tc4 b { margin-left: 10px }
.tagvnlist .tc6 { text-align: right; padding-right: 10px; }
-/***** Tag/trait list (/g/list, /i/list) *****/
+/***** Tag list (/g/list) *****/
-.browse.taglist .tc1 { width: 100px; white-space: nowrap }
+.browse.taglist .tc1 { width: 120px; white-space: nowrap }
+.browse.taglist .tc2 { width: 50px; white-space: nowrap }
+.browse.taglist tbody .tc3 a { margin-right: 10px }
/***** Tag links *****/
diff --git a/elm/Lib/Api.elm b/elm/Lib/Api.elm
index fd4a3a7e..b1e22193 100644
--- a/elm/Lib/Api.elm
+++ b/elm/Lib/Api.elm
@@ -45,6 +45,7 @@ showResponse res =
BadCurPass -> "Current password is invalid."
MailChange -> unexp
ImgFormat -> "Unrecognized image format, only JPEG and PNG are accepted."
+ DupNames _ -> "Name or alias already in the database."
Releases _ -> unexp
BoardResult _ -> unexp
TagResult _ -> unexp
diff --git a/elm/Lib/DropDown.elm b/elm/Lib/DropDown.elm
index 286a61cb..1e6204ac 100644
--- a/elm/Lib/DropDown.elm
+++ b/elm/Lib/DropDown.elm
@@ -1,4 +1,4 @@
-module Lib.DropDown exposing (Config, init, sub, toggle, view)
+module Lib.DropDown exposing (Config, init, sub, toggle, view, onClickOutside)
import Browser.Events as E
import Json.Decode as JD
diff --git a/elm/Lib/Html.elm b/elm/Lib/Html.elm
index 2d7d516c..440abd00 100644
--- a/elm/Lib/Html.elm
+++ b/elm/Lib/Html.elm
@@ -125,10 +125,11 @@ inputTextArea nam val onch attrs = textarea (
, onInput onch
, rows 4
, cols 50
+ , value val
]
++ attrs
++ (if nam == "" then [] else [ id nam, name nam ])
- ) [ text val ]
+ ) []
inputCheck : String -> Bool -> (Bool -> m) -> Html m
diff --git a/elm/Report.elm b/elm/Report.elm
index f63a9411..342b37da 100644
--- a/elm/Report.elm
+++ b/elm/Report.elm
@@ -56,7 +56,7 @@ reasons =
, submit = True
, msg = nomsg
}
- , { label = "Off-topic / wrong board"
+ , { label = "Off-topic"
, vis = objtype "tw"
, submit = True
, msg = nomsg
@@ -69,7 +69,7 @@ reasons =
, { label = "Unmarked spoilers"
, vis = vis
, submit = True
- , msg = \o -> if editable o then [] else
+ , msg = \o -> if not (editable o) then [] else
[ text "VNDB is an open wiki, it is often easier if you removed the spoilers yourself by "
, a [ href ("/" ++ o ++ "/edit") ] [ text " editing the entry" ]
, text ". You likely know more about this entry than our moderators, after all. "
@@ -79,6 +79,11 @@ reasons =
, text " so that others may be able to help you."
]
}
+ , { label = "Unmarked or improperly flagged NSFW image"
+ , vis = objtype "vc"
+ , submit = True
+ , msg = nomsg
+ }
, { label = "Incorrect information"
, vis = editable
, submit = False
diff --git a/elm/Reviews/Edit.elm b/elm/Reviews/Edit.elm
index 5b3bc347..925de964 100644
--- a/elm/Reviews/Edit.elm
+++ b/elm/Reviews/Edit.elm
@@ -30,11 +30,13 @@ type alias Model =
, vntitle : String
, rid : Maybe Int
, spoiler : Bool
+ , locked : Bool
, isfull : Bool
, text : TP.Model
, releases : List GRE.RecvReleases
, delete : Bool
, delState : Api.State
+ , mod : Bool
}
@@ -46,11 +48,13 @@ init d =
, vntitle = d.vntitle
, rid = d.rid
, spoiler = d.spoiler
+ , locked = d.locked
, isfull = d.isfull
, text = TP.bbcode d.text
, releases = d.releases
, delete = False
, delState = Api.Normal
+ , mod = d.mod
}
@@ -60,6 +64,7 @@ encode m =
, vid = m.vid
, rid = m.rid
, spoiler = m.spoiler
+ , locked = m.locked
, isfull = m.isfull
, text = m.text.data
}
@@ -69,6 +74,7 @@ type Msg
= Release (Maybe Int)
| Full Bool
| Spoiler Bool
+ | Locked Bool
| Text TP.Msg
| Submit
| Submitted GApi.Response
@@ -83,6 +89,7 @@ update msg model =
Release i -> ({ model | rid = i }, Cmd.none)
Full b -> ({ model | isfull = b }, Cmd.none)
Spoiler b -> ({ model | spoiler = b }, Cmd.none)
+ Locked b -> ({ model | locked = b }, Cmd.none)
Text m -> let (nm,nc) = TP.update m model.text in ({ model | text = nm }, Cmd.map Text nc)
Submit -> ({ model | state = Api.Loading }, GRE.send (encode model) Submitted)
@@ -143,6 +150,8 @@ view model =
, br [] []
, b [ class "grayedout" ] [ text "You do not have to check this option if all spoilers in your review are marked with [spoiler] tags." ]
]
+ , if not model.mod then text "" else
+ formField "" [ label [] [ inputCheck "" model.locked Locked, text " Locked for commenting." ] ]
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "text::Review"
[ TP.view "sum" model.text Text 700 ([rows (if model.isfull then 30 else 10), cols 50] ++ GRE.valText)
diff --git a/elm/Subscribe.elm b/elm/Subscribe.elm
new file mode 100644
index 00000000..ca70a675
--- /dev/null
+++ b/elm/Subscribe.elm
@@ -0,0 +1,99 @@
+module Subscribe exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Lib.Html exposing (..)
+import Lib.Api as Api
+import Lib.DropDown exposing (onClickOutside)
+import Gen.Api as GApi
+import Gen.Subscribe as GS
+
+
+main : Program GS.Send Model Msg
+main = Browser.element
+ { init = \e -> ({ state = Api.Normal, opened = False, data = e}, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = \m -> if m.opened then onClickOutside "subscribe" (Opened False) else Sub.none
+ }
+
+type alias Model =
+ { state : Api.State
+ , opened : Bool
+ , data : GS.Send
+ }
+
+type Msg
+ = Opened Bool
+ | SubNum Bool Bool
+ | SubReview Bool
+ | SubApply Bool
+ | Submitted GApi.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ let dat = model.data
+ save nd = ({ model | data = nd, state = Api.Loading }, GS.send nd Submitted)
+ in
+ case msg of
+ Opened b -> ({ model | opened = b }, Cmd.none)
+ SubNum v b -> save { dat | subnum = if b then Just v else Nothing }
+ SubReview b -> save { dat | subreview = b }
+ SubApply b -> save { dat | subapply = b }
+ Submitted e -> ({ model | state = if e == GApi.Success then Api.Normal else Api.Error e }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ let
+ dat = model.data
+ t = String.left 1 dat.id
+ msg txt = p [] [ text txt, text " These can be disabled globally in your ", a [ href "/u/notifies" ] [ text "notification settings" ], text "." ]
+ in
+ div []
+ [ a [ href "#", onClickD (Opened (not model.opened))
+ , class (if (dat.noti > 0 && dat.subnum /= Just False) || dat.subnum == Just True || dat.subreview || dat.subapply then "active" else "inactive")
+ ] [ text "๐Ÿ””" ]
+ , if not model.opened then text ""
+ else div [] [ div []
+ [ h4 []
+ [ if model.state == Api.Loading then span [ class "spinner", style "float" "right" ] [] else text ""
+ , text "Manage Notifications"
+ ]
+ , case (t, dat.noti) of
+ ("t", 1) -> msg "You receive notifications for replies because you have posted in this thread."
+ ("t", 2) -> msg "You receive notifications for replies because this thread is linked to your personal board."
+ ("t", 3) -> msg "You receive notifications for replies because you have posted in this thread and it is linked to your personal board."
+ ("w", 1) -> msg "You receive notifications for new comments because you have commented on this review."
+ ("w", 2) -> msg "You receive notifications for new comments because this is your review."
+ ("w", 3) -> msg "You receive notifications for new comments because this is your review and you have commented it."
+ (_, 1) -> msg "You receive edit notifications for this entry because you have contributed to it."
+ _ -> text ""
+ , if dat.noti == 0 then text "" else
+ label []
+ [ inputCheck "" (dat.subnum == Just False) (SubNum False)
+ , case t of
+ "t" -> text " Disable notifications only for this thread."
+ "w" -> text " Disable notifications only for this review."
+ _ -> text " Disable edit notifications only for this entry."
+ ]
+ , if t == "i" then text "" else label []
+ [ inputCheck "" (dat.subnum == Just True) (SubNum True)
+ , case t of
+ "t" -> text " Enable notifications for new replies"
+ "w" -> text " Enable notifications for new comments"
+ _ -> text " Enable notifications for new edits"
+ , if dat.noti == 0 then text "." else text ", regardless of the global setting."
+ ]
+ , if t /= "v" then text "" else
+ label [] [ inputCheck "" dat.subreview SubReview, text " Enable notifications for new reviews." ]
+ , if t /= "i" then text "" else
+ label [] [ inputCheck "" dat.subapply SubApply, text " Enable notifications when this trait is applied or removed from a character." ]
+ , case model.state of
+ Api.Error e -> b [ class "standout" ] [ br [] [], text (Api.showResponse e) ]
+ _ -> text ""
+ ] ]
+ ]
diff --git a/elm/TagEdit.elm b/elm/TagEdit.elm
new file mode 100644
index 00000000..e8abedaa
--- /dev/null
+++ b/elm/TagEdit.elm
@@ -0,0 +1,237 @@
+module TagEdit exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Browser
+import Browser.Navigation exposing (load)
+import Lib.Html exposing (..)
+import Lib.TextPreview as TP
+import Lib.Api as Api
+import Lib.Util exposing (..)
+import Lib.Autocomplete as A
+import Lib.Ffi as Ffi
+import Gen.Api as GApi
+import Gen.Types exposing (tagCategories)
+import Gen.TagEdit as GTE
+
+
+main : Program GTE.Recv Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type alias Model =
+ { formstate : Api.State
+ , id : Maybe Int
+ , name : String
+ , aliases : String
+ , state : Int
+ , cat : String
+ , description : TP.Model
+ , searchable : Bool
+ , applicable : Bool
+ , defaultspoil : Int
+ , parents : List GTE.RecvParents
+ , parentAdd : A.Model GApi.ApiTagResult
+ , addedby : String
+ , wipevotes : Bool
+ , merge : List GTE.RecvParents
+ , mergeAdd : A.Model GApi.ApiTagResult
+ , canMod : Bool
+ , dupNames : List GApi.ApiDupNames
+ }
+
+
+init : GTE.Recv -> Model
+init d =
+ { formstate = Api.Normal
+ , id = d.id
+ , name = d.name
+ , aliases = String.join "\n" d.aliases
+ , state = d.state
+ , cat = d.cat
+ , description = TP.bbcode d.description
+ , searchable = d.searchable
+ , applicable = d.applicable
+ , defaultspoil = d.defaultspoil
+ , parents = d.parents
+ , parentAdd = A.init ""
+ , addedby = d.addedby
+ , wipevotes = False
+ , merge = []
+ , mergeAdd = A.init ""
+ , canMod = d.can_mod
+ , dupNames = []
+ }
+
+
+splitAliases : String -> List String
+splitAliases l = String.lines l |> List.map String.trim |> List.filter (\s -> s /= "")
+
+findDup : Model -> String -> List GApi.ApiDupNames
+findDup model a = List.filter (\t -> String.toLower t.name == String.toLower a) model.dupNames
+
+isValid : Model -> Bool
+isValid model = not (List.any (findDup model >> List.isEmpty >> not) (model.name :: splitAliases model.aliases))
+
+parentConfig : A.Config Msg GApi.ApiTagResult
+parentConfig = { wrap = ParentSearch, id = "parentadd", source = A.tagSource }
+
+mergeConfig : A.Config Msg GApi.ApiTagResult
+mergeConfig = { wrap = MergeSearch, id = "mergeadd", source = A.tagSource }
+
+
+encode : Model -> GTE.Send
+encode m =
+ { id = m.id
+ , name = m.name
+ , aliases = splitAliases m.aliases
+ , state = m.state
+ , cat = m.cat
+ , description = m.description.data
+ , searchable = m.searchable
+ , applicable = m.applicable
+ , defaultspoil = m.defaultspoil
+ , parents = List.map (\l -> {id=l.id}) m.parents
+ , wipevotes = m.wipevotes
+ , merge = List.map (\l -> {id=l.id}) m.merge
+ }
+
+
+type Msg
+ = Name String
+ | Aliases String
+ | State Int
+ | Searchable Bool
+ | Applicable Bool
+ | Cat String
+ | DefaultSpoil Int
+ | Description TP.Msg
+ | ParentDel Int
+ | ParentSearch (A.Msg GApi.ApiTagResult)
+ | WipeVotes Bool
+ | MergeDel Int
+ | MergeSearch (A.Msg GApi.ApiTagResult)
+ | Submit
+ | Submitted (GApi.Response)
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Name s -> ({ model | name = s }, Cmd.none)
+ Aliases s -> ({ model | aliases = String.replace "," "\n" s }, Cmd.none)
+ State n -> ({ model | state = n }, Cmd.none)
+ Searchable b -> ({ model | searchable = b }, Cmd.none)
+ Applicable b -> ({ model | applicable = b }, Cmd.none)
+ Cat s -> ({ model | cat = s }, Cmd.none)
+ DefaultSpoil n-> ({ model | defaultspoil = n }, Cmd.none)
+ WipeVotes b -> ({ model | wipevotes = b }, Cmd.none)
+ Description m -> let (nm,nc) = TP.update m model.description in ({ model | description = nm }, Cmd.map Description nc)
+
+ ParentDel i -> ({ model | parents = delidx i model.parents }, Cmd.none)
+ ParentSearch m ->
+ let (nm, c, res) = A.update parentConfig m model.parentAdd
+ in case res of
+ Nothing -> ({ model | parentAdd = nm }, c)
+ Just p ->
+ if List.any (\e -> e.id == p.id) model.parents
+ then ({ model | parentAdd = nm }, c)
+ else ({ model | parentAdd = A.clear nm "", parents = model.parents ++ [{ id = p.id, name = p.name}] }, c)
+
+ MergeDel i -> ({ model | merge = delidx i model.merge }, Cmd.none)
+ MergeSearch m ->
+ let (nm, c, res) = A.update mergeConfig m model.mergeAdd
+ in case res of
+ Nothing -> ({ model | mergeAdd = nm }, c)
+ Just p -> ({ model | mergeAdd = A.clear nm "", merge = model.merge ++ [{ id = p.id, name = p.name}] }, c)
+
+ Submit -> ({ model | formstate = Api.Loading }, GTE.send (encode model) Submitted)
+ Submitted (GApi.DupNames l) -> ({ model | dupNames = l, formstate = Api.Normal }, Cmd.none)
+ Submitted (GApi.Redirect s) -> (model, load s)
+ Submitted r -> ({ model | formstate = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ form_ Submit (model.formstate == Api.Loading)
+ [ div [ class "mainbox" ]
+ [ h1 [] [ text <| if model.id == Nothing then "Submit new tag" else "Edit tag" ]
+ , table [ class "formtable" ] <|
+ [ if model.id == Nothing then text "" else
+ formField "Added by" [ span [ Ffi.innerHtml model.addedby ] [], br_ 2 ]
+ , formField "name::Primary name" [ inputText "name" model.name Name GTE.valName ]
+ , formField "aliases::Aliases"
+ -- BUG: Textarea doesn't validate the maxlength and patterns for aliases, we don't have a client-side fallback check either.
+ [ inputTextArea "aliases" model.aliases Aliases []
+ , let dups = List.concatMap (findDup model) (model.name :: splitAliases model.aliases)
+ in if List.isEmpty dups
+ then span [] [ br [] [], text "Tag name and aliases must be unique and self-describing." ]
+ else div []
+ [ b [ class "standout" ] [ text "The following tag names are already present in the database:" ]
+ , ul [] <| List.map (\t ->
+ li [] [ a [ href ("/g"++String.fromInt t.id) ] [ text t.name ] ]
+ ) dups
+ ]
+ ]
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
+ , if not model.canMod then text "" else
+ formField "state::State" [ inputSelect "state" model.state State GTE.valState
+ [ (0, "Awaiting Moderation")
+ , (1, "Deleted/hidden")
+ , (2, "Approved")
+ ]
+ ]
+ , if not model.canMod then text "" else
+ formField "" [ label [] [ inputCheck "" model.searchable Searchable, text " Searchable (people can use this tag to find VNs)" ] ]
+ , if not model.canMod then text "" else
+ formField "" [ label [] [ inputCheck "" model.applicable Applicable, text " Applicable (people can apply this tag to VNs)" ] ]
+ , formField "cat::Category" [ inputSelect "cat" model.cat Cat GTE.valCat tagCategories ]
+ , formField "defaultspoil::Default spoiler level" [ inputSelect "defaultspoil" model.defaultspoil DefaultSpoil GTE.valDefaultspoil
+ [ (0, "No spoiler")
+ , (1, "Minor spoiler")
+ , (2, "Major spoiler")
+ ] ]
+ , text "" -- aliases
+ , formField "description::Description"
+ [ TP.view "description" model.description Description 700 ([rows 12, cols 50] ++ GTE.valDescription) []
+ , text "What should the tag be used for? Having a good description helps users choose which tags to link to a VN."
+ ]
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
+ , formField "Parent tags"
+ [ table [ class "compact" ] <| List.indexedMap (\i p -> tr []
+ [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| "g" ++ String.fromInt p.id ++ ":" ] ]
+ , td [] [ a [ href <| "/g" ++ String.fromInt p.id ] [ text p.name ] ]
+ , td [] [ inputButton "remove" (ParentDel i) [] ]
+ ]
+ ) model.parents
+ , A.view parentConfig model.parentAdd [placeholder "Add parent tag..."]
+ ]
+ ]
+ ++ if not model.canMod || model.id == Nothing then [] else
+ [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "DANGER ZONE" ] ]
+ , formField ""
+ [ inputCheck "" model.wipevotes WipeVotes
+ , text " Delete all direct votes on this tag. WARNING: cannot be undone!", br [] []
+ , b [ class "grayedout" ] [ text "Does not affect votes on child tags. Old votes may still show up for 24 hours due to database caching." ]
+ ]
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
+ , formField "Merge votes"
+ [ text "All direct votes on the listed tags will be moved to this tag. WARNING: cannot be undone!", br [] []
+ , table [ class "compact" ] <| List.indexedMap (\i p -> tr []
+ [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| "g" ++ String.fromInt p.id ++ ":" ] ]
+ , td [] [ a [ href <| "/g" ++ String.fromInt p.id ] [ text p.name ] ]
+ , td [] [ inputButton "remove" (MergeDel i) [] ]
+ ]
+ ) model.merge
+ , A.view mergeConfig model.mergeAdd [placeholder "Add tag to merge..."]
+ ]
+ ]
+ ]
+ , div [ class "mainbox" ]
+ [ fieldset [ class "submit" ] [ submitButton "Submit" model.formstate (isValid model) ] ]
+ ]
diff --git a/elm/Tagmod.elm b/elm/Tagmod.elm
index 1e0cb408..82b6fd13 100644
--- a/elm/Tagmod.elm
+++ b/elm/Tagmod.elm
@@ -168,7 +168,6 @@ viewTag t sel vid mod =
[ onMouseOver (SetSel t.id Note)
, onMouseOut (SetSel 0 NoSel)
, onClickD (SetSel t.id NoteSet)
- , title <| if t.notes == "" then "set note" else t.notes
, style "opacity" <| if t.notes == "" then "0.5" else "1.0"
] [ text "๐Ÿ’ฌ" ]
]
diff --git a/elm/TraitEdit.elm b/elm/TraitEdit.elm
new file mode 100644
index 00000000..f7090af1
--- /dev/null
+++ b/elm/TraitEdit.elm
@@ -0,0 +1,209 @@
+module TraitEdit exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Browser
+import Browser.Navigation exposing (load)
+import Lib.Html exposing (..)
+import Lib.TextPreview as TP
+import Lib.Api as Api
+import Lib.Util exposing (..)
+import Lib.Autocomplete as A
+import Lib.Ffi as Ffi
+import Gen.Api as GApi
+import Gen.TraitEdit as GTE
+
+
+main : Program GTE.Recv Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type alias Model =
+ { formstate : Api.State
+ , id : Maybe Int
+ , name : String
+ , alias : String
+ , state : Int
+ , sexual : Bool
+ , description : TP.Model
+ , searchable : Bool
+ , applicable : Bool
+ , defaultspoil : Int
+ , parents : List GTE.RecvParents
+ , parentAdd : A.Model GApi.ApiTraitResult
+ , order : Int
+ , addedby : String
+ , canMod : Bool
+ , dupNames : List GApi.ApiDupNames
+ }
+
+
+init : GTE.Recv -> Model
+init d =
+ { formstate = Api.Normal
+ , id = d.id
+ , name = d.name
+ , alias = d.alias
+ , state = d.state
+ , sexual = d.sexual
+ , description = TP.bbcode d.description
+ , searchable = d.searchable
+ , applicable = d.applicable
+ , defaultspoil = d.defaultspoil
+ , parents = d.parents
+ , parentAdd = A.init ""
+ , order = d.order
+ , addedby = d.addedby
+ , canMod = d.can_mod
+ , dupNames = []
+ }
+
+
+splitAliases : String -> List String
+splitAliases l = String.lines l |> List.map String.trim |> List.filter (\s -> s /= "")
+
+findDup : Model -> String -> List GApi.ApiDupNames
+findDup model a = List.filter (\t -> String.toLower t.name == String.toLower a) model.dupNames
+
+isValid : Model -> Bool
+isValid model = not (List.any (findDup model >> List.isEmpty >> not) (model.name :: splitAliases model.alias))
+
+parentConfig : A.Config Msg GApi.ApiTraitResult
+parentConfig = { wrap = ParentSearch, id = "parentadd", source = A.traitSource }
+
+
+encode : Model -> GTE.Send
+encode m =
+ { id = m.id
+ , name = m.name
+ , alias = m.alias
+ , state = m.state
+ , sexual = m.sexual
+ , description = m.description.data
+ , searchable = m.searchable
+ , applicable = m.applicable
+ , defaultspoil = m.defaultspoil
+ , parents = List.map (\l -> {id=l.id}) m.parents
+ , order = m.order
+ }
+
+
+type Msg
+ = Name String
+ | Alias String
+ | State Int
+ | Searchable Bool
+ | Applicable Bool
+ | Sexual Bool
+ | DefaultSpoil Int
+ | Description TP.Msg
+ | ParentDel Int
+ | ParentSearch (A.Msg GApi.ApiTraitResult)
+ | Order String
+ | Submit
+ | Submitted (GApi.Response)
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Name s -> ({ model | name = s }, Cmd.none)
+ Alias s -> ({ model | alias = String.replace "," "\n" s }, Cmd.none)
+ State n -> ({ model | state = n }, Cmd.none)
+ Searchable b -> ({ model | searchable = b }, Cmd.none)
+ Applicable b -> ({ model | applicable = b }, Cmd.none)
+ Sexual b -> ({ model | sexual = b }, Cmd.none)
+ DefaultSpoil n-> ({ model | defaultspoil = n }, Cmd.none)
+ Order s -> ({ model | order = Maybe.withDefault 0 (String.toInt s) }, Cmd.none)
+ Description m -> let (nm,nc) = TP.update m model.description in ({ model | description = nm }, Cmd.map Description nc)
+
+ ParentDel i -> ({ model | parents = delidx i model.parents }, Cmd.none)
+ ParentSearch m ->
+ let (nm, c, res) = A.update parentConfig m model.parentAdd
+ in case res of
+ Nothing -> ({ model | parentAdd = nm }, c)
+ Just p ->
+ if List.any (\e -> e.id == p.id) model.parents
+ then ({ model | parentAdd = nm }, c)
+ else ({ model | parentAdd = A.clear nm "", parents = model.parents ++ [{ id = p.id, name = p.name, group = p.group_name }] }, c)
+
+ Submit -> ({ model | formstate = Api.Loading }, GTE.send (encode model) Submitted)
+ Submitted (GApi.DupNames l) -> ({ model | dupNames = l, formstate = Api.Normal }, Cmd.none)
+ Submitted (GApi.Redirect s) -> (model, load s)
+ Submitted r -> ({ model | formstate = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ form_ Submit (model.formstate == Api.Loading)
+ [ div [ class "mainbox" ]
+ [ h1 [] [ text <| if model.id == Nothing then "Submit new trait" else "Edit trait" ]
+ , table [ class "formtable" ]
+ [ if model.id == Nothing then text "" else
+ formField "Added by" [ span [ Ffi.innerHtml model.addedby ] [], br_ 2 ]
+ , formField "name::Primary name" [ inputText "name" model.name Name GTE.valName ]
+ , formField "alias::Aliases"
+ -- BUG: Textarea doesn't validate the maxlength and patterns for aliases, we don't have a client-side fallback check either.
+ [ inputTextArea "alias" model.alias Alias []
+ , let dups = List.concatMap (findDup model) (model.name :: splitAliases model.alias)
+ in if List.isEmpty dups
+ then span [] [ br [] [], text "Trait name and aliases must be self-describing and unique within the same group." ]
+ else div []
+ [ b [ class "standout" ] [ text "The following trait names are already present in the same group:" ]
+ , ul [] <| List.map (\t ->
+ li [] [ a [ href ("/i"++String.fromInt t.id) ] [ text t.name ] ]
+ ) dups
+ ]
+ ]
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
+ , if not model.canMod then text "" else
+ formField "state::State" [ inputSelect "state" model.state State GTE.valState
+ [ (0, "Awaiting Moderation")
+ , (1, "Deleted/hidden")
+ , (2, "Approved")
+ ]
+ ]
+ , if not model.canMod then text "" else
+ formField "" [ label [] [ inputCheck "" model.searchable Searchable, text " Searchable (people can use this trait to find characters)" ] ]
+ , if not model.canMod then text "" else
+ formField "" [ label [] [ inputCheck "" model.applicable Applicable, text " Applicable (people can apply this trait to characters)" ] ]
+ , formField "" [ label [] [ inputCheck "" model.sexual Sexual, text " Indicates sexual content" ] ]
+ , formField "defaultspoil::Default spoiler level" [ inputSelect "defaultspoil" model.defaultspoil DefaultSpoil GTE.valDefaultspoil
+ [ (0, "No spoiler")
+ , (1, "Minor spoiler")
+ , (2, "Major spoiler")
+ ] ]
+ , text "" -- aliases
+ , formField "description::Description"
+ [ TP.view "description" model.description Description 700 ([rows 12, cols 50] ++ GTE.valDescription) []
+ , text "What should the trait be used for? Having a good description helps users choose which traits to assign to characters."
+ ]
+ , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
+ , formField "Parent traits"
+ [ table [ class "compact" ] <| List.indexedMap (\i p -> tr []
+ [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| "i" ++ String.fromInt p.id ++ ":" ] ]
+ , td []
+ [ Maybe.withDefault (text "") <| Maybe.map (\g -> b [ class "grayedout" ] [ text (g ++ " / ") ]) p.group
+ , a [ href <| "/i" ++ String.fromInt p.id ] [ text p.name ]
+ ]
+ , td [] [ inputButton "remove" (ParentDel i) [] ]
+ ]
+ ) model.parents
+ , A.view parentConfig model.parentAdd [placeholder "Add parent trait..."]
+ ]
+ , if not (List.isEmpty model.parents) then text "" else
+ formField "order::Group order"
+ [ inputText "order" (String.fromInt model.order) Order (style "width" "50px" :: GTE.valOrder)
+ , text " Only meaningful if this trait is as a \"group\", i.e. a trait without any parents."
+ , text " This number determines the order in which the groups are displayed on character pages."
+ ]
+ ]
+ ]
+ , div [ class "mainbox" ]
+ [ fieldset [ class "submit" ] [ submitButton "Submit" model.formstate (isValid model) ] ]
+ ]
diff --git a/elm/VNEdit.elm b/elm/VNEdit.elm
index 4fadbf2d..711089de 100644
--- a/elm/VNEdit.elm
+++ b/elm/VNEdit.elm
@@ -65,6 +65,7 @@ type alias Model =
, seiyuuSearch: A.Model GApi.ApiStaffResult
, seiyuuDef : Int -- character id for newly added seiyuu
, screenshots : List (Int,Img.Image,Maybe Int) -- internal id, img, rel
+ , scrQueue : List File
, scrUplRel : Maybe Int
, scrUplNum : Maybe Int
, scrId : Int -- latest used internal id
@@ -99,6 +100,7 @@ init d =
, seiyuuSearch= A.init ""
, seiyuuDef = Maybe.withDefault 0 <| List.head <| List.map (\c -> c.id) d.chars
, screenshots = List.indexedMap (\n i -> (n, Img.info (Just i.info), i.rid)) d.screenshots
+ , scrQueue = []
, scrUplRel = Nothing
, scrUplNum = Nothing
, scrId = 100
@@ -184,6 +186,19 @@ type Msg
| DupResults GApi.Response
+scrProcessQueue : (Model, Cmd Msg) -> (Model, Cmd Msg)
+scrProcessQueue (model, msg) =
+ case model.scrQueue of
+ (f::fl) ->
+ if List.any (\(_,i,_) -> i.imgState == Img.Loading) model.screenshots
+ then (model, msg)
+ else
+ let (im,ic) = Img.upload Api.Sf f
+ in ( { model | scrQueue = fl, scrId = model.scrId + 1, screenshots = model.screenshots ++ [(model.scrId, im, model.scrUplRel)] }
+ , Cmd.batch [ msg, Cmd.map (ScrMsg model.scrId) ic ] )
+ _ -> (model, msg)
+
+
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
@@ -248,20 +263,13 @@ update msg model =
ScrUpl f1 fl ->
if 1 + List.length fl > 10 - List.length model.screenshots
then ({ model | scrUplNum = Just (1 + List.length fl) }, Cmd.none)
- else
- let imgs = List.map (Img.upload Api.Sf) (f1::fl)
- in ( { model
- | scrId = model.scrId + 100
- , scrUplNum = Nothing
- , screenshots = model.screenshots ++ List.indexedMap (\n (i,_) -> (model.scrId+n,i,model.scrUplRel)) imgs
- }
- , List.indexedMap (\n (_,c) -> Cmd.map (ScrMsg (model.scrId+n)) c) imgs |> Cmd.batch)
+ else scrProcessQueue ({ model | scrQueue = (f1::fl), scrUplNum = Nothing }, Cmd.none)
ScrMsg id m ->
let f (i,s,r) =
if i /= id then ((i,s,r), Cmd.none)
else let (nm,nc) = Img.update m s in ((i,nm,r), Cmd.map (ScrMsg id) nc)
lst = List.map f model.screenshots
- in ({ model | screenshots = List.map Tuple.first lst }, Cmd.batch (ivRefresh True :: List.map Tuple.second lst))
+ in scrProcessQueue ({ model | screenshots = List.map Tuple.first lst }, Cmd.batch (ivRefresh True :: List.map Tuple.second lst))
ScrRel n s -> ({ model | screenshots = List.map (\(i,img,r) -> if i == n then (i,img,s) else (i,img,r)) model.screenshots }, Cmd.none)
ScrDel n -> ({ model | screenshots = List.filter (\(i,_,_) -> i /= n) model.screenshots }, ivRefresh True)
@@ -293,6 +301,7 @@ isValid model = not
|| relAlias model /= Nothing
|| not (Img.isValid model.image)
|| List.any (\(_,i,r) -> r == Nothing || not (Img.isValid i)) model.screenshots
+ || not (List.isEmpty model.scrQueue)
|| hasDuplicates (List.map (\s -> (s.aid, s.role)) model.staff)
|| hasDuplicates (List.map (\s -> (s.aid, s.cid)) model.seiyuu)
)
@@ -531,7 +540,13 @@ view model =
add =
let free = 10 - List.length model.screenshots
in
- if free <= 0
+ if not (List.isEmpty model.scrQueue)
+ then [ b [] [ text "Uploading screenshots" ]
+ , br [] []
+ , text <| (String.fromInt (List.length model.scrQueue)) ++ " remaining... "
+ , span [ class "spinner" ] []
+ ]
+ else if free <= 0
then [ b [] [ text "Enough screenshots" ]
, br [] []
, text "The limit of 10 screenshots per visual novel has been reached. If you want to add a new screenshot, please remove an existing one first."
diff --git a/lib/VNDB/DB/Tags.pm b/lib/VNDB/DB/Tags.pm
index e412e10f..1104bad8 100644
--- a/lib/VNDB/DB/Tags.pm
+++ b/lib/VNDB/DB/Tags.pm
@@ -5,7 +5,7 @@ use strict;
use warnings;
use Exporter 'import';
-our @EXPORT = qw|dbTagGet dbTTTree dbTagEdit dbTagAdd dbTagMerge dbTagStats dbTagWipeVotes|;
+our @EXPORT = qw|dbTagGet dbTTTree dbTagStats|;
# %options->{ id noid name search state searchable applicable page results what sort reverse }
@@ -123,53 +123,6 @@ sub dbTTTree {
}
-# args: tag id, %options->{ columns in the tags table + parents + aliases }
-sub dbTagEdit {
- my($self, $id, %o) = @_;
-
- $self->dbExec('UPDATE tags !H WHERE id = ?', {
- $o{upddate} ? ('added = NOW()' => 1) : (),
- map exists($o{$_}) ? ("$_ = ?" => $o{$_}) : (), qw|name searchable applicable description state cat defaultspoil|
- }, $id);
- if($o{aliases}) {
- $self->dbExec('DELETE FROM tags_aliases WHERE tag = ?', $id);
- $self->dbExec('INSERT INTO tags_aliases (tag, alias) VALUES (?, ?)', $id, $_) for (@{$o{aliases}});
- }
- if($o{parents}) {
- $self->dbExec('DELETE FROM tags_parents WHERE tag = ?', $id);
- $self->dbExec('INSERT INTO tags_parents (tag, parent) VALUES (?, ?)', $id, $_) for(@{$o{parents}});
- }
-}
-
-
-# same args as dbTagEdit, without the first tag id
-# returns the id of the new tag
-sub dbTagAdd {
- my($self, %o) = @_;
- my $id = $self->dbRow('INSERT INTO tags (name, searchable, applicable, description, state, cat, defaultspoil, addedby) VALUES (!l, ?) RETURNING id',
- [ map $o{$_}, qw|name searchable applicable description state cat defaultspoil| ], $o{addedby}||$self->authInfo->{id}
- )->{id};
- $self->dbExec('INSERT INTO tags_parents (tag, parent) VALUES (?, ?)', $id, $_) for(@{$o{parents}});
- $self->dbExec('INSERT INTO tags_aliases (tag, alias) VALUES (?, ?)', $id, $_) for (@{$o{aliases}});
- return $id;
-}
-
-
-sub dbTagMerge {
- my($self, $id, @merge) = @_;
- $self->dbExec(q|
- DELETE FROM tags_vn tv
- WHERE tag IN(!l)
- AND EXISTS(SELECT 1 FROM tags_vn ti WHERE ti.tag = ? AND ti.uid = tv.uid AND ti.vid = tv.vid)|, \@merge, $id);
- $self->dbExec('UPDATE tags_vn SET tag = ? WHERE tag IN(!l)', $id, \@merge);
- $self->dbExec('UPDATE tags_aliases SET tag = ? WHERE tag IN(!l)', $id, \@merge);
- $self->dbExec('INSERT INTO tags_aliases (tag, alias) VALUES (?, ?)', $id, $_->{name})
- for (@{$self->dbAll('SELECT name FROM tags WHERE id IN(!l)', \@merge)});
- $self->dbExec('DELETE FROM tags_parents WHERE tag IN(!l)', \@merge);
- $self->dbExec('DELETE FROM tags WHERE id IN(!l)', \@merge);
-}
-
-
# Fetch all tags related to a VN
# Argument: %options->{ vid minrating state results what page sort reverse }
# sort: name, rating
@@ -205,11 +158,5 @@ sub dbTagStats {
return wantarray ? ($r, $np) : $r;
}
-
-# Deletes all votes on a tag.
-sub dbTagWipeVotes {
- $_[0]->dbExec('DELETE FROM tags_vn WHERE tag = ?', $_[1])
-}
-
1;
diff --git a/lib/VNDB/DB/Traits.pm b/lib/VNDB/DB/Traits.pm
index 019f512f..ac0e81b4 100644
--- a/lib/VNDB/DB/Traits.pm
+++ b/lib/VNDB/DB/Traits.pm
@@ -10,7 +10,7 @@ use strict;
use warnings;
use Exporter 'import';
-our @EXPORT = qw|dbTraitGet dbTraitEdit dbTraitAdd|;
+our @EXPORT = qw|dbTraitGet|;
# Options: id noid search name state searchable applicable what results page sort reverse
@@ -82,32 +82,5 @@ sub dbTraitGet {
}
-# args: trait id, %options->{ columns in the traits table + parents }
-sub dbTraitEdit {
- my($self, $id, %o) = @_;
-
- $self->dbExec('UPDATE traits !H WHERE id = ?', {
- $o{upddate} ? ('added = NOW()' => 1) : (),
- map exists($o{$_}) ? ("\"$_\" = ?" => $o{$_}) : (), qw|name searchable applicable description state alias group order sexual defaultspoil|
- }, $id);
- if($o{parents}) {
- $self->dbExec('DELETE FROM traits_parents WHERE trait = ?', $id);
- $self->dbExec('INSERT INTO traits_parents (trait, parent) VALUES (?, ?)', $id, $_) for(@{$o{parents}});
- }
-}
-
-
-# same args as dbTraitEdit, without the first trait id
-# returns the id of the new trait
-sub dbTraitAdd {
- my($self, %o) = @_;
- my $id = $self->dbRow('INSERT INTO traits (name, searchable, applicable, description, state, alias, "group", "order", sexual, defaultspoil, addedby) VALUES (!l, ?) RETURNING id',
- [ map $o{$_}, qw|name searchable applicable description state alias group order sexual defaultspoil| ], $o{addedby}||$self->authInfo->{id}
- )->{id};
- $self->dbExec('INSERT INTO traits_parents (trait, parent) VALUES (?, ?)', $id, $_) for(@{$o{parents}});
- return $id;
-}
-
-
1;
diff --git a/lib/VNDB/Handler/Misc.pm b/lib/VNDB/Handler/Misc.pm
index 565523e6..d2cb9c0d 100644
--- a/lib/VNDB/Handler/Misc.pm
+++ b/lib/VNDB/Handler/Misc.pm
@@ -13,18 +13,6 @@ TUWF::register(
qr{nospam}, \&nospam,
qr{xml/prefs\.xml}, \&prefs,
qr{opensearch\.xml}, \&opensearch,
-
- # redirects for old URLs
- qr{u([1-9]\d*)/tags}, sub { $_[0]->resRedirect("/g/links?u=$_[1]", 'perm') },
- qr{(.*[^/]+)/+}, sub { $_[0]->resRedirect("/$_[1]", 'perm') },
- qr{([pv])}, sub { $_[0]->resRedirect("/$_[1]/all", 'perm') },
- qr{v/search}, sub { $_[0]->resRedirect("/v/all?q=".uri_escape($_[0]->reqGet('q')||''), 'perm') },
- qr{notes}, sub { $_[0]->resRedirect('/d8', 'perm') },
- qr{faq}, sub { $_[0]->resRedirect('/d6', 'perm') },
- qr{v([1-9]\d*)/(?:stats|scr)},
- sub { $_[0]->resRedirect("/v$_[1]", 'perm') },
- qr{u/list(/[a-z0]|/all)?},
- sub { my $l = defined $_[1] ? $_[1] : '/all'; $_[0]->resRedirect("/u$l", 'perm') },
);
diff --git a/lib/VNDB/Handler/Producers.pm b/lib/VNDB/Handler/Producers.pm
index e25e3320..44201e79 100644
--- a/lib/VNDB/Handler/Producers.pm
+++ b/lib/VNDB/Handler/Producers.pm
@@ -9,68 +9,10 @@ use VNDB::Types;
TUWF::register(
- qr{p/([a-z0]|all)} => \&list,
qr{xml/producers\.xml} => \&pxml,
);
-sub list {
- my($self, $char) = @_;
-
- my $f = $self->formValidate(
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 'q', required => 0, default => '' },
- );
- return $self->resNotFound if $f->{_err};
-
- my($list, $np) = $self->dbProducerGet(
- $char ne 'all' ? ( char => $char ) : (),
- $f->{q} ? ( search => $f->{q} ) : (),
- results => 150,
- page => $f->{p}
- );
-
- $self->htmlHeader(title => 'Browse producers');
-
- div class => 'mainbox';
- h1 'Browse producers';
- form action => '/p/all', 'accept-charset' => 'UTF-8', method => 'get';
- $self->htmlSearchBox('p', $f->{q});
- end;
- p class => 'browseopts';
- for ('all', 'a'..'z', 0) {
- a href => "/p/$_", $_ eq $char ? (class => 'optselected') : (), $_ eq 'all' ? 'ALL' : $_ ? uc $_ : '#';
- }
- end;
- end;
-
- my $pageurl = "/p/$char" . ($f->{q} ? "?q=$f->{q}" : '');
- $self->htmlBrowseNavigate($pageurl, $f->{p}, $np, 't');
- div class => 'mainbox producerbrowse';
- h1 $f->{q} ? 'Search results' : 'Producer list';
- if(!@$list) {
- p 'No results found';
- } else {
- # spread the results over 3 equivalent-sized lists
- my $perlist = @$list/3 < 1 ? 1 : @$list/3;
- for my $c (0..(@$list < 3 ? $#$list : 2)) {
- ul;
- for ($perlist*$c..($perlist*($c+1))-1) {
- li;
- cssicon 'lang '.$list->[$_]{lang}, $LANGUAGE{$list->[$_]{lang}};
- a href => "/p$list->[$_]{id}", title => $list->[$_]{original}, $list->[$_]{name};
- end;
- }
- end;
- }
- }
- clearfloat;
- end 'div';
- $self->htmlBrowseNavigate($pageurl, $f->{p}, $np, 'b');
- $self->htmlFooter;
-}
-
-
# peforms a (simple) search and returns the results in XML format
sub pxml {
my $self = shift;
diff --git a/lib/VNDB/Handler/Tags.pm b/lib/VNDB/Handler/Tags.pm
index 55bf99db..bced924f 100644
--- a/lib/VNDB/Handler/Tags.pm
+++ b/lib/VNDB/Handler/Tags.pm
@@ -11,12 +11,6 @@ use VNDB::Types;
TUWF::register(
qr{g([1-9]\d*)}, \&tagpage,
- qr{g([1-9]\d*)/(edit)}, \&tagedit,
- qr{g([1-9]\d*)/(add)}, \&tagedit,
- qr{g/new}, \&tagedit,
- qr{g/list}, \&taglist,
- qr{u([1-9]\d*)/tags}, \&usertags,
- qr{g}, \&tagindex,
qr{g/debug}, \&fulltree,
qr{xml/tags\.xml}, \&tagxml,
);
@@ -146,315 +140,6 @@ sub tagpage {
}
-sub tagedit {
- my($self, $tag, $act) = @_;
-
- my($frm, $par);
- if($act && $act eq 'add') {
- $par = $self->dbTagGet(id => $tag)->[0];
- return $self->resNotFound if !$par;
- $frm->{parents} = $par->{name};
- $frm->{cat} = $par->{cat};
- $tag = undef;
- }
-
- return $self->htmlDenied if !$self->authCan('tag') || $tag && !$self->authCan('tagmod');
-
- my $t = $tag && $self->dbTagGet(id => $tag, what => 'parents(1) aliases addedby')->[0];
- return $self->resNotFound if $tag && !$t;
-
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'name', required => 1, maxlength => 250, regex => [ qr/^[^,]+$/, 'A comma is not allowed in tag names' ] },
- { post => 'state', required => 0, default => 0, enum => [ 0..2 ] },
- { post => 'cat', required => 1, enum => [ keys %TAG_CATEGORY ] },
- { post => 'catrec', required => 0 },
- { post => 'searchable', required => 0, default => 0 },
- { post => 'applicable', required => 0, default => 0 },
- { post => 'alias', required => 0, maxlength => 1024, default => '', regex => [ qr/^[^,]+$/s, 'No comma allowed in aliases' ] },
- { post => 'description', required => 0, maxlength => 10240, default => '' },
- { post => 'defaultspoil',required => 0, default => 0, enum => [ 0..2 ] },
- { post => 'parents', required => !$self->authCan('tagmod'), default => '' },
- { post => 'merge', required => 0, default => '' },
- { post => 'wipevotes', required => 0, default => 0 },
- );
- my @aliases = split /[\t\s]*\n[\t\s]*/, $frm->{alias};
- my @parents = split /[\t\s]*,[\t\s]*/, $frm->{parents};
- my @merge = split /[\t\s]*,[\t\s]*/, $frm->{merge};
- if(!$frm->{_err}) {
- my @dups = @{$self->dbTagGet(name => $frm->{name}, noid => $tag)};
- push @dups, @{$self->dbTagGet(name => $_, noid => $tag)} for @aliases;
- push @{$frm->{_err}}, \sprintf 'Tag <a href="/g%d">%s</a> already exists!', $_->{id}, xml_escape $_->{name} for @dups;
- for(@parents, @merge) {
- my $c = $self->dbTagGet(name => $_, noid => $tag);
- push @{$frm->{_err}}, "Tag '$_' not found" if !@$c;
- $_ = $c->[0]{id};
- }
- }
-
- if(!$frm->{_err}) {
- if(!$self->authCan('tagmod')) {
- $frm->{state} = 0;
- $frm->{searchable} = $frm->{applicable} = 1;
- }
- my %opts = (
- name => $frm->{name},
- state => $frm->{state},
- cat => $frm->{cat},
- description => $frm->{description},
- searchable => $frm->{searchable}?1:0,
- applicable => $frm->{applicable}?1:0,
- defaultspoil => $frm->{defaultspoil},
- aliases => \@aliases,
- parents => \@parents,
- );
- if(!$tag) {
- $tag = $self->dbTagAdd(%opts);
- } else {
- $self->dbTagEdit($tag, %opts, upddate => $frm->{state} == 2 && $t->{state} != 2);
- _set_childs_cat($self, $tag, $frm->{cat}) if $frm->{catrec};
- }
- $self->dbTagWipeVotes($tag) if $self->authCan('tagmod') && $frm->{wipevotes};
- $self->dbTagMerge($tag, @merge) if $self->authCan('tagmod') && @merge;
- $self->resRedirect("/g$tag", 'post');
- return;
- }
- }
-
- if($tag) {
- $frm->{$_} ||= $t->{$_} for (qw|name searchable applicable description state cat defaultspoil|);
- $frm->{alias} ||= join "\n", @{$t->{aliases}};
- $frm->{parents} ||= join ', ', map $_->{name}, @{$t->{parents}};
- }
-
- my $title = $par ? "Add child tag to $par->{name}" : $tag ? "Edit tag: $t->{name}" : 'Add new tag';
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('g', $par || $t, 'edit') if $t || $par;
-
- if(!$self->authCan('tagmod')) {
- div class => 'mainbox';
- h1 'Requesting new tag';
- div class => 'notice';
- h2 'Your tag must be approved';
- p;
- txt 'Because all tags have to be approved by moderators, it can take a while before it will show up in the tag list'
- .' or on visual novel pages. You can still vote on tag even if it has not been approved yet, though.';
- br; br;
- txt 'Also, make sure you\'ve read the ';
- a href => '/d10', 'guidelines';
- txt ' so you can predict whether your tag will be accepted or not.';
- end;
- end;
- end;
- }
-
- $self->htmlForm({ frm => $frm, action => $par ? "/g$par->{id}/add" : $tag ? "/g$tag/edit" : '/g/new' }, 'tagedit' => [ $title,
- [ input => short => 'name', name => 'Primary name' ],
- $self->authCan('tagmod') ? (
- $tag ?
- [ static => label => 'Added by', content => sub { VNWeb::HTML::user_($t); '' } ] : (),
- [ select => short => 'state', name => 'State', options => [
- [0, 'Awaiting moderation'], [1, 'Deleted/hidden'], [2, 'Approved'] ] ],
- [ checkbox => short => 'searchable', name => 'Searchable (people can use this tag to filter VNs)' ],
- [ checkbox => short => 'applicable', name => 'Applicable (people can apply this tag to VNs)' ],
- ) : (),
- [ select => short => 'cat', name => 'Category', options => [
- map [$_, $TAG_CATEGORY{$_}], keys %TAG_CATEGORY ] ],
- $self->authCan('tagmod') && $tag ? (
- [ checkbox => short => 'catrec', name => 'Also edit all child tags to have this category' ],
- [ static => content => 'WARNING: This will overwrite the category field for all child tags, this action can not be reverted!' ],
- ) : (),
- [ textarea => short => 'alias', name => "Aliases\n(separated by newlines)", cols => 30, rows => 4 ],
- [ textarea => short => 'description', name => 'Description' ],
- [ static => content => 'What should the tag be used for? Having a good description helps users choose which tags to link to a VN.' ],
- [ select => short => 'defaultspoil', name => 'Default spoiler level', options => [ map [$_, fmtspoil $_], 0..2 ] ],
- [ static => content => 'This is the spoiler level that will be used by default when everyone has voted "neutral".' ],
- [ input => short => 'parents', name => 'Parent tags' ],
- [ static => content => 'Comma separated list of tag names to be used as parent for this tag.' ],
- $self->authCan('tagmod') ? (
- [ part => title => 'DANGER: Merge tags' ],
- [ input => short => 'merge', name => 'Tags to merge' ],
- [ static => content =>
- 'Comma separated list of tag names to merge into this one.'
- .' All votes and aliases/names will be moved over to this tag, and the old tags will be deleted.'
- .' Just leave this field empty if you don\'t intend to do a merge.'
- .'<br />WARNING: this action cannot be undone!' ],
-
- [ part => title => 'DANGER: Delete tag votes' ],
- [ checkbox => short => 'wipevotes', name => 'Remove all votes on this tag. WARNING: cannot be undone!' ],
- ) : (),
- ]);
- $self->htmlFooter;
-}
-
-# recursively edit all child tags and set the category field
-# Note: this can be done more efficiently by doing everything in one UPDATE
-# query, but that takes more code and this feature isn't used very often
-# anyway.
-sub _set_childs_cat {
- my($self, $tag, $cat) = @_;
- my %done;
-
- my $e;
- $e = sub {
- my $l = shift;
- for (@$l) {
- $self->dbTagEdit($_->{id}, cat => $cat) if !$done{$_->{id}}++;
- $e->($_->{sub}) if $_->{sub};
- }
- };
-
- my $childs = $self->dbTTTree(tag => $tag, 25);
- $e->($childs);
-}
-
-
-sub taglist {
- my $self = shift;
-
- my $f = $self->formValidate(
- { get => 's', required => 0, default => 'name', enum => ['added', 'name'] },
- { get => 'o', required => 0, default => 'a', enum => ['a', 'd'] },
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 't', required => 0, default => -1, enum => [ -1..2 ] },
- { get => 'q', required => 0, default => '' },
- );
- return $self->resNotFound if $f->{_err};
-
- my($t, $np) = $self->dbTagGet(
- sort => $f->{s}, reverse => $f->{o} eq 'd',
- page => $f->{p},
- results => 50,
- state => $f->{t},
- search => $f->{q}
- );
-
- $self->htmlHeader(title => 'Browse tags');
- div class => 'mainbox';
- h1 'Browse tags';
- form action => '/g/list', 'accept-charset' => 'UTF-8', method => 'get';
- input type => 'hidden', name => 't', value => $f->{t};
- $self->htmlSearchBox('g', $f->{q});
- end;
- p class => 'browseopts';
- a href => "/g/list?q=$f->{q};t=-1", $f->{t} == -1 ? (class => 'optselected') : (), 'All';
- a href => "/g/list?q=$f->{q};t=0", $f->{t} == 0 ? (class => 'optselected') : (), 'Awaiting moderation';
- a href => "/g/list?q=$f->{q};t=1", $f->{t} == 1 ? (class => 'optselected') : (), 'Deleted';
- a href => "/g/list?q=$f->{q};t=2", $f->{t} == 2 ? (class => 'optselected') : (), 'Accepted';
- end;
- if(!@$t) {
- p 'No results found';
- }
- end 'div';
- if(@$t) {
- $self->htmlBrowse(
- class => 'taglist',
- options => $f,
- nextpage => $np,
- items => $t,
- pageurl => "/g/list?t=$f->{t};q=$f->{q};s=$f->{s};o=$f->{o}",
- sorturl => "/g/list?t=$f->{t};q=$f->{q}",
- header => [
- [ 'Created', 'added' ],
- [ 'Tag', 'name' ],
- ],
- row => sub {
- my($s, $n, $l) = @_;
- Tr;
- td class => 'tc1', fmtage $l->{added};
- td class => 'tc3';
- a href => "/g$l->{id}", $l->{name};
- if($f->{t} == -1) {
- b class => 'grayedout', ' awaiting moderation' if $l->{state} == 0;
- b class => 'grayedout', ' deleted' if $l->{state} == 1;
- }
- end;
- end 'tr';
- }
- );
- }
- $self->htmlFooter;
-}
-
-
-sub tagindex {
- my $self = shift;
-
- $self->htmlHeader(title => 'Tag index');
- div class => 'mainbox';
- a class => 'addnew', href => "/g/new", 'Create new tag' if $self->authCan('tag');
- h1 'Search tags';
- form action => '/g/list', 'accept-charset' => 'UTF-8', method => 'get';
- $self->htmlSearchBox('g', '');
- end;
- end;
-
- my $t = $self->dbTTTree(tag => 0, 2);
- childtags($self, 'Tag tree', 'g', {childs => $t});
-
- table class => 'mainbox threelayout';
- Tr;
-
- # Recently added
- td;
- a class => 'right', href => '/g/list', 'Browse all tags';
- my $r = $self->dbTagGet(sort => 'added', reverse => 1, results => 10, state => 2);
- h1 'Recently added';
- ul;
- for (@$r) {
- li;
- txt fmtage $_->{added};
- txt ' ';
- a href => "/g$_->{id}", $_->{name};
- end;
- }
- end;
- end;
-
- # Popular
- td;
- a class => 'addnew', href => "/g/links", 'Recently tagged';
- $r = $self->dbTagGet(sort => 'items', reverse => 1, searchable => 1, applicable => 1, results => 10);
- h1 'Popular tags';
- ul;
- for (@$r) {
- li;
- a href => "/g$_->{id}", $_->{name};
- txt " ($_->{c_items})";
- end;
- }
- end;
- end;
-
- # Moderation queue
- td;
- h1 'Awaiting moderation';
- $r = $self->dbTagGet(state => 0, sort => 'added', reverse => 1, results => 10);
- ul;
- li 'Moderation queue empty! yay!' if !@$r;
- for (@$r) {
- li;
- txt fmtage $_->{added};
- txt ' ';
- a href => "/g$_->{id}", $_->{name};
- end;
- }
- li;
- br;
- a href => '/g/list?t=0;o=d;s=added', 'Moderation queue';
- txt ' - ';
- a href => '/g/list?t=1;o=d;s=added', 'Denied tags';
- end;
- end;
- end;
-
- end 'tr';
- end 'table';
- $self->htmlFooter;
-}
-
-
# non-translatable debug page
sub fulltree {
my $self = shift;
diff --git a/lib/VNDB/Handler/Traits.pm b/lib/VNDB/Handler/Traits.pm
index e69b673e..d3c717e1 100644
--- a/lib/VNDB/Handler/Traits.pm
+++ b/lib/VNDB/Handler/Traits.pm
@@ -9,11 +9,6 @@ use VNDB::Func;
TUWF::register(
qr{i([1-9]\d*)}, \&traitpage,
- qr{i([1-9]\d*)/(edit)}, \&traitedit,
- qr{i([1-9]\d*)/(add)}, \&traitedit,
- qr{i/new}, \&traitedit,
- qr{i/list}, \&traitlist,
- qr{i}, \&traitindex,
qr{xml/traits\.xml}, \&traitxml,
);
@@ -134,293 +129,6 @@ sub traitpage {
}
-sub traitedit {
- my($self, $trait, $act) = @_;
-
- my($frm, $par);
- if($act && $act eq 'add') {
- $par = $self->dbTraitGet(id => $trait)->[0];
- return $self->resNotFound if !$par;
- $frm->{parents} = $par->{id};
- $trait = undef;
- }
-
- return $self->htmlDenied if !$self->authCan('edit') || $trait && !$self->authCan('tagmod');
-
- my $t = $trait && $self->dbTraitGet(id => $trait, what => 'parents(1) addedby')->[0];
- return $self->resNotFound if $trait && !$t;
-
- if($self->reqMethod eq 'POST') {
- return if !$self->authCheckCode;
- $frm = $self->formValidate(
- { post => 'name', required => 1, maxlength => 250, regex => [ qr/^[^,]+$/, 'A comma is not allowed in trait names' ] },
- { post => 'state', required => 0, default => 0, enum => [ 0..2 ] },
- { post => 'searchable', required => 0, default => 0 },
- { post => 'applicable', required => 0, default => 0 },
- { post => 'sexual', required => 0, default => 0 },
- { post => 'alias', required => 0, maxlength => 1024, default => '', regex => [ qr/^[^,]+$/s, 'No comma allowed in aliases' ] },
- { post => 'description', required => 0, maxlength => 10240, default => '' },
- { post => 'parents', required => !$self->authCan('tagmod'), default => '', regex => [ qr/^(?:$|(?:[1-9]\d*)(?: +[1-9]\d*)*)$/, 'Parent traits must be a space-separated list of trait IDs' ] },
- { post => 'order', required => 0, default => 0, template => 'uint' },
- { post => 'defaultspoil',required => 0, default => 0, enum => [0..2] },
- );
- my @parents = split /[\t ]+/, $frm->{parents};
- my $group = undef;
- if(!$frm->{_err}) {
- for(@parents) {
- my $c = $self->dbTraitGet(id => $_);
- push @{$frm->{_err}}, "Trait '$_' not found" if !@$c;
- $group //= $c->[0]{group}||$c->[0]{id} if @$c;
- }
- }
- if(!$frm->{_err}) {
- my @dups = @{$self->dbTraitGet(name => $frm->{name}, noid => $trait, group => $group)};
- push @dups, @{$self->dbTraitGet(name => $_, noid => $trait, group => $group)} for split /[\t\s]*\n[\t\s]*/, $frm->{alias};
- push @{$frm->{_err}}, \sprintf 'Trait <a href="/i%d">%s</a> already exists within the same group.', $_->{id}, xml_escape $_->{name} for @dups;
- }
-
- if(!$frm->{_err}) {
- if(!$self->authCan('tagmod')) {
- $frm->{state} = 0;
- $frm->{applicable} = $frm->{searchable} = 1;
- }
- my %opts = (
- name => $frm->{name},
- state => $frm->{state},
- description => $frm->{description},
- searchable => $frm->{searchable}?1:0,
- applicable => $frm->{applicable}?1:0,
- sexual => $frm->{sexual}?1:0,
- alias => $frm->{alias},
- order => $frm->{order},
- defaultspoil => $frm->{defaultspoil},
- parents => \@parents,
- group => $group,
- );
- if(!$trait) {
- $trait = $self->dbTraitAdd(%opts);
- } else {
- $self->dbTraitEdit($trait, %opts, upddate => $frm->{state} == 2 && $t->{state} != 2) if $trait;
- _set_childs_group($self, $trait, $group||$trait) if ($group||0) != ($t->{group}||0);
- }
- $self->resRedirect("/i$trait", 'post');
- return;
- }
- }
-
- if($t) {
- $frm->{$_} ||= $t->{$_} for (qw|name searchable applicable sexual description state alias order defaultspoil|);
- $frm->{parents} ||= join ' ', map $_->{id}, @{$t->{parents}};
- }
-
- my $title = $par ? "Add child trait to $par->{name}" : $t ? "Edit trait: $t->{name}" : 'Add new trait';
- $self->htmlHeader(title => $title, noindex => 1);
- $self->htmlMainTabs('i', $par || $t, 'edit') if $t || $par;
-
- if(!$self->authCan('tagmod')) {
- div class => 'mainbox';
- h1 'Requesting new trait';
- div class => 'notice';
- h2 'Your trait must be approved';
- p;
- lit 'Because all traits have to be approved by moderators, it can take a while before your trait will show up in the listings or can be used on character entries.';
- end;
- end;
- end;
- }
-
- $self->htmlForm({ frm => $frm, action => $par ? "/i$par->{id}/add" : $t ? "/i$trait/edit" : '/i/new' }, 'traitedit' => [ $title,
- [ input => short => 'name', name => 'Primary name' ],
- $self->authCan('tagmod') ? (
- $t ?
- [ static => label => 'Added by', content => sub { VNWeb::HTML::user_($t); '' } ] : (),
- [ select => short => 'state', name => 'State', options => [
- [0,'Awaiting moderation'], [1,'Deleted/hidden'], [2,'Approved'] ] ],
- [ checkbox => short => 'searchable', name => 'Searchable (people can use this trait to filter characters)' ],
- [ checkbox => short => 'applicable', name => 'Applicable (people can apply this trait to characters)' ],
- ) : (),
- [ checkbox => short => 'sexual', name => 'Indicates sexual content' ],
- [ textarea => short => 'alias', name => "Aliases\n(Separated by newlines)", cols => 30, rows => 4 ],
- [ textarea => short => 'description', name => 'Description' ],
- [ select => short => 'defaultspoil', name => 'Default spoiler level', options => [ map [$_, fmtspoil $_], 0..2 ] ],
- [ static => content => 'This is the spoiler level that will be selected by default when adding this trait to a character.' ],
- [ input => short => 'parents', name => 'Parent traits' ],
- [ static => content => 'List of trait IDs to be used as parent for this trait, separated by a space.' ],
- $self->authCan('tagmod') ? (
- [ input => short => 'order', name => 'Group number', width => 50, post => ' (Only used if this trait is a group. Used for ordering, lowest first)' ],
- ) : (),
- ]);
-
- $self->htmlFooter;
-}
-
-# recursively edit all child traits and set the group field
-sub _set_childs_group {
- my($self, $trait, $group) = @_;
- my %done;
-
- my $e;
- $e = sub {
- my $l = shift;
- for (@$l) {
- $self->dbTraitEdit($_->{id}, group => $group) if !$done{$_->{id}}++;
- $e->($_->{sub}) if $_->{sub};
- }
- };
- $e->($self->dbTTTree(trait => $trait, 25));
-}
-
-
-sub traitlist {
- my $self = shift;
-
- my $f = $self->formValidate(
- { get => 's', required => 0, default => 'name', enum => ['added', 'name'] },
- { get => 'o', required => 0, default => 'a', enum => ['a', 'd'] },
- { get => 'p', required => 0, default => 1, template => 'page' },
- { get => 't', required => 0, default => -1, enum => [ -1..2 ] },
- { get => 'q', required => 0, default => '' },
- );
- return $self->resNotFound if $f->{_err};
-
- my($t, $np) = $self->dbTraitGet(
- sort => $f->{s}, reverse => $f->{o} eq 'd',
- page => $f->{p},
- results => 50,
- state => $f->{t},
- search => $f->{q}
- );
-
- $self->htmlHeader(title => 'Browse traits');
- div class => 'mainbox';
- h1 'Browse traits';
- form action => '/i/list', 'accept-charset' => 'UTF-8', method => 'get';
- input type => 'hidden', name => 't', value => $f->{t};
- $self->htmlSearchBox('i', $f->{q});
- end;
- p class => 'browseopts';
- a href => "/i/list?q=$f->{q};t=-1", $f->{t} == -1 ? (class => 'optselected') : (), 'All';
- a href => "/i/list?q=$f->{q};t=0", $f->{t} == 0 ? (class => 'optselected') : (), 'Awaiting moderation';
- a href => "/i/list?q=$f->{q};t=1", $f->{t} == 1 ? (class => 'optselected') : (), 'Deleted';
- a href => "/i/list?q=$f->{q};t=2", $f->{t} == 2 ? (class => 'optselected') : (), 'Accepted';
- end;
- if(!@$t) {
- p 'No results found';
- }
- end 'div';
- if(@$t) {
- $self->htmlBrowse(
- class => 'taglist',
- options => $f,
- nextpage => $np,
- items => $t,
- pageurl => "/i/list?t=$f->{t};q=$f->{q};s=$f->{s};o=$f->{o}",
- sorturl => "/i/list?t=$f->{t};q=$f->{q}",
- header => [
- [ 'Created', 'added' ],
- [ 'Trait', 'name' ],
- ],
- row => sub {
- my($s, $n, $l) = @_;
- Tr;
- td class => 'tc1', fmtage $l->{added};
- td class => 'tc3';
- if($l->{group}) {
- b class => 'grayedout', $l->{groupname}.' / ';
- }
- a href => "/i$l->{id}", $l->{name};
- if($f->{t} == -1) {
- b class => 'grayedout', ' awaiting moderation' if $l->{state} == 0;
- b class => 'grayedout', ' deleted' if $l->{state} == 1;
- }
- end;
- end 'tr';
- }
- );
- }
- $self->htmlFooter;
-}
-
-
-sub traitindex {
- my $self = shift;
-
- $self->htmlHeader(title => 'Trait index');
- div class => 'mainbox';
- a class => 'addnew', href => "/i/new", 'Create new trait' if $self->authCan('edit');
- h1 'Search traits';
- form action => '/i/list', 'accept-charset' => 'UTF-8', method => 'get';
- $self->htmlSearchBox('i', '');
- end;
- end;
-
- my $t = $self->dbTTTree(trait => 0, 2);
- childtags($self, 'Trait tree', 'i', {childs => $t}, 'order');
-
- table class => 'mainbox threelayout';
- Tr;
-
- # Recently added
- td;
- a class => 'right', href => '/i/list', 'Browse all traits';
- my $r = $self->dbTraitGet(sort => 'added', reverse => 1, results => 10);
- h1 'Recently added';
- ul;
- for (@$r) {
- li;
- txt fmtage $_->{added};
- txt ' ';
- b class => 'grayedout', $_->{groupname}.' / ' if $_->{group};
- a href => "/i$_->{id}", $_->{name};
- end;
- }
- end;
- end;
-
- # Popular
- td;
- h1 'Popular traits';
- ul;
- $r = $self->dbTraitGet(sort => 'items', reverse => 1, results => 10);
- for (@$r) {
- li;
- b class => 'grayedout', $_->{groupname}.' / ' if $_->{group};
- a href => "/i$_->{id}", $_->{name};
- txt " ($_->{c_items})";
- end;
- }
- end;
- end;
-
- # Moderation queue
- td;
- h1 'Awaiting moderation';
- $r = $self->dbTraitGet(state => 0, sort => 'added', reverse => 1, results => 10);
- ul;
- li 'Moderation queue empty! yay!' if !@$r;
- for (@$r) {
- li;
- txt fmtage $_->{added};
- txt ' ';
- b class => 'grayedout', $_->{groupname}.' / ' if $_->{group};
- a href => "/i$_->{id}", $_->{name};
- end;
- }
- li;
- br;
- a href => '/i/list?t=0;o=d;s=added', 'Moderation queue';
- txt ' - ';
- a href => '/i/list?t=1;o=d;s=added', 'Denied traits';
- end;
- end;
- end;
-
- end 'tr';
- end 'table';
- $self->htmlFooter;
-}
-
-
sub traitxml {
my $self = shift;
diff --git a/lib/VNDB/Handler/VNPage.pm b/lib/VNDB/Handler/VNPage.pm
index 1198a421..b546c436 100644
--- a/lib/VNDB/Handler/VNPage.pm
+++ b/lib/VNDB/Handler/VNPage.pm
@@ -9,18 +9,10 @@ use VNDB::Types;
TUWF::register(
- qr{v/rand} => \&rand,
qr{v([1-9]\d*)/releases} => \&releases,
- qr{v([1-9]\d*)/staff} => sub { $_[0]->resRedirect("/v$_[1]#staff") },
);
-sub rand {
- my $self = shift;
- $self->resRedirect('/v'.$self->filFetchDB(vn => undef, undef, {results => 1, sort => 'rand'})->[0]{id}, 'temp');
-}
-
-
# Description of each column, field:
# id: Identifier used in URLs
# sort_field: Name of the field when sorting
diff --git a/lib/VNDB/Util/Auth.pm b/lib/VNDB/Util/Auth.pm
index b05c86f9..f3094ff0 100644
--- a/lib/VNDB/Util/Auth.pm
+++ b/lib/VNDB/Util/Auth.pm
@@ -10,58 +10,10 @@ use VNWeb::Auth;
our @EXPORT = qw|
- authInit authLogin authLogout authInfo authCan authSetPass authAdminSetPass
- authResetPass authIsValidToken authGetCode authCheckCode authPref
+ authInfo authCan authGetCode authCheckCode authPref
|;
-# login, arguments: user, password, url-to-redirect-to-on-success
-# returns 1 on success (redirected), 0 otherwise (no reply sent)
-sub authLogin {
- my(undef, $user, $pass, $to) = @_;
- my $success = auth->login($user, $pass);
- tuwf->resRedirect($to, 'post') if $success;
- $success
-}
-
-# clears authentication cookie and redirects to /
-sub authLogout {
- auth->logout;
- tuwf->resRedirect('/', 'temp');
-}
-
-
-# Replaces the user's password with a random token that can be used to reset the password.
-sub authResetPass {
- my(undef, $mail) = @_;
- auth->resetpass($mail)
-}
-
-
-sub authIsValidToken {
- my(undef, $uid, $token) = @_;
- auth->isvalidtoken($uid, $token)
-}
-
-
-# uid, new_pass, url_to_redir_to, 'token'|'pass', $token_or_pass
-# Changes the user's password, invalidates all existing sessions, creates a new
-# session and redirects.
-sub authSetPass {
- my(undef, $uid, $pass, $redir, $oldtype, $oldpass) = @_;
-
- my $success = auth->setpass($uid, $oldtype eq 'token' ? $oldpass : undef, $oldtype eq 'pass' ? $oldpass : undef, $pass);
- tuwf->resRedirect($redir, 'post') if $success;
- $success
-}
-
-
-sub authAdminSetPass {
- my(undef, $uid, $pass) = @_;
- auth->admin_setpass($uid, $pass);
-}
-
-
sub authInfo {
# Used to return a lot more, but only the id is still used now.
# (code using other fields has been migrated)
diff --git a/lib/VNDB/Util/BrowseHTML.pm b/lib/VNDB/Util/BrowseHTML.pm
index 29d131c5..3eb460a6 100644
--- a/lib/VNDB/Util/BrowseHTML.pm
+++ b/lib/VNDB/Util/BrowseHTML.pm
@@ -151,7 +151,7 @@ sub htmlBrowseVN {
Tr;
if($tagscore) {
td class => 'tc_s';
- VNWeb::Tags::Lib::tagscore_($l->{tagscore});
+ VNWeb::TT::Lib::tagscore_($l->{tagscore});
end;
}
td class => $tagscore ? 'tc_t' : 'tc1';
diff --git a/lib/VNDB/Util/FormHTML.pm b/lib/VNDB/Util/FormHTML.pm
deleted file mode 100644
index 85b7fab9..00000000
--- a/lib/VNDB/Util/FormHTML.pm
+++ /dev/null
@@ -1,282 +0,0 @@
-
-package VNDB::Util::FormHTML;
-
-use strict;
-use warnings;
-use TUWF ':html';
-use Exporter 'import';
-use POSIX 'strftime';
-use VNDB::Func;
-
-our @EXPORT = qw| htmlFormError htmlFormPart htmlForm |;
-
-
-# Displays friendly error message when form validation failed
-# Argument is the return value of formValidate, and an optional
-# argument indicating whether we should create a special mainbox
-# for the errors.
-sub htmlFormError {
- my($self, $frm, $mainbox) = @_;
- return if !$frm->{_err};
- if($mainbox) {
- div class => 'mainbox';
- h1 'Error';
- }
- div class => 'warning';
- h2 'Form could not be sent:';
- ul;
- for my $e (@{$frm->{_err}}) {
- if(!ref $e) {
- li $e;
- next;
- }
- if(ref $e eq 'SCALAR') {
- li; lit $$e; end;
- next;
- }
- my($field, $type, $rule) = @$e;
- ($type, $rule) = ('template', 'editsum') if $type eq 'required' && $field eq 'editsum';
-
- li "$field is a required field" if $type eq 'required';;
- li "$field: minimum number of values is $rule" if $type eq 'mincount';
- li "$field: maximum number of values is $rule" if $type eq 'maxcount';
- li "$field: should have at least $rule characters" if $type eq 'minlength';
- li "$field: only $rule characters allowed" if $type eq 'maxlength';
- li "$field must be one of the following: ".join(', ', @$rule) if $type eq 'enum';
- li $rule->[1] if $type eq 'func' || $type eq 'regex';
- if($type eq 'template') {
- li "$field: Invalid number" if $rule eq 'int' || $rule eq 'num' || $rule eq 'uint' || $rule eq 'page' || $rule eq 'id';
- li "$field: Invalid URL" if $rule eq 'weburl';
- li "$field: only ASCII characters allowed" if $rule eq 'ascii';
- li "Invalid email address" if $rule eq 'email';
- li "$field may only contain lowercase alphanumeric characters and a hyphen" if $rule eq 'uname';
- li 'Invalid JAN/UPC/EAN' if $rule eq 'gtin';
- li "$field: Malformed data or invalid input" if $rule eq 'json';
- li 'Invalid release date' if $rule eq 'rdate';
- li 'Invalid Wikidata ID' if $rule eq 'wikidata';
- if($rule eq 'editsum') {
- li; lit 'Please read <a href="/d5#4">the guidelines</a> on how to use the edit summary.'; end;
- }
- }
- }
- end;
- end 'div';
- end if $mainbox;
-}
-
-
-# Generates a form part.
-# A form part is a arrayref, with the first element being the type of the part,
-# and all other elements forming a hash with options specific to that type.
-# Type Options
-# hidden short, (value)
-# json short, (value) # Same as hidden, but value is passed through json_encode()
-# input short, name, (value, allow0, width, pre, post)
-# passwd short, name
-# static content, (label, nolabel)
-# check name, short, (value)
-# select name, short, options, (width, multi, size)
-# radio name, short, options
-# text name, short, (rows, cols)
-# date name, short
-# part title
-sub htmlFormPart {
- my($self, $frm, $fp) = @_;
- my($type, %o) = @$fp;
- local $_ = $type;
-
- if(/hidden/ || /json/) {
- Tr class => 'hidden';
- td colspan => 2;
- my $val = $o{value}||$frm->{$o{short}};
- input type => 'hidden', id => $o{short}, name => $o{short}, value => /json/ ? json_encode($val||[]) : $val||'';
- end;
- end;
- return
- }
-
- if(/part/) {
- Tr class => 'newpart';
- td colspan => 2, $o{title};
- end;
- return;
- }
-
- if(/check/) {
- Tr class => 'newfield';
- td class => 'label';
- lit '&#xa0;';
- end;
- td class => 'field';
- input type => 'checkbox', name => $o{short}, id => $o{short}, tabindex => 10,
- value => $o{value}||1, ($frm->{$o{short}}||0) eq ($o{value}||1) ? ( checked => 'checked' ) : ();
- label for => $o{short};
- lit $o{name};
- end;
- end;
- end;
- return;
- }
-
- Tr $o{name}||$o{label} ? (class => 'newfield') : ();
- if(!$o{nolabel}) {
- td class => 'label';
- if($o{short} && $o{name}) {
- label for => $o{short};
- lit $o{name};
- end;
- } elsif($o{label}) {
- txt $o{label};
- } else {
- lit '&#xa0;';
- }
- end;
- }
- td class => 'field', $o{nolabel} ? (colspan => 2) : ();
- if(/input/) {
- lit $o{pre} if $o{pre};
- input type => 'text', class => 'text', name => $o{short}, id => $o{short}, tabindex => 10,
- value => $o{value} // ($o{allow0} ? $frm->{$o{short}}//'' : $frm->{$o{short}}||''), $o{width} ? (style => "width: $o{width}px") : ();
- lit $o{post} if $o{post};
- }
- if(/passwd/) {
- input type => 'password', class => 'text', name => $o{short}, id => $o{short}, tabindex => 10,
- value => $frm->{$o{short}}||'';
- }
- if(/static/) {
- lit ref $o{content} eq 'CODE' ? $o{content}->($self, \%o) : $o{content};
- }
- if(/select/) {
- my $l='';
- Select name => $o{short}, id => $o{short}, tabindex => 10,
- $o{width} ? (style => "width: $o{width}px") : (), $o{multi} ? (multiple => 'multiple', size => $o{size}||5) : ();
- for my $p (@{$o{options}}) {
- if($p->[2] && $l ne $p->[2]) {
- end if $l;
- $l = $p->[2];
- optgroup label => $l;
- }
- my $sel = defined $frm->{$o{short}} && ($frm->{$o{short}} eq $p->[0] || ref($frm->{$o{short}}) eq 'ARRAY' && grep $_ eq $p->[0], @{$frm->{$o{short}}});
- option value => $p->[0], $sel ? (selected => 'selected') : (), $p->[1];
- }
- end if $l;
- end;
- }
- if(/radio/) {
- for my $p (@{$o{options}}) {
- input type => 'radio', id => "$o{short}_$p->[0]", name => $o{short}, value => $p->[0], tabindex => 10,
- defined $frm->{$o{short}} && $frm->{$o{short}} eq $p->[0] ? (checked => 'checked') : ();
- label for => "$o{short}_$p->[0]", $p->[1];
- }
- }
- if(/date/) {
- input type => 'hidden', id => $o{short}, name => $o{short}, value => $frm->{$o{short}}||'', class => 'dateinput';
- }
- if(/text/) {
- textarea name => $o{short}, id => $o{short}, rows => $o{rows}||5, cols => $o{cols}||60, tabindex => 10, $frm->{$o{short}}||'';
- }
- end;
- end 'tr';
-}
-
-
-# Generates a form, first argument is a hashref with global options, keys:
-# frm => the $frm as returned by formValidate,
-# action => The location the form should POST to (also used as form id)
-# method => post/get
-# upload => 1/0, adds an enctype.
-# nosubmit => 1/0, hides the submit button
-# editsum => 1/0, adds an edit summary field before the submit button
-# continue => 2/1/0, replace submit button with continue buttons
-# preview => 1/0, add preview button
-# noformcode=> 1/0, remove the formcode field
-# The other arguments are a list of subforms in the form
-# of (subform-name => [form parts]). Each subform is shown as a
-# (JavaScript-powered) tab, and has it's own 'mainbox'. This function
-# automatically calls htmlFormError and adds a 'formcode' field.
-sub htmlForm {
- my($self, $options, @subs) = @_;
- form action => '/nospam?'.$options->{action}, method => $options->{method}||'post', 'accept-charset' => 'utf-8',
- $options->{upload} ? (enctype => 'multipart/form-data') : ();
-
- if(!$options->{noformcode}) {
- div class => 'hidden';
- input type => 'hidden', name => 'formcode', value => $self->authGetCode($options->{action});
- end;
- }
-
- $self->htmlFormError($options->{frm}, 1);
-
- # tabs
- if(@subs > 2) {
- div class => 'maintabs left';
- ul id => 'jt_select';
- for (0..$#subs/2) {
- li class => 'left';
- a href => "#$subs[$_*2]", id => "jt_sel_$subs[$_*2]", $subs[$_*2+1][0];
- end;
- }
- li class => 'left';
- a href => '#all', id => 'jt_sel_all', 'All items';
- end;
- end 'ul';
- end 'div';
- }
-
- # form subs
- while(my($short, $parts) = (shift(@subs), shift(@subs))) {
- last if !$short || !$parts;
- my $name = shift @$parts;
- div class => 'mainbox', id => 'jt_box_'.$short;
- h1 $name;
- fieldset;
- legend $name;
- table class => 'formtable';
- $self->htmlFormPart($options->{frm}, $_) for @$parts;
- end;
- end;
- end 'div';
- }
-
- # db mod / edit summary / submit button
- if(!$options->{nosubmit}) {
- div class => 'mainbox';
- fieldset class => 'submit';
- if($options->{editsum}) {
- # hidden / locked checkbox
- if($self->authCan('dbmod')) {
- input type => 'checkbox', name => 'ihid', id => 'ihid', value => 1,
- tabindex => 10, $options->{frm}{ihid} ? (checked => 'checked') : ();
- label for => 'ihid', 'Deleted';
- input type => 'checkbox', name => 'ilock', id => 'ilock', value => 1,
- tabindex => 10, $options->{frm}{ilock} ? (checked => 'checked') : ();
- label for => 'ilock', 'Locked';
- br; txt 'Note: edit summary of the last edit should indicate the reason for the deletion.'; br;
- }
-
- # edit summary
- h2;
- txt 'Edit summary';
- b class => 'standout', ' (English please!)';
- end;
- textarea name => 'editsum', id => 'editsum', rows => 4, cols => 50, tabindex => 10, $options->{frm}{editsum}||'';
- br;
- }
- if(!$options->{continue}) {
- input type => 'submit', value => 'Submit', class => 'submit', tabindex => 10;
- } else {
- input type => 'submit', value => 'Continue', class => 'submit', tabindex => 10;
- input type => 'submit', name => 'continue_ign', value => 'Continue and ignore duplicates',
- class => 'submit', style => 'width: auto', tabindex => 10 if $options->{continue} == 2;
- }
- input type => 'submit', value => 'Preview', id => 'preview', name => 'preview', class => 'submit', tabindex => 10 if $options->{preview};
- end;
- end 'div';
- }
-
- end 'form';
-}
-
-
-1;
-
diff --git a/lib/VNDB/Util/LayoutHTML.pm b/lib/VNDB/Util/LayoutHTML.pm
index a18542f8..7d070f94 100644
--- a/lib/VNDB/Util/LayoutHTML.pm
+++ b/lib/VNDB/Util/LayoutHTML.pm
@@ -35,6 +35,7 @@ sub htmlFooter { # %options => { pref_code => 1 }
noscript id => 'pref_code', title => $self->authGetCode('/xml/prefs.xml'), ''
if $o{pref_code} && $self->authInfo->{id};
script type => 'text/javascript', src => $self->{url_static}.'/f/vndb.js?'.$self->{version}, '';
+ VNWeb::HTML::_scripts_({});
end 'body';
end 'html';
}
diff --git a/lib/VNDB/Util/Misc.pm b/lib/VNDB/Util/Misc.pm
index 0423e35b..6342c0c5 100644
--- a/lib/VNDB/Util/Misc.pm
+++ b/lib/VNDB/Util/Misc.pm
@@ -7,9 +7,8 @@ use Exporter 'import';
use TUWF ':html';
use VNDB::Func;
use VNDB::Types;
-use VNDB::BBCode;
-our @EXPORT = qw|filFetchDB filCompat bbSubstLinks|;
+our @EXPORT = qw|filFetchDB filCompat|;
our %filfields = (
@@ -90,11 +89,5 @@ sub filCompat {
}
-
-sub bbSubstLinks {
- shift; bb_subst_links @_;
-}
-
-
1;
diff --git a/lib/VNDB/Util/ValidateTemplates.pm b/lib/VNDB/Util/ValidateTemplates.pm
index 7966b319..e28abcb2 100644
--- a/lib/VNDB/Util/ValidateTemplates.pm
+++ b/lib/VNDB/Util/ValidateTemplates.pm
@@ -4,107 +4,13 @@ package VNDB::Util::ValidateTemplates;
use strict;
use warnings;
-use TUWF 'kv_validate';
-use VNDB::Func 'json_decode';
-use VNDBUtil 'gtintype';
-use Time::Local 'timegm';
TUWF::set(
validate_templates => {
id => { template => 'uint', max => 1<<40 },
page => { template => 'uint', max => 1000 },
- uname => { regex => qr/^[a-z0-9-]*$/, func => sub { $_[0] !~ /^-*[a-z][0-9]+-*$/ }, minlength => 2, maxlength => 15 },
- gtin => { func => \&gtintype },
- editsum => { maxlength => 5000, minlength => 2 },
- json => { func => \&json_validate, inherit => ['json_fields','json_maxitems','json_unique','json_sort'], default => [] },
- rdate => { template => 'uint', min => 0, max => 99999999, func => \&rdate_validate, default => 0 },
- wikidata => { func => \&wikidata_id, default => undef },
}
);
-
-sub wikidata_id {
- $_[0] =~ s/^Q//;
- $_[0] =~ /^([0-9]{1,9})$/
-}
-
-
-# Figure out if a field is treated as a number in kv_validate().
-sub json_validate_is_num {
- my $opts = shift;
- return 0 if !$opts->{template};
- return 1 if $opts->{template} eq 'num' || $opts->{template} eq 'int' || $opts->{template} eq 'uint';
- my $t = TUWF::set('validate_templates')->{$opts->{template}};
- return $t && json_validate_is_num($t);
-}
-
-
-sub json_validate_sort {
- my($sort, $fields, $data) = @_;
-
- # Figure out which fields need to use number comparison
- my %nums;
- for my $k (@$sort) {
- my $f = (grep $_->{field} eq $k, @$fields)[0];
- $nums{$k}++ if json_validate_is_num($f);
- }
-
- # Sort
- return [sort {
- for(@$sort) {
- my $r = $nums{$_} ? $a->{$_} <=> $b->{$_} : $a->{$_} cmp $b->{$_};
- return $r if $r;
- }
- 0
- } @$data];
-}
-
-# Special validation function for simple JSON structures as form fields. It can
-# only validate arrays of key-value objects. The key-value objects are then
-# validated using kv_validate.
-# TODO: json_unique implies json_sort on the same fields? These options tend to be the same.
-sub json_validate {
- my($val, $opts) = @_;
- my $fields = $opts->{json_fields};
- my $maxitems = $opts->{json_maxitems};
- my $unique = $opts->{json_unique};
- my $sort = $opts->{json_sort};
- $unique = [$unique] if $unique && !ref $unique;
- $sort = [$sort] if $sort && !ref $sort;
-
- my $data = eval { json_decode $val };
- $_[0] = $@ ? [] : $data;
- return 0 if $@ || ref $data ne 'ARRAY';
- return 0 if defined($maxitems) && @$data > $maxitems;
-
- my %known_fields = map +($_->{field},1), @$fields;
- my %unique;
-
- for my $i (0..$#$data) {
- return 0 if ref $data->[$i] ne 'HASH';
- # Require that all keys are known and have a scalar value.
- return 0 if grep !$known_fields{$_} || ref($data->[$i]{$_}), keys %{$data->[$i]};
- $data->[$i] = kv_validate({ field => sub { $data->[$i]{shift()} } }, $TUWF::OBJ->{_TUWF}{validate_templates}, $fields);
- return 0 if $data->[$i]{_err};
- return 0 if $unique && $unique{ join '|||', map $data->[$i]{$_}, @$unique }++;
- }
-
- $_[0] = json_validate_sort($sort, $fields, $data) if $sort;
- return 1;
-}
-
-
-sub rdate_validate {
- return 0 if $_[0] ne 0 && $_[0] !~ /^(\d{4})(\d{2})(\d{2})$/;
- my($y, $m, $d) = defined $1 ? ($1, $2, $3) : (0,0,0);
-
- # Normalization ought to be done in JS, but do it here again because we can't trust browsers
- ($m, $d) = (0, 0) if $y == 0;
- $m = 99 if $y == 9999;
- $d = 99 if $m == 99;
- $_[0] = $y*10000 + $m*100 + $d;
-
- return 0 if $y && $d != 99 && !eval { timegm(0, 0, 0, $d, $m-1, $y) };
- return 1;
-}
+1;
diff --git a/lib/VNWeb/Auth.pm b/lib/VNWeb/Auth.pm
index 907fb2f4..9707108f 100644
--- a/lib/VNWeb/Auth.pm
+++ b/lib/VNWeb/Auth.pm
@@ -287,6 +287,17 @@ sub prefSet {
}
+# Mark any notifications for a particular item for the current user as read.
+# Arguments: $vndbid, $num||[@nums]||<missing>
+sub notiRead {
+ my($self, $id, $num) = @_;
+ tuwf->dbExeci('
+ UPDATE notifications SET read = NOW() WHERE read IS NULL AND uid =', \$self->uid, 'AND iid =', \$id,
+ @_ == 2 ? () : !defined $num ? 'AND num IS NULL' : !ref $num ? sql 'AND num =', \$num : sql 'AND num IN', $num
+ ) if $self->uid;
+}
+
+
# Add an entry to the audit log.
sub audit {
my($self, $affected_uid, $action, $detail) = @_;
diff --git a/lib/VNWeb/Discussions/Edit.pm b/lib/VNWeb/Discussions/Edit.pm
index dddc1ac8..b85514ec 100644
--- a/lib/VNWeb/Discussions/Edit.pm
+++ b/lib/VNWeb/Discussions/Edit.pm
@@ -47,10 +47,11 @@ elm_api DiscussionsEdit => $FORM_OUT, $FORM_IN, sub {
return tuwf->resNotFound if $tid && !$t->{id};
return elm_Unauth if !can_edit t => $t;
+ tuwf->dbExeci(q{DELETE FROM notifications WHERE iid =}, \$tid) if $tid && auth->permBoardmod && ($data->{delete} || $data->{hidden});
+
if($tid && $data->{delete} && auth->permBoardmod) {
auth->audit($t->{user_id}, 'post delete', "deleted $tid.1");
tuwf->dbExeci('DELETE FROM threads WHERE id =', \$tid);
- tuwf->dbExeci(q{DELETE FROM notifications WHERE iid =}, \$tid);
return elm_Redirect '/t';
}
auth->audit($t->{user_id}, 'post edit', "edited $tid.1") if $tid && $t->{user_id} != auth->uid;
diff --git a/lib/VNWeb/Discussions/PostEdit.pm b/lib/VNWeb/Discussions/PostEdit.pm
index a645fb6f..520a215f 100644
--- a/lib/VNWeb/Discussions/PostEdit.pm
+++ b/lib/VNWeb/Discussions/PostEdit.pm
@@ -44,11 +44,12 @@ elm_api DiscussionsPostEdit => $FORM_OUT, $FORM_IN, sub {
return tuwf->resNotFound if !$t->{id};
return elm_Unauth if !can_edit t => $t;
+ tuwf->dbExeci(q{DELETE FROM notifications WHERE iid =}, \$id, 'AND num =', \$num) if auth->permBoardmod && ($data->{delete} || $data->{hidden});
+
if($data->{delete} && auth->permBoardmod) {
auth->audit($t->{user_id}, 'post delete', "deleted $id.$num");
tuwf->dbExeci('DELETE FROM threads_posts WHERE tid =', \$id, 'AND num =', \$num);
tuwf->dbExeci('DELETE FROM reviews_posts WHERE id =', \$id, 'AND num =', \$num);
- tuwf->dbExeci(q{DELETE FROM notifications WHERE iid =}, \$id, 'AND num =', \$num);
return elm_Redirect "/$id";
}
auth->audit($t->{user_id}, 'post edit', "edited $id.$num") if $t->{user_id} != auth->uid;
diff --git a/lib/VNWeb/Discussions/Thread.pm b/lib/VNWeb/Discussions/Thread.pm
index 3fd67dbe..dd443d93 100644
--- a/lib/VNWeb/Discussions/Thread.pm
+++ b/lib/VNWeb/Discussions/Thread.pm
@@ -192,12 +192,9 @@ TUWF::get qr{/$RE{tid}(?:(?<sep>[\./])$RE{num})?}, sub {
GROUP BY tpo.id, tpo.option, tpm.optid'
);
- # Mark a notification for this thread as read, if there is one.
- tuwf->dbExeci(
- 'UPDATE notifications SET read = NOW() WHERE uid =', \auth->uid, 'AND iid =', \$id, 'AND read IS NULL'
- ) if auth && $t->{count} <= $page*25;
+ auth->notiRead($id, [ map $_->{num}, $posts->@* ]) if @$posts;
- framework_ title => $t->{title}, $num ? (js => 1, pagevars => {sethash=>$num}) : (), sub {
+ framework_ title => $t->{title}, type => 't', dbobj => $t, $num ? (js => 1, pagevars => {sethash=>$num}) : (), sub {
metabox_ $t;
elm_ 'Discussions.Poll' => $POLL_OUT, {
question => $t->{poll_question},
diff --git a/lib/VNWeb/Elm.pm b/lib/VNWeb/Elm.pm
index 13165959..4e9d435b 100644
--- a/lib/VNWeb/Elm.pm
+++ b/lib/VNWeb/Elm.pm
@@ -51,6 +51,10 @@ our %apis = (
BadCurPass => [], # Current password is incorrect when changing password
MailChange => [], # A confirmation mail has been sent to change a user's email address
ImgFormat => [], # Unrecognized image format
+ DupNames => [ { aoh => { # Duplicate names/aliases (for tags & traits)
+ id => { id => 1 },
+ name => {},
+ } } ],
Releases => [ { aoh => { # Response to 'Release'
id => { id => 1 },
title => {},
@@ -416,6 +420,7 @@ sub write_types {
$data .= def creditTypes=> 'List (String, String)' => list map tuple(string $_, string $CREDIT_TYPE{$_}), keys %CREDIT_TYPE;
$data .= def producerRelations=> 'List (String, String)' => list map tuple(string $_, string $PRODUCER_RELATION{$_}{txt}), keys %PRODUCER_RELATION;
$data .= def producerTypes=> 'List (String, String)' => list map tuple(string $_, string $PRODUCER_TYPE{$_}), keys %PRODUCER_TYPE;
+ $data .= def tagCategories=> 'List (String, String)' => list map tuple(string $_, string $TAG_CATEGORY{$_}), keys %TAG_CATEGORY;
$data .= def curYear => Int => (gmtime)[5]+1900;
write_module Types => $data;
@@ -438,7 +443,7 @@ sub write_extlinks {
, patt : List String
}
- reg r = Maybe.withDefault Regex.never (Regex.fromStringWith {caseInsensitive=True, multiline=False} r)
+ reg r = Maybe.withDefault Regex.never (Regex.fromStringWith {caseInsensitive=False, multiline=False} r)
delidx n l = List.take n l ++ List.drop (n+1) l
toint v = Maybe.withDefault 0 (String.toInt v)
diff --git a/lib/VNWeb/HTML.pm b/lib/VNWeb/HTML.pm
index 772f3ebc..3cc13494 100644
--- a/lib/VNWeb/HTML.pm
+++ b/lib/VNWeb/HTML.pm
@@ -336,6 +336,40 @@ sub _footer_ {
}
+sub _maintabs_subscribe_ {
+ my($o, $id) = @_;
+ return if !auth || $id !~ /^[twvrpcsdi]/;
+
+ my $noti =
+ $id =~ /^t/ ? tuwf->dbVali('SELECT SUM(x) FROM (
+ SELECT 1 FROM threads_posts tp, users u WHERE u.id =', \auth->uid, 'AND tp.uid =', \auth->uid, 'AND tp.tid =', \$id, ' AND u.notify_post
+ UNION SELECT 1+1 FROM threads_boards tb WHERE tb.tid =', \$id, 'AND tb.type = \'u\' AND tb.iid =', \auth->uid, '
+ ) x(x)')
+
+ : $id =~ /^w/ ? (auth->pref('notify_post') || auth->pref('notify_comment')) && tuwf->dbVali('SELECT SUM(x) FROM (
+ SELECT 1 FROM reviews_posts wp, users u WHERE u.id =', \auth->uid, 'AND wp.uid =', \auth->uid, 'AND wp.id =', \$id, 'AND u.notify_post
+ UNION SELECT 1+1 FROM reviews w, users u WHERE u.id =', \auth->uid, 'AND w.uid =', \auth->uid, 'AND w.id =', \$id, 'AND u.notify_comment
+ ) x(x)')
+
+ : $id =~ /^[vrpcsd]/ && auth->pref('notify_dbedit') && tuwf->dbVali('
+ SELECT 1 FROM changes WHERE type = vndbid_type(', \$id, ')::dbentry_type AND itemid = vndbid_num(', \$id, ') AND requester =', \auth->uid);
+
+ my $sub = tuwf->dbRowi('SELECT subnum, subreview, subapply FROM notification_subs WHERE uid =', \auth->uid, 'AND iid =', \$id);
+
+ li_ id => 'subscribe', sub {
+ elm_ Subscribe => $VNWeb::User::Notifications::SUB, {
+ id => $id,
+ noti => $noti||0,
+ subnum => $sub->{subnum},
+ subreview => $sub->{subreview}||0,
+ subapply => $sub->{subapply}||0,
+ }, sub {
+ a_ href => '#', class => ($noti && (!defined $sub->{subnum} || $sub->{subnum})) || $sub->{subnum} || $sub->{subreview} || $sub->{subapply} ? 'active' : 'inactive', '๐Ÿ””';
+ };
+ };
+}
+
+
sub _maintabs_ {
my $opt = shift;
my($t, $o, $sel) = @{$opt}{qw/type dbobj tab/};
@@ -353,13 +387,13 @@ sub _maintabs_ {
div_ class => 'maintabs right', sub {
ul_ sub {
- t '' => "/$id", $id;
+ t '' => "/$id", $id if $t ne 't';
t rg => "/$id/rg", 'relations'
if $t =~ /[vp]/ && tuwf->dbVali('SELECT 1 FROM', $t eq 'v' ? 'vn_relations' : 'producers_relations', 'WHERE id =', \$o->{id}, 'LIMIT 1');
t releases => "/$id/releases", 'releases' if $t eq 'v';
- t edit => "/$id/edit", 'edit' if can_edit $t, $o;
+ t edit => "/$id/edit", 'edit' if $t ne 't' && can_edit $t, $o;
t copy => "/$id/copy", 'copy' if $t =~ /[rc]/ && can_edit $t, $o;
t tagmod => "/$id/tagmod", 'modify tags' if $t eq 'v' && auth->permTag && !$o->{entry_hidden};
@@ -381,6 +415,7 @@ sub _maintabs_ {
};
t hist => "/$id/hist", 'history' if $t =~ /[uvrpcsd]/;
+ _maintabs_subscribe_ $o, $id;
}
}
}
@@ -426,6 +461,17 @@ sub _hidden_msg_ {
}
+sub _scripts_ {
+ my($o) = @_;
+ script_ type => 'application/json', id => 'pagevars', sub {
+ # Escaping rules for a JSON <script> context are kinda weird, but more efficient than regular xml_escape().
+ lit_(JSON::XS->new->canonical->encode(tuwf->req->{pagevars}) =~ s{</}{<\\/}rg =~ s/<!--/<\\u0021--/rg);
+ } if keys tuwf->req->{pagevars}->%*;
+ script_ type => 'application/javascript', src => config->{url_static}.'/f/elm.js?'.config->{version}, '' if tuwf->req->{pagevars}{elm};
+ script_ type => 'application/javascript', src => config->{url_static}.'/f/plain.js?'.config->{version}, '' if $o->{js} || tuwf->req->{pagevars}{elm};
+}
+
+
# Options:
# title => $title
# index => 1/0, default 0
@@ -456,12 +502,7 @@ sub framework_ {
$cont->() unless $o{hiddenmsg} && _hidden_msg_ \%o;
div_ id => 'footer', \&_footer_;
};
- script_ type => 'application/json', id => 'pagevars', sub {
- # Escaping rules for a JSON <script> context are kinda weird, but more efficient than regular xml_escape().
- lit_(JSON::XS->new->canonical->encode(tuwf->req->{pagevars}) =~ s{</}{<\\/}rg =~ s/<!--/<\\u0021--/rg);
- } if keys tuwf->req->{pagevars}->%*;
- script_ type => 'application/javascript', src => config->{url_static}.'/f/elm.js?'.config->{version}, '' if tuwf->req->{pagevars}{elm};
- script_ type => 'application/javascript', src => config->{url_static}.'/f/plain.js?'.config->{version}, '' if $o{js} || tuwf->req->{pagevars}{elm};
+ _scripts_ \%o;
}
}
}
diff --git a/lib/VNWeb/Images/Vote.pm b/lib/VNWeb/Images/Vote.pm
index e8a8b2f1..5d5c747e 100644
--- a/lib/VNWeb/Images/Vote.pm
+++ b/lib/VNWeb/Images/Vote.pm
@@ -23,18 +23,6 @@ elm_api Images => $SEND, { excl_voted => { anybool => 1 } }, sub {
state $stats = tuwf->dbRowi('SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE c_weight > 0) AS referenced FROM images');
- # Return an empty set when the user has voted on >90% of the (referenced) images.
- # Limiting the number of images a user can vote on has two effects:
- # - When the user has voted on everything, they'd be able to immediately
- # vote on newly added images, meaning they can be used to influence votes
- # from multiple accounts.
- # - When a user has voted on a lot of images, the algorithm to select new
- # images to vote on will become too slow (need to sample everything to
- # find an unvoted image) or may randomly not return images (depending on
- # the initial table sample).
- # (Note: c_imgvotes also counts votes on unreferenced images, so this limit may be a little too strict)
- return elm_ImageResult [] if $data->{excl_voted} && my_votes() > $stats->{referenced}*0.90;
-
# Performing a proper weighted sampling on the entire images table is way
# too slow, so we do a TABLESAMPLE to first randomly select a number of
# rows and then get a weighted sampling from that. The TABLESAMPLE fraction
diff --git a/lib/VNWeb/Misc/Feeds.pm b/lib/VNWeb/Misc/Feeds.pm
index 6d3a3eba..fdc6606c 100644
--- a/lib/VNWeb/Misc/Feeds.pm
+++ b/lib/VNWeb/Misc/Feeds.pm
@@ -11,6 +11,7 @@ sub feed {
my($path, $title, $data) = @_;
my $base = tuwf->reqBaseURI();
+ tuwf->resHeader('Content-Type', 'application/atom+xml; charset=UTF-8');
xml;
tag feed => xmlns => 'http://www.w3.org/2005/Atom', 'xml:lang' => 'en', 'xml:base' => "$base/", sub {
tag title => $title;
diff --git a/lib/VNWeb/Misc/HomePage.pm b/lib/VNWeb/Misc/HomePage.pm
index f71d776b..2ad4f85b 100644
--- a/lib/VNWeb/Misc/HomePage.pm
+++ b/lib/VNWeb/Misc/HomePage.pm
@@ -170,7 +170,7 @@ sub reviews_ {
FROM reviews w
JOIN vn v ON v.id = w.vid
LEFT JOIN users u ON u.id = w.uid
- WHERE ', $full ? '' : 'NOT', 'w.isfull
+ WHERE NOT w.c_flagged AND', $full ? '' : 'NOT', 'w.isfull
ORDER BY w.id DESC LIMIT 10'
);
h1_ sub {
@@ -194,6 +194,7 @@ sub recent_comments_ {
JOIN reviews_posts wp ON wp.id = w.id AND wp.num = w.c_lastnum
JOIN vn v ON v.id = w.vid
LEFT JOIN users u ON u.id = wp.uid
+ WHERE NOT w.c_flagged
ORDER BY wp.date DESC LIMIT 10'
);
h1_ sub {
diff --git a/lib/VNWeb/Misc/Redirects.pm b/lib/VNWeb/Misc/Redirects.pm
new file mode 100644
index 00000000..964c4e24
--- /dev/null
+++ b/lib/VNWeb/Misc/Redirects.pm
@@ -0,0 +1,42 @@
+package VNWeb::Misc::Redirects;
+
+use VNWeb::Prelude;
+use VNWeb::Filters;
+
+
+# VNDB URLs don't have a trailing /, redirect if we get one.
+TUWF::get qr{(/.+?)/+}, sub { tuwf->resRedirect(tuwf->capture(1).tuwf->reqQuery(), 'perm') };
+
+# These two are ancient.
+TUWF::get qr{/notes}, sub { tuwf->resRedirect('/d8', 'perm') };
+TUWF::get qr{/faq}, sub { tuwf->resRedirect('/d6', 'perm') };
+
+TUWF::get qr{/p}, sub { tuwf->resRedirect('/p/all'.tuwf->reqQuery(), 'perm') };
+TUWF::get qr{/v}, sub { tuwf->resRedirect('/v/all'.tuwf->reqQuery(), 'perm') };
+TUWF::get qr{/v/search}, sub { tuwf->resRedirect('/v/all'.tuwf->reqQuery(), 'perm') };
+
+TUWF::get qr{/u/list(/[a-z0]|/all)?}, sub { tuwf->resRedirect('/u'.(tuwf->capture(1)//'/all'), 'perm') };
+
+TUWF::get qr{/$RE{uid}/tags}, sub { tuwf->resRedirect('/g/links?u='.tuwf->capture('id'), 'perm') };
+
+TUWF::get qr{/$RE{vid}/staff}, sub { tuwf->resRedirect(sprintf '/v%s#staff', tuwf->capture('id')) };
+TUWF::get qr{/$RE{vid}/stats}, sub { tuwf->resRedirect(sprintf '/v%s#stats', tuwf->capture('id')) };
+TUWF::get qr{/$RE{vid}/scr}, sub { tuwf->resRedirect(sprintf '/v%s#screenshots', tuwf->capture('id')) };
+
+
+TUWF::get qr{/v/rand}, sub {
+ state $stats ||= tuwf->dbRowi('SELECT COUNT(*) AS total, COUNT(*) FILTER(WHERE NOT hidden) AS subset FROM vn');
+ state $sample ||= 100*min 1, (100 / $stats->{subset}) * ($stats->{total} / $stats->{subset});
+
+ my $filt = auth->pref('filter_vn') && eval { filter_parse v => auth->pref('filter_vn') };
+ my $vn = tuwf->dbVali('
+ SELECT id
+ FROM vn v', $filt ? '' : ('TABLESAMPLE SYSTEM (', \$sample, ')'), '
+ WHERE NOT hidden AND', filter_vn_query($filt||{}), '
+ ORDER BY random() LIMIT 1'
+ );
+ return tuwf->resNotFound if !$vn;
+ tuwf->resRedirect("/v$vn", 'temp');
+};
+
+1;
diff --git a/lib/VNWeb/Producers/List.pm b/lib/VNWeb/Producers/List.pm
new file mode 100644
index 00000000..bd301aaf
--- /dev/null
+++ b/lib/VNWeb/Producers/List.pm
@@ -0,0 +1,62 @@
+package VNWeb::Producers::List;
+
+use VNWeb::Prelude;
+
+
+sub listing_ {
+ my($opt, $list, $count) = @_;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ paginate_ \&url, $opt->{p}, [$count, 150], 't';
+ div_ class => 'mainbox producerbrowse', sub {
+ h1_ $opt->{q} ? 'Search results' : 'Browse producers';
+ if(!@$list) {
+ p_ 'No results found.';
+ } else {
+ ul_ sub {
+ li_ sub {
+ abbr_ class => "icons lang $_->{lang}", title => $LANGUAGE{$_->{lang}}, '';
+ a_ href => "/p$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ } for @$list;
+ }
+ }
+ };
+ paginate_ \&url, $opt->{p}, [$count, 150], 'b';
+}
+
+
+TUWF::get qr{/p/(?<char>all|[a-z0])}, sub {
+ my $char = tuwf->capture('char');
+ my $opt = tuwf->validate(get =>
+ p => { upage => 1 },
+ q => { onerror => '' },
+ )->data;
+
+ my $qs = defined $opt->{q} && '%'.sql_like($opt->{q}).'%';
+ my $where = sql_and 'NOT p.hidden',
+ $qs ? sql 'p.name ILIKE', \$qs, 'OR p.original ILIKE', \$qs, 'OR p.alias ILIKE', \$qs : (),
+ $char eq 0 ? "ascii(p.name) not between ascii('a') and ascii('z') AND ascii(p.name) not between ascii('A') and ascii('Z')" :
+ $char ne 'all' ? sql 'p.name ILIKE', \"$char%" : ();
+
+ my $count = tuwf->dbVali('SELECT COUNT(*) FROM producers p WHERE', $where);
+ my $list = tuwf->dbPagei({ results => 150, page => $opt->{p} },
+ 'SELECT p.id, p.name, p.original, p.lang FROM producers p WHERE', $where, 'ORDER BY p.name'
+ );
+
+ framework_ title => 'Browse producers', sub {
+ div_ class => 'mainbox', sub {
+ h1_ 'Browse producers';
+ form_ action => '/p/all', method => 'get', sub {
+ searchbox_ p => $opt->{q};
+ };
+ p_ class => 'browseopts', sub {
+ a_ href => "/p/$_", $_ eq $char ? (class => 'optselected') : (), $_ eq 'all' ? 'ALL' : $_ ? uc $_ : '#'
+ for ('all', 'a'..'z', 0);
+ };
+ };
+ listing_ $opt, $list, $count;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/Reviews/Edit.pm b/lib/VNWeb/Reviews/Edit.pm
index 34930489..c7b829bc 100644
--- a/lib/VNWeb/Reviews/Edit.pm
+++ b/lib/VNWeb/Reviews/Edit.pm
@@ -12,7 +12,9 @@ my $FORM = {
spoiler => { anybool => 1 },
isfull => { anybool => 1 },
text => { maxlength => 100_000, required => 0, default => '' },
+ locked => { anybool => 1 },
+ mod => { _when => 'out', anybool => 1 },
releases => { _when => 'out', $VNWeb::Elm::apis{Releases}[0]->%* },
};
@@ -38,7 +40,9 @@ TUWF::get qr{/$RE{vid}/addreview}, sub {
p_ 'You can only submit 5 reviews per day. Check back later!';
};
} else {
- elm_ 'Reviews.Edit' => $FORM_OUT, { elm_empty($FORM_OUT)->%*, vid => $v->{id}, vntitle => $v->{title}, releases => releases_by_vn $v->{id} };
+ elm_ 'Reviews.Edit' => $FORM_OUT, { elm_empty($FORM_OUT)->%*,
+ vid => $v->{id}, vntitle => $v->{title}, releases => releases_by_vn($v->{id}), mod => auth->permBoardmod()
+ };
}
};
};
@@ -46,13 +50,14 @@ TUWF::get qr{/$RE{vid}/addreview}, sub {
TUWF::get qr{/$RE{wid}/edit}, sub {
my $e = tuwf->dbRowi(
- 'SELECT r.id, r.uid AS user_id, r.vid, r.rid, r.isfull, r.text, r.spoiler, v.title AS vntitle
+ 'SELECT r.id, r.uid AS user_id, r.vid, r.rid, r.isfull, r.text, r.spoiler, r.locked, v.title AS vntitle
FROM reviews r JOIN vn v ON v.id = r.vid WHERE r.id =', \tuwf->capture('id')
);
return tuwf->resNotFound if !$e->{id};
return tuwf->resDenied if !can_edit w => $e;
$e->{releases} = releases_by_vn $e->{vid};
+ $e->{mod} = auth->permBoardmod;
framework_ title => "Edit review for $e->{vntitle}", type => 'w', dbobj => $e, tab => 'edit', sub {
elm_ 'Reviews.Edit' => $FORM_OUT, $e;
};
@@ -64,9 +69,11 @@ elm_api ReviewsEdit => $FORM_OUT, $FORM_IN, sub {
my($data) = @_;
my $id = delete $data->{id};
- my $review = $id ? tuwf->dbRowi('SELECT id, uid AS user_id FROM reviews WHERE id =', \$id) : {};
+ my $review = $id ? tuwf->dbRowi('SELECT id, locked, uid AS user_id FROM reviews WHERE id =', \$id) : {};
return elm_Unauth if !can_edit w => $review;
+ $data->{locked} = $review->{locked}||0 if !auth->permBoardmod;
+
validate_dbid 'SELECT id FROM vn WHERE id IN', $data->{vid};
validate_dbid 'SELECT id FROM releases WHERE id IN', $data->{rid} if defined $data->{rid};
@@ -94,6 +101,7 @@ elm_api ReviewsDelete => undef, { id => { vndbid => 'w' } }, sub {
my $review = tuwf->dbRowi('SELECT id, uid AS user_id FROM reviews WHERE id =', \$data->{id});
return elm_Unauth if !can_edit w => $review;
auth->audit($review->{user_id}, 'review delete', "deleted $review->{id}");
+ tuwf->dbExeci('DELETE FROM notifications WHERE iid =', \$data->{id});
tuwf->dbExeci('DELETE FROM reviews WHERE id =', \$data->{id});
elm_Success
};
diff --git a/lib/VNWeb/Reviews/Elm.pm b/lib/VNWeb/Reviews/Elm.pm
index 385c8b0f..f3e28516 100644
--- a/lib/VNWeb/Reviews/Elm.pm
+++ b/lib/VNWeb/Reviews/Elm.pm
@@ -13,13 +13,12 @@ my $VOTE_IN = form_compile in => $VOTE;
our $VOTE_OUT = form_compile out => $VOTE;
elm_api ReviewsVote => $VOTE_OUT, $VOTE_IN, sub {
- return elm_Unauth if !auth;
my($data) = @_;
- my %id = (uid => auth->uid, id => $data->{id});
+ my %id = (auth ? (uid => auth->uid) : (ip => norm_ip tuwf->reqIP), id => $data->{id});
my %val = (vote => $data->{my}?1:0, overrule => auth->permBoardmod ? $data->{overrule}?1:0 : 0, date => sql 'NOW()');
tuwf->dbExeci(
defined $data->{my}
- ? sql 'INSERT INTO reviews_votes', {%id,%val}, 'ON CONFLICT (id,uid) DO UPDATE SET', \%val
+ ? sql 'INSERT INTO reviews_votes', {%id,%val}, 'ON CONFLICT (id,', auth ? 'uid' : 'ip', ') DO UPDATE SET', \%val
: sql 'DELETE FROM reviews_votes WHERE', \%id
);
elm_Success
diff --git a/lib/VNWeb/Reviews/Lib.pm b/lib/VNWeb/Reviews/Lib.pm
index 2872966c..1f7c6e4e 100644
--- a/lib/VNWeb/Reviews/Lib.pm
+++ b/lib/VNWeb/Reviews/Lib.pm
@@ -7,8 +7,8 @@ our @EXPORT = qw/reviews_vote_ reviews_format/;
sub reviews_vote_ {
my($w) = @_;
span_ sub {
- elm_ 'Reviews.Vote' => $VNWeb::Reviews::Elm::VOTE_OUT, {%$w, mod => auth->permBoardmod} if auth && ($w->{can} || auth->permBoardmod);
- b_ class => 'grayedout', sprintf ' %d/%d', $w->{c_up}, $w->{c_down} if auth->permBoardmod;
+ elm_ 'Reviews.Vote' => $VNWeb::Reviews::Elm::VOTE_OUT, {%$w, mod => auth->permBoardmod||0} if $w->{can} || auth->permBoardmod;
+ b_ class => 'grayedout', sprintf ' %.2f/%.2f', $w->{c_up}/100, $w->{c_down}/100 if auth->permBoardmod;
}
}
diff --git a/lib/VNWeb/Reviews/List.pm b/lib/VNWeb/Reviews/List.pm
index 94c65625..eed0abf9 100644
--- a/lib/VNWeb/Reviews/List.pm
+++ b/lib/VNWeb/Reviews/List.pm
@@ -27,7 +27,7 @@ sub tablebox_ {
td_ class => 'tc3', fmtvote $_->{vote};
td_ class => 'tc4', $_->{isfull} ? 'Full' : 'Mini';
td_ class => 'tc5', sub { a_ href => "/$_->{id}", $_->{title}; b_ class => 'grayedout', ' (flagged)' if $_->{c_flagged} };
- td_ class => 'tc6', sprintf '๐Ÿ‘ %d ๐Ÿ‘Ž %d', $_->{c_up}, $_->{c_down} if auth->isMod;
+ td_ class => 'tc6', sprintf '๐Ÿ‘ %.2f ๐Ÿ‘Ž %.2f', $_->{c_up}/100, $_->{c_down}/100 if auth->isMod;
td_ class => 'tc7', $_->{c_count};
td_ class => 'tc8', $_->{c_lastnum} ? sub {
user_ $_, 'lu_';
diff --git a/lib/VNWeb/Reviews/Page.pm b/lib/VNWeb/Reviews/Page.pm
index 8822b2ed..83f8dc21 100644
--- a/lib/VNWeb/Reviews/Page.pm
+++ b/lib/VNWeb/Reviews/Page.pm
@@ -12,7 +12,7 @@ my $COMMENT = form_compile any => {
elm_api ReviewsComment => undef, $COMMENT, sub {
my($data) = @_;
- my $w = tuwf->dbRowi('SELECT id, false AS locked FROM reviews WHERE id =', \$data->{id});
+ my $w = tuwf->dbRowi('SELECT id, locked FROM reviews WHERE id =', \$data->{id});
return tuwf->resNotFound if !$w->{id};
return elm_Unauth if !can_edit t => $w;
@@ -46,16 +46,20 @@ sub review_ {
tr_ sub {
td_ 'By';
td_ sub {
- b_ style => 'float: right', 'Vote: '.fmtvote($w->{vote}) if $w->{vote};
+ b_ style => 'float: right; padding-left: 25px', 'Vote: '.fmtvote($w->{vote}) if $w->{vote};
user_ $w;
my($date, $lastmod) = map $_&&fmtdate($_,'compact'), $w->@{'date', 'lastmod'};
txt_ " on $date";
b_ class => 'grayedout', " last updated on $lastmod" if $lastmod && $date ne $lastmod;
+ br_ if $w->{c_flagged} || $w->{locked};
if($w->{c_flagged}) {
br_;
- br_;
b_ class => 'grayedout', 'Flagged: this review is below the voting threshold and not visible on the VN page.';
}
+ if($w->{locked}) {
+ br_;
+ b_ class => 'grayedout', 'Locked: commenting on this review has been disabled.';
+ }
}
};
tr_ class => 'reviewnotspoil', sub {
@@ -81,7 +85,7 @@ sub review_ {
TUWF::get qr{/$RE{wid}(?:(?<sep>[\./])$RE{num})?}, sub {
my($id, $sep, $num) = (tuwf->capture('id'), tuwf->capture('sep')||'', tuwf->capture('num'));
my $w = tuwf->dbRowi(
- 'SELECT r.id, r.vid, r.rid, r.isfull, r.text, r.spoiler, COALESCE(c.count,0) AS count, r.c_flagged, r.c_up, r.c_down, uv.vote, rm.id IS NULL AS can
+ 'SELECT r.id, r.vid, r.rid, r.isfull, r.text, r.spoiler, r.locked, COALESCE(c.count,0) AS count, r.c_flagged, r.c_up, r.c_down, uv.vote, rm.id IS NULL AS can
, v.title, rel.title AS rtitle, rel.original AS roriginal, rel.type AS rtype, rv.vote AS my, COALESCE(rv.overrule,false) AS overrule
, ', sql_user(), ',', sql_totime('r.date'), 'AS date,', sql_totime('r.lastmod'), 'AS lastmod
FROM reviews r
@@ -90,7 +94,7 @@ TUWF::get qr{/$RE{wid}(?:(?<sep>[\./])$RE{num})?}, sub {
LEFT JOIN users u ON u.id = r.uid
LEFT JOIN ulist_vns uv ON uv.uid = r.uid AND uv.vid = r.vid
LEFT JOIN (SELECT id, COUNT(*) FROM reviews_posts GROUP BY id) AS c(id,count) ON c.id = r.id
- LEFT JOIN reviews_votes rv ON rv.id = r.id AND rv.uid =', \auth->uid, '
+ LEFT JOIN reviews_votes rv ON rv.id = r.id AND', auth ? ('rv.uid =', \auth->uid) : ('rv.ip =', \norm_ip tuwf->reqIP), '
LEFT JOIN reviews rm ON rm.vid = r.vid AND rm.uid =', \auth->uid, '
WHERE r.id =', \$id
);
@@ -115,10 +119,8 @@ TUWF::get qr{/$RE{wid}(?:(?<sep>[\./])$RE{num})?}, sub {
);
return tuwf->resNotFound if $num && !grep $_->{num} == $num, @$posts;
- # Mark a notification for this thread as read, if there is one.
- tuwf->dbExeci(
- 'UPDATE notifications SET read = NOW() WHERE uid =', \auth->uid, 'AND iid =', \$id, 'AND read IS NULL'
- ) if auth && $w->{count} <= $page*25;
+ auth->notiRead($id, undef);
+ auth->notiRead($id, [ map $_->{num}, $posts->@* ]) if @$posts;
my $title = "Review of $w->{title}";
framework_ title => $title, index => 1, type => 'w', dbobj => $w,
@@ -135,7 +137,7 @@ TUWF::get qr{/$RE{wid}(?:(?<sep>[\./])$RE{num})?}, sub {
} else {
div_ id => 'threadstart', '';
}
- elm_ 'Reviews.Comment' => $COMMENT, { id => $w->{id}, msg => '' } if $w->{count} <= $page*25 && can_edit t => {%$w,locked=>0};
+ elm_ 'Reviews.Comment' => $COMMENT, { id => $w->{id}, msg => '' } if $w->{count} <= $page*25 && can_edit t => $w;
};
};
diff --git a/lib/VNWeb/Reviews/VNTab.pm b/lib/VNWeb/Reviews/VNTab.pm
index 796193b1..2f11d439 100644
--- a/lib/VNWeb/Reviews/VNTab.pm
+++ b/lib/VNWeb/Reviews/VNTab.pm
@@ -14,7 +14,7 @@ sub reviews_ {
FROM reviews r
LEFT JOIN users u ON r.uid = u.id
LEFT JOIN ulist_vns uv ON uv.uid = r.uid AND uv.vid = r.vid
- LEFT JOIN reviews_votes rv ON rv.uid =', \auth->uid, ' AND rv.id = r.id
+ LEFT JOIN reviews_votes rv ON rv.id = r.id AND', auth ? ('rv.uid =', \auth->uid) : ('rv.ip =', \norm_ip tuwf->reqIP), '
LEFT JOIN reviews rm ON rm.vid = r.vid AND rm.uid =', \auth->uid, '
WhERE NOT r.c_flagged AND r.vid =', \$v->{id}, 'AND', ($mini ? 'NOT' : ''), 'r.isfull
ORDER BY r.c_up-r.c_down DESC'
@@ -43,7 +43,7 @@ sub reviews_ {
txt_ '>';
};
my $html = reviews_format $r, maxlength => $mini ? undef : 700;
- $html .= '...' if !$mini;
+ $html .= xml_string sub { txt_ '... '; a_ href => "/$r->{id}#review", ' Read more ยป' } if !$mini;
if($r->{spoiler}) {
label_ class => 'review_spoil', sub {
input_ type => 'checkbox', class => 'visuallyhidden', (auth->pref('spoilers')||0) == 2 ? ('checked', 'checked') : (), undef;
@@ -55,7 +55,6 @@ sub reviews_ {
}
};
div_ sub {
- a_ href => "/$r->{id}#review", 'Full review ยป' if !$mini;
a_ href => "/$r->{id}#threadstart", $r->{c_count} == 1 ? '1 comment' : "$r->{c_count} comments";
reviews_vote_ $r;
};
diff --git a/lib/VNWeb/Traits/Elm.pm b/lib/VNWeb/TT/Elm.pm
index fc0d0207..f109dadd 100644
--- a/lib/VNWeb/Traits/Elm.pm
+++ b/lib/VNWeb/TT/Elm.pm
@@ -1,7 +1,27 @@
-package VNWeb::Traits::Elm;
+package VNWeb::TT::Elm;
use VNWeb::Prelude;
+elm_api Tags => undef, { search => {} }, sub {
+ my $q = shift->{search};
+ my $qs = sql_like $q;
+
+ elm_TagResult tuwf->dbPagei({ results => 15, page => 1 },
+ 'SELECT t.id, t.name, t.searchable, t.applicable, t.state
+ FROM (',
+ sql_join('UNION ALL',
+ $q =~ /^$RE{gid}$/ ? sql('SELECT 1, id FROM tags WHERE id =', \"$+{id}") : (),
+ sql('SELECT 1+substr_score(lower(name),', \$qs, '), id FROM tags WHERE name ILIKE', \"%$qs%"),
+ sql('SELECT 10+substr_score(lower(alias),', \$qs, '), tag FROM tags_aliases WHERE alias ILIKE', \"%$qs%"),
+ ), ') x (prio, id)
+ JOIN tags t ON t.id = x.id
+ WHERE t.state <> 1
+ GROUP BY t.id, t.name, t.searchable, t.applicable, t.state
+ ORDER BY MIN(x.prio), t.name
+ ')
+};
+
+
elm_api Traits => undef, { search => {} }, sub {
my $q = shift->{search};
my $qs = sql_like $q;
diff --git a/lib/VNWeb/TT/Index.pm b/lib/VNWeb/TT/Index.pm
new file mode 100644
index 00000000..16d5076a
--- /dev/null
+++ b/lib/VNWeb/TT/Index.pm
@@ -0,0 +1,133 @@
+package VNWeb::TT::Index;
+
+use VNWeb::Prelude;
+use VNWeb::TT::Lib 'enrich_group';
+
+
+sub tree_ {
+ my($type) = @_;
+ my $table = $type eq 'g' ? 'tag' : 'trait';
+ my $top = tuwf->dbAlli(
+ "SELECT id, name, c_items FROM ${table}s WHERE state = 1+1 AND NOT EXISTS(SELECT 1 FROM ${table}s_parents WHERE $table = id)
+ ORDER BY ", $type eq 'g' ? 'name' : '"order"'
+ );
+
+ enrich childs => id => parent => sub { sql
+ "SELECT tp.parent, t.id, t.name, t.c_items FROM ${table}s t JOIN ${table}s_parents tp ON tp.$table = t.id WHERE state = 1+1 AND tp.parent IN", $_, 'ORDER BY name'
+ }, $top;
+ $top = [ sort { $b->{childs}->@* <=> $a->{childs}->@* } @$top ] if $type eq 'g';
+
+ my sub lnk_ {
+ a_ href => "/$type$_[0]{id}", $_[0]{name};
+ b_ class => 'grayedout', " ($_[0]{c_items})" if $_[0]{c_items};
+ }
+ div_ class => 'mainbox', sub {
+ h1_ $type eq 'g' ? 'Tag tree' : 'Trait tree';
+ ul_ class => 'tagtree', sub {
+ li_ sub {
+ lnk_ $_;
+ my $sub = $_->{childs};
+ ul_ sub {
+ li_ sub {
+ txt_ '> ';
+ lnk_ $_;
+ } for grep $_, $sub->@[0 .. (@$sub > 6 ? 4 : 5)];
+ li_ sub {
+ my $num = @$sub-5;
+ txt_ '> ';
+ a_ href => "/$type$_->{id}", style => 'font-style: italic', sprintf '%d more %s%s', $num, $table, $num == 1 ? '' : 's';
+ } if @$sub > 6;
+ } if @$sub;
+ } for @$top;
+ };
+ clearfloat_;
+ br_;
+ };
+}
+
+
+sub recent_ {
+ my($type) = @_;
+ my $lst = tuwf->dbAlli('SELECT id, name, ', sql_totime('added'), 'AS added FROM', $type eq 'g' ? 'tags' : 'traits', 'WHERE state = 1+1 ORDER BY added DESC LIMIT 10');
+ enrich_group $type, $lst;
+ p_ class => 'mainopts', sub {
+ a_ href => "/$type/list", 'Browse all '.($type eq 'g' ? 'tags' : 'traits');
+ };
+ h1_ 'Recently added';
+ ul_ sub {
+ li_ sub {
+ txt_ fmtage $_->{added};
+ txt_ ' ';
+ b_ class => 'grayedout', "$_->{group} / " if $_->{group};
+ a_ href => "/$type$_->{id}", $_->{name};
+ } for @$lst;
+ };
+}
+
+
+sub popular_ {
+ my($type) = @_;
+ my $lst = tuwf->dbAlli('SELECT id, name, c_items FROM', $type eq 'g' ? 'tags' : 'traits', 'WHERE state = 1+1 AND c_items > 0 AND applicable ORDER BY c_items DESC LIMIT 10');
+ enrich_group $type, $lst;
+ p_ class => 'mainopts', sub {
+ a_ href => '/g/links', 'Recently tagged';
+ } if $type eq 'g';
+ h1_ 'Popular';
+ ul_ sub {
+ li_ sub {
+ b_ class => 'grayedout', "$_->{group} / " if $_->{group};
+ a_ href => "/$type$_->{id}", $_->{name};
+ txt_ " ($_->{c_items})";
+ } for @$lst;
+ };
+}
+
+
+sub moderation_ {
+ my($type) = @_;
+ my $lst = tuwf->dbAlli('SELECT id, name, ', sql_totime('added'), 'AS added FROM', $type eq 'g' ? 'tags' : 'traits', 'WHERE state = 0 ORDER BY added DESC LIMIT 10');
+ enrich_group $type, $lst;
+ h1_ 'Awaiting moderation';
+ ul_ sub {
+ li_ 'The moderation queue is empty!' if !@$lst;
+ li_ sub {
+ txt_ fmtage $_->{added};
+ txt_ ' ';
+ b_ class => 'grayedout', "$_->{group} / " if $_->{group};
+ a_ href => "/$type$_->{id}", $_->{name};
+ } for @$lst;
+ li_ sub {
+ br_;
+ a_ href => "/$type/list?t=0;o=d;s=added", 'Moderation queue';
+ txt_ ' - ';
+ a_ href => "/$type/list?t=1;o=d;s=added", $type eq 'g' ? 'Denied tags' : 'Denied traits';
+ };
+ };
+}
+
+
+TUWF::get qr{/(?<type>[gi])}, sub {
+ my $type = tuwf->capture('type');
+ framework_ title => $type eq 'g' ? 'Tag index' : 'Trait index', index => 1, sub {
+ div_ class => 'mainbox', sub {
+ p_ class => 'mainopts', sub {
+ a_ href => "/$type/new", 'Create a new'.($type eq 'g' ? 'tag' : 'trait') if can_edit $type => {};
+ };
+ h1_ $type eq 'g' ? 'Search tags' : 'Search traits';
+ form_ action => "/$type/list", sub {
+ searchbox_ $type => '';
+ };
+ };
+ tree_ $type;
+ table_ class => 'mainbox threelayout', sub {
+ tr_ sub {
+ td_ sub { recent_ $type };
+ td_ sub { popular_ $type };
+ td_ sub { moderation_ $type };
+ };
+ };
+
+ };
+};
+
+1;
diff --git a/lib/VNWeb/TT/Lib.pm b/lib/VNWeb/TT/Lib.pm
new file mode 100644
index 00000000..7521b4f0
--- /dev/null
+++ b/lib/VNWeb/TT/Lib.pm
@@ -0,0 +1,23 @@
+package VNWeb::TT::Lib;
+
+use VNWeb::Prelude;
+use Exporter 'import';
+
+our @EXPORT = qw/ tagscore_ enrich_group /;
+
+sub tagscore_ {
+ my($s, $ign) = @_;
+ div_ mkclass(tagscore => 1, negative => $s < 0, ignored => $ign), sub {
+ span_ sprintf '%.1f', $s;
+ div_ style => sprintf('width: %.0fpx', abs $s/3*30), '';
+ };
+}
+
+
+# Add a 'group' name for traits
+sub enrich_group {
+ my($type, @lst) = @_;
+ enrich_merge id => 'SELECT t.id, g.name AS "group" FROM traits t JOIN traits g ON g.id = t."group" WHERE t.id IN', @lst if $type eq 'i';
+}
+
+1;
diff --git a/lib/VNWeb/TT/List.pm b/lib/VNWeb/TT/List.pm
new file mode 100644
index 00000000..8cabc773
--- /dev/null
+++ b/lib/VNWeb/TT/List.pm
@@ -0,0 +1,105 @@
+package VNWeb::TT::List;
+
+use VNWeb::Prelude;
+use VNWeb::TT::Lib 'enrich_group';
+
+
+sub listing_ {
+ my($type, $opt, $list, $count) = @_;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ paginate_ \&url, $opt->{p}, [$count, 50], 't';
+ div_ class => 'mainbox browse taglist', sub {
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc1', sub { txt_ 'Created'; sortable_ 'added', $opt, \&url };
+ td_ class => 'tc2', sub { txt_ $type eq 'g' ? 'VNs' : 'Chars'; sortable_ 'items', $opt, \&url };
+ td_ class => 'tc3', sub { txt_ 'Name'; sortable_ 'name', $opt, \&url };
+ } };
+ tr_ sub {
+ td_ class => 'tc1', fmtage $_->{added};
+ td_ class => 'tc2', $_->{c_items}||'-';
+ td_ class => 'tc3', sub {
+ b_ class => 'grayedout', "$_->{group} / " if $_->{group};
+ a_ href => "/$type$_->{id}", $_->{name};
+ join_ ',', sub { b_ class => 'grayedout', ' '.$_ },
+ $_->{state} == 0 ? 'awaiting moderation' : $_->{state} == 1 ? 'deleted' : (),
+ !$_->{applicable} ? 'not applicable' : (),
+ !$_->{searchable} ? 'not searchable' : ();
+ };
+ } for @$list;
+ };
+ };
+ paginate_ \&url, $opt->{p}, [$count, 50], 'b';
+}
+
+
+TUWF::get qr{/(?<type>[gi])/list}, sub {
+ my $type = tuwf->capture('type');
+ my $opt = tuwf->validate(get =>
+ s => { onerror => 'name', enum => ['added', 'name', 'vns', 'items'] },
+ o => { onerror => 'a', enum => ['a', 'd'] },
+ p => { upage => 1 },
+ t => { onerror => undef, enum => [ -1..2 ] },
+ a => { undefbool => 1 },
+ b => { undefbool => 1 },
+ q => { onerror => '' },
+ )->data;
+ $opt->{s} = 'items' if $opt->{s} eq 'vns';
+ $opt->{t} = undef if $opt->{t} && $opt->{t} == -1; # for legacy URLs
+
+ my $qs = $opt->{q} && '%'.sql_like($opt->{q}).'%';
+ my $where = sql_and
+ defined $opt->{t} ? sql 't.state =', \$opt->{t} : (),
+ defined $opt->{a} ? sql 't.applicable =', \$opt->{a} : (),
+ defined $opt->{b} ? sql 't.searchable =', \$opt->{b} : (),
+ $type eq 'g' ? (
+ $opt->{q} ? sql 't.name ILIKE', \$qs, 'OR t.id IN(SELECT tag FROM tags_aliases WHERE alias ILIKE', \$qs, ')' : ()
+ ) : (
+ $opt->{q} ? sql 't.name ILIKE', \$qs, 'OR t.alias ILIKE', \$qs : ()
+ );
+
+ my $table = $type eq 'g' ? 'tags' : 'traits';
+ my $count = tuwf->dbVali("SELECT COUNT(*) FROM $table t WHERE", $where);
+ my $list = tuwf->dbPagei({ results => 50, page => $opt->{p} },'
+ SELECT t.id, t.name, t.state, t.searchable, t.applicable, t.c_items,', sql_totime('t.added'), "as added
+ FROM $table t
+ WHERE ", $where, '
+ ORDER BY', {qw|added id name name items c_items|}->{$opt->{s}}, {qw|a ASC d DESC|}->{$opt->{o}}, ', id'
+ );
+
+ enrich_group $type, $list;
+
+ framework_ title => "Browse $table", index => 1, sub {
+ div_ class => 'mainbox', sub {
+ h1_ "Browse $table";
+ form_ action => "/$type/list", method => 'get', sub {
+ searchbox_ $type => $opt->{q};
+ };
+ my sub opt_ {
+ my($k,$v,$lbl) = @_;
+ a_ href => '?'.query_encode(%$opt,p=>undef,$k=>$v), defined $opt->{$k} eq defined $v && (!defined $v || $opt->{$k} == $v) ? (class => 'optselected') : (), $lbl;
+ }
+ p_ class => 'browseopts', sub {
+ opt_ t => undef, 'All';
+ opt_ t => 0, 'Awaiting moderation';
+ opt_ t => 1, 'Deleted';
+ opt_ t => 2, 'Accepted';
+ };
+ p_ class => 'browseopts', sub {
+ opt_ a => undef, 'All';
+ opt_ a => 0, 'Not applicable';
+ opt_ a => 1, 'Applicable';
+ };
+ p_ class => 'browseopts', sub {
+ opt_ b => undef, 'All';
+ opt_ b => 0, 'Not searchable';
+ opt_ b => 1, 'Searchable';
+ };
+ };
+ listing_ $type, $opt, $list, $count if $count;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/TT/TagEdit.pm b/lib/VNWeb/TT/TagEdit.pm
new file mode 100644
index 00000000..87013d41
--- /dev/null
+++ b/lib/VNWeb/TT/TagEdit.pm
@@ -0,0 +1,155 @@
+package VNWeb::TT::TagEdit;
+
+use VNWeb::Prelude;
+
+# TODO: Let users edit their own tag while it's still waiting for approval?
+
+my $FORM = {
+ id => { required => 0, id => 1 },
+ name => { maxlength => 250, regex => qr/^[^,\r\n]+$/ },
+ aliases => { type => 'array', values => { maxlength => 250, regex => qr/^[^,\r\n]+$/ } },
+ state => { uint => 1, range => [0,2] },
+ cat => { enum => \%TAG_CATEGORY, default => 'cont' },
+ description => { maxlength => 10240 },
+ searchable => { anybool => 1, default => 1 },
+ applicable => { anybool => 1, default => 1 },
+ defaultspoil => { uint => 1, range => [0,2] },
+ parents => { aoh => {
+ id => { id => 1 },
+ name => { _when => 'out' },
+ } },
+ wipevotes => { _when => 'in', anybool => 1 },
+ merge => { _when => 'in', aoh => { id => { id => 1 } } },
+
+ addedby => { _when => 'out' },
+ can_mod => { _when => 'out', anybool => 1 },
+};
+
+my $FORM_OUT = form_compile out => $FORM;
+my $FORM_IN = form_compile in => $FORM;
+
+
+TUWF::get qr{/$RE{gid}/edit}, sub {
+ my $g = tuwf->dbRowi('
+ SELECT g.id, g.name, g.description, g.state, g.cat, g.defaultspoil, g.searchable, g.applicable
+ , ', sql_user('u', 'addedby_'), '
+ FROM tags g
+ LEFT JOIN users u ON g.addedby = u.id
+ WHERE g.id =', \tuwf->capture('id')
+ );
+ return tuwf->resNotFound if !$g->{id};
+
+ enrich_flatten aliases => id => tag => 'SELECT tag, alias FROM tags_aliases WHERE tag IN', $g;
+ enrich parents => id => tag => 'SELECT gp.tag, g.id, g.name FROM tags_parents gp JOIN tags g ON g.id = gp.parent WHERE gp.tag IN', $g;
+
+ return tuwf->resDenied if !can_edit g => $g;
+
+ $g->{addedby} = xml_string sub { user_ $g, 'addedby_'; };
+ $g->{can_mod} = auth->permTagmod;
+
+ framework_ title => "Edit $g->{name}", type => 'g', dbobj => $g, tab => 'edit', sub {
+ elm_ TagEdit => $FORM_OUT, $g;
+ };
+};
+
+
+TUWF::get qr{/(?:$RE{gid}/add|g/new)}, sub {
+ my $id = tuwf->capture('id');
+ my $g = tuwf->dbRowi('SELECT id, name, cat FROM tags WHERE id =', \$id);
+ return tuwf->resDenied if !can_edit g => {};
+ return tuwf->resNotFound if $id && !$g->{id};
+
+ my $e = elm_empty($FORM_OUT);
+ $e->{can_mod} = auth->permTagmod;
+ if($id) {
+ $e->{parents} = [$g];
+ $e->{cat} = $g->{cat};
+ }
+
+ framework_ title => 'Submit a new tag', sub {
+ div_ class => 'mainbox', sub {
+ h1_ 'Requesting new tag';
+ div_ class => 'notice', sub {
+ h2_ 'Your tag must be approved';
+ p_ sub {
+ txt_ 'All tags have to be approved by a moderator, so it can take a while before it will show up in the tag list'
+ .' or on visual novel pages. You can still vote on the tag even if it has not been approved yet.';
+ br_;
+ br_;
+ txt_ 'Make sure you\'ve read the '; a_ href => '/d10', 'guidelines'; txt_ ' to increase the chances of getting your tag accepted.';
+ }
+ }
+ } if !auth->permTagmod;
+ elm_ TagEdit => $FORM_OUT, $e;
+ };
+};
+
+
+elm_api TagEdit => $FORM_OUT, $FORM_IN, sub {
+ my($data) = @_;
+ my $id = delete $data->{id};
+ my $g = !$id ? {} : tuwf->dbRowi('SELECT id, addedby, state FROM tags WHERE id =', \$id);
+ return tuwf->resNotFound if $id && !$g->{id};
+ return elm_Unauth if !can_edit g => $g;
+
+ $data->{addedby} = $g->{addedby} // auth->uid;
+ if(!auth->permTagmod) {
+ $data->{state} = 0;
+ $data->{applicable} = $data->{searchable} = 1;
+ }
+
+ my $dups = tuwf->dbAlli('
+ SELECT id, name
+ FROM (SELECT id, name FROM tags UNION SELECT tag, alias FROM tags_aliases) n(id,name)
+ WHERE ', sql_and(
+ $id ? sql 'id <>', \$id : (),
+ sql 'lower(name) IN', [ map lc($_), $data->{name}, $data->{aliases}->@* ]
+ )
+ );
+ return elm_DupNames $dups if @$dups;
+
+ # Make sure parent IDs exists and are not a child tag of the current tag (i.e. don't allow cycles)
+ validate_dbid sub {
+ 'SELECT id FROM tags WHERE', sql_and
+ $id ? sql 'id NOT IN(WITH RECURSIVE t(id) AS (SELECT', \$id, '::int UNION SELECT tag FROM tags_parents tp JOIN t ON t.id = tp.parent) SELECT id FROM t)' : (),
+ sql 'id IN', $_[0]
+ }, map $_->{id}, $data->{parents}->@*;
+
+ my %set = map +($_,$data->{$_}), qw/name description state addedby cat defaultspoil searchable applicable/;
+ $set{added} = sql 'NOW()' if $id && $data->{state} == 2 && $g->{state} != 2;
+ tuwf->dbExeci('UPDATE tags SET', \%set, 'WHERE id =', \$id) if $id;
+ $id = tuwf->dbVali('INSERT INTO tags', \%set, 'RETURNING id') if !$id;
+
+ tuwf->dbExeci('DELETE FROM tags_aliases WHERE tag =', \$id);
+ tuwf->dbExeci('INSERT INTO tags_aliases (tag,alias) VALUES(', \$id, ',', \$_, ')') for $data->{aliases}->@*;
+
+ tuwf->dbExeci('DELETE FROM tags_parents WHERE tag =', \$id);
+ tuwf->dbExeci('INSERT INTO tags_parents (tag,parent) VALUES(', \$id, ',', \$_->{id}, ')') for $data->{parents}->@*;
+
+ auth->audit(undef, 'tag edit', "g$id") if $id; # Since we don't have edit histories for tags yet.
+
+ if(auth->permTagmod && $data->{wipevotes}) {
+ my $num = tuwf->dbExeci('DELETE FROM tags_vn WHERE tag =', \$id);
+ auth->audit(undef, 'tag wipe', "Wiped $num votes on g$id");
+ }
+
+ if(auth->permTagmod && $data->{merge}->@*) {
+ my @merge = map $_->{id}, $data->{merge}->@*;
+ # Bugs:
+ # - Arbitrarily takes one vote if there are duplicates, should ideally try to merge them instead.
+ # - The 'ignore' flag will be inconsistent if set and the same VN has been voted on for multiple tags.
+ my $mov = tuwf->dbExeci('
+ INSERT INTO tags_vn (tag,vid,uid,vote,spoiler,date,ignore,notes)
+ SELECT ', \$id, ',vid,uid,vote,spoiler,date,ignore,notes
+ FROM tags_vn WHERE tag IN', \@merge, '
+ ON CONFLICT (tag,vid,uid) DO NOTHING'
+ );
+ my $del = tuwf->dbExeci('DELETE FROM tags_vn tv WHERE tag IN', \@merge);
+ my $lst = join ',', map "g$_", @merge;
+ auth->audit(undef, 'tag merge', "Moved $mov/$del votes from $lst to g$id");
+ }
+
+ elm_Redirect "/g$id";
+};
+
+1;
diff --git a/lib/VNWeb/Tags/Links.pm b/lib/VNWeb/TT/TagLinks.pm
index e3294520..0948a309 100644
--- a/lib/VNWeb/Tags/Links.pm
+++ b/lib/VNWeb/TT/TagLinks.pm
@@ -1,7 +1,7 @@
-package VNWeb::Tags::Links;
+package VNWeb::TT::TagLinks;
use VNWeb::Prelude;
-use VNWeb::Tags::Lib;
+use VNWeb::TT::Lib;
sub listing_ {
diff --git a/lib/VNWeb/TT/TraitEdit.pm b/lib/VNWeb/TT/TraitEdit.pm
new file mode 100644
index 00000000..d780c82a
--- /dev/null
+++ b/lib/VNWeb/TT/TraitEdit.pm
@@ -0,0 +1,140 @@
+package VNWeb::TT::TraitEdit;
+
+use VNWeb::Prelude;
+
+my $FORM = {
+ id => { required => 0, id => 1 },
+ name => { maxlength => 250, regex => qr/^[^,\r\n]+$/ },
+ alias => { maxlength => 1024, regex => qr/^[^,]+$/, required => 0, default => '' },
+ state => { uint => 1, range => [0,2] },
+ sexual => { anybool => 1 },
+ description => { maxlength => 10240 },
+ searchable => { anybool => 1, default => 1 },
+ applicable => { anybool => 1, default => 1 },
+ defaultspoil => { uint => 1, range => [0,2] },
+ parents => { aoh => {
+ id => { id => 1 },
+ name => { _when => 'out' },
+ group => { _when => 'out', required => 0 },
+ } },
+ order => { uint => 1 },
+
+ addedby => { _when => 'out' },
+ can_mod => { _when => 'out', anybool => 1 },
+};
+
+my $FORM_OUT = form_compile out => $FORM;
+my $FORM_IN = form_compile in => $FORM;
+
+
+TUWF::get qr{/$RE{iid}/edit}, sub {
+ my $e = tuwf->dbRowi('
+ SELECT i.id, i.name, i.alias, i.description, i.state, i.sexual, i.defaultspoil, i.searchable, i.applicable, i.order
+ , ', sql_user('u', 'addedby_'), '
+ FROM traits i
+ LEFT JOIN users u ON i.addedby = u.id
+ WHERE i.id =', \tuwf->capture('id')
+ );
+ return tuwf->resNotFound if !$e->{id};
+
+ enrich parents => id => trait => '
+ SELECT ip.trait, i.id, i.name, g.name AS group
+ FROM traits_parents ip JOIN traits i ON i.id = ip.parent LEFT JOIN traits g ON g.id = i.group WHERE ip.trait IN', $e;
+
+ return tuwf->resDenied if !can_edit i => $e;
+
+ $e->{addedby} = xml_string sub { user_ $e, 'addedby_'; };
+ $e->{can_mod} = auth->permTagmod;
+
+ framework_ title => "Edit $e->{name}", type => 'i', dbobj => $e, tab => 'edit', sub {
+ elm_ TraitEdit => $FORM_OUT, $e;
+ };
+};
+
+
+TUWF::get qr{/(?:$RE{iid}/add|i/new)}, sub {
+ my $id = tuwf->capture('id');
+ my $i = tuwf->dbRowi('SELECT i.id, i.name, g.name AS "group", i.sexual FROM traits i LEFT JOIN traits g ON g.id = i."group" WHERE i.id =', \$id);
+ return tuwf->resDenied if !can_edit i => {};
+ return tuwf->resNotFound if $id && !$i->{id};
+
+ my $e = elm_empty($FORM_OUT);
+ $e->{can_mod} = auth->permTagmod;
+ if($id) {
+ $e->{parents} = [$i];
+ $e->{sexual} = $i->{sexual};
+ }
+
+ framework_ title => 'Submit a new trait', sub {
+ div_ class => 'mainbox', sub {
+ h1_ 'Requesting new trait';
+ div_ class => 'notice', sub {
+ h2_ 'Your trait must be approved';
+ p_ sub {
+ txt_ 'All traits have to be approved by a moderator, so it can take a while before it will show up in the trait list.';
+ br_;
+ br_;
+ txt_ 'Make sure you\'ve read the '; a_ href => '/d10', 'guidelines'; txt_ ' to increase the chances of getting your trait accepted.';
+ }
+ }
+ } if !auth->permTagmod;
+ elm_ TraitEdit => $FORM_OUT, $e;
+ };
+};
+
+
+elm_api TraitEdit => $FORM_OUT, $FORM_IN, sub {
+ my($data) = @_;
+ my $id = delete $data->{id};
+ my $e = !$id ? {} : tuwf->dbRowi('SELECT id, addedby, state FROM traits WHERE id =', \$id);
+ return tuwf->resNotFound if $id && !$e->{id};
+ return elm_Unauth if !can_edit i => $e;
+
+
+ $data->{addedby} = $e->{addedby} // auth->uid;
+ if(!auth->permTagmod) {
+ $data->{state} = 0;
+ $data->{applicable} = $data->{searchable} = 1;
+ }
+ $data->{order} = 0 if $data->{parents}->@*;
+
+ # Make sure parent IDs exists and are not a child trait of the current trait (i.e. don't allow cycles)
+ my @parents = map $_->{id}, $data->{parents}->@*;
+ validate_dbid sub {
+ 'SELECT id FROM traits WHERE', sql_and
+ $id ? sql 'id NOT IN(WITH RECURSIVE t(id) AS (SELECT', \$id, '::int UNION SELECT trait FROM traits_parents tp JOIN t ON t.id = tp.parent) SELECT id FROM t)' : (),
+ sql 'id IN', $_[0]
+ }, @parents;
+
+ # It's technically possible for a trait to be in multiple groups, but the DB schema doesn't support that so let's get the group from the first parent (sorted by id).
+ $data->{group} = tuwf->dbVali('SELECT coalesce("group",id) FROM traits WHERE id IN', \@parents, 'ORDER BY id LIMIT 1');
+
+ # (Ideally this checks all groups that this trait applies in, but that's more annoying to implement)
+ my $re = '[\t\s]*\n[\t\s]*';
+ my $dups = tuwf->dbAlli('
+ SELECT n.id, n.name
+ FROM (SELECT id, name FROM traits UNION ALL SELECT id, s FROM traits, regexp_split_to_table(alias, ', \$re, ') a(s) WHERE s <> \'\') n(id,name)
+ JOIN traits t ON n.id = t.id
+ WHERE ', sql_and(
+ $id ? sql 'n.id <>', \$id : (),
+ sql('t."group" IS NOT DISTINCT FROM', \$data->{group}),
+ sql 'lower(n.name) IN', [ map lc($_), $data->{name}, grep length($_), split /$re/, $data->{alias} ]
+ )
+ );
+ return elm_DupNames $dups if @$dups;
+
+ my %set = map +($_,$data->{$_}), qw/name alias description state addedby sexual defaultspoil searchable applicable/;
+ $set{'"group"'} = $data->{group};
+ $set{'"order"'} = $data->{order};
+ $set{added} = sql 'NOW()' if $id && $data->{state} == 2 && $e->{state} != 2;
+ tuwf->dbExeci('UPDATE traits SET', \%set, 'WHERE id =', \$id) if $id;
+ $id = tuwf->dbVali('INSERT INTO traits', \%set, 'RETURNING id') if !$id;
+
+ tuwf->dbExeci('DELETE FROM traits_parents WHERE trait =', \$id);
+ tuwf->dbExeci('INSERT INTO traits_parents (trait,parent) VALUES(', \$id, ',', \$_->{id}, ')') for $data->{parents}->@*;
+
+ auth->audit(undef, 'trait edit', "i$id") if $id; # Since we don't have edit histories for traits yet.
+ elm_Redirect "/i$id";
+};
+
+1;
diff --git a/lib/VNWeb/Tags/Elm.pm b/lib/VNWeb/Tags/Elm.pm
deleted file mode 100644
index 089487d7..00000000
--- a/lib/VNWeb/Tags/Elm.pm
+++ /dev/null
@@ -1,24 +0,0 @@
-package VNWeb::Tags::Elm;
-
-use VNWeb::Prelude;
-
-elm_api Tags => undef, { search => {} }, sub {
- my $q = shift->{search};
- my $qs = sql_like $q;
-
- elm_TagResult tuwf->dbPagei({ results => 15, page => 1 },
- 'SELECT t.id, t.name, t.searchable, t.applicable, t.state
- FROM (',
- sql_join('UNION ALL',
- $q =~ /^$RE{gid}$/ ? sql('SELECT 1, id FROM tags WHERE id =', \"$+{id}") : (),
- sql('SELECT 1+substr_score(lower(name),', \$qs, '), id FROM tags WHERE name ILIKE', \"%$qs%"),
- sql('SELECT 10+substr_score(lower(alias),', \$qs, '), tag FROM tags_aliases WHERE alias ILIKE', \"%$qs%"),
- ), ') x (prio, id)
- JOIN tags t ON t.id = x.id
- WHERE t.state <> 1
- GROUP BY t.id, t.name, t.searchable, t.applicable, t.state
- ORDER BY MIN(x.prio), t.name
- ')
-};
-
-1;
diff --git a/lib/VNWeb/Tags/Lib.pm b/lib/VNWeb/Tags/Lib.pm
deleted file mode 100644
index 61220186..00000000
--- a/lib/VNWeb/Tags/Lib.pm
+++ /dev/null
@@ -1,16 +0,0 @@
-package VNWeb::Tags::Lib;
-
-use VNWeb::Prelude;
-use Exporter 'import';
-
-our @EXPORT = qw/ tagscore_ /;
-
-sub tagscore_ {
- my($s, $ign) = @_;
- div_ mkclass(tagscore => 1, negative => $s < 0, ignored => $ign), sub {
- span_ sprintf '%.1f', $s;
- div_ style => sprintf('width: %.0fpx', abs $s/3*30), '';
- };
-}
-
-1;
diff --git a/lib/VNWeb/User/Notifications.pm b/lib/VNWeb/User/Notifications.pm
index 40417c63..aa97b064 100644
--- a/lib/VNWeb/User/Notifications.pm
+++ b/lib/VNWeb/User/Notifications.pm
@@ -3,13 +3,17 @@ package VNWeb::User::Notifications;
use VNWeb::Prelude;
my %ntypes = (
- pm => 'Private Message',
- dbdel => 'Entry you contributed to has been deleted',
- listdel => 'VN in your list has been deleted',
- dbedit => 'Entry you contributed to has been edited',
- announce => 'Site announcement',
- post => 'Reply to a thread you\'ve posted in',
- comment => 'Comment on your review',
+ pm => 'Message on your board',
+ dbdel => 'Entry you contributed to has been deleted',
+ listdel => 'VN in your list has been deleted',
+ dbedit => 'Entry you contributed to has been edited',
+ announce => 'Site announcement',
+ post => 'Reply to a thread you posted in',
+ comment => 'Comment on your review',
+ subpost => 'Reply to a thread you subscribed to',
+ subedit => 'Entry you subscribed to has been edited',
+ subreview => 'New review for a VN you subscribed to',
+ subapply => 'Trait you subscribed to has been (un)applied',
);
@@ -71,16 +75,23 @@ sub listing_ {
tr_ $_->{read} ? () : (class => 'unread'), sub {
my $l = $_;
my $lid = $l->{iid}.($l->{num}?'.'.$l->{num}:'');
- my $url = "/u$id/notify/$l->{id}/$lid";
td_ class => 'tc1', sub { input_ type => 'checkbox', name => 'notifysel', value => $l->{id}; };
- td_ class => 'tc2', $ntypes{$l->{ntype}};
+ td_ class => 'tc2', sub {
+ # Hide some not very interesting overlapping notification types
+ my %t = map +($_,1), $l->{ntype}->@*;
+ delete $t{subpost} if $t{post} || $t{comment} || $t{pm};
+ delete $t{post} if $t{pm};
+ delete $t{subedit} if $t{dbedit};
+ delete $t{dbedit} if $t{dbdel};
+ join_ \&br_, sub { txt_ $ntypes{$_} }, sort keys %t;
+ };
td_ class => 'tc3', fmtage $l->{date};
- td_ class => 'tc4', sub { a_ href => $url, $lid };
+ td_ class => 'tc4', sub { a_ href => "/$lid", $lid };
td_ class => 'tc5', sub {
- a_ href => $url, sub {
+ a_ href => "/$lid", sub {
txt_ $l->{iid} =~ /^w/ ? ($l->{num} ? 'Comment on ' : 'Review of ') :
$l->{iid} =~ /^t/ ? ($l->{num} == 1 ? 'New thread ' : 'Reply to ') : 'Edit of ';
- i_ $l->{c_title};
+ i_ $l->{title};
txt_ ' by ';
i_ user_displayname $l;
};
@@ -99,6 +110,10 @@ sub listing_ {
}
+# Redirect so that elm/Subscribe.elm can link to this page without knowing our uid.
+TUWF::get qr{/u/notifies}, sub { auth ? tuwf->resRedirect('/u'.auth->uid.'/notifies') : tuwf->resNotFound };
+
+
TUWF::get qr{/$RE{uid}/notifies}, sub {
my $id = tuwf->capture('id');
return tuwf->resNotFound if !auth || $id != auth->uid;
@@ -109,17 +124,16 @@ TUWF::get qr{/$RE{uid}/notifies}, sub {
)->data;
my $where = sql_and(
- sql('uid =', \$id),
- $opt->{r} ? () : 'read IS NULL'
+ sql('n.uid =', \$id),
+ $opt->{r} ? () : 'n.read IS NULL'
);
- my $count = tuwf->dbVali('SELECT count(*) FROM notifications WHERE', $where);
+ my $count = tuwf->dbVali('SELECT count(*) FROM notifications n WHERE', $where);
my $list = tuwf->dbPagei({ results => 25, page => $opt->{p} },
- 'SELECT n.id, n.ntype, n.iid, n.num, n.c_title
+ 'SELECT n.id, n.ntype::text[] AS ntype, n.iid, n.num, t.title, ', sql_user(), '
, ', sql_totime('n.date'), ' as date
, ', sql_totime('n.read'), ' as read
- , ', sql_user(),
- 'FROM notifications n
- LEFT JOIN users u ON u.id = n.c_byuser
+ FROM notifications n, item_info(n.iid, n.num) t
+ LEFT JOIN users u ON u.id = t.uid
WHERE ', $where,
'ORDER BY n.id', $opt->{r} ? 'DESC' : 'ASC'
);
@@ -181,6 +195,8 @@ TUWF::post qr{/$RE{uid}/notify_update}, sub {
};
+# XXX: Not currently used anymore, just visiting the destination pages will mark the relevant notifications as read
+# (but that's subject to change in the future, so let's keep this around)
TUWF::get qr{/$RE{uid}/notify/$RE{num}/(?<lid>[a-z0-9\.]+)}, sub {
my $id = tuwf->capture('id');
return tuwf->resNotFound if !auth || $id != auth->uid;
@@ -188,4 +204,38 @@ TUWF::get qr{/$RE{uid}/notify/$RE{num}/(?<lid>[a-z0-9\.]+)}, sub {
tuwf->resRedirect('/'.tuwf->capture('lid'), 'temp');
};
+
+
+# It's a bit annoying to add auth->notiRead() to each revision page, so do that in bulk with a simple hook.
+TUWF::hook before => sub {
+ auth->notiRead($+{vndbid}, $+{rev}) if auth && tuwf->reqPath() =~ qr{^/(?<vndbid>[vrpcsd]$RE{num})\.(?<rev>$RE{num})$};
+};
+
+
+
+
+our $SUB = form_compile any => {
+ id => { vndbid => [qw|t w v r p c s d i|] },
+ subnum => { required => 0, jsonbool => 1 },
+ subreview => { anybool => 1 },
+ subapply => { anybool => 1 },
+ noti => { uint => 1 }, # Whether the user already gets 'subnum' notifications for this entry (see HTML.pm for possible values)
+};
+
+elm_api Subscribe => undef, $SUB, sub {
+ my($data) = @_;
+
+ delete $data->{noti};
+ $data->{subnum} = $data->{subnum}?1:0 if defined $data->{subnum}; # 'jsonbool' isn't understood by SQL
+ $data->{subreview} = 0 if $data->{id} !~ /^v/;
+
+ my %where = (iid => delete $data->{id}, uid => auth->uid);
+ if(!defined $data->{subnum} && !$data->{subreview} && !$data->{subapply}) {
+ tuwf->dbExeci('DELETE FROM notification_subs WHERE', \%where);
+ } else {
+ tuwf->dbExeci('INSERT INTO notification_subs', {%where, %$data}, 'ON CONFLICT (iid,uid) DO UPDATE SET', $data);
+ }
+ elm_Success
+};
+
1;
diff --git a/lib/VNWeb/VN/Page.pm b/lib/VNWeb/VN/Page.pm
index ed4f6e75..a2d0bb4a 100644
--- a/lib/VNWeb/VN/Page.pm
+++ b/lib/VNWeb/VN/Page.pm
@@ -495,7 +495,7 @@ sub staff_ {
@$c = sort { $a->[1] <=> $b->[1] } @$c;
}
- div_ class => 'mainbox', 'data-mainbox-summarize' => 200, sub {
+ div_ class => 'mainbox', id => 'staff', 'data-mainbox-summarize' => 200, sub {
h1_ 'Staff';
div_ class => sprintf('vnstaff vnstaff-%d', scalar @$_), sub {
ul_ sub {
@@ -622,7 +622,7 @@ sub stats_ {
} if $v->{c_votecount};
}
- div_ class => 'mainbox', sub {
+ div_ class => 'mainbox', id => 'stats', sub {
h1_ 'User stats';
if(!@$stats) {
p_ 'Nobody has voted on this visual novel yet...';
diff --git a/lib/VNWeb/VN/Tagmod.pm b/lib/VNWeb/VN/Tagmod.pm
index 70c82970..c6af98c4 100644
--- a/lib/VNWeb/VN/Tagmod.pm
+++ b/lib/VNWeb/VN/Tagmod.pm
@@ -1,7 +1,6 @@
package VNWeb::VN::Tagmod;
use VNWeb::Prelude;
-use VNWeb::Tags::Lib;
my $FORM = {
diff --git a/lib/VNWeb/Validation.pm b/lib/VNWeb/Validation.pm
index 4d398aac..e457aba2 100644
--- a/lib/VNWeb/Validation.pm
+++ b/lib/VNWeb/Validation.pm
@@ -182,6 +182,10 @@ sub validate_dbid {
# Otherwise, checks if the user can edit the review.
# Requires the 'uid' field.
#
+# g/i:
+# If no 'id' field, checks if the user can create a new tag/trait.
+# Otherwise, checks if the user can edit the entry.
+#
# 'dbentry_type's:
# If no 'id' field, checks whether the user can create a new entry.
# Otherwise, requires 'entry_hidden' and 'entry_locked' fields.
@@ -214,6 +218,10 @@ sub can_edit {
return auth && auth->uid == $entry->{user_id};
}
+ if($type eq 'g' || $type eq 'i') {
+ return auth && (auth->permTagmod || !$entry->{id});
+ }
+
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});
diff --git a/sql/func.sql b/sql/func.sql
index 6cb3735d..3b0edfdb 100644
--- a/sql/func.sql
+++ b/sql/func.sql
@@ -61,18 +61,24 @@ CREATE OR REPLACE FUNCTION update_vncache(integer) RETURNS void AS $$
GROUP BY rv.vid
), 0),
c_olang = ARRAY(
- SELECT lang
- FROM releases_lang
- WHERE id = (
- SELECT r.id
- FROM releases_vn rv
- JOIN releases r ON rv.id = r.id
- WHERE r.released > 0
- AND NOT r.hidden
- AND rv.vid = $1
- ORDER BY r.released
- LIMIT 1
- )
+ SELECT rl.lang
+ FROM releases_lang rl
+ JOIN releases r ON r.id = rl.id
+ JOIN releases_vn rv ON r.id = rv.id
+ WHERE rv.vid = $1
+ AND NOT r.hidden
+ AND r.released > 0
+ AND NOT EXISTS(
+ SELECT 1
+ FROM releases r2
+ JOIN releases_vn rv2 ON r2.id = rv2.id
+ WHERE rv2.vid = $1
+ AND NOT r2.hidden
+ AND r2.released > 0
+ AND r2.released < r.released
+ )
+ GROUP BY rl.lang
+ ORDER BY rl.lang
),
c_languages = ARRAY(
SELECT rl.lang
@@ -164,8 +170,10 @@ BEGIN
ELSE 0 END AS weight
FROM (
SELECT i.id, count(iv.id) AS votecount
- , avg(sexual) FILTER(WHERE NOT iv.ignore) AS sexual_avg, stddev_pop(sexual) FILTER(WHERE NOT iv.ignore) AS sexual_stddev
- , avg(violence) FILTER(WHERE NOT iv.ignore) AS violence_avg, stddev_pop(violence) FILTER(WHERE NOT iv.ignore) AS violence_stddev
+ , greatest(avg(sexual) FILTER(WHERE NOT iv.ignore), max(sexual) FILTER(WHERE u.perm_imgmod)) AS sexual_avg
+ , greatest(avg(violence) FILTER(WHERE NOT iv.ignore), max(violence) FILTER(WHERE u.perm_imgmod)) AS violence_avg
+ , stddev_pop(sexual) FILTER(WHERE NOT iv.ignore) AS sexual_stddev
+ , stddev_pop(violence) FILTER(WHERE NOT iv.ignore) AS violence_stddev
FROM images i
LEFT JOIN image_votes iv ON iv.id = i.id
LEFT JOIN users u ON u.id = iv.uid
@@ -182,15 +190,10 @@ END; $$ LANGUAGE plpgsql;
-- Update reviews.c_up, c_down and c_flagged
CREATE OR REPLACE FUNCTION update_reviews_votes_cache(vndbid) RETURNS void AS $$
BEGIN
- WITH stats(id,up,down,flag) AS (
+ WITH stats(id,up,down) AS (
SELECT r.id
- , COUNT(*) FILTER(WHERE rv.vote AND NOT u.ign_votes AND r2.id IS NULL)
- , COUNT(*) FILTER(WHERE NOT rv.vote AND NOT u.ign_votes AND r2.id IS NULL)
- -- flag score = up-down < -10, overrule votes count for 10000 (this algorithm is subject to tuning)
- , COALESCE(
- SUM((CASE WHEN rv.vote THEN 1 ELSE -1 END)*(CASE WHEN rv.overrule THEN 10000 ELSE 1 END))
- FILTER(WHERE NOT u.ign_votes AND (r2.id IS NULL OR rv.overrule)),
- 0) < -1000
+ , COALESCE(SUM(CASE WHEN rv.overrule THEN 100000 WHEN rv.ip IS NULL THEN 100 ELSE 1 END) FILTER(WHERE rv.vote AND u.ign_votes IS DISTINCT FROM true AND r2.id IS NULL), 0)
+ , COALESCE(SUM(CASE WHEN rv.overrule THEN 100000 WHEN rv.ip IS NULL THEN 100 ELSE 1 END) FILTER(WHERE NOT rv.vote AND u.ign_votes IS DISTINCT FROM true AND r2.id IS NULL), 0)
FROM reviews r
LEFT JOIN reviews_votes rv ON rv.id = r.id
LEFT JOIN users u ON u.id = rv.uid
@@ -198,8 +201,8 @@ BEGIN
WHERE $1 IS NULL OR r.id = $1
GROUP BY r.id
)
- UPDATE reviews SET c_up = up, c_down = down, c_flagged = flag
- FROM stats WHERE reviews.id = stats.id AND (reviews.c_up,reviews.c_down,reviews.c_flagged) <> (stats.up,stats.down,stats.flag);
+ UPDATE reviews SET c_up = up, c_down = down, c_flagged = up-down<-10000
+ FROM stats WHERE reviews.id = stats.id AND (c_up,c_down,c_flagged) <> (up,down,up-down<10000);
END; $$ LANGUAGE plpgsql;
@@ -347,6 +350,38 @@ CREATE OR REPLACE FUNCTION ulist_labels_create(integer) RETURNS void AS $$
$$ LANGUAGE SQL;
+-- Returns the title and (where applicable) uid of the user who created the thing for almost every supported vndbid + num.
+-- While a function like this would be super useful in many places, it's too slow to be used in large or popular listings.
+-- A VIEW that can be joined would offer much better optimization possibilities, but I've not managed to write that in a performant way yet.
+-- A MATERIALIZED VIEW would likely be the fastest approach, but keeping that up-to-date seems like a pain.
+--
+-- Not currently supported: i#, g#, u#, ch#, cv#, sf#
+CREATE OR REPLACE FUNCTION item_info(id vndbid, num int) RETURNS TABLE(title text, uid int) AS $$
+ -- x#.#
+ SELECT v.title, h.requester FROM changes h JOIN vn_hist v ON h.id = v.chid WHERE h.type = 'v' AND vndbid_type($1) = 'v' AND h.itemid = vndbid_num($1) AND $2 IS NOT NULL AND h.rev = $2
+ UNION ALL SELECT r.title, h.requester FROM changes h JOIN releases_hist r ON h.id = r.chid WHERE h.type = 'r' AND vndbid_type($1) = 'r' AND h.itemid = vndbid_num($1) AND $2 IS NOT NULL AND h.rev = $2
+ UNION ALL SELECT p.name, h.requester FROM changes h JOIN producers_hist p ON h.id = p.chid WHERE h.type = 'p' AND vndbid_type($1) = 'p' AND h.itemid = vndbid_num($1) AND $2 IS NOT NULL AND h.rev = $2
+ UNION ALL SELECT c.name, h.requester FROM changes h JOIN chars_hist c ON h.id = c.chid WHERE h.type = 'c' AND vndbid_type($1) = 'c' AND h.itemid = vndbid_num($1) AND $2 IS NOT NULL AND h.rev = $2
+ UNION ALL SELECT d.title, h.requester FROM changes h JOIN docs_hist d ON h.id = d.chid WHERE h.type = 'd' AND vndbid_type($1) = 'd' AND h.itemid = vndbid_num($1) AND $2 IS NOT NULL AND h.rev = $2
+ UNION ALL SELECT sa.name, h.requester FROM changes h JOIN staff_hist s ON h.id = s.chid JOIN staff_alias_hist sa ON sa.chid = s.chid AND sa.aid = s.aid WHERE h.type = 's' AND vndbid_type($1) = 's' AND h.itemid = vndbid_num($1) AND $2 IS NOT NULL AND h.rev = $2
+ -- x#
+ UNION ALL SELECT title, NULL FROM vn WHERE vndbid_type($1) = 'v' AND id = vndbid_num($1) AND $2 IS NULL
+ UNION ALL SELECT title, NULL FROM releases WHERE vndbid_type($1) = 'r' AND id = vndbid_num($1) AND $2 IS NULL
+ UNION ALL SELECT name, NULL FROM producers WHERE vndbid_type($1) = 'p' AND id = vndbid_num($1) AND $2 IS NULL
+ UNION ALL SELECT name, NULL FROM chars WHERE vndbid_type($1) = 'c' AND id = vndbid_num($1) AND $2 IS NULL
+ UNION ALL SELECT title, NULL FROM docs WHERE vndbid_type($1) = 'd' AND id = vndbid_num($1) AND $2 IS NULL
+ UNION ALL SELECT sa.name, NULL FROM staff s JOIN staff_alias sa ON sa.aid = s.aid WHERE vndbid_type($1) = 's' AND s.id = vndbid_num($1) AND $2 IS NOT NULL AND $2 IS NULL
+ -- t#
+ UNION ALL SELECT title, NULL FROM threads WHERE vndbid_type($1) = 't' AND id = $1 AND $2 IS NULL
+ -- t#.#
+ UNION ALL SELECT t.title, tp.uid FROM threads t JOIN threads_posts tp ON tp.tid = t.id WHERE vndbid_type($1) = 't' AND t.id = $1 AND $2 IS NOT NULL AND tp.num = $2
+ -- w#
+ UNION ALL SELECT v.title, w.uid FROM reviews w JOIN vn v ON v.id = w.vid WHERE vndbid_type($1) = 'w' AND w.id = $1 AND $2 IS NULL
+ -- w#.#
+ UNION ALL SELECT v.title, wp.uid FROM reviews w JOIN vn v ON v.id = w.vid JOIN reviews_posts wp ON wp.id = w.id WHERE vndbid_type($1) = 'w' AND w.id = $1 AND $2 IS NOT NULL AND wp.num = $2
+$$ LANGUAGE SQL ROWS 1;
+
+
----------------------------------------------------------
@@ -467,23 +502,9 @@ BEGIN
PERFORM traits_chars_calc(xedit.itemid);
END IF;
- -- Call notify_dbdel() if an entry has been deleted
- -- Call notify_listdel() if a vn/release entry has been deleted
- IF xoldchid IS NOT NULL
- AND EXISTS(SELECT 1 FROM changes WHERE id = xoldchid AND NOT ihid)
- AND EXISTS(SELECT 1 FROM changes WHERE id = xedit.chid AND ihid)
- THEN
- PERFORM notify_dbdel(xtype, xedit);
- IF xtype = 'v' OR xtype = 'r' THEN
- PERFORM notify_listdel(xtype, xedit);
- END IF;
- END IF;
-
- -- Call notify_dbedit() if a non-hidden entry has been edited
- IF xoldchid IS NOT NULL AND EXISTS(SELECT 1 FROM changes WHERE id = xedit.chid AND NOT ihid)
- THEN
- PERFORM notify_dbedit(xtype, xedit);
- END IF;
+ -- Create edit notifications
+ INSERT INTO notifications (uid, ntype, iid, num)
+ SELECT n.uid, n.ntype, n.iid, n.num FROM changes c, notify(vndbid(c.type::text, c.itemid), c.rev, c.requester) n WHERE c.id = xedit.chid;
-- Make sure all visual novels linked to a release have a corresponding entry
-- in ulist_vns for users who have the release in rlists. This is action (3) in
@@ -516,70 +537,128 @@ $$ LANGUAGE plpgsql;
----------------------------------------------------------
--- called when an entry has been deleted
-CREATE OR REPLACE FUNCTION notify_dbdel(xtype dbentry_type, xedit edit_rettype) RETURNS void AS $$
- INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
- SELECT DISTINCT 'dbdel'::notification_ntype, h.requester, vndbid(xtype::text, xedit.itemid), xedit.rev, x.title, h2.requester
- FROM changes h
- -- join info about the deletion itself
- JOIN changes h2 ON h2.id = xedit.chid
- -- Fetch the latest name/title of the entry
- -- this method may look a bit unintuitive, but it's way faster than doing LEFT JOINs
- JOIN ( SELECT v.title FROM vn v WHERE xtype = 'v' AND v.id = xedit.itemid
- UNION SELECT r.title FROM releases r WHERE xtype = 'r' AND r.id = xedit.itemid
- UNION SELECT p.name FROM producers p WHERE xtype = 'p' AND p.id = xedit.itemid
- UNION SELECT c.name FROM chars c WHERE xtype = 'c' AND c.id = xedit.itemid
- UNION SELECT d.title FROM docs d WHERE xtype = 'd' AND d.id = xedit.itemid
- UNION SELECT sa.name FROM staff s JOIN staff_alias sa ON sa.aid = s.aid WHERE xtype = 's' AND s.id = xedit.itemid
- ) x(title) ON true
- WHERE h.type = xtype AND h.itemid = xedit.itemid
- AND h.requester <> 1 -- exclude Multi
- AND h.requester <> h2.requester; -- exclude the user who deleted the entry
-$$ LANGUAGE sql;
-
-
-
--- Called when a non-deleted item has been edited.
-CREATE OR REPLACE FUNCTION notify_dbedit(xtype dbentry_type, xedit edit_rettype) RETURNS void AS $$
- INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
- SELECT DISTINCT 'dbedit'::notification_ntype, h.requester, vndbid(xtype::text, xedit.itemid), xedit.rev, x.title, h2.requester
- FROM changes h
- -- join info about the edit itself
- JOIN changes h2 ON h2.id = xedit.chid
- -- Fetch the latest name/title of the entry
- JOIN ( SELECT v.title FROM vn v WHERE xtype = 'v' AND v.id = xedit.itemid
- UNION SELECT r.title FROM releases r WHERE xtype = 'r' AND r.id = xedit.itemid
- UNION SELECT p.name FROM producers p WHERE xtype = 'p' AND p.id = xedit.itemid
- UNION SELECT c.name FROM chars c WHERE xtype = 'c' AND c.id = xedit.itemid
- UNION SELECT d.title FROM docs d WHERE xtype = 'd' AND d.id = xedit.itemid
- UNION SELECT sa.name FROM staff s JOIN staff_alias sa ON sa.aid = s.aid WHERE xtype = 's' AND s.id = xedit.itemid
- ) x(title) ON true
- WHERE h.type = xtype AND h.itemid = xedit.itemid
- AND h.requester <> h2.requester -- exclude the user who edited the entry
- AND h2.requester <> 1 -- exclude edits by Multi
- -- exclude users who don't want this notify
- AND EXISTS(SELECT 1 FROM users u WHERE u.id = h.requester AND notify_dbedit);
-$$ LANGUAGE sql;
-
-
+-- Called after a certain event has occurred (new edit, post, etc).
+-- 'iid' and 'num' identify the item that has been created.
+-- 'uid' indicates who created the item, providing an easy method of not creating a notification for that user.
+-- (can technically be fetched with a DB lookup, too)
+CREATE OR REPLACE FUNCTION notify(iid vndbid, num integer, uid integer) RETURNS TABLE (uid integer, ntype notification_ntype[], iid vndbid, num int) AS $$
+ SELECT uid, array_agg(ntype), $1, $2
+ FROM (
--- called when a VN/release entry has been deleted
-CREATE OR REPLACE FUNCTION notify_listdel(xtype dbentry_type, xedit edit_rettype) RETURNS void AS $$
- INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
- SELECT DISTINCT 'listdel'::notification_ntype, u.uid, vndbid(xtype::text, xedit.itemid), xedit.rev, x.title, c.requester
- -- look for users who should get this notify
- FROM (
- SELECT uid FROM ulist_vns WHERE xtype = 'v' AND vid = xedit.itemid
- UNION SELECT uid FROM rlists WHERE xtype = 'r' AND rid = xedit.itemid
- ) u
- -- fetch info about this edit
- JOIN changes c ON c.id = xedit.chid
- JOIN (
- SELECT title FROM vn WHERE xtype = 'v' AND id = xedit.itemid
- UNION SELECT title FROM releases WHERE xtype = 'r' AND id = xedit.itemid
- ) x ON true
- WHERE c.requester <> u.uid;
-$$ LANGUAGE sql;
+ -- pm
+ SELECT 'pm'::notification_ntype, tb.iid
+ FROM threads_boards tb
+ WHERE vndbid_type($1) = 't' AND tb.tid = $1 AND tb.type = 'u'
+ AND NOT EXISTS(SELECT 1 FROM notification_subs ns WHERE ns.iid = $1 AND ns.uid = tb.iid AND ns.subnum = false)
+
+ -- dbdel
+ UNION
+ SELECT 'dbdel', c_all.requester
+ FROM changes c_cur, changes c_all, changes c_pre
+ WHERE c_cur.type = vndbid_type($1)::dbentry_type AND c_cur.itemid = vndbid_num($1) AND c_cur.rev = $2 -- Current edit
+ AND c_pre.type = vndbid_type($1)::dbentry_type AND c_pre.itemid = vndbid_num($1) AND c_pre.rev = $2-1 -- Previous edit, to check if .ihid changed
+ AND c_all.type = vndbid_type($1)::dbentry_type AND c_all.itemid = vndbid_num($1) -- All edits on this entry, to see whom to notify
+ AND c_cur.ihid AND NOT c_pre.ihid
+ AND $2 > 1 AND vndbid_type($1) IN('v', 'r', 'p', 'c', 's', 'd')
+
+ -- listdel
+ UNION
+ SELECT 'listdel', u.uid
+ FROM changes c_cur, changes c_pre,
+ ( SELECT uid FROM ulist_vns WHERE vndbid_type($1) = 'v' AND vid = vndbid_num($1) -- TODO: Could use an index on ulist_vns.vid
+ UNION ALL
+ SELECT uid FROM rlists WHERE vndbid_type($1) = 'r' AND rid = vndbid_num($1) -- TODO: Could also use an index, but the rlists table isn't that large so it's still okay
+ ) u(uid)
+ WHERE c_cur.type = vndbid_type($1)::dbentry_type AND c_cur.itemid = vndbid_num($1) AND c_cur.rev = $2 -- Current edit
+ AND c_pre.type = vndbid_type($1)::dbentry_type AND c_pre.itemid = vndbid_num($1) AND c_pre.rev = $2-1 -- Previous edit, to check if .ihid changed
+ AND c_cur.ihid AND NOT c_pre.ihid
+ AND $2 > 1 AND vndbid_type($1) IN('v','r')
+
+ -- dbedit
+ UNION
+ SELECT 'dbedit', c.requester
+ FROM changes c
+ JOIN users u ON u.id = c.requester
+ WHERE c.type = vndbid_type($1)::dbentry_type AND c.itemid = vndbid_num($1)
+ AND $2 > 1 AND vndbid_type($1) IN('v', 'r', 'p', 'c', 's', 'd')
+ AND $3 <> 1 -- Exclude edits by Multi
+ AND u.notify_dbedit
+ AND NOT EXISTS(SELECT 1 FROM notification_subs ns WHERE ns.iid = $1 AND ns.uid = c.requester AND ns.subnum = false)
+
+ -- subedit
+ UNION
+ SELECT 'subedit', ns.uid
+ FROM notification_subs ns
+ WHERE $2 > 1 AND vndbid_type($1) IN('v', 'r', 'p', 'c', 's', 'd')
+ AND $3 <> 1 -- Exclude edits by Multi
+ AND ns.iid = $1 AND ns.subnum
+
+ -- announce
+ UNION
+ SELECT 'announce', u.id
+ FROM threads t
+ JOIN threads_boards tb ON tb.tid = t.id
+ JOIN users u ON u.notify_announce
+ WHERE vndbid_type($1) = 't' AND $2 = 1 AND t.id = $1 AND tb.type = 'an'
+
+ -- post (threads_posts)
+ UNION
+ SELECT 'post', u.id
+ FROM threads t, threads_posts tp
+ JOIN users u ON tp.uid = u.id
+ WHERE t.id = $1 AND tp.tid = $1 AND vndbid_type($1) = 't' AND $2 > 1 AND NOT t.private AND NOT t.hidden AND u.notify_post
+ AND NOT EXISTS(SELECT 1 FROM notification_subs ns WHERE ns.iid = $1 AND ns.uid = tp.uid AND ns.subnum = false)
+
+ -- post (reviews_posts)
+ UNION
+ SELECT 'post', u.id
+ FROM reviews_posts wp
+ JOIN users u ON wp.uid = u.id
+ WHERE wp.id = $1 AND vndbid_type($1) = 'w' AND $2 IS NOT NULL AND u.notify_post
+ AND NOT EXISTS(SELECT 1 FROM notification_subs ns WHERE ns.iid = $1 AND ns.uid = wp.uid AND ns.subnum = false)
+
+ -- subpost (threads_posts)
+ UNION
+ SELECT 'subpost', ns.uid
+ FROM threads t, notification_subs ns
+ WHERE t.id = $1 AND ns.iid = $1 AND vndbid_type($1) = 't' AND $2 > 1 AND NOT t.private AND NOT t.hidden AND ns.subnum
+
+ -- subpost (reviews_posts)
+ UNION
+ SELECT 'subpost', ns.uid
+ FROM notification_subs ns
+ WHERE ns.iid = $1 AND vndbid_type($1) = 'w' AND $2 IS NOT NULL AND ns.subnum
+
+ -- comment
+ UNION
+ SELECT 'comment', u.id
+ FROM reviews w
+ JOIN users u ON w.uid = u.id
+ WHERE w.id = $1 AND vndbid_type($1) = 'w' AND $2 IS NOT NULL AND u.notify_comment
+ AND NOT EXISTS(SELECT 1 FROM notification_subs ns WHERE ns.iid = $1 AND ns.uid = w.uid AND NOT ns.subnum)
+
+ -- subreview
+ UNION
+ SELECT 'subreview', ns.uid
+ FROM reviews w, notification_subs ns
+ WHERE w.id = $1 AND vndbid_type($1) = 'w' AND $2 IS NULL AND ns.iid = vndbid('v', w.vid) AND ns.subreview
+
+ -- subapply
+ UNION
+ SELECT 'subapply', uid
+ FROM notification_subs
+ WHERE subapply AND vndbid_type($1) = 'c' AND $2 IS NOT NULL
+ AND iid IN(
+ WITH new(tid) AS (SELECT vndbid('i', tid) FROM chars_traits_hist WHERE chid = (SELECT id FROM changes WHERE type = 'c' AND itemid = vndbid_num($1) AND rev = $2)),
+ old(tid) AS (SELECT vndbid('i', tid) FROM chars_traits_hist WHERE chid = (SELECT id FROM changes WHERE type = 'c' AND itemid = vndbid_num($1) AND $2 > 1 AND rev = $2-1))
+ (SELECT tid FROM old EXCEPT SELECT tid FROM new) UNION (SELECT tid FROM new EXCEPT SELECT tid FROM old)
+ )
+
+ ) AS noti(ntype, uid)
+ WHERE uid <> $3
+ AND uid <> 1 -- No announcements for Multi
+ GROUP BY uid;
+$$ LANGUAGE SQL;
diff --git a/sql/perms.sql b/sql/perms.sql
index 6ce6393d..13b8fbd6 100644
--- a/sql/perms.sql
+++ b/sql/perms.sql
@@ -19,6 +19,7 @@ GRANT SELECT, INSERT ON docs_hist TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON images TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON image_votes TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON login_throttle TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON notification_subs TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON notifications TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON producers TO vndb_site;
GRANT SELECT, INSERT ON producers_hist TO vndb_site;
diff --git a/sql/schema.sql b/sql/schema.sql
index 2ee7d24d..046bb833 100644
--- a/sql/schema.sql
+++ b/sql/schema.sql
@@ -57,7 +57,7 @@ CREATE TYPE edit_rettype AS (itemid integer, chid integer, rev integer);
CREATE TYPE gender AS ENUM ('unknown', 'm', 'f', 'b');
CREATE TYPE language AS ENUM ('ar', 'bg', 'ca', 'cs', 'da', 'de', 'el', 'en', 'eo', 'es', 'fi', 'fr', 'gd', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'mk', 'ms', 'lt', 'lv', 'nl', 'no', 'pl', 'pt-pt', 'pt-br', 'ro', 'ru', 'sk', 'sl', 'sv', 'ta', 'th', 'tr', 'uk', 'vi', 'zh');
CREATE TYPE medium AS ENUM ('cd', 'dvd', 'gdr', 'blr', 'flp', 'mrt', 'mem', 'umd', 'nod', 'in', 'otc');
-CREATE TYPE notification_ntype AS ENUM ('pm', 'dbdel', 'listdel', 'dbedit', 'announce', 'post', 'comment');
+CREATE TYPE notification_ntype AS ENUM ('pm', 'dbdel', 'listdel', 'dbedit', 'announce', 'post', 'comment', 'subpost', 'subedit', 'subreview', 'subapply');
CREATE TYPE platform AS ENUM ('win', 'dos', 'lin', 'mac', 'ios', 'and', 'dvd', 'bdp', 'fmt', 'gba', 'gbc', 'msx', 'nds', 'nes', 'p88', 'p98', 'pce', 'pcf', 'psp', 'ps1', 'ps2', 'ps3', 'ps4', 'psv', 'drc', 'sat', 'sfc', 'swi', 'wii', 'wiu', 'n3d', 'x68', 'xb1', 'xb3', 'xbo', 'web', 'oth');
CREATE TYPE producer_type AS ENUM ('co', 'in', 'ng');
CREATE TYPE producer_relation AS ENUM ('old', 'new', 'sub', 'par', 'imp', 'ipa', 'spa', 'ori');
@@ -245,17 +245,30 @@ CREATE TABLE login_throttle (
timeout timestamptz NOT NULL
);
+-- notification_subs
+CREATE TABLE notification_subs (
+ uid integer NOT NULL,
+ iid vndbid NOT NULL,
+ -- Indicates a subscription on the creation of a new 'num' for the item, i.e. new post, new comment, new edit.
+ -- Affects the following ntypes: dbedit, subedit, pm, post, comment, subpost. Does not affect: dbdel, listdel.
+ -- NULL = Default behavior as if this entry did not have a row; i.e. use users.notify_post / users.notify_comment / users.notify_dbedit settings.
+ -- true = Default behavior + get subedit/subpost notifications for this entry.
+ -- false = Disable all affected ntypes for this entry.
+ subnum boolean,
+ subreview boolean NOT NULL DEFAULT false, -- VNs
+ subapply boolean NOT NULL DEFAULT false, -- Traits
+ PRIMARY KEY(iid,uid)
+);
+
-- notifications
CREATE TABLE notifications (
id serial PRIMARY KEY,
uid integer NOT NULL,
date timestamptz NOT NULL DEFAULT NOW(),
read timestamptz,
- ntype notification_ntype NOT NULL,
+ ntype notification_ntype[] NOT NULL,
iid vndbid NOT NULL,
- num integer,
- c_title text NOT NULL,
- c_byuser integer
+ num integer
);
-- producers
@@ -511,7 +524,8 @@ CREATE TABLE reviews (
c_count smallint NOT NULL DEFAULT 0,
c_lastnum smallint,
isfull boolean NOT NULL,
- c_flagged boolean NOT NULL DEFAULT false
+ c_flagged boolean NOT NULL DEFAULT false,
+ locked boolean NOT NULL DEFAULT false
);
-- reviews_posts
@@ -532,7 +546,8 @@ CREATE TABLE reviews_votes (
uid int,
date timestamptz NOT NULL,
vote boolean NOT NULL, -- true = upvote, false = downvote
- overrule boolean NOT NULL DEFAULT false
+ overrule boolean NOT NULL DEFAULT false,
+ ip inet -- Only for anonymous votes
);
-- rlists
diff --git a/sql/tableattrs.sql b/sql/tableattrs.sql
index f00db33d..829640d8 100644
--- a/sql/tableattrs.sql
+++ b/sql/tableattrs.sql
@@ -18,8 +18,8 @@ ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_vid_fkey
ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
ALTER TABLE image_votes ADD CONSTRAINT image_votes_id_fkey FOREIGN KEY (id) REFERENCES images (id) ON DELETE CASCADE;
ALTER TABLE image_votes ADD CONSTRAINT image_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE notification_subs ADD CONSTRAINT notification_subs_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE notifications ADD CONSTRAINT notifications_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-ALTER TABLE notifications ADD CONSTRAINT notifications_c_byuser_fkey FOREIGN KEY (c_byuser) REFERENCES users (id) ON DELETE SET DEFAULT;
ALTER TABLE producers ADD CONSTRAINT producers_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
ALTER TABLE producers_hist ADD CONSTRAINT producers_chid_id_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE producers_hist ADD CONSTRAINT producers_hist_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
@@ -119,7 +119,7 @@ CREATE INDEX chars_vns_vid ON chars_vns (vid);
CREATE INDEX chars_image ON chars (image);
CREATE UNIQUE INDEX image_votes_pkey ON image_votes (uid, id);
CREATE INDEX image_votes_id ON image_votes (id);
-CREATE INDEX notifications_uid ON notifications (uid);
+CREATE INDEX notifications_uid_iid ON notifications (uid,iid);
CREATE INDEX releases_producers_pid ON releases_producers (pid);
CREATE INDEX releases_vn_vid ON releases_vn (vid);
CREATE INDEX reports_new ON reports (date) WHERE status = 'new';
@@ -128,6 +128,7 @@ CREATE UNIQUE INDEX reviews_vid_uid ON reviews (vid,uid);
CREATE INDEX reviews_uid ON reviews (uid);
CREATE INDEX reviews_posts_uid ON reviews_posts (uid);
CREATE UNIQUE INDEX reviews_votes_id_uid ON reviews_votes (id,uid);
+CREATE UNIQUE INDEX reviews_votes_id_ip ON reviews_votes (id,ip);
CREATE INDEX staff_alias_id ON staff_alias (id);
CREATE UNIQUE INDEX tags_vn_pkey ON tags_vn (tag,vid,uid);
CREATE INDEX tags_vn_date ON tags_vn (date);
diff --git a/sql/triggers.sql b/sql/triggers.sql
index 2fd34f1c..a1acfd90 100644
--- a/sql/triggers.sql
+++ b/sql/triggers.sql
@@ -228,109 +228,44 @@ CREATE TRIGGER vn_vnsearch_notify AFTER UPDATE ON vn FOR EACH ROW WHEN (OLD.c_se
--- Add a notification when someone posts in someone's board.
+-- Create notifications for new posts.
-CREATE OR REPLACE FUNCTION notify_pm() RETURNS trigger AS $$
-BEGIN
- INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
- SELECT 'pm', tb.iid, t.id, NEW.num, t.title, NEW.uid
- FROM threads t
- JOIN threads_boards tb ON tb.tid = t.id
- WHERE t.id = NEW.tid
- AND tb.type = 'u'
- AND tb.iid <> NEW.uid -- don't notify when posting in your own board
- AND NOT EXISTS( -- don't notify when you haven't read an earlier post in the thread yet
- SELECT 1
- FROM notifications n
- WHERE n.uid = tb.iid
- AND n.iid = t.id
- AND n.read IS NULL
- );
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-
-CREATE TRIGGER notify_pm AFTER INSERT ON threads_posts FOR EACH ROW EXECUTE PROCEDURE notify_pm();
-
-
-
-
--- Add a notification when a thread is created in /t/an
-
-CREATE OR REPLACE FUNCTION notify_announce() RETURNS trigger AS $$
+CREATE OR REPLACE FUNCTION notify_post() RETURNS trigger AS $$
BEGIN
- INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
- SELECT 'announce', u.id, t.id, 1, t.title, NEW.uid
- FROM threads t
- JOIN threads_boards tb ON tb.tid = t.id
- -- get the users who want this announcement
- JOIN users u ON u.notify_announce
- WHERE t.id = NEW.tid
- AND tb.type = 'an' -- announcement board
- AND NOT t.hidden;
+ INSERT INTO notifications (uid, ntype, iid, num) SELECT uid, ntype, iid, num FROM notify(NEW.tid, NEW.num, NEW.uid) n;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-CREATE TRIGGER notify_announce AFTER INSERT ON threads_posts FOR EACH ROW WHEN (NEW.num = 1) EXECUTE PROCEDURE notify_announce();
+CREATE TRIGGER notify_post AFTER INSERT ON threads_posts FOR EACH ROW EXECUTE PROCEDURE notify_post();
--- Add a notification on new posts
+-- Create notifications for new review comments.
-CREATE OR REPLACE FUNCTION notify_post() RETURNS trigger AS $$
+CREATE OR REPLACE FUNCTION notify_comment() RETURNS trigger AS $$
BEGIN
- INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
- SELECT DISTINCT 'post'::notification_ntype, u.id, t.id, NEW.num, t.title, NEW.uid
- FROM threads t
- JOIN threads_posts tp ON tp.tid = t.id
- JOIN users u ON tp.uid = u.id
- WHERE t.id = NEW.tid
- AND u.notify_post
- AND u.id <> NEW.uid
- AND NOT t.hidden
- AND NOT t.private -- don't leak posts in private threads, these are handled by notify_pm anyway
- AND NOT EXISTS( -- don't notify when you haven't read an earlier post in the thread yet (also avoids double notification with notify_pm)
- SELECT 1
- FROM notifications n
- WHERE n.uid = u.id
- AND n.iid = t.id
- AND n.read IS NULL
- );
+ INSERT INTO notifications (uid, ntype, iid, num) SELECT uid, ntype, iid, num FROM notify(NEW.id, NEW.num, NEW.uid) n;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-CREATE TRIGGER notify_post AFTER INSERT ON threads_posts FOR EACH ROW EXECUTE PROCEDURE notify_post();
+CREATE TRIGGER notify_comment AFTER INSERT ON reviews_posts FOR EACH ROW EXECUTE PROCEDURE notify_comment();
--- Add a notification on new comment to review
+-- Create notifications for new reviews.
-CREATE OR REPLACE FUNCTION notify_comment() RETURNS trigger AS $$
+CREATE OR REPLACE FUNCTION notify_review() RETURNS trigger AS $$
BEGIN
- INSERT INTO notifications (ntype, uid, iid, num, c_title, c_byuser)
- SELECT 'comment', u.id, w.id, NEW.num, v.title, NEW.uid
- FROM reviews w
- JOIN vn v ON v.id = w.vid
- JOIN users u ON w.uid = u.id
- WHERE w.id = NEW.id
- AND u.notify_comment
- AND u.id <> NEW.uid
- AND NOT EXISTS( -- don't notify when you haven't read earlier comments yet
- SELECT 1
- FROM notifications n
- WHERE n.uid = u.id
- AND n.iid = w.id
- AND n.read IS NULL
- );
+ INSERT INTO notifications (uid, ntype, iid, num) SELECT uid, ntype, iid, num FROM notify(NEW.id, NULL, NEW.uid) n;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-CREATE TRIGGER notify_comment AFTER INSERT ON reviews_posts FOR EACH ROW EXECUTE PROCEDURE notify_comment();
+CREATE TRIGGER notify_review AFTER INSERT ON reviews FOR EACH ROW EXECUTE PROCEDURE notify_review();
diff --git a/util/updates/2020-09-20-reviews-locked.sql b/util/updates/2020-09-20-reviews-locked.sql
new file mode 100644
index 00000000..ee66cf71
--- /dev/null
+++ b/util/updates/2020-09-20-reviews-locked.sql
@@ -0,0 +1 @@
+ALTER TABLE reviews ADD COLUMN locked boolean NOT NULL DEFAULT false;
diff --git a/util/updates/2020-10-08-extra-notifications.sql b/util/updates/2020-10-08-extra-notifications.sql
new file mode 100644
index 00000000..ef0f574b
--- /dev/null
+++ b/util/updates/2020-10-08-extra-notifications.sql
@@ -0,0 +1,45 @@
+-- Simplified triggers, all the logic is consolidated in notify().
+DROP TRIGGER notify_pm ON threads_posts;
+DROP TRIGGER notify_announce ON threads_posts;
+DROP FUNCTION notify_pm();
+DROP FUNCTION notify_announce();
+
+DROP FUNCTION notify_dbdel(dbentry_type, edit_rettype);
+DROP FUNCTION notify_dbedit(dbentry_type, edit_rettype);
+DROP FUNCTION notify_listdel(dbentry_type, edit_rettype);
+
+-- Table changes
+ALTER TABLE notifications ALTER COLUMN ntype TYPE notification_ntype[] USING ARRAY[ntype];
+ALTER TABLE notifications DROP COLUMN c_title;
+ALTER TABLE notifications DROP COLUMN c_byuser;
+
+DROP INDEX notifications_uid;
+CREATE INDEX notifications_uid_iid ON notifications (uid,iid);
+
+-- Merge duplicate notifications (dbdel & listdel could cause duplicates)
+UPDATE notifications n SET ntype = ntype || ARRAY['dbdel'::notification_ntype]
+ WHERE ntype = ARRAY['listdel'::notification_ntype]
+ AND EXISTS(SELECT 1 FROM notifications m WHERE m.id <> n.id AND m.uid = n.uid AND m.iid = n.iid AND m.num IS NOT DISTINCT FROM n.num AND m.ntype = ARRAY['dbdel'::notification_ntype]);
+DELETE FROM notifications n
+ WHERE ntype = ARRAY['dbdel'::notification_ntype]
+ AND EXISTS(SELECT 1 FROM notifications m WHERE m.id <> n.id AND m.uid = n.uid AND m.iid = n.iid AND m.num IS NOT DISTINCT FROM n.num AND m.ntype = ARRAY['listdel'::notification_ntype,'dbdel']);
+-- For some reason a few notifications from 2014 were duplicated, let's just get rid of those.
+DELETE FROM notifications n WHERE EXISTS(SELECT 1 FROM notifications m WHERE m.id <> n.id AND m.uid = n.uid AND m.iid = n.iid AND m.num IS NOT DISTINCT FROM n.num AND m.id > n.id);
+
+-- Subscriptions
+ALTER TYPE notification_ntype ADD VALUE 'subpost' AFTER 'comment';
+ALTER TYPE notification_ntype ADD VALUE 'subedit' AFTER 'subpost';
+ALTER TYPE notification_ntype ADD VALUE 'subreview' AFTER 'subedit';
+
+CREATE TABLE notification_subs (
+ uid integer NOT NULL,
+ iid vndbid NOT NULL,
+ subnum boolean,
+ subreview boolean NOT NULL DEFAULT false,
+ PRIMARY KEY(iid,uid)
+);
+ALTER TABLE notification_subs ADD CONSTRAINT notification_subs_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+
+\i sql/func.sql
+\i sql/triggers.sql
+\i sql/perms.sql
diff --git a/util/updates/2020-10-13-notifications-subapply.sql b/util/updates/2020-10-13-notifications-subapply.sql
new file mode 100644
index 00000000..e20ad2a6
--- /dev/null
+++ b/util/updates/2020-10-13-notifications-subapply.sql
@@ -0,0 +1,3 @@
+ALTER TYPE notification_ntype ADD VALUE 'subapply' AFTER 'subreview';
+ALTER TABLE notification_subs ADD COLUMN subapply boolean NOT NULL DEFAULT false;
+\i sql/func.sql
diff --git a/util/updates/2020-10-15-reviews-anonymous-votes.sql b/util/updates/2020-10-15-reviews-anonymous-votes.sql
new file mode 100644
index 00000000..721543f6
--- /dev/null
+++ b/util/updates/2020-10-15-reviews-anonymous-votes.sql
@@ -0,0 +1,4 @@
+ALTER TABLE reviews_votes ADD COLUMN ip inet;
+CREATE UNIQUE INDEX reviews_votes_id_ip ON reviews_votes (id,ip);
+\i sql/func.sql
+SELECT update_reviews_votes_cache(id) FROM reviews;