summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/icons/lang/fa.pngbin0 -> 312 bytes
-rw-r--r--data/style.css101
-rw-r--r--elm/AdvSearch/Fields.elm499
-rw-r--r--elm/AdvSearch/Main.elm117
-rw-r--r--elm/AdvSearch/Producers.elm95
-rw-r--r--elm/AdvSearch/Query.elm159
-rw-r--r--elm/AdvSearch/RDate.elm98
-rw-r--r--elm/AdvSearch/Range.elm187
-rw-r--r--elm/AdvSearch/Set.elm257
-rw-r--r--elm/AdvSearch/Tags.elm109
-rw-r--r--elm/CharEdit.elm45
-rw-r--r--elm/Discussions/Edit.elm2
-rw-r--r--elm/Discussions/Poll.elm2
-rw-r--r--elm/Discussions/PostEdit.elm2
-rw-r--r--elm/Discussions/Reply.elm2
-rw-r--r--elm/DocEdit.elm2
-rw-r--r--elm/ImageFlagging.elm8
-rw-r--r--elm/Lib/Api.elm1
-rw-r--r--elm/Lib/Autocomplete.elm4
-rw-r--r--elm/Lib/DropDown.elm4
-rw-r--r--elm/Lib/Html.elm17
-rw-r--r--elm/Lib/Image.elm7
-rw-r--r--elm/Lib/RDate.elm22
-rw-r--r--elm/ProducerEdit.elm6
-rw-r--r--elm/ReleaseEdit.elm10
-rw-r--r--elm/Report.elm11
-rw-r--r--elm/Reviews/Comment.elm2
-rw-r--r--elm/Reviews/Edit.elm2
-rw-r--r--elm/StaffEdit.elm4
-rw-r--r--elm/Subscribe.elm99
-rw-r--r--elm/TagEdit.elm237
-rw-r--r--elm/Tagmod.elm3
-rw-r--r--elm/TraitEdit.elm209
-rw-r--r--elm/UList/SaveDefault.elm2
-rw-r--r--elm/User/Edit.elm2
-rw-r--r--elm/User/Login.elm2
-rw-r--r--elm/User/PassReset.elm2
-rw-r--r--elm/User/PassSet.elm2
-rw-r--r--elm/User/Register.elm2
-rw-r--r--elm/VNEdit.elm73
-rw-r--r--elm/searchtabs.js11
-rw-r--r--lib/Multi/API.pm8
-rw-r--r--lib/Multi/Denpa.pm39
-rw-r--r--lib/VNDB/Config.pm2
-rw-r--r--lib/VNDB/DB/Tags.pm55
-rw-r--r--lib/VNDB/DB/Traits.pm29
-rw-r--r--lib/VNDB/ExtLinks.pm4
-rw-r--r--lib/VNDB/Func.pm3
-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/Types.pm1
-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/AdvSearch.pm486
-rw-r--r--lib/VNWeb/Auth.pm11
-rw-r--r--lib/VNWeb/Chars/Edit.pm2
-rw-r--r--lib/VNWeb/Chars/Page.pm2
-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.pm66
-rw-r--r--lib/VNWeb/Images/Vote.pm30
-rw-r--r--lib/VNWeb/Misc/Redirects.pm42
-rw-r--r--lib/VNWeb/Producers/List.pm62
-rw-r--r--lib/VNWeb/Releases/Edit.pm2
-rw-r--r--lib/VNWeb/Releases/Lib.pm21
-rw-r--r--lib/VNWeb/Releases/Page.pm2
-rw-r--r--lib/VNWeb/Reviews/Edit.pm1
-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.pm16
-rw-r--r--lib/VNWeb/Reviews/VNTab.pm10
-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/ULists/Elm.pm9
-rw-r--r--lib/VNWeb/User/Notifications.pm88
-rw-r--r--lib/VNWeb/VN/List.pm108
-rw-r--r--lib/VNWeb/VN/Page.pm182
-rw-r--r--lib/VNWeb/VN/Tagmod.pm1
-rw-r--r--lib/VNWeb/Validation.pm28
-rw-r--r--sql/func.sql290
-rw-r--r--sql/perms.sql5
-rw-r--r--sql/schema.sql39
-rw-r--r--sql/tableattrs.sql5
-rw-r--r--sql/triggers.sql91
-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
-rw-r--r--util/updates/2020-11-09-images-uids-cache.sql5
-rw-r--r--util/updates/2020-11-10-persian-language.sql1
-rw-r--r--util/updates/2020-11-19-releases-official.sql20
107 files changed, 4297 insertions, 1718 deletions
diff --git a/data/icons/lang/fa.png b/data/icons/lang/fa.png
new file mode 100644
index 00000000..32aa1c44
--- /dev/null
+++ b/data/icons/lang/fa.png
Binary files differ
diff --git a/data/style.css b/data/style.css
index 5e1301c4..8f1ce3bd 100644
--- a/data/style.css
+++ b/data/style.css
@@ -71,27 +71,30 @@ div.warning h2, div.notice h2 { font-size: 13px; font-weight: bold; margin: 0; }
.elm_dd > a { color: $maintext$; display: block; border: none; padding-right: 15px; position: relative }
.elm_dd > a > span:last-child { position: absolute; right: 5px; top: 0; width: 16px; text-align: right; display: block }
.elm_dd > a > span:last-child i { visibility: hidden; font-style: normal }
+.elm_dd > a .nowrap { display: block; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.elm_dd > a:hover > span:last-child > i,
.elm_dd > a:focus > span:last-child > i { visibility: visible }
.elm_dd > div { position: relative; float: right; width: 0; height: 0 }
-.elm_dd > div > ul { position: absolute; right: -10px; top: 0; border: 1px solid $border$; background-color: $secbg$; z-index: 1000; list-style-type: none; margin: 0; padding: 0; max-width: 400px; overflow: hidden }
+.elm_dd > div > div { position: absolute; right: -10px; top: 0; border: 1px solid $border$; background-color: $secbg$; z-index: 1000; margin: 0; padding: 0; max-width: 400px }
.elm_dd.search > div { float: left }
-.elm_dd.search > div > ul { right: auto; left: 0; top: 23px }
-.elm_dd > div > ul li { white-space: nowrap }
-.elm_dd > div > ul li a { display: block; border: 0; padding: 3px 5px 3px 3px }
-.elm_dd > div > ul li a.active,
-.elm_dd > div > ul li a:hover { background: $boxbg$ }
-.elm_dd > div > ul li p { white-space: normal; padding: 3px 5px 3px 3px }
-.elm_dd > div > ul li.separator { margin-bottom: 22px }
+.elm_dd.search > div > div { right: auto; left: 0; top: 23px }
+.elm_dd ul { width: 100%; list-style-type: none; margin: 0; padding: 0 }
+.elm_dd ul li { white-space: nowrap }
+.elm_dd ul li a { display: block; border: 0; padding: 3px 5px 3px 3px }
+.elm_dd ul li a.active,
+.elm_dd ul li a:hover { background: $boxbg$ }
+.elm_dd ul li p { white-space: normal; padding: 3px 5px 3px 3px }
+.elm_dd ul li.separator { margin-bottom: 22px }
+
.maintabs .elm_dd > a { box-sizing: border-box; height: 21px; padding: 1px 15px 0 7px; border: 1px solid $border$; border-bottom: none; background-color: $tabbg$; color: $maintext$ }
-.elm_votedd .elm_dd > div > ul li { text-align: left }
+.elm_votedd .elm_dd ul li { text-align: left }
.elm_dd_input .elm_dd > a { background-color: $secbg$; color: $maintext$; border: 1px solid $secborder$; font: 14px "Tahoma", "Arial", sans-serif; padding: 1px 15px 1px 2px; margin: -1px }
.elm_dd_noarrow .elm_dd > a { padding-right: 0 }
.elm_dd_noarrow .elm_dd > a > span:last-child { display: none }
.elm_dd_hover .elm_dd > div { display: none }
.elm_dd_hover .elm_dd:hover > div { display: block }
.elm_dd_left .elm_dd > div { float: left }
-.elm_dd_left .elm_dd > div > ul { right: 0; top: -20px }
+.elm_dd_left .elm_dd > div > div { right: 0; top: -20px }
.elm_dd_relextlink .elm_dd > a { padding-left: 4px; color: $link$ }
.elm_dd_relextlink ul a { text-align: right }
.elm_dd_relextlink ul span { color: $maintext$; padding-right: 10px }
@@ -156,7 +159,7 @@ input.text, input.submit, select, textarea {
form, fieldset { border: 0; display: block }
legend { display: none; }
optgroup option { padding-left: 10px; font-style: normal; }
-input.submit { background: $boxbg$; padding: 1px 5px; }
+input.submit { background: $boxbg$; padding: 1px 5px; cursor: pointer }
input.text, select { width: 200px; }
input[type=number] { -moz-appearance:textfield }
input[type=number]::-webkit-outer-spin-button, input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0 }
@@ -306,11 +309,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,20 +520,34 @@ 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 }
+
+/***** VN tags tab (/v+/tags) *******/
+
+.vntaglist { list-style-type: none; column-width: 400px }
+.vntaglist li.tagvnlist-top:not(:first-child) { margin-top: 30px }
+.vntaglist li.tagvnlist-parent { margin: 5px 0 3px 0 }
+.vntaglist li.tagvnlist-parent a { color: $maintext$; font-weight: bold }
+.vntaglist li.tagvnlist-inherited a { color: $grayedout$ }
+.vntaglist li:not(.tagvnlist-inherited) b.grayedout { color: $link$ }
+.vntaglist h3 a { color: $maintext$ }
+.vntaglist li { list-style-type: none; padding-right: 30px }
+.vntaglist li .tagscore { margin-right: 10px }
+
+
+
/***** Vote stats ****/
.votestats { width: 630px; margin: 0 auto; }
@@ -599,8 +619,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 +650,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 +878,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 +929,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 *****/
@@ -1095,6 +1126,28 @@ p.filselect i { font-style: normal }
+
+/****** Advanced Search *******/
+
+.advsearch { max-width: 800px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center }
+.advsearch .advnest { display: flex }
+.advsearch .advnest > div:nth-child(1) { flex: 0 0 45px; }
+.advsearch .advnest > div:nth-child(2) { flex: 1; border-left: 1px dashed $border$ }
+.advsearch .elm_dd_input { margin: 5px; width: 150px }
+.advsearch .elm_dd_input.elm_dd_noarrow { width: 13px }
+.advsearch .advrow { display: flex; flex-wrap: wrap }
+.advsearch .advbut { width: 100%; background-color: $_blendbg$; text-align: right; white-space: nowrap }
+.advsearch .advbut > * { display: inline-block; box-sizing: border-box; height: 20px; padding: 3px 5px 0 2px; cursor: pointer; border-bottom: none; font-size: 16px }
+.advsearch .advbut > b { color: $grayedout$; font-style: normal }
+.advsearch .advheader { box-sizing: border-box; background-color: $_blendbg$; padding: 3px; width: 100%; margin-bottom: 2px }
+.advsearch .advheader > h3 { text-align: center; font-weight: bold; font-size: inherit; margin-bottom: 3px }
+.advsearch .advheader .opts { display: flex; justify-content: space-between; align-items: flex-end }
+.advsearch .advheader .opts > * { margin: 0 }
+.advsearch .advheader .opselect > * { display: inline-block; font-size: 18px; padding: 0 5px }
+.advsearch input.submit { margin-top: 5px }
+
+
+
/****** Image flagging *******/
/* divs:
diff --git a/elm/AdvSearch/Fields.elm b/elm/AdvSearch/Fields.elm
new file mode 100644
index 00000000..f82385b4
--- /dev/null
+++ b/elm/AdvSearch/Fields.elm
@@ -0,0 +1,499 @@
+module AdvSearch.Fields exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Array as A
+import Lib.Util exposing (..)
+import Lib.Html exposing (..)
+import Lib.DropDown as DD
+import Lib.Api as Api
+import AdvSearch.Set as AS
+import AdvSearch.Producers as AP
+import AdvSearch.Tags as AG
+import AdvSearch.RDate as AD
+import AdvSearch.Range as AR
+import AdvSearch.Query exposing (..)
+
+
+-- "Nested" fields are a container for other fields.
+-- The code for nested fields is tightly coupled with the generic 'Field' abstraction below.
+
+type NestType = NAnd | NOr | NRel | NRelNeg | NChar | NCharNeg
+
+type alias NestModel =
+ { ntype : NestType
+ , qtype : QType
+ , fields : List Field
+ , add : DD.Config NestMsg
+ , addtype : QType
+ }
+
+
+type NestMsg
+ = NAddToggle Bool
+ | NAdd Int
+ | NAddType QType
+ | NField Int FieldMsg
+ | NType NestType Bool
+
+
+nestInit : NestType -> QType -> List Field -> Data -> (Data, NestModel)
+nestInit ntype qtype list dat =
+ let
+ -- Make sure that subtype nesting always has an and/or field
+ addNest ndat mod =
+ let (ndat2,f) = fieldCreate -1 (Tuple.mapSecond FMNest (nestInit NAnd mod.qtype mod.fields ndat))
+ in (ndat2, { mod | fields = [f] })
+ ensureNest (ndat,mod) =
+ case (ntype, mod.fields) of
+ (NAnd, _) -> (ndat,mod)
+ (NOr, _) -> (ndat,mod)
+ (_, [(_,_,FMNest m)]) -> if m.ntype == NAnd || m.ntype == NOr then (ndat,mod) else addNest ndat mod
+ _ -> addNest ndat mod
+ in ensureNest
+ ( { dat | objid = dat.objid+1 }
+ , { ntype = ntype
+ , qtype = qtype
+ , fields = list
+ , add = DD.init ("advsearch_field"++String.fromInt dat.objid) NAddToggle
+ , addtype = qtype
+ }
+ )
+
+
+nestUpdate : Data -> NestMsg -> NestModel -> (Data, NestModel, Cmd NestMsg)
+nestUpdate dat msg model =
+ case msg of
+ NAddToggle b -> (dat, { model | add = DD.toggle model.add b, addtype = model.qtype }, Cmd.none)
+ NAdd n ->
+ let (ndat,f) = fieldInit n dat
+ (ndat2,f2) =
+ if model.qtype == model.addtype then (ndat, f) else
+ let nt = case model.addtype of
+ R -> NRel
+ C -> NChar
+ _ -> NAnd
+ (nd,subm) = nestInit nt model.addtype [f] ndat
+ in fieldCreate -1 (nd, FMNest subm)
+ in (ndat2, { model | add = DD.toggle model.add False, addtype = model.qtype, fields = model.fields ++ [f2] }, Cmd.none)
+ NAddType t -> (dat, { model | addtype = t }, Cmd.none)
+ NField n FDel -> (dat, { model | fields = delidx n model.fields }, Cmd.none)
+ NField n FMoveSub ->
+ let subfields = List.drop n model.fields |> List.take 1 |> List.map (\(fid,fdd,fm) -> (fid, DD.toggle fdd False, fm))
+ (ndat,subm) = nestInit (if model.ntype == NAnd then NOr else NAnd) model.qtype subfields dat
+ (ndat2,subf) = fieldCreate -1 (ndat, FMNest subm)
+ in (ndat2, { model | fields = modidx n (always subf) model.fields }, Cmd.none)
+ NField n m ->
+ case List.head (List.drop n model.fields) of
+ Nothing -> (dat, model, Cmd.none)
+ Just f ->
+ let (ndat, nf, nc) = fieldUpdate dat m f
+ in (ndat, { model | fields = modidx n (always nf) model.fields }, Cmd.map (NField n) nc)
+ NType n _ -> (dat, { model | ntype = n }, Cmd.none)
+
+
+nestToQuery : NestModel -> Maybe Query
+nestToQuery model =
+ case (model.ntype, List.filterMap fieldToQuery model.fields) of
+ (_, [] ) -> Nothing
+ (NRel, [x]) -> Just (QQuery 50 Eq x)
+ (NRelNeg, [x]) -> Just (QQuery 50 Ne x)
+ (NChar, [x]) -> Just (QQuery 51 Eq x)
+ (NCharNeg,[x]) -> Just (QQuery 51 Ne x)
+ (_, [x]) -> Just x
+ (NAnd, xs ) -> Just (QAnd xs)
+ (NOr, xs ) -> Just (QOr xs)
+ _ -> Nothing
+
+
+nestFromQuery : NestType -> QType -> Data -> Query -> Maybe (Data, NestModel)
+nestFromQuery ntype qtype dat q =
+ let init nt qt l =
+ let (ndat,fl) = List.foldr (\f (d,a) -> let (nd,fm) = fieldFromQuery qt d f in (nd,(fm::a))) (dat,[]) l
+ in nestInit nt qt fl ndat
+
+ initSub op nt ntNeg qt val =
+ case op of
+ Eq -> Just (init nt qt [val])
+ Ne -> Just (init ntNeg qt [val])
+ _ -> Nothing
+ in case (qtype, ntype, q) of
+ (V, NRel, QQuery 50 op r) -> initSub op NRel NRelNeg R r
+ (V, NChar, QQuery 51 op r) -> initSub op NChar NCharNeg C r
+ (_, NAnd, QAnd l) -> Just (init NAnd qtype l)
+ (_, NOr, QOr l) -> Just (init NOr qtype l)
+ _ -> Nothing
+
+
+nestFieldView : Data -> Field -> Html FieldMsg
+nestFieldView dat f =
+ let (fddv, fbody) = fieldView dat f
+ showDd = case f of
+ (_,_,FMNest m) -> (m.ntype /= NAnd && m.ntype /= NOr) || List.length m.fields > 1
+ _ -> False
+ in if showDd then div [ class "advnest" ] [ fddv, fbody ] else fbody
+
+
+nestView : Data -> NestModel -> (Html NestMsg, () -> List (Html NestMsg), Html NestMsg)
+nestView dat model =
+ let
+ isNest (_,(_,_,f)) =
+ case f of
+ FMNest _ -> True
+ _ -> False
+ list = List.indexedMap (\a b -> (a,b)) model.fields
+ nests = List.filter isNest list
+ plains = List.filter (not << isNest) list
+ subtype = model.ntype /= NAnd && model.ntype /= NOr
+
+ pViews = List.map (\(i,f) -> Html.map (NField i) (Tuple.first (fieldView { dat | level = if subtype then 0 else dat.level+1 } f))) plains
+ nViews = List.map (\(i,f) -> Html.map (NField i) (nestFieldView { dat | level = if subtype then 0 else dat.level+1 } f)) nests
+
+ add =
+ if model.ntype /= NAnd && model.ntype /= NOr then text "" else
+ div [ class "elm_dd_input elm_dd_noarrow" ]
+ [ DD.view model.add Api.Normal (text "+") <| \() ->
+ [ div [ class "advheader", style "width" "200px" ]
+ [ h3 [] [ text "Add filter" ]
+ , div [ class "opts" ] <|
+ let opts = case model.qtype of
+ V -> [ V, R, C ]
+ C -> []
+ R -> []
+ f t = case t of
+ V -> "VN"
+ R -> "Release"
+ C -> "Character"
+ in List.map (\t -> if t == model.addtype then b [] [ text (f t) ] else a [ href "#", onClickD (NAddType t) ] [ text (f t) ]) opts
+ ]
+ , ul [] <|
+ List.map (\(n,f) ->
+ if f.qtype /= model.addtype || f.title == "" then text ""
+ else li [] [ a [ href "#", onClickD (NAdd n)] [ text f.title ] ]
+ ) <| A.toIndexedList fields
+ ]
+ ]
+
+ lbl = text <|
+ case model.ntype of
+ NAnd -> "And"
+ NOr -> "Or"
+ NRel -> "Rel"
+ NRelNeg -> "¬Rel"
+ NChar -> "Char"
+ NCharNeg-> "¬Char"
+
+ cont () =
+ [ ul [] <|
+ if model.ntype == NAnd || model.ntype == NOr
+ then [ li [] [ linkRadio (model.ntype == NAnd) (NType NAnd) [ text "And: All filters must match" ] ]
+ , li [] [ linkRadio (model.ntype == NOr ) (NType NOr ) [ text "Or: At least one filter must match" ] ]
+ ]
+ else if model.ntype == NRel || model.ntype == NRelNeg
+ then [ li [] [ linkRadio (model.ntype == NRel) (NType NRel) [ text "Has a release that matches these filters" ] ]
+ , li [] [ linkRadio (model.ntype == NRelNeg) (NType NRelNeg) [ text "Does not have a release that matches these filters" ] ]
+ ]
+ else [ li [] [ linkRadio (model.ntype == NChar) (NType NChar) [ text "Has a character that matches these filters" ] ]
+ , li [] [ linkRadio (model.ntype == NCharNeg) (NType NCharNeg) [ text "Does not have a character that matches these filters" ] ]
+ ]
+ ]
+ body =
+ div []
+ <| div [ class "advrow" ] (pViews ++ if List.isEmpty nests then [add] else [])
+ :: nViews
+ ++ (if List.isEmpty nests then [] else [add])
+ in (lbl, cont, body)
+
+
+
+
+
+-- Generic field abstraction.
+-- (this is where typeclasses would have been *awesome*)
+--
+-- The following functions and definitions are only intended to provide field
+-- listings and function dispatchers, if the implementation of anything in here
+-- is longer than a single line, it should get its own definition near where
+-- the rest of that field is defined.
+
+type alias Field = (Int, DD.Config FieldMsg, FieldModel) -- The Int is the index into 'fields'
+
+type FieldModel
+ = FMCustom Query -- A read-only placeholder for Query values that failed to parse into a Field
+ | FMNest NestModel
+ | FMLang (AS.Model String)
+ | FMOLang (AS.Model String)
+ | FMPlatform (AS.Model String)
+ | FMLength (AS.Model Int)
+ | FMRole (AS.Model String)
+ | FMBlood (AS.Model String)
+ | FMSexChar (AS.Model String)
+ | FMSexSpoil (AS.Model String)
+ | FMHeight (AR.Model Int)
+ | FMWeight (AR.Model Int)
+ | FMBust (AR.Model Int)
+ | FMWaist (AR.Model Int)
+ | FMHips (AR.Model Int)
+ | FMCup (AR.Model String)
+ | FMAge (AR.Model Int)
+ | FMPopularity (AR.Model Int)
+ | FMRating (AR.Model Int)
+ | FMVotecount (AR.Model Int)
+ | FMDeveloper AP.Model
+ | FMRDate AD.Model
+ | FMTag AG.Model
+
+type FieldMsg
+ = FSCustom () -- Not actually used at the moment
+ | FSNest NestMsg
+ | FSLang (AS.Msg String)
+ | FSOLang (AS.Msg String)
+ | FSPlatform (AS.Msg String)
+ | FSLength (AS.Msg Int)
+ | FSRole (AS.Msg String)
+ | FSBlood (AS.Msg String)
+ | FSSexChar (AS.Msg String)
+ | FSSexSpoil (AS.Msg String)
+ | FSHeight AR.Msg
+ | FSWeight AR.Msg
+ | FSBust AR.Msg
+ | FSWaist AR.Msg
+ | FSHips AR.Msg
+ | FSCup AR.Msg
+ | FSAge AR.Msg
+ | FSPopularity AR.Msg
+ | FSRating AR.Msg
+ | FSVotecount AR.Msg
+ | FSDeveloper AP.Msg
+ | FSRDate AD.Msg
+ | FSTag AG.Msg
+ | FToggle Bool
+ | FDel -- intercepted in nestUpdate
+ | FMoveSub -- intercepted in nestUpdate
+ | FMovePar
+
+type alias FieldDesc =
+ { qtype : QType
+ , title : String -- How it's listed in the field selection menu.
+ , quick : Maybe Int -- Whether it should be included in the default set of fields ("quick mode") and in which order.
+ , init : Data -> (Data, FieldModel) -- How to initialize an empty field
+ , fromQuery : Data -> Query -> Maybe (Data, FieldModel) -- How to initialize the field from a query
+ }
+
+
+-- XXX: Should this be lazily initialized instead? May impact JS load time like this.
+fields : A.Array FieldDesc
+fields =
+ let f qtype title quick wrap init fromq =
+ { qtype = qtype
+ , title = title
+ , quick = quick
+ , init = \d -> (Tuple.mapSecond wrap (init d))
+ , fromQuery = \d q -> Maybe.map (Tuple.mapSecond wrap) (fromq d q)
+ }
+ in A.fromList
+ -- IMPORTANT: This list is processed in reverse order when reading a Query
+ -- into Fields, so "catch all" fields must be listed first. In particular,
+ -- FMNest with and/or should go before everything else.
+
+ -- T TITLE QUICK WRAP INIT FROM_QUERY
+ [ f V "" Nothing FMNest (nestInit NAnd V []) (nestFromQuery NAnd V)
+ , f V "" Nothing FMNest (nestInit NOr V []) (nestFromQuery NOr V)
+ , f R "" Nothing FMNest (nestInit NAnd R []) (nestFromQuery NAnd R)
+ , f R "" Nothing FMNest (nestInit NOr R []) (nestFromQuery NOr R)
+ , f C "" Nothing FMNest (nestInit NAnd C []) (nestFromQuery NAnd C)
+ , f C "" Nothing FMNest (nestInit NOr C []) (nestFromQuery NOr C)
+
+ , f V "Language" (Just 1) FMLang AS.init AS.langFromQuery
+ , f V "Original language" (Just 2) FMOLang AS.init AS.olangFromQuery
+ , f V "Platform" (Just 3) FMPlatform AS.init AS.platformFromQuery
+ , f V "Tags" (Just 4) FMTag AG.init (AG.fromQuery -1)
+ , f V "" Nothing FMTag AG.init (AG.fromQuery 0)
+ , f V "" Nothing FMTag AG.init (AG.fromQuery 1)
+ , f V "" Nothing FMTag AG.init (AG.fromQuery 2)
+ , f V "Length" Nothing FMLength AS.init AS.lengthFromQuery
+ , f V "Developer" Nothing FMDeveloper AP.init AP.devFromQuery
+ , f V "Release date" Nothing FMRDate AD.init AD.fromQuery
+ , f V "Popularity" Nothing FMPopularity AR.popularityInit AR.popularityFromQuery
+ , f V "Rating" Nothing FMRating AR.ratingInit AR.ratingFromQuery
+ , f V "Number of votes" Nothing FMVotecount AR.votecountInit AR.votecountFromQuery
+ , f V "" Nothing FMNest (nestInit NRel R []) (nestFromQuery NRel V)
+ , f V "" Nothing FMNest (nestInit NChar C []) (nestFromQuery NChar V)
+
+ , f R "Language" (Just 1) FMLang AS.init AS.langFromQuery
+ , f R "Platform" (Just 2) FMPlatform AS.init AS.platformFromQuery
+ , f R "Developer" Nothing FMDeveloper AP.init AP.devFromQuery
+ , f R "Release date" Nothing FMRDate AD.init AD.fromQuery
+
+ , f C "Role" (Just 1) FMRole AS.init AS.roleFromQuery
+ , f C "Age" Nothing FMAge AR.ageInit AR.ageFromQuery
+ , f C "Sex" (Just 2) FMSexChar AS.init (AS.sexFromQuery AS.SexChar)
+ , f C "Sex (spoiler)" Nothing FMSexSpoil AS.init (AS.sexFromQuery AS.SexSpoil)
+ , f C "Blood type" Nothing FMBlood AS.init AS.bloodFromQuery
+ , f C "Height" Nothing FMHeight AR.heightInit AR.heightFromQuery
+ , f C "Weight" Nothing FMWeight AR.weightInit AR.weightFromQuery
+ , f C "Bust" Nothing FMBust AR.bustInit AR.bustFromQuery
+ , f C "Waist" Nothing FMWaist AR.waistInit AR.waistFromQuery
+ , f C "Hips" Nothing FMHips AR.hipsInit AR.hipsFromQuery
+ , f C "Cup size" Nothing FMCup AR.cupInit AR.cupFromQuery
+ ]
+
+
+fieldUpdate : Data -> FieldMsg -> Field -> (Data, Field, Cmd FieldMsg)
+fieldUpdate dat msg_ (num, dd, model) =
+ let maps f m = (dat, (num, dd, f m), Cmd.none) -- Simple version: update function returns a Model
+ mapf fm fc (d,m,c) = (d, (num, dd, fm m), Cmd.map fc c) -- Full version: update function returns (Data, Model, Cmd)
+ mapc fm fc (d,m,c) = (d, (num, DD.toggle dd False, fm m), Cmd.map fc c) -- Full version that also closes the DD (Ugly hack...)
+ noop = (dat, (num, dd, model), Cmd.none)
+ in case (msg_, model) of
+ -- Move to parent node is tricky, needs to be intercepted at this point so that we can access the parent NestModel.
+ (FSNest (NField parentNum (FSNest (NField fieldNum FMovePar))), FMNest grandModel) ->
+ case List.head <| List.drop parentNum grandModel.fields of
+ Just (_,_,FMNest parentModel) ->
+ let fieldField = List.drop fieldNum parentModel.fields |> List.take 1
+ newFields = List.map (\(fid,fdd,fm) -> (fid, DD.toggle fdd False, fm)) fieldField
+ newParentModel = { parentModel | fields = delidx fieldNum parentModel.fields }
+ newGrandFields =
+ (if List.isEmpty newParentModel.fields
+ then delidx parentNum grandModel.fields
+ else modidx parentNum (\(pid,pdd,_) -> (pid,pdd,FMNest newParentModel)) grandModel.fields
+ ) ++ newFields
+ newGrandModel = { grandModel | fields = newGrandFields }
+ in (dat, (num,dd,FMNest newGrandModel), Cmd.none)
+ _ -> noop
+
+ (FSNest (NType a b), FMNest m) -> mapc FMNest FSNest (nestUpdate dat (NType a b) m)
+ (FSNest msg, FMNest m) -> mapf FMNest FSNest (nestUpdate dat msg m)
+ (FSLang msg, FMLang m) -> maps FMLang (AS.update msg m)
+ (FSOLang msg, FMOLang m) -> maps FMOLang (AS.update msg m)
+ (FSPlatform msg, FMPlatform m) -> maps FMPlatform (AS.update msg m)
+ (FSLength msg, FMLength m) -> maps FMLength (AS.update msg m)
+ (FSRole msg, FMRole m) -> maps FMRole (AS.update msg m)
+ (FSBlood msg, FMBlood m) -> maps FMBlood (AS.update msg m)
+ (FSSexChar msg, FMSexChar m) -> maps FMSexChar (AS.update msg m)
+ (FSSexSpoil msg, FMSexSpoil m) -> maps FMSexSpoil (AS.update msg m)
+ (FSHeight msg, FMHeight m) -> maps FMHeight (AR.update msg m)
+ (FSWeight msg, FMWeight m) -> maps FMWeight (AR.update msg m)
+ (FSBust msg, FMBust m) -> maps FMBust (AR.update msg m)
+ (FSWaist msg, FMWaist m) -> maps FMWaist (AR.update msg m)
+ (FSHips msg, FMHips m) -> maps FMHips (AR.update msg m)
+ (FSCup msg, FMCup m) -> maps FMCup (AR.update msg m)
+ (FSAge msg, FMAge m) -> maps FMAge (AR.update msg m)
+ (FSPopularity msg,FMPopularity m)->maps FMPopularity (AR.update msg m)
+ (FSRating msg, FMRating m) -> maps FMRating (AR.update msg m)
+ (FSVotecount msg,FMVotecount m)-> maps FMVotecount (AR.update msg m)
+ (FSDeveloper msg,FMDeveloper m)-> mapf FMDeveloper FSDeveloper (AP.update dat msg m)
+ (FSRDate msg, FMRDate m) -> maps FMRDate (AD.update msg m)
+ (FSTag msg, FMTag m) -> mapf FMTag FSTag (AG.update dat msg m)
+ (FToggle b, _) -> (dat, (num, DD.toggle dd b, model), Cmd.none)
+ _ -> noop
+
+
+fieldView : Data -> Field -> (Html FieldMsg, Html FieldMsg)
+fieldView dat (_, dd, model) =
+ let ddv lbl cont = div [ class "elm_dd_input" ]
+ [ DD.view dd Api.Normal lbl <| \() ->
+ div [ class "advbut" ]
+ [ if dat.level == 0
+ then b [ title "Can't delete the top-level filter" ] [ text "⊗" ]
+ else a [ href "#", onClickD FDel, title "Delete this filter" ] [ text "⊗" ]
+ , if dat.level <= 1
+ then b [ title "Can't move this filter to parent branch" ] [ text "↰" ]
+ else a [ href "#", onClickD FMovePar, title "Move this filter to parent branch" ] [ text "↰" ]
+ , if dat.level == 0
+ then b [ title "Can't move this filter into a subbranch" ] [ text "↳" ]
+ else a [ href "#", onClickD FMoveSub, title "Create new branch for this filter" ] [ text "↳" ]
+ ] :: cont ()
+ ]
+ vf f (lbl,cont,body) = (ddv (Html.map f lbl) (\() -> List.map (Html.map f) (cont ())), Html.map f body)
+ vs f (lbl,cont) = vf f (lbl,cont,text "")
+ in case model of
+ FMCustom m -> vs FSCustom (text "Unrecognized query", \() -> [text ""]) -- TODO: Display the Query
+ FMNest m -> vf FSNest (nestView dat m)
+ FMLang m -> vs FSLang (AS.langView False m)
+ FMOLang m -> vs FSOLang (AS.langView True m)
+ FMPlatform m -> vs FSPlatform (AS.platformView m)
+ FMLength m -> vs FSLength (AS.lengthView m)
+ FMRole m -> vs FSRole (AS.roleView m)
+ FMBlood m -> vs FSBlood (AS.bloodView m)
+ FMSexChar m -> vs FSSexChar (AS.sexView AS.SexChar m)
+ FMSexSpoil m -> vs FSSexSpoil (AS.sexView AS.SexSpoil m)
+ FMHeight m -> vs FSHeight (AR.heightView m)
+ FMWeight m -> vs FSWeight (AR.weightView m)
+ FMBust m -> vs FSBust (AR.bustView m)
+ FMWaist m -> vs FSWaist (AR.waistView m)
+ FMHips m -> vs FSHips (AR.hipsView m)
+ FMCup m -> vs FSCup (AR.cupView m)
+ FMAge m -> vs FSAge (AR.ageView m)
+ FMPopularity m->vs FSPopularity(AR.popularityView m)
+ FMRating m -> vs FSRating (AR.ratingView m)
+ FMVotecount m-> vs FSVotecount(AR.votecountView m)
+ FMDeveloper m-> vs FSDeveloper(AP.devView dat m)
+ FMRDate m -> vs FSRDate (AD.view m)
+ FMTag m -> vs FSTag (AG.view dat m)
+
+
+fieldToQuery : Field -> Maybe Query
+fieldToQuery (_, _, model) =
+ case model of
+ FMCustom m -> Just m
+ FMNest m -> nestToQuery m
+ FMLang m -> AS.toQuery (QStr 2) m
+ FMOLang m -> AS.toQuery (QStr 3) m
+ FMPlatform m -> AS.toQuery (QStr 4) m
+ FMLength m -> AS.toQuery (QInt 5) m
+ FMRole m -> AS.toQuery (QStr 2) m
+ FMBlood m -> AS.toQuery (QStr 3) m
+ FMSexChar m -> AS.toQuery (QStr 4) m
+ FMSexSpoil m -> AS.toQuery (QStr 5) m
+ FMHeight m -> AR.toQuery (QInt 6) m
+ FMWeight m -> AR.toQuery (QInt 7) m
+ FMBust m -> AR.toQuery (QInt 8) m
+ FMWaist m -> AR.toQuery (QInt 9) m
+ FMHips m -> AR.toQuery (QInt 10) m
+ FMCup m -> AR.toQuery (QStr 11) m
+ FMAge m -> AR.toQuery (QInt 12) m
+ FMPopularity m->AR.toQuery (QInt 9) m
+ FMRating m -> AR.toQuery (QInt 10) m
+ FMVotecount m-> AR.toQuery (QInt 11) m
+ FMDeveloper m-> AP.toQuery (QInt 6) m
+ FMRDate m -> AD.toQuery m
+ FMTag m -> AG.toQuery m
+
+
+fieldCreate : Int -> (Data,FieldModel) -> (Data,Field)
+fieldCreate fid (dat,fm) =
+ ( {dat | objid = dat.objid + 1}
+ , (fid, DD.init ("advsearch_field" ++ String.fromInt dat.objid) FToggle, fm)
+ )
+
+
+fieldInit : Int -> Data -> (Data,Field)
+fieldInit n dat =
+ case A.get n fields of
+ Just f -> fieldCreate n (f.init dat)
+ Nothing -> fieldCreate -1 (dat, FMCustom (QAnd [])) -- Shouldn't happen.
+
+
+fieldFromQuery : QType -> Data -> Query -> (Data,Field)
+fieldFromQuery qtype dat q =
+ let (field, _) =
+ A.foldr (\f (af,n) ->
+ case (if af /= Nothing || f.qtype /= qtype then Nothing else f.fromQuery dat q) of
+ Nothing -> (af,n-1)
+ Just ret -> (Just (fieldCreate n ret), 0)
+ ) (Nothing,A.length fields-1) fields
+ in case field of
+ Just ret -> ret
+ Nothing -> fieldCreate -1 (dat, FMCustom q)
+
+
+fieldSub : Field -> Sub FieldMsg
+fieldSub (_,dd,fm) =
+ case fm of
+ FMNest m ->
+ Sub.batch
+ <| DD.sub dd
+ :: Sub.map FSNest (DD.sub m.add)
+ :: List.indexedMap (\i -> Sub.map (FSNest << NField i) << fieldSub) m.fields
+ _ -> DD.sub dd
diff --git a/elm/AdvSearch/Main.elm b/elm/AdvSearch/Main.elm
new file mode 100644
index 00000000..ca9fde3e
--- /dev/null
+++ b/elm/AdvSearch/Main.elm
@@ -0,0 +1,117 @@
+module AdvSearch.Main exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Set
+import Dict
+import Array as A
+import Json.Encode as JE
+import Json.Decode as JD
+import Gen.Api as GApi
+import AdvSearch.Query exposing (..)
+import AdvSearch.Fields exposing (..)
+
+
+main : Program Recv Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = \m -> Sub.map Field (fieldSub m.query)
+ }
+
+type alias Recv =
+ { query : JE.Value
+ , qtype : String
+ , defaultSpoil : Int
+ , producers : List GApi.ApiProducerResult
+ , tags : List GApi.ApiTagResult
+ }
+
+type alias Model =
+ { query : Field
+ , qtype : QType
+ , data : Data
+ }
+
+type Msg
+ = Field FieldMsg
+
+
+-- Add default set of fields (if they aren't present yet) and sort the list
+normalize : Model -> Model
+normalize model =
+ let present = List.foldl (\(n,_,_) a -> Set.insert n a) Set.empty
+ defaults pres = A.foldl (\f (al,dat,an) ->
+ if f.qtype == model.qtype && f.quick /= Nothing && not (Set.member an pres)
+ then let (ndat, nf) = fieldInit an dat
+ in (nf::al, ndat, an+1)
+ else (al,dat,an+1)
+ ) ([],model.data,0) fields
+ cmp (an,add,am) (bn,bdd,bm) = -- Sort active filters before empty ones, then order by 'quick', fallback to title
+ let aq = fieldToQuery (an,add,am) /= Nothing
+ bq = fieldToQuery (bn,bdd,bm) /= Nothing
+ af = A.get an fields
+ bf = A.get bn fields
+ ao = Maybe.andThen (\d -> d.quick) af |> Maybe.withDefault 9999
+ bo = Maybe.andThen (\d -> d.quick) bf |> Maybe.withDefault 9999
+ at = Maybe.map (\d -> d.title) af |> Maybe.withDefault ""
+ bt = Maybe.map (\d -> d.title) bf |> Maybe.withDefault ""
+ in if aq && not bq then LT else if not aq && bq then GT
+ else if ao /= bo then compare ao bo else compare at bt
+ in case model.query of
+ (qid, qdd, FMNest qm) ->
+ let (nl, dat, _) = defaults (present qm.fields)
+ nqm = { qm | fields = List.sortWith cmp (nl++qm.fields) }
+ in { model | query = (qid, qdd, FMNest nqm), data = dat }
+ _ -> model
+
+
+init : Recv -> Model
+init arg =
+ let dat = { objid = 0
+ , level = 0
+ , defaultSpoil = arg.defaultSpoil
+ , producers = Dict.fromList <| List.map (\p -> (p.id,p)) <| arg.producers
+ , tags = Dict.fromList <| List.map (\t -> (t.id,t)) <| arg.tags
+ }
+ qtype = if arg.qtype == "v" then V else R
+
+ (ndat, query) = JD.decodeValue decodeQuery arg.query |> Result.toMaybe |> Maybe.withDefault (QAnd []) |> fieldFromQuery qtype dat
+
+ -- We always want the top-level query to be a Nest type.
+ addtoplvl = let (_,m) = fieldCreate -1 (Tuple.mapSecond FMNest (nestInit NAnd qtype [query] ndat)) in m
+ nquery = case query of
+ (_,_,FMNest m) -> if m.ntype == NAnd || m.ntype == NOr then query else addtoplvl
+ _ -> addtoplvl
+
+ -- Is this a "simple" query? i.e. one that consists of at most a single level of nesting
+ isSimple = case nquery of
+ (_,_,FMNest m) -> List.all (\f -> case f of
+ (_,_,FMNest _) -> False
+ _ -> True) m.fields
+ _ -> True
+
+ model = { query = nquery
+ , qtype = qtype
+ , data = { ndat | objid = ndat.objid + 5 } -- +5 for the creation of nQuery
+ }
+ in if isSimple then normalize model else model
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Field m ->
+ let (ndat, nm, nc) = fieldUpdate model.data m model.query
+ in ({ model | data = ndat, query = nm }, Cmd.map Field nc)
+
+
+view : Model -> Html Msg
+view model = div [ class "advsearch" ]
+ [ input [ type_ "hidden", id "f", name "f", value <| Maybe.withDefault "" <| Maybe.map encQuery (fieldToQuery model.query) ] []
+ , Html.map Field (nestFieldView model.data model.query)
+ , input [ type_ "submit", class "submit", value "Search" ] []
+ ]
diff --git a/elm/AdvSearch/Producers.elm b/elm/AdvSearch/Producers.elm
new file mode 100644
index 00000000..ab36f74a
--- /dev/null
+++ b/elm/AdvSearch/Producers.elm
@@ -0,0 +1,95 @@
+module AdvSearch.Producers exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Set
+import Dict
+import Lib.Autocomplete as A
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Gen.Api as GApi
+import AdvSearch.Query exposing (..)
+import AdvSearch.Set as S
+
+
+
+type alias Model =
+ { sel : S.Model Int
+ , conf : A.Config Msg GApi.ApiProducerResult
+ , search : A.Model GApi.ApiProducerResult
+ }
+
+type Msg
+ = Sel (S.Msg Int)
+ | Search (A.Msg GApi.ApiProducerResult)
+
+
+init : Data -> (Data, Model)
+init dat =
+ let (ndat, sel) = S.init dat
+ in ( { ndat | objid = ndat.objid + 1 }
+ , { sel = { sel | single = False }
+ , conf = { wrap = Search, id = "advsearch_prod" ++ String.fromInt ndat.objid, source = A.producerSource }
+ , search = A.init ""
+ }
+ )
+
+
+update : Data -> Msg -> Model -> (Data, Model, Cmd Msg)
+update dat msg model =
+ case msg of
+ Sel m -> (dat, { model | sel = S.update m model.sel }, Cmd.none)
+ Search m ->
+ let (nm, c, res) = A.update model.conf m model.search
+ in case res of
+ Nothing -> (dat, { model | search = nm }, c)
+ Just p ->
+ if Set.member p.id model.sel.sel then (dat, { model | search = nm }, c)
+ else ( { dat | producers = Dict.insert p.id p dat.producers }
+ , { model | search = A.clear nm "", sel = S.update (S.Sel p.id True) model.sel }
+ , c )
+
+
+toQuery f m = S.toQuery f m.sel
+
+fromQuery f dat q =
+ S.fromQuery f dat q |> Maybe.map (\(ndat,sel) ->
+ ( { ndat | objid = ndat.objid+1 }
+ , { sel = { sel | single = False }
+ , conf = { wrap = Search, id = "advsearch_prod" ++ String.fromInt ndat.objid, source = A.producerSource }
+ , search = A.init ""
+ }
+ ))
+
+
+devFromQuery = fromQuery (\q ->
+ case q of
+ QInt 6 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+devView : Data -> Model -> (Html Msg, () -> List (Html Msg))
+devView dat model =
+ ( case Set.toList model.sel.sel of
+ [] -> b [ class "grayedout" ] [ text "Developer" ]
+ [s] -> span [ class "nowrap" ]
+ [ S.lblPrefix model.sel
+ , b [ class "grayedout" ] [ text <| "p" ++ String.fromInt s ++ ":" ]
+ , Dict.get s dat.producers |> Maybe.map (\p -> p.name) |> Maybe.withDefault "" |> text
+ ]
+ l -> span [] [ S.lblPrefix model.sel, text <| "Developers (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Developer" ]
+ , Html.map Sel (S.opts model.sel True False)
+ ]
+ , ul [] <| List.map (\s ->
+ li []
+ [ inputButton "X" (Sel (S.Sel s False)) []
+ , b [ class "grayedout" ] [ text <| " p" ++ String.fromInt s ++ ": " ]
+ , Dict.get s dat.producers |> Maybe.map (\p -> p.name) |> Maybe.withDefault "" |> text
+ ]
+ ) (Set.toList model.sel.sel)
+ , A.view model.conf model.search [ placeholder "Search..." ]
+ ]
+ )
diff --git a/elm/AdvSearch/Query.elm b/elm/AdvSearch/Query.elm
new file mode 100644
index 00000000..0edb6065
--- /dev/null
+++ b/elm/AdvSearch/Query.elm
@@ -0,0 +1,159 @@
+module AdvSearch.Query exposing (..)
+
+import Json.Encode as JE
+import Json.Decode as JD
+import Dict
+import Gen.Api as GApi
+
+-- Generic dynamically typed representation of a query.
+-- Used only as an intermediate format to help with encoding/decoding.
+-- Corresponds to the compact JSON form.
+type QType = V | R | C
+type Op = Eq | Ne | Ge | Gt | Le | Lt
+type Query
+ = QAnd (List Query)
+ | QOr (List Query)
+ | QInt Int Op Int
+ | QStr Int Op String
+ | QQuery Int Op Query
+ | QTuple Int Op Int Int
+
+
+encodeOp : Op -> JE.Value
+encodeOp o = JE.string <|
+ case o of
+ Eq -> "="
+ Ne -> "!="
+ Ge -> ">="
+ Gt -> ">"
+ Le -> "<="
+ Lt -> "<"
+
+encodeQuery : Query -> JE.Value
+encodeQuery q =
+ case q of
+ QAnd l -> JE.list identity (JE.int 0 :: List.map encodeQuery l)
+ QOr l -> JE.list identity (JE.int 1 :: List.map encodeQuery l)
+ QInt s o a -> JE.list identity [JE.int s, encodeOp o, JE.int a]
+ QStr s o a -> JE.list identity [JE.int s, encodeOp o, JE.string a]
+ QQuery s o a -> JE.list identity [JE.int s, encodeOp o, encodeQuery a]
+ QTuple s o a b -> JE.list identity [JE.int s, encodeOp o, JE.int a, JE.int b]
+
+
+
+-- Drops the first item in the list, decodes the rest
+decodeQList : JD.Decoder (List Query)
+decodeQList =
+ let dec l = List.map (JD.decodeValue decodeQuery) (List.drop 1 l) -- [Result Query]
+ f v r = Result.andThen (\a -> Result.map (\e -> (e::a)) v) r -- Result Query -> Result [Query] -> Result [Query]
+ res l = case List.foldr f (Ok []) (dec l) of -- Decoder [Query]
+ Err e -> JD.fail (JD.errorToString e)
+ Ok v -> JD.succeed v
+ in JD.list JD.value |> JD.andThen res -- [Value]
+
+decodeOp : JD.Decoder Op
+decodeOp = JD.string |> JD.andThen (\s ->
+ case s of
+ "=" -> JD.succeed Eq
+ "!=" -> JD.succeed Ne
+ ">=" -> JD.succeed Ge
+ ">" -> JD.succeed Gt
+ "<=" -> JD.succeed Le
+ "<" -> JD.succeed Lt
+ _ -> JD.fail "Invalid operator")
+
+decodeQuery : JD.Decoder Query
+decodeQuery = JD.index 0 JD.int |> JD.andThen (\s ->
+ case s of
+ 0 -> JD.map QAnd decodeQList
+ 1 -> JD.map QOr decodeQList
+ _ -> JD.oneOf
+ [ JD.map2 (QInt s ) (JD.index 1 decodeOp) (JD.index 2 JD.int)
+ , JD.map2 (QStr s ) (JD.index 1 decodeOp) (JD.index 2 JD.string)
+ , JD.map2 (QQuery s) (JD.index 1 decodeOp) (JD.index 2 decodeQuery)
+ , JD.map2 (\o (a,b) -> QTuple s o a b) (JD.index 1 decodeOp) <| JD.index 2 <| JD.map2 (\a b -> (a,b)) (JD.index 0 JD.int) (JD.index 1 JD.int)
+ ]
+ )
+
+
+
+
+-- Encode a Query to the compact query format. See lib/VNWeb/AdvSearch.pm for details.
+
+encIntAlpha : Int -> String
+encIntAlpha n = String.slice n (n+1) "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-"
+
+encIntRaw : Int -> Int -> String
+encIntRaw len n = (if len > 1 then encIntRaw (len-1) (n//64) else "") ++ encIntAlpha (modBy 64 n)
+
+encInt : Int -> Maybe String
+encInt n = if n < 0 then Nothing
+ else if n < 49 then Just <| encIntAlpha n
+ else if n < 689 then Just <| encIntAlpha (49 + (n-49)//64) ++ encIntAlpha (modBy 64 (n-49))
+ else if n < 4785 then Just <| "X" ++ encIntRaw 2 (n-689)
+ else if n < 266929 then Just <| "Y" ++ encIntRaw 3 (n-4785)
+ else if n < 17044145 then Just <| "Z" ++ encIntRaw 4 (n-266929)
+ else if n < 1090785969 then Just <| "_" ++ encIntRaw 5 (n-17044145)
+ else if n < 69810262705 then Just <| "-" ++ encIntRaw 6 (n-1090785969)
+ else Nothing
+
+
+encStrMap : Dict.Dict Char String
+encStrMap = Dict.fromList <| List.indexedMap (\n c -> (c,"_"++Maybe.withDefault "" (encInt n))) <| String.toList " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
+
+encStr : String -> String
+encStr = String.foldl (\c s -> s ++ Maybe.withDefault (String.fromChar c) (Dict.get c encStrMap)) ""
+
+
+encQuery : Query -> String
+encQuery query =
+ let fint n = Maybe.withDefault "" (encInt n)
+ lst n l = let nl = List.map encQuery l in fint n ++ fint (List.length nl) ++ String.concat nl
+ encOp o =
+ case o of
+ Eq -> 0
+ Ne -> 1
+ Ge -> 2
+ Gt -> 3
+ Le -> 4
+ Lt -> 5
+ encTypeOp o t = fint (encOp o + 8*t)
+ encStrField n o v =
+ let s = encStr v
+ f l = fint n ++ encTypeOp o l ++ s
+ in case String.length s of
+ 2 -> f 2
+ 3 -> f 3
+ l -> f 4 ++ "-"
+ in case query of
+ QAnd l -> lst 0 l
+ QOr l -> lst 1 l
+ QInt n o v ->
+ case encInt v of -- Integers that can't be represented in encoded form will be encoded as strings
+ Just s -> fint n ++ encTypeOp o 0 ++ s
+ Nothing -> encStrField n o (String.fromInt v)
+ QStr n o v -> encStrField n o v
+ QQuery n o q -> fint n ++ encTypeOp o 1 ++ encQuery q
+ QTuple n o a b -> fint n ++ encTypeOp o 5 ++ fint a ++ fint b
+
+
+showOp : Op -> String
+showOp op =
+ case op of
+ Eq -> "="
+ Ne -> "≠"
+ Le -> "≤"
+ Lt -> "<"
+ Ge -> "≥"
+ Gt -> ">"
+
+
+-- Global data that's passed around for Fields
+-- (defined here because everything imports this module)
+type alias Data =
+ { objid : Int -- Incremental integer for global identifiers
+ , level : Int -- Nesting level of the field being processed
+ , defaultSpoil : Int
+ , producers : Dict.Dict Int GApi.ApiProducerResult
+ , tags : Dict.Dict Int GApi.ApiTagResult
+ }
diff --git a/elm/AdvSearch/RDate.elm b/elm/AdvSearch/RDate.elm
new file mode 100644
index 00000000..a5eeed25
--- /dev/null
+++ b/elm/AdvSearch/RDate.elm
@@ -0,0 +1,98 @@
+module AdvSearch.RDate exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Lib.Html exposing (..)
+import Lib.RDate as R
+import AdvSearch.Query exposing (..)
+
+
+type alias Model =
+ { op : Op
+ , fuzzy : Bool
+ , date : R.RDate
+ }
+
+
+type Msg
+ = MOp Op Bool
+ | Fuzzy Bool
+ | Date R.RDate
+
+
+update : Msg -> Model -> Model
+update msg model =
+ case msg of
+ MOp o _ -> { model | op = o }
+ Fuzzy f -> { model | fuzzy = f }
+ Date d -> { model | date = d }
+
+
+init : Data -> (Data, Model)
+init dat = (dat,
+ { op = Le
+ , fuzzy = True
+ , date = 1
+ })
+
+
+toQuery : Model -> Maybe Query
+toQuery model = Just <|
+ let f o date = QInt 7 o date
+ e = R.expand model.date
+ ystart = R.compact { y=e.y, m= 1, d= 1 }
+ mstart = R.compact { y=e.y, m=e.m, d= 1 }
+ in
+ if not model.fuzzy || e.y == 0 || e.y == 9999 then f model.op model.date else
+ case (model.op, e.m, e.d) of
+ -- Fuzzy (in)equality turns into a date range
+ (Eq, 99, 99) -> QAnd [ f Ge ystart, f Le model.date ]
+ (Eq, _, 99) -> QAnd [ f Ge mstart, f Le model.date ]
+ (Ne, 99, 99) -> QOr [ f Lt ystart, f Gt model.date ]
+ (Ne, _, 99) -> QOr [ f Lt mstart, f Gt model.date ]
+ -- Fuzzy Ge and Lt just need the date adjusted to the correct boundary
+ (Ge, 99, 99) -> f Ge ystart
+ (Ge, _, 99) -> f Ge mstart
+ (Lt, 99, 99) -> f Lt ystart
+ (Lt, _, 99) -> f Lt mstart
+ _ -> f model.op model.date
+
+
+fromQuery : Data -> Query -> Maybe (Data, Model)
+fromQuery dat q =
+ let m op fuzzy date = Just (dat, { op = op, fuzzy = fuzzy, date = date })
+ fuzzyNeq op start end =
+ let se = R.expand start
+ ee = R.expand end
+ in if se.y == ee.y && (ee.m < 99 || se.m == 1) && se.d == 1 && ee.d == 99 then m op True end else Nothing
+ canFuzzy o e = e.y == 0 || e.y == 9999 || e.d /= 99 || o == Gt || o == Le
+ in
+ case q of
+ QAnd [QInt 7 Ge start, QInt 7 Le end] -> fuzzyNeq Eq start end
+ QOr [QInt 7 Lt start, QInt 7 Gt end] -> fuzzyNeq Ne start end
+ QInt 7 o v -> m o (canFuzzy o (R.expand v)) v
+ _ -> Nothing
+
+
+view : Model -> (Html Msg, () -> List (Html Msg))
+view model =
+ ( text <| showOp model.op ++ " " ++ R.format (R.expand model.date)
+ , \() ->
+ [ div [ class "advheader", style "width" "290px" ]
+ [ h3 [] [ text "Release date" ]
+ , div [ class "opts" ]
+ [ div [ class "opselect" ] <|
+ List.map (\op ->
+ if model.op == op then b [] [ text (showOp op) ] else a [ href "#", onClickD (MOp op True) ] [ text (showOp op) ]
+ ) [Eq, Ne, Ge, Gt, Le, Lt]
+ , if (R.expand model.date).d /= 99 then text "" else
+ linkRadio model.fuzzy Fuzzy [ span [ title
+ <| "Without fuzzy matching, partial dates will always match after the last date of the chosen time period, "
+ ++ "e.g. \"< 2010-10\" would also match anything released in 2010-10 and \"= 2010-10\" would only match releases for which we don't know the exact date."
+ ++ "\n\nFuzzy match will adjust the query to do what you mean."
+ ] [ text "fuzzy" ] ]
+ ]
+ ]
+ , R.view model.date True True Date
+ ]
+ )
diff --git a/elm/AdvSearch/Range.elm b/elm/AdvSearch/Range.elm
new file mode 100644
index 00000000..d7aa1a7f
--- /dev/null
+++ b/elm/AdvSearch/Range.elm
@@ -0,0 +1,187 @@
+module AdvSearch.Range exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Array
+import Lib.Ffi as Ffi
+import Gen.Types as GT
+import AdvSearch.Query exposing (..)
+
+
+type alias Model a =
+ { op : Op
+ , val : Int
+ , lst : Array.Array a
+ }
+
+
+type Msg
+ = MOp Op Bool
+ | Val String
+
+
+update : Msg -> Model a -> Model a
+update msg model =
+ case msg of
+ MOp o _ -> { model | op = o }
+ Val n -> { model | val = Maybe.withDefault 0 (String.toInt n) }
+
+fromQuery : (Data, Model comparable) -> Op -> comparable -> Maybe (Data, Model comparable)
+fromQuery (dat,m) op v = Array.foldl (\v2 (i,r) -> (i+1, if v2 == v then Just i else r)) (0,Nothing) m.lst |> Tuple.second |> Maybe.map (\i -> (dat,{ m | val = i, op = op }))
+
+toQuery : (Op -> a -> Query) -> Model a -> Maybe Query
+toQuery f m = Array.get m.val m.lst |> Maybe.map (\v -> f m.op v)
+
+view : String -> (a -> String) -> Model a -> (Html Msg, () -> List (Html Msg))
+view lbl fmt model =
+ let val n = Array.get n model.lst |> Maybe.map fmt |> Maybe.withDefault ""
+ in
+ ( text <| lbl ++ " " ++ showOp model.op ++ " " ++ val model.val
+ , \() ->
+ [ div [ class "advheader", style "width" "290px" ]
+ [ h3 [] [ text lbl ]
+ , div [ class "opts" ]
+ [ div [ class "opselect" ] <|
+ List.map (\op ->
+ if model.op == op then b [] [ text (showOp op) ] else a [ href "#", onClickD (MOp op True) ] [ text (showOp op) ]
+ ) [Eq, Ne, Ge, Gt, Le, Lt]
+ ]
+ ]
+ , div [ style "display" "flex", style "justify-content" "space-between", style "margin-top" "5px" ]
+ [ b [ class "grayedout" ] [ text (val 0) ]
+ , b [] [ text (val model.val) ]
+ , b [ class "grayedout" ] [ text (val (Array.length model.lst - 1)) ]
+ ]
+ , input
+ [ type_ "range"
+ , Html.Attributes.min "0"
+ , Html.Attributes.max (String.fromInt (Array.length model.lst - 1))
+ , value (String.fromInt model.val)
+ , onInput Val
+ , style "width" "290px"
+ ] []
+ ]
+ )
+
+
+
+
+heightInit dat = (dat, { op = Ge, val = 150, lst = Array.initialize 300 (\n -> n+1) })
+
+heightFromQuery d q =
+ case q of
+ QInt 6 op v -> fromQuery (heightInit d) op v
+ _ -> Nothing
+
+heightView = view "Height" (\v -> String.fromInt v ++ "cm")
+
+
+
+
+weightInit dat = (dat, { op= Ge, val = 60, lst = Array.initialize 401 identity })
+
+weightFromQuery d q =
+ case q of
+ QInt 7 op v -> fromQuery (weightInit d) op v
+ _ -> Nothing
+
+weightView = view "Weight" (\v -> String.fromInt v ++ "kg")
+
+
+
+
+bustInit dat = (dat, { op = Ge, val = 40, lst = Array.initialize 101 (\n -> n+20) })
+
+bustFromQuery d q =
+ case q of
+ QInt 8 op v -> fromQuery (bustInit d) op v
+ _ -> Nothing
+
+bustView = view "Bust" (\v -> String.fromInt v ++ "cm")
+
+
+
+
+waistInit dat = (dat, { op = Ge, val = 40, lst = Array.initialize 101 (\n -> n+20) })
+
+waistFromQuery d q =
+ case q of
+ QInt 9 op v -> fromQuery (waistInit d) op v
+ _ -> Nothing
+
+waistView = view "Waist" (\v -> String.fromInt v ++ "cm")
+
+
+
+
+hipsInit dat = (dat, { op = Ge, val = 40, lst = Array.initialize 101 (\n -> n+20) })
+
+hipsFromQuery d q =
+ case q of
+ QInt 10 op v -> fromQuery (hipsInit d) op v
+ _ -> Nothing
+
+hipsView = view "Hips" (\v -> String.fromInt v ++ "cm")
+
+
+
+
+cupInit dat = (dat, { op = Ge, val = 3, lst = Array.fromList (List.map Tuple.first (List.drop 1 GT.cupSizes)) })
+
+cupFromQuery d q =
+ case q of
+ QStr 11 op v -> fromQuery (cupInit d) op v
+ _ -> Nothing
+
+cupView = view "Cup size" identity
+
+
+
+
+ageInit dat = (dat, { op = Ge, val = 17, lst = Array.initialize 120 (\n -> n+1) })
+
+ageFromQuery d q =
+ case q of
+ QInt 12 op v -> fromQuery (ageInit d) op v
+ _ -> Nothing
+
+ageView = view "Age" (\v -> if v == 1 then "1 year" else String.fromInt v ++ " years")
+
+
+
+
+popularityInit dat = (dat, { op = Ge, val = 10, lst = Array.initialize 101 identity })
+
+popularityFromQuery d q =
+ case q of
+ QInt 9 op v -> fromQuery (popularityInit d) op v
+ _ -> Nothing
+
+popularityView = view "Popularity" String.fromInt
+
+
+
+
+ratingInit dat = (dat, { op = Ge, val = 40, lst = Array.initialize 91 (\v -> v+10) })
+
+ratingFromQuery d q =
+ case q of
+ QInt 10 op v -> fromQuery (ratingInit d) op v
+ _ -> Nothing
+
+ratingView = view "Rating" (\v -> Ffi.fmtFloat (toFloat v / 10) 1)
+
+
+
+
+votecountInit dat = (dat, { op = Ge, val = 10, lst = Array.fromList [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 2000, 3000, 4000, 5000 ] })
+
+votecountFromQuery d q =
+ case q of
+ QInt 11 op v -> fromQuery (votecountInit d) op v
+ _ -> Nothing
+
+votecountView = view "# Votes" String.fromInt
diff --git a/elm/AdvSearch/Set.elm b/elm/AdvSearch/Set.elm
new file mode 100644
index 00000000..1e3ad18e
--- /dev/null
+++ b/elm/AdvSearch/Set.elm
@@ -0,0 +1,257 @@
+module AdvSearch.Set exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Set
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Gen.Types as GT
+import AdvSearch.Query exposing (..)
+
+
+type alias Model a =
+ { sel : Set.Set a
+ , single : Bool
+ , and : Bool
+ , neg : Bool
+ , last : Set.Set a -- Last selection before switching to single mode, if there were multiple items selected
+ }
+
+type Msg a
+ = Sel a Bool
+ | Neg Bool
+ | And Bool
+ | Single Bool
+ | Mode -- Toggle between single / multi (or) / multi (and)
+
+
+init : Data -> (Data, Model a)
+init dat = (dat, { sel = Set.empty, single = True, and = False, neg = False, last = Set.empty })
+
+
+update : Msg comparable -> Model comparable -> Model comparable
+update msg model =
+ let singleMode m =
+ { m | sel = if m.single then Set.fromList <| List.take 1 <| Set.toList m.sel
+ else if model.single && not m.single && not (Set.isEmpty model.last) then m.last
+ else m.sel
+ , last = if m.single && not model.single && Set.size m.sel > 1 then m.sel else Set.empty }
+ in
+ case msg of
+ Sel v b -> { model | last = Set.empty, sel = if not b then Set.remove v model.sel else if model.single then Set.fromList [v] else Set.insert v model.sel }
+ Neg b -> { model | neg = b }
+ And b -> { model | and = b }
+ Single b -> singleMode { model | single = b }
+ Mode -> singleMode { model | single = not model.single && model.and, and = not model.single && not model.and }
+
+
+toQuery : (Op -> a -> Query) -> Model a -> Maybe Query
+toQuery f m =
+ case (m.neg, m.and, Set.toList m.sel) of
+ (_,_,[]) -> Nothing
+ (n,_,[v]) -> Just (f (if n then Ne else Eq) v)
+ (False, False, l) -> Just <| QOr <| List.map (\v -> f Eq v) l
+ (True , False, l) -> Just <| QAnd <| List.map (\v -> f Ne v) l
+ (False, True , l) -> Just <| QAnd <| List.map (\v -> f Eq v) l
+ (True , True , l) -> Just <| QOr <| List.map (\v -> f Ne v) l
+
+
+-- Only recognizes queries generated by setToQuery, doesn't handle alternative query structures.
+-- Usage:
+-- setFromQuery (\q -> case q of
+-- QStr 2 op v -> Just (op, v)
+-- _ -> Nothing) model
+fromQuery : (Query -> Maybe (Op,comparable)) -> Data -> Query -> Maybe (Data, Model comparable)
+fromQuery f dat q =
+ let single and qs = f qs |> Maybe.andThen (\(op,v) ->
+ if op /= Ne && op /= Eq
+ then Nothing
+ else Just (dat, { sel = Set.fromList [v], and = xor and (op == Ne), neg = (op == Ne), single = True, last = Set.empty }))
+ lst mm xqs =
+ case (mm, xqs) of
+ (Nothing, _) -> Nothing
+ (_, []) -> mm
+ (Just (_,m), x :: xs) -> f x |> Maybe.andThen (\(op,v) ->
+ if (op /= Ne && op /= Eq) || (op == Ne) /= m.neg
+ then Nothing
+ else lst (Just (dat, {m | single = False, sel = Set.insert v m.sel})) xs)
+ in case q of
+ QAnd (x::xs) -> lst (single True x) xs
+ QOr (x::xs) -> lst (single False x) xs
+ _ -> single False q
+
+
+lblPrefix m = text <| (if m.neg then "¬" else "") ++ (if m.single || Set.size m.sel == 1 then "" else if m.and then "∀ " else "∃ ")
+
+
+optsMode m canAnd canSingle =
+ a [ href "#"
+ , onClickD (if canAnd && canSingle then Mode else if canSingle then Single (not m.single) else And (not m.and))
+ , title <| if m.single then "Single-selection mode" else if m.and then "Entry must match all selected items" else "Entry must match at least one item"
+ ] [ text <| "Mode:" ++ if m.single then "single" else if m.and then "all" else "any" ]
+
+opts m canAnd canSingle = div [ class "opts" ]
+ [ optsMode m canAnd canSingle
+ , linkRadio m.neg Neg [ text "invert" ]
+ ]
+
+
+
+
+-- Language
+
+langView orig model =
+ let tprefix = if orig then "O " else "L "
+ in
+ ( case Set.toList model.sel of
+ [] -> b [ class "grayedout" ] [ text <| if orig then "Orig language" else "Language" ]
+ [v] -> span [ class "nowrap" ] [ text tprefix, lblPrefix model, langIcon v, text <| Maybe.withDefault "" (lookup v GT.languages) ]
+ l -> span [ class "nowrap" ] <| text tprefix :: lblPrefix model :: List.intersperse (text "") (List.map langIcon l)
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text <| if orig then "Language the visual novel has been originally written in." else "Language(s) in which the visual novel is available." ]
+ , opts model (not orig) True
+ ]
+ , ul [ style "columns" "2"] <| List.map (\(l,t) -> li [] [ linkRadio (Set.member l model.sel) (Sel l) [ langIcon l, text t ] ]) GT.languages
+ ]
+ )
+
+langFromQuery = fromQuery (\q ->
+ case q of
+ QStr 2 op v -> Just (op, v)
+ _ -> Nothing)
+
+olangFromQuery = fromQuery (\q ->
+ case q of
+ QStr 3 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Platform
+
+platformView model =
+ ( case Set.toList model.sel of
+ [] -> b [ class "grayedout" ] [ text "Platform" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, platformIcon v, text <| Maybe.withDefault "" (lookup v GT.platforms) ]
+ l -> span [ class "nowrap" ] <| lblPrefix model :: List.intersperse (text "") (List.map langIcon l)
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Platforms for which the visual novel is available." ]
+ , opts model True True
+ ]
+ , ul [ style "columns" "2"] <| List.map (\(p,t) ->
+ li [classList [("separator", p == "web")]] [ linkRadio (Set.member p model.sel) (Sel p) [ platformIcon p, text t ] ]
+ ) GT.platforms
+ ]
+ )
+
+platformFromQuery = fromQuery (\q ->
+ case q of
+ QStr 4 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Length
+
+lengthView model =
+ ( case Set.toList model.sel of
+ [] -> b [ class "grayedout" ] [ text "Length" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.vnLengths) ]
+ l -> span [] [ lblPrefix model, text <| "Length (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Length (estimated play time)" ]
+ , opts model False True ]
+ , ul [] <| List.map (\(l,t) -> li [] [ linkRadio (Set.member l model.sel) (Sel l) [ text t ] ]) GT.vnLengths
+ ]
+ )
+
+lengthFromQuery = fromQuery (\q ->
+ case q of
+ QInt 5 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Character role
+
+roleView model =
+ ( case Set.toList model.sel of
+ [] -> b [ class "grayedout" ] [ text "Role" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.charRoles) ]
+ l -> span [] [ lblPrefix model, text <| "Role (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Role" ]
+ , opts model True True ]
+ , ul [] <| List.map (\(l,t) -> li [] [ linkRadio (Set.member l model.sel) (Sel l) [ text t ] ]) GT.charRoles
+ ]
+ )
+
+roleFromQuery = fromQuery (\q ->
+ case q of
+ QStr 2 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Blood type
+
+bloodView model =
+ ( case Set.toList model.sel of
+ [] -> b [ class "grayedout" ] [ text "Blood type" ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| "Blood type " ++ Maybe.withDefault "" (lookup v GT.bloodTypes) ]
+ l -> span [] [ lblPrefix model, text <| "Blood type (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Blood type" ]
+ , opts model False True ]
+ , ul [] <| List.map (\(l,t) -> li [] [ linkRadio (Set.member l model.sel) (Sel l) [ text t ] ]) GT.bloodTypes
+ ]
+ )
+
+bloodFromQuery = fromQuery (\q ->
+ case q of
+ QStr 3 op v -> Just (op, v)
+ _ -> Nothing)
+
+
+
+
+-- Sex / gender
+
+type SexType
+ = SexChar -- chars sex
+ | SexSpoil -- chars sex-spoil
+ | SexGender -- staff gender
+
+sexView stype model =
+ let lbl = case stype of
+ SexChar -> "Sex"
+ SexSpoil -> "Spoiler-sex"
+ SexGender -> "Gender"
+ in
+ ( case Set.toList model.sel of
+ [] -> b [ class "grayedout" ] [ text lbl ]
+ [v] -> span [ class "nowrap" ] [ lblPrefix model, text <| lbl ++ ": " ++ Maybe.withDefault "" (lookup v GT.genders) ]
+ l -> span [] [ lblPrefix model, text <| lbl ++ " (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text lbl ]
+ , opts model False True ]
+ , ul [] <| List.map (\(l,t) -> if stype == SexGender && l == "b" then text "" else li [] [ linkRadio (Set.member l model.sel) (Sel l) [ text t ] ]) GT.genders
+ ]
+ )
+
+sexFromQuery stype = fromQuery (\q ->
+ case (stype, q) of
+ (SexChar, QStr 4 op v) -> Just (op, v)
+ (SexSpoil, QStr 5 op v) -> Just (op, v)
+ -- TODO: SexGender
+ _ -> Nothing)
diff --git a/elm/AdvSearch/Tags.elm b/elm/AdvSearch/Tags.elm
new file mode 100644
index 00000000..dee6621a
--- /dev/null
+++ b/elm/AdvSearch/Tags.elm
@@ -0,0 +1,109 @@
+module AdvSearch.Tags exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Set
+import Dict
+import Lib.Autocomplete as A
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Gen.Api as GApi
+import AdvSearch.Query exposing (..)
+import AdvSearch.Set as S
+
+
+
+type alias Model =
+ { sel : S.Model (Int,Int) -- Tag, Level
+ , conf : A.Config Msg GApi.ApiTagResult
+ , search : A.Model GApi.ApiTagResult
+ , spoiler : Int
+ }
+
+type Msg
+ = Sel (S.Msg (Int,Int))
+ | Level (Int,Int) Int
+ | Spoiler
+ | Search (A.Msg GApi.ApiTagResult)
+
+
+init : Data -> (Data, Model)
+init dat =
+ let (ndat, sel) = S.init dat
+ in ( { ndat | objid = ndat.objid + 1 }
+ , { sel = { sel | single = False, and = True }
+ , conf = { wrap = Search, id = "advsearch_tag" ++ String.fromInt ndat.objid, source = A.tagSource }
+ , search = A.init ""
+ , spoiler = dat.defaultSpoil
+ }
+ )
+
+
+update : Data -> Msg -> Model -> (Data, Model, Cmd Msg)
+update dat msg model =
+ case msg of
+ Sel m -> (dat, { model | sel = S.update m model.sel }, Cmd.none)
+ Level (t,ol) nl -> (dat, { model | sel = S.update (S.Sel (t,ol) False) model.sel |> S.update (S.Sel (t,nl) True) }, Cmd.none)
+ Spoiler -> (dat, { model | spoiler = if model.spoiler < 2 then model.spoiler + 1 else 0 }, Cmd.none)
+ Search m ->
+ let (nm, c, res) = A.update model.conf m model.search
+ in case res of
+ Nothing -> (dat, { model | search = nm }, c)
+ Just t ->
+ ( { dat | tags = Dict.insert t.id t dat.tags }
+ , { model | search = A.clear nm "", sel = S.update (S.Sel (t.id,0) True) model.sel }
+ , c )
+
+
+toQuery m = S.toQuery (\o (t,l) -> if m.spoiler == 0 && l == 0 then QInt 8 o t else QTuple 8 o t (l*3+m.spoiler)) m.sel
+
+fromQuery spoil dat q =
+ let f qr = case qr of
+ QInt 8 op t -> if spoil == 0 then Just (op, (t,0)) else Nothing
+ QTuple 8 op t v -> if modBy 3 v == spoil then Just (op, (t,v//3)) else Nothing
+ _ -> Nothing
+ in
+ S.fromQuery f dat q |> Maybe.map (\(ndat,sel) ->
+ ( { ndat | objid = ndat.objid+1 }
+ , { sel = { sel | single = False }
+ , conf = { wrap = Search, id = "advsearch_tag" ++ String.fromInt ndat.objid, source = A.tagSource }
+ , search = A.init ""
+ , spoiler = spoil
+ }
+ ))
+
+
+view : Data -> Model -> (Html Msg, () -> List (Html Msg))
+view dat model =
+ ( case Set.toList model.sel.sel of
+ [] -> b [ class "grayedout" ] [ text "Tags" ]
+ [(s,_)] -> span [ class "nowrap" ]
+ [ S.lblPrefix model.sel
+ , b [ class "grayedout" ] [ text <| "g" ++ String.fromInt s ++ ":" ]
+ , Dict.get s dat.tags |> Maybe.map (\t -> t.name) |> Maybe.withDefault "" |> text
+ ]
+ l -> span [] [ S.lblPrefix model.sel, text <| "Tags (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "Tags" ]
+ , div [ class "opts" ]
+ [ Html.map Sel (S.optsMode model.sel True False)
+ , a [ href "#", onClickD Spoiler ]
+ [ text <| if model.spoiler == 0 then "no spoilers" else if model.spoiler == 1 then "minor spoilers" else "major spoilers" ]
+ , linkRadio model.sel.neg (Sel << S.Neg) [ text "invert" ]
+ ]
+ ]
+ , ul [] <| List.map (\(t,l) ->
+ li []
+ [ inputButton "X" (Sel (S.Sel (t,l) False)) []
+ , inputSelect "" l (Level (t,l)) [style "width" "60px"] <|
+ (0, "any")
+ :: List.map (\i -> (i, String.fromInt (i//5) ++ "." ++ String.fromInt (2*(modBy 5 i)) ++ "+")) (List.range 1 14)
+ ++ [(15, "3.0")]
+ , b [ class "grayedout" ] [ text <| " g" ++ String.fromInt t ++ ": " ]
+ , Dict.get t dat.tags |> Maybe.map (\e -> e.name) |> Maybe.withDefault "" |> text
+ ]
+ ) (Set.toList model.sel.sel)
+ , A.view model.conf model.search [ placeholder "Search..." ]
+ ]
+ )
diff --git a/elm/CharEdit.elm b/elm/CharEdit.elm
index 837fbb4b..acf94de2 100644
--- a/elm/CharEdit.elm
+++ b/elm/CharEdit.elm
@@ -8,8 +8,11 @@ import Browser
import Browser.Navigation exposing (load)
import Dict
import Set
+import Task
+import Process
import File exposing (File)
import File.Select as FSel
+import Lib.Ffi as Ffi
import Lib.Util exposing (..)
import Lib.Html exposing (..)
import Lib.TextPreview as TP
@@ -43,6 +46,7 @@ type Tab
type alias Model =
{ state : Api.State
, tab : Tab
+ , invalidDis : Bool
, editsum : Editsum.Model
, name : String
, original : String
@@ -82,6 +86,7 @@ init : GCE.Recv -> Model
init d =
{ state = Api.Normal
, tab = General
+ , invalidDis = False
, editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden }
, name = d.name
, original = d.original
@@ -158,6 +163,8 @@ vnConfig = { wrap = VnSearch, id = "vnadd", source = A.vnSource }
type Msg
= Editsum Editsum.Msg
| Tab Tab
+ | Invalid Tab
+ | InvalidEnable
| Submit
| Submitted GApi.Response
| Name String
@@ -201,6 +208,9 @@ update msg model =
case msg of
Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
Tab t -> ({ model | tab = t }, Cmd.none)
+ Invalid t -> if model.invalidDis || model.tab == All || model.tab == t then (model, Cmd.none) else
+ ({ model | tab = t, invalidDis = True }, Task.attempt (always InvalidEnable) (Ffi.elemCall "reportValidity" "mainform" |> Task.andThen (\_ -> Process.sleep 100)))
+ InvalidEnable -> ({ model | invalidDis = False }, Cmd.none)
Name s -> ({ model | name = s }, Cmd.none)
Original s -> ({ model | original = s }, Cmd.none)
Alias s -> ({ model | alias = s }, Cmd.none)
@@ -275,6 +285,7 @@ isValid model = not
( (model.name /= "" && model.name == model.original)
|| hasDuplicates (List.map (\v -> (v.vid, Maybe.withDefault 0 v.rid)) model.vns)
|| not (Img.isValid model.image)
+ || (model.mainHas && model.main /= Nothing && model.main == model.id)
)
@@ -289,19 +300,20 @@ view : Model -> Html Msg
view model =
let
geninfo =
- [ formField "name::Name (romaji)" [ inputText "name" model.name Name GCE.valName ]
+ [ formField "name::Name (romaji)" [ inputText "name" model.name Name (onInvalid (Invalid General) :: GCE.valName) ]
, formField "original::Original name"
- [ inputText "original" model.original Original GCE.valOriginal
+ [ inputText "original" model.original Original (onInvalid (Invalid General) :: GCE.valOriginal)
, if model.name /= "" && model.name == model.original
then b [ class "standout" ] [ br [] [], text "Should not be the same as the Name (romaji). Leave blank is the original name is already in the latin alphabet" ]
else text ""
]
, formField "alias::Aliases"
- [ inputTextArea "alias" model.alias Alias (rows 3 :: GCE.valAlias)
+ [ inputTextArea "alias" model.alias Alias (rows 3 :: onInvalid (Invalid General) :: GCE.valAlias)
, br [] []
, text "(Un)official aliases, separated by a newline. Must not include spoilers!"
]
- , formField "desc::Description" [ TP.view "desc" model.desc Desc 600 (style "height" "150px" :: GCE.valDesc) [ b [ class "standout" ] [ text "English please!" ] ] ]
+ , formField "desc::Description" [ TP.view "desc" model.desc Desc 600 (style "height" "150px" :: onInvalid (Invalid General) :: GCE.valDesc)
+ [ b [ class "standout" ] [ text "English please!" ] ] ]
, formField "bmonth::Birthday"
[ inputSelect "bmonth" model.bMonth BMonth [style "width" "128px"]
[ ( 0, "Unknown")
@@ -321,7 +333,7 @@ view model =
, if model.bMonth == 0 then text ""
else inputSelect "" model.bDay BDay [style "width" "70px"] <| List.map (\i -> (i, String.fromInt i)) <| List.range 1 31
]
- , formField "age::Age" [ inputNumber "age" model.age Age GCE.valAge, text " years" ]
+ , formField "age::Age" [ inputNumber "age" model.age Age (onInvalid (Invalid General) :: GCE.valAge), text " years" ]
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Body" ] ]
, formField "gender::Sex"
@@ -338,13 +350,13 @@ view model =
, inputSelect "" gen (\s -> SpoilGender (Just s)) [] GT.genders
]
]
- , formField "sbust::Bust" [ inputNumber "sbust" (if model.sBust == 0 then Nothing else Just model.sBust ) SBust GCE.valS_Bust, text " cm" ]
- , formField "swaist::Waist" [ inputNumber "swiast" (if model.sWaist == 0 then Nothing else Just model.sWaist) SWaist GCE.valS_Waist,text " cm" ]
- , formField "ship::Hips" [ inputNumber "ship" (if model.sHip == 0 then Nothing else Just model.sHip ) SHip GCE.valS_Hip, text " cm" ]
- , formField "height::Height" [ inputNumber "height" (if model.height == 0 then Nothing else Just model.height) Height GCE.valHeight, text " cm" ]
- , formField "weight::Weight" [ inputNumber "weight" model.weight Weight GCE.valWeight, text " kg" ]
- , formField "bloodt::Blood type" [ inputSelect "bloodt" model.bloodt BloodT [] GT.bloodTypes ]
- , formField "cupsize::Cup size" [ inputSelect "cupsize" model.cupSize CupSize [] GT.cupSizes ]
+ , formField "sbust::Bust" [ inputNumber "sbust" (if model.sBust == 0 then Nothing else Just model.sBust ) SBust (onInvalid (Invalid General) :: GCE.valS_Bust), text " cm" ]
+ , formField "swaist::Waist" [ inputNumber "swiast" (if model.sWaist == 0 then Nothing else Just model.sWaist) SWaist (onInvalid (Invalid General) :: GCE.valS_Waist),text " cm" ]
+ , formField "ship::Hips" [ inputNumber "ship" (if model.sHip == 0 then Nothing else Just model.sHip ) SHip (onInvalid (Invalid General) :: GCE.valS_Hip), text " cm" ]
+ , formField "height::Height" [ inputNumber "height" (if model.height == 0 then Nothing else Just model.height) Height (onInvalid (Invalid General) :: GCE.valHeight), text " cm" ]
+ , formField "weight::Weight" [ inputNumber "weight" model.weight Weight (onInvalid (Invalid General) :: GCE.valWeight), text " kg" ]
+ , formField "bloodt::Blood type" [ inputSelect "bloodt" model.bloodt BloodT [onInvalid (Invalid General)] GT.bloodTypes ]
+ , formField "cupsize::Cup size" [ inputSelect "cupsize" model.cupSize CupSize [onInvalid (Invalid General)] GT.cupSizes ]
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Instance" ] ]
] ++ if model.mainRef
@@ -359,6 +371,7 @@ view model =
[ text "Selected character: "
, b [ class "grayedout" ] [ text <| "c" ++ String.fromInt m ++ ": " ]
, a [ href <| "/c" ++ String.fromInt m ] [ text model.mainName ]
+ , if Just m == model.id then b [ class "standout" ] [ br [] [], text "A character can't be an instance of itself. Please select another character or disable the above checkbox to remove the instance." ] else text ""
]) model.main
, br [] []
, A.view mainConfig model.mainSearch [placeholder "Set character..."]
@@ -370,7 +383,7 @@ view model =
[ td [] [ Img.viewImg model.image ]
, td []
[ h2 [] [ text "Image ID" ]
- , input ([ type_ "text", class "text", tabindex 10, value (Maybe.withDefault "" model.image.id), onInputValidation ImageSet ] ++ GCE.valImage) []
+ , input ([ type_ "text", class "text", tabindex 10, value (Maybe.withDefault "" model.image.id), onInvalid (Invalid Image), onInputValidation ImageSet ] ++ GCE.valImage) []
, br [] []
, text "Use an image that already exists on the server or empty to remove the current image."
, br_ 2
@@ -378,13 +391,13 @@ view model =
, inputButton "Browse image" ImageSelect []
, br [] []
, text "Image must be in JPEG or PNG format and at most 10 MiB. Images larger than 256x300 will automatically be resized."
- , case Img.viewVote model.image of
+ , case Img.viewVote model.image ImageMsg (Invalid Image) of
Nothing -> text ""
Just v ->
div []
[ br [] []
, text "Please flag this image: (see the ", a [ href "/d19" ] [ text "image flagging guidelines" ], text " for guidance)"
- , Html.map ImageMsg v
+ , v
]
]
] ]
@@ -483,7 +496,7 @@ view model =
[ ("add", tr [] [ td [ colspan 4 ] [ br_ 1, A.view vnConfig model.vnSearch [placeholder "Add visual novel..."] ] ]) ]
in
- form_ Submit (model.state == Api.Loading)
+ form_ "mainform" Submit (model.state == Api.Loading)
[ div [ class "maintabs left" ]
[ ul []
[ li [ classList [("tabselected", model.tab == General)] ] [ a [ href "#", onClickD (Tab General) ] [ text "General info" ] ]
diff --git a/elm/Discussions/Edit.elm b/elm/Discussions/Edit.elm
index 6008cdef..082b4634 100644
--- a/elm/Discussions/Edit.elm
+++ b/elm/Discussions/Edit.elm
@@ -204,7 +204,7 @@ view model =
in
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox" ]
[ h1 [] [ text <| if model.tid == Nothing then "Create new thread" else "Edit thread" ]
, table [ class "formtable" ] <|
diff --git a/elm/Discussions/Poll.elm b/elm/Discussions/Poll.elm
index 04761530..ec30fb06 100644
--- a/elm/Discussions/Poll.elm
+++ b/elm/Discussions/Poll.elm
@@ -109,7 +109,7 @@ view model =
else text ""
]
in
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox" ]
[ h1 [] [ text model.data.question ]
, table [ class "votebooth" ]
diff --git a/elm/Discussions/PostEdit.elm b/elm/Discussions/PostEdit.elm
index 0eb787d2..a46638a4 100644
--- a/elm/Discussions/PostEdit.elm
+++ b/elm/Discussions/PostEdit.elm
@@ -79,7 +79,7 @@ update msg model =
view : Model -> Html Msg
view model =
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox" ]
[ h1 [] [ text "Edit post" ]
, table [ class "formtable" ] <|
diff --git a/elm/Discussions/Reply.elm b/elm/Discussions/Reply.elm
index 3581c91f..1769b06c 100644
--- a/elm/Discussions/Reply.elm
+++ b/elm/Discussions/Reply.elm
@@ -58,7 +58,7 @@ update msg model =
view : Model -> Html Msg
view model =
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox" ] <| [
if model.old
then
diff --git a/elm/DocEdit.elm b/elm/DocEdit.elm
index 9fbea631..df2b333e 100644
--- a/elm/DocEdit.elm
+++ b/elm/DocEdit.elm
@@ -75,7 +75,7 @@ update msg model =
view : Model -> Html Msg
view model =
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox" ]
[ h1 [] [ text <| "Edit d" ++ String.fromInt model.id ]
, table [ class "formtable" ]
diff --git a/elm/ImageFlagging.elm b/elm/ImageFlagging.elm
index 0e99f1b5..275f90ec 100644
--- a/elm/ImageFlagging.elm
+++ b/elm/ImageFlagging.elm
@@ -47,8 +47,9 @@ type alias Model =
, changes : Dict.Dict String GIV.SendVotes
, saved : Bool
, saveTimer : Bool
- , loadState : Api.State
, saveState : Api.State
+ , loadState : Api.State
+ , loadDone : Bool -- If we have received the last batch of images
, pWidth : Int
, pHeight : Int
}
@@ -71,6 +72,7 @@ init d =
, saveTimer = False
, saveState = Api.Normal
, loadState = Api.Normal
+ , loadDone = False
, pWidth = d.pWidth
, pHeight = d.pHeight
}
@@ -132,7 +134,7 @@ update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
let -- Load more images if we're about to run out
load (m,c) =
- if not m.single && m.loadState /= Api.Loading && Array.length m.images - m.index <= 3
+ if not m.loadDone && not m.single && m.loadState /= Api.Loading && Array.length m.images - m.index <= 3
then ({ m | loadState = Api.Loading }, Cmd.batch [ c, GI.send { excl_voted = m.exclVoted } Load ])
else (m,c)
-- Start a timer to save changes
@@ -158,7 +160,7 @@ update msg model =
Desc s v -> ({ model | desc = (s,v) }, Cmd.none)
Load (GApi.ImageResult l) ->
- let nm = { model | loadState = Api.Normal, images = Array.append model.images (Array.fromList l) }
+ let nm = { model | loadState = Api.Normal, loadDone = List.length l < 30, images = Array.append model.images (Array.fromList l) }
nc = if nm.index < 1000 then nm
else { nm | index = nm.index - 100, images = Array.slice 100 (Array.length nm.images) nm.images }
in pre (nc, Cmd.none)
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/Autocomplete.elm b/elm/Lib/Autocomplete.elm
index 5c5dd33d..9b693e28 100644
--- a/elm/Lib/Autocomplete.elm
+++ b/elm/Lib/Autocomplete.elm
@@ -340,7 +340,7 @@ view cfg model attrs =
)
in div [ class "elm_dd", class "search", style "width" "300px" ]
- [ div [ classList [("hidden", not visible)] ] [ Keyed.node "ul" [] <| msg ++ List.map item model.results ]
- , input
+ [ div [ classList [("hidden", not visible)] ] [ div [] [ Keyed.node "ul" [] <| msg ++ List.map item model.results ] ]
+ , Html.form [] [ input ]
, span [ class "spinner", classList [("hidden", not model.loading)] ] []
]
diff --git a/elm/Lib/DropDown.elm b/elm/Lib/DropDown.elm
index 286a61cb..3de02f11 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
@@ -64,5 +64,5 @@ view conf status lbl cont =
Api.Loading -> [ lbl, span [] [ span [ class "spinner" ] [] ] ]
Api.Error e -> [ b [ class "standout" ] [ text "error" ], span [] [ i [] [ text "▾" ] ] ]
, div [ classList [("hidden", not conf.opened)] ]
- <| if conf.opened then cont () else [ text "" ]
+ [ if conf.opened then div [] (cont ()) else text "" ]
]
diff --git a/elm/Lib/Html.elm b/elm/Lib/Html.elm
index 2d7d516c..01f5d844 100644
--- a/elm/Lib/Html.elm
+++ b/elm/Lib/Html.elm
@@ -25,6 +25,8 @@ onInputValidation msg = custom "input" <|
targetValue
(JD.at ["target", "validity", "valid"] JD.bool)
+onInvalid : msg -> Attribute msg
+onInvalid msg = on "invalid" (JD.succeed msg)
-- Multi-<br> (ugly but oh, so, convenient)
br_ : Int -> Html m
@@ -33,9 +35,9 @@ br_ n = if n == 1 then br [] [] else span [] <| List.repeat n <| br [] []
-- Quick short-hand way of creating a form that can be disabled.
-- Usage:
--- form_ Submit_msg (state == Disabled) [contents]
-form_ : msg -> Bool -> List (Html msg) -> Html msg
-form_ sub dis cont = Html.form [ onSubmit sub ]
+-- form_ id Submit_msg (state == Disabled) [contents]
+form_ : String -> msg -> Bool -> List (Html msg) -> Html msg
+form_ s sub dis cont = Html.form [ id s, onSubmit sub ]
[ fieldset [disabled dis] cont ]
@@ -125,10 +127,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
@@ -154,14 +157,14 @@ inputRadio nam val onch = input (
-- Same as an inputText, but formats/parses an integer as Q###
-inputWikidata : String -> Maybe Int -> (Maybe Int -> m) -> Html m
-inputWikidata nam val onch =
+inputWikidata : String -> Maybe Int -> (Maybe Int -> m) -> List (Attribute m) -> Html m
+inputWikidata nam val onch attr =
inputText nam
(case val of
Nothing -> ""
Just v -> "Q" ++ String.fromInt v)
(\v -> onch <| if v == "" then Nothing else String.toInt <| if String.startsWith "Q" v then String.dropLeft 1 v else v)
- [ pattern "^Q?[1-9][0-9]{0,8}$" ]
+ (pattern "^Q?[1-9][0-9]{0,8}$" :: attr)
-- Similar to inputCheck and inputRadio with a label, except this is just a link.
diff --git a/elm/Lib/Image.elm b/elm/Lib/Image.elm
index 31bab0b3..37cc26b4 100644
--- a/elm/Lib/Image.elm
+++ b/elm/Lib/Image.elm
@@ -143,14 +143,15 @@ viewImg image =
]
-viewVote : Image -> Maybe (Html Msg)
-viewVote model =
+viewVote : Image -> (Msg -> a) -> a -> Maybe (Html a)
+viewVote model wrap msg =
let
rad i sex val = input
[ type_ "radio"
, tabindex 10
, required True
- , onCheck <| (if sex then MySex else MyVio) val
+ , onInvalid msg
+ , onCheck <| \b -> wrap <| (if sex then MySex else MyVio) val b
, checked <| (if sex then i.my_sexual else i.my_violence) == Just val
, name <| "imgvote-" ++ (if sex then "sex" else "vio") ++ "-" ++ Maybe.withDefault "" model.id
] []
diff --git a/elm/Lib/RDate.elm b/elm/Lib/RDate.elm
index 67888114..1eeac80d 100644
--- a/elm/Lib/RDate.elm
+++ b/elm/Lib/RDate.elm
@@ -1,8 +1,9 @@
-- Utility module and UI widget for handling release dates.
--
--- Release dates are integers with the following format: 0 or yyyymmdd
+-- Release dates are integers with the following format: 0, 1 or yyyymmdd
-- Special values
--- 0 -> unknown
+-- 0 -> unknown
+-- 1 -> "today" (only used as filter)
-- 99999999 -> TBA
-- yyyy9999 -> year known, month & day unknown
-- yyyymm99 -> year & month known, day unknown
@@ -47,15 +48,17 @@ fromDate d =
normalize : RDateComp -> RDateComp
normalize r =
- if r.y == 0 then { y = 0, m = 0, d = 0 }
+ if r.y == 0 then { y = 0, m = 0, d = clamp 0 1 r.y }
else if r.y == 9999 then { y = 9999, m = 99, d = 99 }
- else if r.m == 99 then { y = r.y, m = 99, d = 99 }
+ else if r.m == 0 || r.m == 99 then { y = r.y, m = 99, d = 99 }
+ else if r.d == 0 then { r | d = 99 }
else r
format : RDateComp -> String
format date =
case (date.y, date.m, date.d) of
+ ( 0, 0, 1) -> "today"
( 0, _, _) -> "unknown"
(9999, _, _) -> "TBA"
( y, 99, 99) -> String.fromInt y
@@ -76,13 +79,14 @@ display today d =
-- longer valid results in an invalid RDate. It also causes the "-day-" option
-- to be selected (which is good), so I don't expect that many people will try
-- to submit the form without changing it.
-view : RDate -> Bool -> (RDate -> msg) -> Html msg
-view ro permitUnknown msg =
+view : RDate -> Bool -> Bool -> (RDate -> msg) -> Html msg
+view ro permitUnknown permitToday msg =
let r = expand ro
range from to f = List.range from to |> List.map (\n -> (f n |> normalize |> compact, String.fromInt n))
- yl = (if permitUnknown then [(0, "Unknown")] else [])
- ++ [(99999999, "TBA")]
- ++ List.reverse (range 1980 (GT.curYear + 5) (\n -> {r|y=n}))
+ yl = (if permitToday then [(1, "Today" )] else [])
+ ++ (if permitUnknown then [(0, "Unknown")] else [])
+ ++ [(99999999, "TBA")]
+ ++ List.reverse (range 1980 (GT.curYear + 5) (\n -> {r|y=n}))
ml = ({r|m=99} |> normalize |> compact, "- month -") :: range 1 12 (\n -> {r|m=n})
maxDay = Date.fromCalendarDate r.y (Date.numberToMonth r.m) 1 |> Date.add Date.Months 1 |> Date.add Date.Days -1 |> Date.day
dl = ({r|d=99} |> normalize |> compact, "- day -") :: range 1 maxDay (\n -> {r|d=n})
diff --git a/elm/ProducerEdit.elm b/elm/ProducerEdit.elm
index 0fd78375..213f8516 100644
--- a/elm/ProducerEdit.elm
+++ b/elm/ProducerEdit.elm
@@ -177,7 +177,7 @@ view model =
++ titles ++
[ formField "lang::Primary language" [ inputSelect "lang" model.lang Lang [] GT.languages ]
, formField "website::Website" [ inputText "website" model.website Website GPE.valWebsite ]
- , formField "l_wikidata::Wikidata ID" [ inputWikidata "l_wikidata" model.lWikidata LWikidata ]
+ , formField "l_wikidata::Wikidata ID" [ inputWikidata "l_wikidata" model.lWikidata LWikidata [] ]
, formField "desc::Description"
[ TP.view "desc" model.desc Desc 600 (style "height" "180px" :: GPE.valDesc) [ b [ class "standout" ] [ text "English please!" ] ] ]
@@ -200,7 +200,7 @@ view model =
]
newform () =
- form_ DupSubmit (model.state == Api.Loading)
+ form_ "" DupSubmit (model.state == Api.Loading)
[ div [ class "mainbox" ] [ h1 [] [ text "Add a new producer" ], table [ class "formtable" ] titles ]
, div [ class "mainbox" ]
[ if List.isEmpty model.dupProds then text "" else
@@ -220,7 +220,7 @@ view model =
]
fullform () =
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox" ] [ h1 [] [ text "Edit producer" ], table [ class "formtable" ] geninfo ]
, div [ class "mainbox" ] [ fieldset [ class "submit" ]
[ Html.map Editsum (Editsum.view model.editsum)
diff --git a/elm/ReleaseEdit.elm b/elm/ReleaseEdit.elm
index 1bcf91c5..1afd4336 100644
--- a/elm/ReleaseEdit.elm
+++ b/elm/ReleaseEdit.elm
@@ -35,6 +35,7 @@ type alias Model =
, title : String
, original : String
, rtype : String
+ , official : Bool
, patch : Bool
, freeware : Bool
, doujin : Bool
@@ -124,6 +125,7 @@ init d =
, title = d.title
, original = d.original
, rtype = d.rtype
+ , official = d.official
, patch = d.patch
, freeware = d.freeware
, doujin = d.doujin
@@ -168,6 +170,7 @@ encode model =
, title = model.title
, original = model.original
, rtype = model.rtype
+ , official = model.official
, patch = model.patch
, freeware = model.freeware
, doujin = model.doujin
@@ -205,6 +208,7 @@ type Msg
= Title String
| Original String
| RType String
+ | Official Bool
| Patch Bool
| Freeware Bool
| Doujin Bool
@@ -244,6 +248,7 @@ update msg model =
Title s -> ({ model | title = s }, Cmd.none)
Original s -> ({ model | original = s }, Cmd.none)
RType s -> ({ model | rtype = s }, Cmd.none)
+ Official b -> ({ model | official = b }, Cmd.none)
Patch b -> ({ model | patch = b }, Cmd.none)
Freeware b -> ({ model | freeware = b }, Cmd.none)
Doujin b -> ({ model | doujin = b }, Cmd.none)
@@ -341,11 +346,12 @@ viewGen model =
, tr [ class "newpart" ] [ td [] [] ]
, formField "rtype::Type" [ inputSelect "rtype" model.rtype RType [] GT.releaseTypes ]
, formField "minage::Age rating" [ inputSelect "minage" model.minage Minage [] GT.ageRatings, text " (*)" ]
+ , formField "" [ label [] [ inputCheck "" model.official Official, text " Official (i.e. sanctioned by the original developer of the visual novel)" ] ]
, formField "" [ label [] [ inputCheck "" model.patch Patch , text " This release is a patch to another release.", text " (*)" ] ]
, formField "" [ label [] [ inputCheck "" model.freeware Freeware, text " Freeware (i.e. available at no cost)" ] ]
, if model.patch then text "" else
formField "" [ label [] [ inputCheck "" model.doujin Doujin , text " Doujin (self-published, not by a company)" ] ]
- , formField "Release date" [ D.view model.released False Released, text " Leave month or day blank if they are unknown." ]
+ , formField "Release date" [ D.view model.released False False Released, text " Leave month or day blank if they are unknown." ]
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Format" ] ]
, formField "Language(s)"
@@ -436,7 +442,7 @@ viewGen model =
view : Model -> Html Msg
view model =
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox" ]
[ h1 [] [ text "General info" ]
, viewGen model
diff --git a/elm/Report.elm b/elm/Report.elm
index f63a9411..f9fb1cd3 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
@@ -158,7 +163,7 @@ view (state,model) =
lst = List.filter (\l -> l.vis model.object) reasons
cur = List.filter (\l -> l.label == model.reason) lst |> List.head |> Maybe.withDefault initial
in
- form_ Submit (state == Api.Loading)
+ form_ "" Submit (state == Api.Loading)
[ div [ class "mainbox" ]
[ h1 [] [ text "Submit report" ]
, if state == Api.Error GApi.Success
diff --git a/elm/Reviews/Comment.elm b/elm/Reviews/Comment.elm
index fba37168..8b2399dd 100644
--- a/elm/Reviews/Comment.elm
+++ b/elm/Reviews/Comment.elm
@@ -38,7 +38,7 @@ update msg (state,id,content) =
view : Model -> Html Msg
view (state,_,content) =
- form_ Submit (state == Api.Loading)
+ form_ "" Submit (state == Api.Loading)
[ div [ class "mainbox" ]
[ fieldset [ class "submit" ]
[ TP.view "msg" content Content 600 ([rows 4, cols 50] ++ GRC.valMsg)
diff --git a/elm/Reviews/Edit.elm b/elm/Reviews/Edit.elm
index 925de964..c5314e27 100644
--- a/elm/Reviews/Edit.elm
+++ b/elm/Reviews/Edit.elm
@@ -110,7 +110,7 @@ view model =
maxChars = if model.isfull then 100000 else 800
len = String.length model.text.data
in
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox" ]
[ h1 [] [ text <| if model.id == Nothing then "Submit a review" else "Edit review" ]
, p [] [ b [] [ text "Rules" ] ]
diff --git a/elm/StaffEdit.elm b/elm/StaffEdit.elm
index 134a409b..a2e4f5df 100644
--- a/elm/StaffEdit.elm
+++ b/elm/StaffEdit.elm
@@ -178,7 +178,7 @@ view model =
]
in
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox staffedit" ]
[ h1 [] [ text "General info" ]
, table [ class "formtable" ]
@@ -191,7 +191,7 @@ view model =
] ]
, formField "lang::Primary Language" [ inputSelect "lang" model.lang Lang [] GT.languages ]
, formField "l_site::Official page" [ inputText "l_site" model.l_site Website (style "width" "400px" :: GSE.valL_Site) ]
- , formField "l_wikidata::Wikidata ID" [ inputWikidata "l_wikidata" model.l_wikidata LWikidata ]
+ , formField "l_wikidata::Wikidata ID" [ inputWikidata "l_wikidata" model.l_wikidata LWikidata [] ]
, formField "l_twitter::Twitter username" [ inputText "l_twitter" model.l_twitter LTwitter GSE.valL_Twitter ]
, formField "l_anidb::AniDB Creator ID" [ inputText "l_anidb" (Maybe.withDefault "" (Maybe.map String.fromInt model.l_anidb)) LAnidb GSE.valL_Anidb ]
, formField "l_pixiv::Pixiv ID" [ inputText "l_pixiv" (if model.l_pixiv == 0 then "" else String.fromInt model.l_pixiv) LPixiv GSE.valL_Pixiv ]
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..2501f7b1
--- /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..97ce93b0 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 "💬" ]
]
@@ -249,7 +248,7 @@ viewFoot state changed add addMsg =
-- The table has a lot of interactivity, the use of Html.Lazy is absolutely necessary for good responsiveness.
view : Model -> Html Msg
view model =
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox" ]
[ h1 [] [ text <| "Edit tags for " ++ model.title ]
, p []
diff --git a/elm/TraitEdit.elm b/elm/TraitEdit.elm
new file mode 100644
index 00000000..6b257ce1
--- /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/UList/SaveDefault.elm b/elm/UList/SaveDefault.elm
index a0945c4b..0ab2cef9 100644
--- a/elm/UList/SaveDefault.elm
+++ b/elm/UList/SaveDefault.elm
@@ -56,7 +56,7 @@ update msg model =
view : Model -> Html Msg
view model =
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ classList [("savedefault", True), ("hidden", model.hid)] ]
[ b [] [ text "Save as default" ]
, br [] []
diff --git a/elm/User/Edit.elm b/elm/User/Edit.elm
index d09c77ae..43ec1212 100644
--- a/elm/User/Edit.elm
+++ b/elm/User/Edit.elm
@@ -266,7 +266,7 @@ view model =
, formField "css::Custom CSS" [ inputTextArea "css" m.customcss (Prefs << Css) ([ rows 5, cols 60 ] ++ GUE.valPrefsCustomcss) ]
]
- in form_ Submit (model.state == Api.Loading)
+ in form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox" ]
[ h1 [] [ text model.title ]
, table [ class "formtable" ] <|
diff --git a/elm/User/Login.elm b/elm/User/Login.elm
index 8b9c15c3..c1c55dfe 100644
--- a/elm/User/Login.elm
+++ b/elm/User/Login.elm
@@ -134,7 +134,7 @@ view model =
]
]
- in form_ Submit (model.state == Api.Loading)
+ in form_ "" Submit (model.state == Api.Loading)
[ if model.insecure then changeBox else loginBox
, div [ class "mainbox" ]
[ fieldset [ class "submit" ]
diff --git a/elm/User/PassReset.elm b/elm/User/PassReset.elm
index 641767d4..dbcb1d57 100644
--- a/elm/User/PassReset.elm
+++ b/elm/User/PassReset.elm
@@ -59,7 +59,7 @@ view model =
[ p [] [ text "Your password has been reset and instructions to set a new one should reach your mailbox in a few minutes." ] ]
]
else
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox" ]
[ h1 [] [ text "Forgot Password" ]
, p []
diff --git a/elm/User/PassSet.elm b/elm/User/PassSet.elm
index 618b4ba1..9ad10748 100644
--- a/elm/User/PassSet.elm
+++ b/elm/User/PassSet.elm
@@ -66,7 +66,7 @@ update msg model =
view : Model -> Html Msg
view model =
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox" ]
[ h1 [] [ text "Set your password" ]
, p [] [ text "Now you can set a password for your account. You will be logged in automatically after your password has been saved." ]
diff --git a/elm/User/Register.elm b/elm/User/Register.elm
index 9afdded4..de8e36ce 100644
--- a/elm/User/Register.elm
+++ b/elm/User/Register.elm
@@ -70,7 +70,7 @@ view model =
[ p [] [ text "Your account has been created! In a few minutes, you should receive an email with instructions to set your password." ] ]
]
else
- form_ Submit (model.state == Api.Loading)
+ form_ "" Submit (model.state == Api.Loading)
[ div [ class "mainbox" ]
[ h1 [] [ text "Create an account" ]
, table [ class "formtable" ]
diff --git a/elm/VNEdit.elm b/elm/VNEdit.elm
index 4fadbf2d..9051a85a 100644
--- a/elm/VNEdit.elm
+++ b/elm/VNEdit.elm
@@ -8,8 +8,11 @@ import Browser
import Browser.Navigation exposing (load)
import Dict
import Set
+import Task
+import Process
import File exposing (File)
import File.Select as FSel
+import Lib.Ffi as Ffi
import Lib.Util exposing (..)
import Lib.Html exposing (..)
import Lib.TextPreview as TP
@@ -46,6 +49,7 @@ type Tab
type alias Model =
{ state : Api.State
, tab : Tab
+ , invalidDis : Bool
, editsum : Editsum.Model
, title : String
, original : String
@@ -65,6 +69,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
@@ -80,6 +85,7 @@ init : GVE.Recv -> Model
init d =
{ state = Api.Normal
, tab = General
+ , invalidDis = False
, editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden }
, title = d.title
, original = d.original
@@ -99,6 +105,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
@@ -146,6 +153,8 @@ seiyuuConfig = { wrap = SeiyuuSearch, id = "seiyuuadd", source = A.staffSource }
type Msg
= Editsum Editsum.Msg
| Tab Tab
+ | Invalid Tab
+ | InvalidEnable
| Submit
| Submitted GApi.Response
| Title String
@@ -184,11 +193,27 @@ 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
Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
Tab t -> ({ model | tab = t }, Cmd.none)
+ Invalid t -> if model.invalidDis || model.tab == All || model.tab == t then (model, Cmd.none) else
+ ({ model | tab = t, invalidDis = True }, Task.attempt (always InvalidEnable) (Ffi.elemCall "reportValidity" "mainform" |> Task.andThen (\_ -> Process.sleep 100)))
+ InvalidEnable -> ({ model | invalidDis = False }, Cmd.none)
Title s -> ({ model | title = s, dupVNs = [] }, Cmd.none)
Original s -> ({ model | original = s, dupVNs = [] }, Cmd.none)
Alias s -> ({ model | alias = s, dupVNs = [] }, Cmd.none)
@@ -248,20 +273,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 +311,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)
)
@@ -303,13 +322,13 @@ view model =
let
titles =
[ formField "title::Title (romaji)"
- [ inputText "title" model.title Title (style "width" "500px" :: GVE.valTitle)
+ [ inputText "title" model.title Title (style "width" "500px" :: onInvalid (Invalid General) :: GVE.valTitle)
, if containsNonLatin model.title
then b [ class "standout" ] [ br [] [], text "This title field should only contain latin-alphabet characters, please put the \"actual\" title in the field below and the romanization above." ]
else text ""
]
, formField "original::Original title"
- [ inputText "original" model.original Original (style "width" "500px" :: GVE.valOriginal)
+ [ inputText "original" model.original Original (style "width" "500px" :: onInvalid (Invalid General) :: GVE.valOriginal)
, if model.title /= "" && model.title == model.original
then b [ class "standout" ] [ br [] [], text "Should not be the same as the Title (romaji). Leave blank is the original title is already in the latin alphabet" ]
else if model.original /= "" && String.toLower model.title /= String.toLower model.original && not (containsNonLatin model.original)
@@ -317,7 +336,7 @@ view model =
else text ""
]
, formField "alias::Aliases"
- [ inputTextArea "alias" model.alias Alias (rows 3 :: GVE.valAlias)
+ [ inputTextArea "alias" model.alias Alias (rows 3 :: onInvalid (Invalid General) :: GVE.valAlias)
, br [] []
, if hasDuplicates <| String.lines <| String.toLower model.alias
then b [ class "standout" ] [ text "List contains duplicate aliases.", br [] [] ]
@@ -339,12 +358,12 @@ view model =
geninfo = titles ++
[ formField "desc::Description"
- [ TP.view "desc" model.desc Desc 600 (style "height" "180px" :: GVE.valDesc) [ b [ class "standout" ] [ text "English please!" ] ]
+ [ TP.view "desc" model.desc Desc 600 (style "height" "180px" :: onInvalid (Invalid General) :: GVE.valDesc) [ b [ class "standout" ] [ text "English please!" ] ]
, text "Short description of the main story. Please do not include spoilers, and don't forget to list the source in case you didn't write the description yourself."
]
, formField "length::Length" [ inputSelect "length" model.length Length [] GT.vnLengths ]
- , formField "l_wikidata::Wikidata ID" [ inputWikidata "l_wikidata" model.lWikidata LWikidata ]
- , formField "l_renai::Renai.us link" [ text "http://renai.us/game/", inputText "l_renai" model.lRenai LRenai [], text ".shtml" ]
+ , formField "l_wikidata::Wikidata ID" [ inputWikidata "l_wikidata" model.lWikidata LWikidata [onInvalid (Invalid General)] ]
+ , formField "l_renai::Renai.us link" [ text "http://renai.us/game/", inputText "l_renai" model.lRenai LRenai (onInvalid (Invalid General) :: GVE.valL_Renai), text ".shtml" ]
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Database relations" ] ]
, formField "Related VNs"
@@ -381,7 +400,7 @@ view model =
[ td [] [ Img.viewImg model.image ]
, td []
[ h2 [] [ text "Image ID" ]
- , input ([ type_ "text", class "text", tabindex 10, value (Maybe.withDefault "" model.image.id), onInputValidation ImageSet ] ++ GVE.valImage) []
+ , input ([ type_ "text", class "text", tabindex 10, value (Maybe.withDefault "" model.image.id), onInputValidation ImageSet, onInvalid (Invalid Image) ] ++ GVE.valImage) []
, br [] []
, text "Use an image that already exists on the server or empty to remove the current image."
, br_ 2
@@ -389,13 +408,13 @@ view model =
, inputButton "Browse image" ImageSelect []
, br [] []
, text "Preferably the cover of the CD/DVD/package. Image must be in JPEG or PNG format and at most 10 MiB. Images larger than 256x400 will automatically be resized."
- , case Img.viewVote model.image of
+ , case Img.viewVote model.image ImageMsg (Invalid Image) of
Nothing -> text ""
Just v ->
div []
[ br [] []
, text "Please flag this image: (see the ", a [ href "/d19" ] [ text "image flagging guidelines" ], text " for guidance)"
- , Html.map ImageMsg v
+ , v
]
]
] ]
@@ -433,7 +452,7 @@ view model =
[ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| "s" ++ String.fromInt s.id ++ ":" ] ]
, td [] [ a [ href <| "/s" ++ String.fromInt s.id ] [ text s.name ] ]
, td [] [ inputSelect "" s.role (StaffRole n) [style "width" "150px" ] GT.creditTypes ]
- , td [] [ inputText "" s.note (StaffNote n) (style "width" "300px" :: GVE.valStaffNote) ]
+ , td [] [ inputText "" s.note (StaffNote n) (style "width" "300px" :: onInvalid (Invalid Staff) :: GVE.valStaffNote) ]
, td [] [ inputButton "remove" (StaffDel n) [] ]
]
in table [] <| head ++ [ foot ] ++ List.indexedMap item model.staff
@@ -472,7 +491,7 @@ view model =
, td []
[ b [ class "grayedout" ] [ text <| "s" ++ String.fromInt s.id ++ ":" ]
, a [ href <| "/s" ++ String.fromInt s.id ] [ text s.name ] ]
- , td [] [ inputText "" s.note (SeiyuuNote n) (style "width" "300px" :: GVE.valSeiyuuNote) ]
+ , td [] [ inputText "" s.note (SeiyuuNote n) (style "width" "300px" :: onInvalid (Invalid Cast) :: GVE.valSeiyuuNote) ]
, td [] [ inputButton "remove" (SeiyuuDel n) [] ]
]
in
@@ -499,7 +518,7 @@ view model =
dimstr (x,y) = String.fromInt x ++ "x" ++ String.fromInt y
in
[ td [] [ Img.viewImg i ]
- , td [] [ Img.viewVote i |> Maybe.map (Html.map (ScrMsg id)) |> Maybe.withDefault (text "") ]
+ , td [] [ Img.viewVote i (ScrMsg id) (Invalid Screenshots) |> Maybe.withDefault (text "") ]
, td []
[ b [] [ text <| "Screenshot #" ++ String.fromInt (n+1) ]
, text " (", a [ href "#", onClickD (ScrDel id) ] [ text "remove" ], text ")"
@@ -531,7 +550,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."
@@ -576,7 +601,7 @@ view model =
]
newform () =
- form_ DupSubmit (model.state == Api.Loading)
+ form_ "" DupSubmit (model.state == Api.Loading)
[ div [ class "mainbox" ] [ h1 [] [ text "Add a new visual novel" ], table [ class "formtable" ] titles ]
, div [ class "mainbox" ]
[ if List.isEmpty model.dupVNs then text "" else
@@ -596,7 +621,7 @@ view model =
]
fullform () =
- form_ Submit (model.state == Api.Loading)
+ form_ "mainform" Submit (model.state == Api.Loading)
[ div [ class "maintabs left" ]
[ ul []
[ li [ classList [("tabselected", model.tab == General )] ] [ a [ href "#", onClickD (Tab General ) ] [ text "General info" ] ]
diff --git a/elm/searchtabs.js b/elm/searchtabs.js
new file mode 100644
index 00000000..eed07ffc
--- /dev/null
+++ b/elm/searchtabs.js
@@ -0,0 +1,11 @@
+document.querySelectorAll('#searchtabs a').forEach(function(l) {
+ l.onclick = function() {
+ var str = document.getElementById('q').value;
+ if(str.length > 1) {
+ if(this.href.indexOf('/g') >= 0 || this.href.indexOf('/i') >= 0)
+ this.href += '/list';
+ this.href += '?q=' + encodeURIComponent(str);
+ }
+ return true;
+ };
+});
diff --git a/lib/Multi/API.pm b/lib/Multi/API.pm
index ac496949..8f6b75d9 100644
--- a/lib/Multi/API.pm
+++ b/lib/Multi/API.pm
@@ -828,7 +828,7 @@ my %GET_CHARACTER = (
},
flags => {
basic => {
- select => 'c.name, c.original, c.gender, c.bloodt, c.b_day, c.b_month',
+ select => 'c.name, c.original, c.gender, c.spoil_gender, c.bloodt, c.b_day, c.b_month',
proc => sub {
$_[0]{original} ||= undef;
$_[0]{gender} = undef if $_[0]{gender} eq 'unknown';
@@ -837,18 +837,20 @@ my %GET_CHARACTER = (
},
},
details => {
- select => 'c.alias AS aliases, vndbid_num(c.image) as image, i.c_sexual_avg, i.c_violence_avg, i.c_votecount, c."desc" AS description',
+ select => 'c.alias AS aliases, vndbid_num(c.image) as image, i.c_sexual_avg, i.c_violence_avg, i.c_votecount, c."desc" AS description, c.age',
proc => sub {
$_[0]{aliases} ||= undef;
$_[0]{description} ||= undef;
$_[0]{image} = $_[0]{image} ? sprintf '%s/ch/%02d/%d.jpg', config->{url_static}, $_[0]{image}%100, $_[0]{image} : undef;
$_[0]{image_flagging} = image_flagging $_[0]{image}, $_[0];
+ $_[0]{age}*=1 if defined $_[0]{age};
},
},
meas => {
- select => 'c.s_bust AS bust, c.s_waist AS waist, c.s_hip AS hip, c.height, c.weight',
+ select => 'c.s_bust AS bust, c.s_waist AS waist, c.s_hip AS hip, c.height, c.weight, c.cup_size',
proc => sub {
$_[0]{$_} = $_[0]{$_} ? $_[0]{$_}*1 : undef for(qw|bust waist hip height weight|);
+ $_[0]{cup_size} ||= undef;
},
},
traits => {
diff --git a/lib/Multi/Denpa.pm b/lib/Multi/Denpa.pm
index bdecd085..99c60231 100644
--- a/lib/Multi/Denpa.pm
+++ b/lib/Multi/Denpa.pm
@@ -4,18 +4,13 @@ use strict;
use warnings;
use Multi::Core;
use AnyEvent::HTTP;
-use JSON::XS 'decode_json';
-use MIME::Base64 'encode_base64';
use VNDB::Config;
-use TUWF::Misc 'uri_escape';
+use VNDB::ExtLinks ();
my %C = (
- api => '',
- user => '',
- pass => '',
clean_timeout => 48*3600,
- check_timeout => 15*60,
+ check_timeout => 10*60,
);
@@ -42,26 +37,25 @@ sub run {
sub data {
my($time, $id, $body, $hdr) = @_;
my $prefix = sprintf '[%.1fs] %s', $time, $id;
- return AE::log warn => "$prefix ERROR: $hdr->{Status} $hdr->{Reason}" if $hdr->{Status} !~ /^2/;
+ return AE::log warn => "$prefix ERROR: $hdr->{Status} $hdr->{Reason}" if $hdr->{Status} !~ /^(2|404)/;
- my $data = eval { decode_json $body };
- if(!$data) {
- AE::log warn => "$prefix Error decoding JSON: $@";
- return;
- }
+ my $listprice = $body =~ m{<meta property="product:price:amount" content="([^"]+)"} && $1;
+ my $currency = $body =~ m{<meta property="product:price:currency" content="([^"]+)"} && $1;
+ my $availability = $body =~ m{<meta property="product:availability" content="([^"]+)"} && $1;
+ my $sku = $body =~ m{<meta property="product:retailer_item_id" content="([^"]+)"} ? $1 : '';
- my($prod) = $data->{products}->@*;
+ # Meta properties aren't set if the product has multiple SKU's (e.g. multi-platform), fall back to some json-ld string.
+ ($listprice, $currency) = ($1,$2) if !$listprice && $body =~ /"priceSpecification":\{"price":"([^"]+)","priceCurrency":"([^"]+)"/;
- if(!$prod || !$prod->{published_at}) {
+ if($hdr->{Status} eq '404' || !$listprice || !$availability || $availability ne 'instock') {
pg_cmd q{UPDATE shop_denpa SET deadsince = COALESCE(deadsince, NOW()), lastfetch = NOW() WHERE id = $1}, [ $id ];
- AE::log info => "$prefix not found.";
+ AE::log info => "$prefix not found or not in stock.";
} else {
- my $price = 'US$ '.$prod->{variants}[0]{price};
- $price = 'free' if $price eq 'US$ 0.00';
+ my $price = $listprice eq '0.00' ? 'free' : ($currency eq 'USD' ? 'US$' : $currency).' '.$listprice;
pg_cmd 'UPDATE shop_denpa SET deadsince = NULL, lastfetch = NOW(), sku = $2, price = $3 WHERE id = $1',
- [ $prod->{handle}, $prod->{variants}[0]{sku}, $price ];
- AE::log debug => "$prefix for $price at $prod->{variants}[0]{sku}";
+ [ $id, $sku, $price ];
+ AE::log debug => "$prefix for $price at $sku";
}
}
@@ -73,9 +67,8 @@ sub sync {
my $id = $res->value(0,0);
my $ts = AE::now;
- my $code = encode_base64("$C{user}:$C{pass}", '');
- http_get $C{api}.'?handle='.uri_escape($id),
- headers => {'User-Agent' => $C{ua}, Authorization => "Basic $code"},
+ http_get sprintf($VNDB::ExtLinks::LINKS{r}{l_denpa}{fmt}, $id),
+ headers => {'User-Agent' => $C{ua}},
timeout => 60,
sub { data(AE::now-$ts, $id, @_) };
};
diff --git a/lib/VNDB/Config.pm b/lib/VNDB/Config.pm
index 01b67a58..12c01509 100644
--- a/lib/VNDB/Config.pm
+++ b/lib/VNDB/Config.pm
@@ -30,7 +30,7 @@ my $config = {
trace_log => 0,
dlsite_url => 'https://www.dlsite.com/%s/work/=/product_id/%%s.html',
- denpa_url => 'https://denpasoft.com/products/%s',
+ denpa_url => 'https://denpasoft.com/product/%s/',
jlist_url => 'https://www.jlist.com/%s',
jbox_url => 'https://www.jbox.com/%s',
mg_r18_url => 'https://www.mangagamer.com/r18/detail.php?product_code=%d',
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/ExtLinks.pm b/lib/VNDB/ExtLinks.pm
index 96b3dd9c..8a28291d 100644
--- a/lib/VNDB/ExtLinks.pm
+++ b/lib/VNDB/ExtLinks.pm
@@ -97,9 +97,9 @@ our %LINKS = (
, regex => qr{([a-z0-9_-]+\.itch\.io/[a-z0-9_-]+)}
, patt => 'https://<artist>.itch.io/<product>' },
l_denpa => { label => 'Denpasoft'
- , fmt => 'https://denpasoft.com/products/%s'
+ , fmt => 'https://denpasoft.com/product/%s/'
, fmt2 => config->{denpa_url}
- , regex => qr{(?:www\.)?denpasoft\.com/products/([a-z0-9-]+).*} },
+ , regex => qr{(?:www\.)?denpasoft\.com/products?/([a-z0-9-]+).*} },
l_jlist => { label => 'J-List'
, fmt => 'https://www.jlist.com/%s'
, fmt2 => sub { config->{ shift->{l_jlist_jbox} ? 'jbox_url' : 'jlist_url' } }
diff --git a/lib/VNDB/Func.pm b/lib/VNDB/Func.pm
index 508b2272..accf3df7 100644
--- a/lib/VNDB/Func.pm
+++ b/lib/VNDB/Func.pm
@@ -309,11 +309,12 @@ sub form_compare {
}
-# Encode query parameters. Takes a hash or hashref with key/values, supports array values.
+# Encode query parameters. Takes a hash or hashref with key/values, supports array values and objects that implement query_encode().
sub query_encode {
my $o = @_ == 1 ? $_[0] : {@_};
return join '&', map {
my($k, $v) = ($_, $o->{$_});
+ $v = $v->query_encode() if ref $v && ref $v ne 'ARRAY';
!defined $v ? () : ref $v ? map "$k=".uri_escape($_), sort @$v : "$k=".uri_escape($v)
} sort keys %$o;
}
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/Types.pm b/lib/VNDB/Types.pm
index 1f8e5867..468e6fa1 100644
--- a/lib/VNDB/Types.pm
+++ b/lib/VNDB/Types.pm
@@ -26,6 +26,7 @@ hash LANGUAGE =>
en => 'English',
eo => 'Esperanto',
es => 'Spanish',
+ fa => 'Persian',
fi => 'Finnish',
fr => 'French',
gd => 'Scottish Gaelic',
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/AdvSearch.pm b/lib/VNWeb/AdvSearch.pm
new file mode 100644
index 00000000..5f6622a2
--- /dev/null
+++ b/lib/VNWeb/AdvSearch.pm
@@ -0,0 +1,486 @@
+package VNWeb::AdvSearch;
+
+# This module comes with query definitions and helper functions to handle
+# advanced search queries. Usage is as follows:
+#
+# my $q = tuwf->validate(get => f => { advsearch => 'v' })->data;
+#
+# $q->sql_where; # Returns an SQL condition for use in a where clause.
+# $q->elm_; # Instantiate an Elm widget
+
+
+use v5.26;
+use warnings;
+use B;
+use POSIX 'strftime';
+use TUWF;
+use VNWeb::Auth;
+use VNWeb::DB;
+use VNWeb::Validation;
+use VNDB::Types;
+
+
+# Search queries should be seen as some kind of low-level assembly for
+# generating complex queries, they're designed to be simple to implement,
+# powerful, extendable and stable. They're also a pain to work with, but that
+# comes with the trade-off.
+#
+# A search query can be expressed in three different representations.
+#
+# Normalized JSON form:
+#
+# $Query = $Combinator || $Predicate
+# $Combinator = [ 'and'||'or', $Query, .. ]
+# $Predicate = [ $Field, $Op, $Value ]
+# $Op = '=', '!=', '>=', '>', '<=', '<'
+# $Field = $string
+# $Value = $Query || $field_specific_json_value
+#
+# This representation is used internally and can be exposed as an API.
+# Eventually.
+#
+# Example:
+#
+# [ 'and'
+# , [ 'or' # No support for array values, so IN() queries need explicit ORs.
+# , [ 'lang', '=', 'en' ]
+# , [ 'lang', '=', 'de' ]
+# , [ 'lang', '=', 'fr' ]
+# ]
+# , [ 'olang', '!=', 'ja' ]
+# , [ 'release', '=', [ 'and' # VN has a release that matches the given query
+# , [ 'released', '>=', '2020-01-01' ]
+# , [ 'developer', '=', 'p30' ]
+# ]
+# ]
+# ]
+#
+# Compact JSON form:
+#
+# $Query = $Combinator || $Predicate
+# $Combinator = [ 0||1, $Query, .. ]
+# $Predicate = [ $Field, $Op, $Value ]
+# $Op = '=', '!=', '>=', '>', '<=', '<'
+# $Field = $integer
+# $Tuple = [ $integer, $integer ]
+# $Value = $integer || $string || $Query || $Tuple
+#
+# Compact JSON form uses integers to represent field names and 'and'/'or'.
+# The field numbers are specific to the query type (e.g. visual novel and
+# release queries). The accepted forms of $Value are much more limited and
+# conversion of values between compact and normalized form is
+# field-dependent.
+#
+# This representation is used as an intermediate format between the
+# normalized JSON form and the compact encoded form. Conversion between
+# normalized JSON and compact JSON form requires knowledge about all fields
+# and their accepted values, while conversion between compact JSON form and
+# compact encoded form can be done mechanically. This is the reason why Elm
+# works with the compact JSON form.
+#
+# Same example:
+#
+# [ 0
+# , [ 1
+# , [ 2, '=', 'de' ]
+# , [ 2, '=', 'en' ]
+# , [ 2, '=', 'fr' ]
+# ]
+# , [ 3, '!=', 'ja' ]
+# , [ 50, '=', [ 0
+# , [ 7, '>=', 20200101 ]
+# , [ 6, '=', 30 ]
+# ]
+# ]
+# ]
+#
+# Compact encoded form:
+#
+# Alternative and more compact representation of the compact JSON form.
+# Intended for use in a URL query string, used characters: [0-9a-zA-Z_-]
+# (plus any unicode characters that may be present in string fields).
+# Not intended to be easy to parse or work with, optimized for short length.
+#
+# Same example: 03132gde2gen2gfr3hjaN180272_0c2vQ60u
+
+
+# INTEGER ENCODING
+#
+# Positive integers are encoded in such a way that the first character
+# indicates the length of the encoded integer, this allows integers to be
+# concatenated without any need for a delimiter. Low numbers are encoded
+# fully in a single character. The two-character encoding uses 10 values from
+# the first character in order to make efficient use of space. The last 5
+# values of the first character are used to indicate the length of integers
+# needing more than 2 characters to encode.
+#
+# Alphabet: 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-
+# (that's base64-url, but with different indices)
+#
+# Full encoding format is as follows:
+# (# representing a character from the alphabet)
+#
+# FIRST FORMAT MIN VALUE MAX VALUE
+# 0..M # 0 48 -> Direct lookup in the alphabet
+# N..W ## 49 688 -> 49 + ($first_character-'N')*64 + $second_character
+# X X## 689 4_784 -> 689 + $first_character*64 + $second_character
+# Y Y### 4_785 266_928 etc.
+# Z Z#### 266_929 17_044_144
+# _ -##### 17_044_145 1_090_785_968
+# - _###### 1_090_785_969 69_810_262_704
+#
+# STRING ENCODING
+#
+# Strings are encoded as-is, with the following characters escaped:
+#
+# [SPACE]!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
+#
+# Escaping is done by taking the index of the character into the above list,
+# encoding that index to an integer according to the integer encoding rules
+# as described above and prefixing it with '_'. Example:
+#
+# "a b-c" -> "a_0b_dc"
+#
+# The end of a string can either be indicated with a '-' character, or the
+# length of the string can be encoded in a preceding field.
+#
+# QUERY ENCODING
+#
+# Int(n) refers to the integer encoding described above.
+# Escape(s) refers to the string encoding described above.
+#
+# $Query = $Predicate | $Combinator
+#
+# $CombiType = 'and' => 0, 'or' => 1
+# $Combinator = Int($CombiType) Int($num_queries) $Query..
+#
+# $Predicate = Int($field_number) $TypedOp $Value
+#
+# Both a Predicate and a Combinator start with an encoded integer. For
+# Combinator this is 0 or 1, for Predicate this is the field number (>=2).
+# A Query must either be self-delimiting or encode its own length, so that
+# these can be directly concatenated.
+#
+# $Op = '=' => 0, '!=' => 1, '>=' => 2, '>' => 3, '<=' => 4, '<' => 5
+# $Type = integer => 0, query => 1, string2 => 2, string3 => 3, stringn => 4, Tuple => 5
+# $TypedOp = Int( $Type*8 + $Op )
+# $Tuple = Int($first) Int($second)
+# $Value = Int($integer)
+# | Escape($string2) | Escape($string3) | Escape($stringn) '-'
+# | $Query
+# | $Tuple
+#
+# The encoded field number of a Predicate is followed by a single encoded
+# integer that covers both the operator and the type of the value. This
+# encoding leaves room for 2 additional operators. There are 3 different
+# string types: string2 and string3 are fixed-length strings of 2 and 3
+# characters, respectively, and $stringn is an arbitrary-length string that
+# ends with the '-' character.
+
+
+my @alpha = (0..9, 'a'..'z', 'A'..'Z', '_', '-');
+my %alpha = map +($alpha[$_],$_), 0..$#alpha;
+
+# Assumption: @escape has less than 49 characters.
+my @escape = split //, " !\"#\$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
+my %escape = map +($escape[$_],$alpha[$_]), 0..$#escape;
+my $escape_re = qr{([${\quotemeta join '', @escape}])};
+
+my @ops = qw/= != >= > <= </;
+my %ops = map +($ops[$_],$_), 0..$#ops;
+
+sub _unescape_str { $_[0] =~ s{_(.)}{ $escape[$alpha{$1} // return] // return }reg }
+sub _escape_str { $_[0] =~ s/$escape_re/_$escape{$1}/rg }
+
+# Read a '-'-delimited string.
+sub _dec_str {
+ my($s, $i) = @_;
+ my $start = $$i;
+ $$i >= length $s and return while substr($s, $$i++, 1) ne '-';
+ _unescape_str substr $s, $start, $$i-$start-1;
+}
+
+sub _dec_int {
+ my($s, $i) = @_;
+ my $c1 = ($alpha{substr $s, $$i++, 1} // return);
+ return $c1 if $c1 < 49;
+ my $n = ($alpha{substr $s, $$i++, 1} // return);
+ return 49 + ($c1-49)*64 + $n if $c1 < 59;
+ $n = $n*64 + ($alpha{substr $s, $$i++, 1} // return) for (1..$c1-59+1);
+ $n + (689, 4785, 266929, 17044145, 1090785969)[$c1-59]
+}
+
+sub _dec_query {
+ my($s, $i) = @_;
+ my $c1 = _dec_int($s, $i) // return;
+ my $c2 = _dec_int($s, $i) // return;
+ return [ $c1, map +(_dec_query($s, $i) // return), 1..$c2 ] if $c1 <= 1;
+ my($op, $type) = ($c2 % 8, int ($c2 / 8));
+ [ $c1, $ops[$op],
+ $type == 0 ? (_dec_int($s, $i) // return) :
+ $type == 1 ? (_dec_query($s, $i) // return) :
+ $type == 2 ? do { my $v = _unescape_str(substr $s, $$i, 2) // return; $$i += 2; $v } :
+ $type == 3 ? do { my $v = _unescape_str(substr $s, $$i, 3) // return; $$i += 3; $v } :
+ $type == 4 ? (_dec_str($s, $i) // return) :
+ $type == 5 ? [ _dec_int($s, $i) // return, _dec_int($s, $i) // return ] : undef ]
+}
+
+sub _enc_int {
+ my($n) = @_;
+ return if $n < 0;
+ return $alpha[$n] if $n < 49;
+ return $alpha[49 + int(($n-49)/64)] . $alpha[($n-49)%64] if $n < 689;
+ sub r { ($_[0] > 1 ? r($_[0]-1,int $_[1]/64) : '').$alpha[$_[1]%64] }
+ return 'X'.r 2, $n - 689 if $n < 4785;
+ return 'Y'.r 3, $n - 4785 if $n < 266929;
+ return 'Z'.r 4, $n - 266929 if $n < 17044145;
+ return '_'.r 5, $n - 17044145 if $n < 1090785969;
+ return '-'.r 6, $n - 1090785969 if $n < 69810262705;
+}
+
+sub _is_tuple { ref $_[0] eq 'ARRAY' && $_[0]->@* == 2 && (local $_ = $_[0][1]) =~ /^[0-9]+$/ }
+
+# Assumes that the query is already in compact JSON form.
+sub _enc_query {
+ my($q) = @_;
+ return ($alpha[$q->[0]])._enc_int($#$q).join '', map _enc_query($_), @$q[1..$#$q] if $q->[0] <= 1;
+ my sub r { _enc_int($q->[0])._enc_int($ops{$q->[1]} + 8*$_[0]) }
+ return r(5)._enc_int($q->[2][0])._enc_int($q->[2][1]) if _is_tuple $q->[2];
+ return r(1)._enc_query($q->[2]) if ref $q->[2];
+ if(!(B::svref_2object(\$q->[2])->FLAGS & B::SVp_POK)) {
+ my $s = _enc_int $q->[2];
+ return r(0).$s if defined $s;
+ }
+ my $esc = _escape_str $q->[2];
+ return r(2).$esc if length $esc == 2;
+ return r(3).$esc if length $esc == 3;
+ r(4).$esc.'-';
+}
+
+
+
+
+# Define a $Field, args:
+# $type -> 'v', 'c', etc.
+# $name -> $Field name, must be stable and unique for the $type.
+# $num -> Numeric identifier for compact encoding, must be >= 2 and same requirements as $name.
+# Fields that don't occur often should use numbers above 50, for better encoding of common fields.
+# $value -> TUWF::Validate schema for value validation, or $query_type to accept a nested query.
+# %options:
+# $op -> Operator definitions and sql() generation functions.
+# sql -> sql() generation function that is called for all operators.
+# compact -> Function to convert a value from normalized JSON form into compact JSON form.
+#
+# An implementation for the '!=' operator will be supplied automatically if it's not explicitely defined.
+# NOTE: That implementation does NOT work for NULL values.
+my(%FIELDS, %NUMFIELDS);
+sub f {
+ my($t, $num, $n, $v, @opts) = @_;
+ my %f = (
+ num => $num,
+ value => ref $v eq 'HASH' ? tuwf->compile($v) : $v,
+ @opts,
+ );
+ $f{'!='} = sub { sql 'NOT (', $f{'='}->(@_), ')' } if $f{'='} && !$f{'!='};
+ $f{vndbid} = ref $v eq 'HASH' && $v->{vndbid} && !ref $v->{vndbid} && $v->{vndbid};
+ $f{int} = ref $f{value} && ($v->{fuzzyrdate} || $f{value}->analyze->{type} eq 'int' || $f{value}->analyze->{type} eq 'bool');
+ $FIELDS{$t}{$n} = \%f;
+ $NUMFIELDS{$t}{$num} = $n;
+}
+
+
+
+f v => 2 => 'lang', { enum => \%LANGUAGE }, '=' => sub { sql 'v.c_languages && ARRAY', \$_, '::language[]' };
+f v => 3 => 'olang', { enum => \%LANGUAGE }, '=' => sub { sql 'v.c_olang && ARRAY', \$_, '::language[]' };
+f v => 4 => 'platform', { enum => \%PLATFORM }, '=' => sub { sql 'v.c_platforms && ARRAY', \$_, '::platform[]' };
+f v => 5 => 'length', { uint => 1, enum => \%VN_LENGTH }, '=' => sub { sql 'v.length =', \$_ };
+f v => 7 => 'released', { fuzzyrdate => 1 }, sql => sub { sql 'v.c_released', $_[0], \($_ == 1 ? strftime('%Y%m%d', gmtime) : $_) };
+f v => 9 => 'popularity',{ uint => 1, range => [ 0, 100] }, sql => sub { sql 'v.c_popularity', $_[0], \($_/100) };
+f v => 10 => 'rating', { uint => 1, range => [10, 100] }, sql => sub { sql 'v.c_rating <> 0 AND v.c_rating', $_[0], \$_ };
+f v => 11 => 'vote-count',{ uint => 1, range => [ 0,1<<30] }, sql => sub { sql 'v.c_votecount', $_[0], \$_ };
+
+f v => 6 => 'developer',{ vndbid => 'p' },
+ '=' => sub { sql 'v.id IN(SELECT rv.vid FROM releases r JOIN releases_vn rv ON rv.id = r.id JOIN releases_producers rp ON rp.id = r.id
+ WHERE NOT r.hidden AND rp.pid = vndbid_num(', \$_, ') AND rp.developer)' };
+
+f v => 8 => 'tag', { type => 'any', func => \&_validate_tag },
+ compact => sub { my $id = ($_->[0] =~ s/^g//r)*1; $_->[1] == 0 && $_->[2] == 0 ? $id : [ $id, int($_->[2]*5)*3 + $_->[1] ] },
+ '=' => sub { sql 'v.id IN(SELECT vid FROM tags_vn_inherit WHERE tag = vndbid_num(', \$_->[0], ') AND spoiler <=', \$_->[1], 'AND rating >=', \$_->[2], ')' };
+
+f v => 50 => 'release', 'r', '=' => sub { sql 'v.id IN(SELECT rv.vid FROM releases r JOIN releases_vn rv ON rv.id = r.id WHERE NOT r.hidden AND', $_, ')' };
+f v => 51 => 'character','c', '=' => sub { sql 'v.id IN(SELECT cv.vid FROM chars c JOIN chars_vns cv ON cv.id = c.id WHERE NOT c.hidden AND', $_, ')' }; # TODO: Spoiler setting?
+
+
+
+f r => 2 => 'lang', { enum => \%LANGUAGE }, '=' => sub { sql 'r.id IN(SELECT id FROM releases_lang WHERE lang =', \$_, ')' };
+f r => 4 => 'platform', { enum => \%PLATFORM }, '=' => sub { sql 'r.id IN(SELECT id FROM releases_platforms WHERE platform =', \$_, ')' };
+f r => 6 => 'developer',{ vndbid => 'p' }, '=' => sub { sql 'r.id IN(SELECT id FROM releases_producers WHERE developer AND pid = vndbid_num(', \$_, '))' };
+f r => 7 => 'released', { fuzzyrdate => 1 }, sql => sub { sql 'r.released', $_[0], \($_ == 1 ? strftime('%Y%m%d', gmtime) : $_) };
+
+
+
+f c => 2 => 'role', { enum => \%CHAR_ROLE }, '=' => sub { sql 'cv.role =', \$_ }; # TODO: SQL is different when not used as a subquery in VN search
+f c => 3 => 'blood-type', { enum => \%BLOOD_TYPE }, '=' => sub { sql 'c.bloodt =', \$_ };
+f c => 4 => 'sex', { enum => \%GENDER }, '=' => sub { sql 'c.gender =', \$_ };
+f c => 5 => 'sex-spoil', { enum => \%GENDER }, '=' => sub { sql '(c.gender =', \$_, 'AND c.spoil_gender IS NULL) OR c.spoil_gender IS NOT DISTINCT FROM', \$_ };
+f c => 6 => 'height', { uint => 1, max => 32767 }, sql => sub { sql 'c.height <> 0 AND c.height', $_[0], \$_ };
+f c => 7 => 'weight', { uint => 1, max => 32767 }, sql => sub { sql 'c.weight', $_[0], \$_ };
+f c => 8 => 'bust', { uint => 1, max => 32767 }, sql => sub { sql 'c.s_bust <> 0 AND c.s_bust', $_[0], \$_ };
+f c => 9 => 'waist', { uint => 1, max => 32767 }, sql => sub { sql 'c.s_waist <> 0 AND c.s_waist', $_[0], \$_ };
+f c => 10 => 'hips', { uint => 1, max => 32767 }, sql => sub { sql 'c.s_hip <> 0 AND c.s_hip', $_[0], \$_ };
+f c => 11 => 'cup', { enum => \%CUP_SIZE }, sql => sub { sql 'c.cup_size <> \'\' AND c.cup_size', $_[0], \$_ };
+f c => 12 => 'age', { uint => 1, max => 32767 }, sql => sub { sql 'c.age <> 0 AND c.age', $_[0], \$_ };
+
+
+
+
+# Accepts either $tag or [$tag, int($minlevel*5)*3+$maxspoil] (for compact form) or [$tag, $maxspoil, $minlevel]. Normalizes to the latter.
+sub _validate_tag {
+ $_[0] = [$_[0],0,0] if ref $_[0] ne 'ARRAY'; # just a tag id
+ my $v = tuwf->compile({ vndbid => 'g' })->validate($_[0][0]);
+ return 0 if $v->err;
+ $_[0][0] = $v->data;
+ if($_[0]->@* == 2) { # compact form
+ return 0 if !defined $_[0][1] || ref $_[0][1] || $_[0][1] !~ /^[0-9]+$/;
+ ($_[0][1],$_[0][2]) = ($_[0][1]%3, int($_[0][1]/3)/5);
+ }
+ # normalized form
+ return 0 if $_[0]->@* != 3;
+ return 0 if !defined $_[0][1] || ref $_[0][1] || $_[0][1] !~ /^[0-2]$/;
+ return 0 if !defined $_[0][2] || ref $_[0][2] || $_[0][2] !~ /^(?:[0-2](?:\.[0-9]+)?|3(?:\.0+)?)$/;
+ 1
+}
+
+
+sub _validate {
+ my($t, $q) = @_;
+ return { msg => 'Invalid query' } if ref $q ne 'ARRAY' || @$q < 2 || !defined $q->[0] || ref $q->[0];
+
+ $q->[0] = $q->[0] == 0 ? 'and' : $q->[0] == 1 ? 'or'
+ : $NUMFIELDS{$t}{$q->[0]} // return { msg => 'Unknown field', field => $q->[0] }
+ if $q->[0] =~ /^[0-9]+$/;
+
+ # combinator
+ if($q->[0] eq 'and' || $q->[0] eq 'or') {
+ for(@$q[1..$#$q]) {
+ my $r = _validate($t, $_);
+ return $r if !$r || ref $r;
+ }
+ return 1;
+ }
+
+ # predicate
+ return { msg => 'Invalid predicate' } if @$q != 3 || !defined $q->[1] || ref $q->[1];
+ my $f = $FIELDS{$t}{$q->[0]};
+ return { msg => 'Unknown field', field => $q->[0] } if !$f;
+ return { msg => 'Invalid operator', field => $q->[0], op => $q->[1] } if !defined $ops{$q->[1]} || (!$f->{$q->[1]} && !$f->{sql});
+ return _validate($f->{value}, $q->[2]) if !ref $f->{value};
+ my $r = $f->{value}->validate($q->[2]);
+ return { msg => 'Invalid value', field => $q->[0], value => $q->[2], error => $r->err } if $r->err;
+ $q->[2] = $r->data;
+ 1
+}
+
+
+# 'advsearch' validation, accepts either a compact encoded string, JSON string or an already decoded array.
+TUWF::set('custom_validations')->{advsearch} = sub { my($t) = @_; +{ required => 0, type => 'any', default => bless({type=>$t}, __PACKAGE__), func => sub {
+ return { msg => 'Invalid JSON', error => $@ =~ s{[\s\r\n]* at /[^ ]+ line.*$}{}smr } if !ref $_[0] && $_[0] =~ /^\[/ && !eval { $_[0] = JSON::XS->new->decode($_[0]); 1 };
+ if(!ref $_[0]) {
+ my($v,$i) = ($_[0],0);
+ return { msg => 'Invalid compact encoded form', character_index => $i } if !($_[0] = _dec_query($v, \$i));
+ return { msg => 'Trailing garbage' } if $i != length $v;
+ }
+ my $v = _validate($t, @_);
+ $_[0] = bless { type => $t, query => $_[0] }, __PACKAGE__ if $v;
+ $v
+} } };
+
+
+sub _sql_where {
+ my($t, $q) = @_;
+ return sql_and map _sql_where($t, $_), @$q[1..$#$q] if $q->[0] eq 'and';
+ return sql_or map _sql_where($t, $_), @$q[1..$#$q] if $q->[0] eq 'or';
+
+ my $f = $FIELDS{$t}{$q->[0]};
+ my $func = $f->{$q->[1]} || $f->{sql};
+ local $_ = ref $f->{value} ? $q->[2] : _sql_where($f->{value}, $q->[2]);
+ $func->($q->[1]);
+}
+
+
+sub sql_where {
+ my($self) = @_;
+ $self->{query} ? _sql_where $self->{type}, $self->{query} : '1=1';
+}
+
+
+sub _compact_json {
+ my($t, $q) = @_;
+ return [ $q->[0] eq 'and' ? 0 : 1, map _compact_json($t, $_), @$q[1..$#$q] ] if $q->[0] eq 'and' || $q->[0] eq 'or';
+
+ my $f = $FIELDS{$t}{$q->[0]};
+ [ int $f->{num}, $q->[1],
+ $f->{compact} ? do { local $_ = $q->[2]; $f->{compact}->($_) }
+ : _is_tuple( $q->[2]) ? [ int($q->[2][0] =~ s/^[a-z]//rg), int($q->[2][1]) ]
+ : $f->{vndbid} ? int ($q->[2] =~ s/^$f->{vndbid}//rg)
+ : $f->{int} ? int $q->[2]
+ : ref $f->{value} ? "$q->[2]" : _compact_json($f->{value}, $q->[2])
+ ]
+}
+
+
+sub compact_json {
+ my($self) = @_;
+ $self->{compact} //= $self->{query} && _compact_json($self->{type}, $self->{query});
+ $self->{compact};
+}
+
+
+sub _extract_ids {
+ my($t,$q,$ids) = @_;
+ if($q->[0] eq 'and' || $q->[0] eq 'or') {
+ _extract_ids($t, $_, $ids) for @$q[1..$#$q];
+ } else {
+ my $f = $FIELDS{$t}{$q->[0]};
+ $ids->{$q->[2]} = 1 if $f->{vndbid};
+ $ids->{$q->[2][0]} = 1 if ref $f->{value} && ref $q->[2] eq 'ARRAY'; # Ugly heuristic, may have false positives
+ _extract_ids($f->{value}, $q->[2], $ids) if !ref $f->{value};
+ }
+}
+
+
+sub elm_ {
+ my($self) = @_;
+
+ my(%o,%ids);
+ _extract_ids($self->{type}, $self->{query}, \%ids) if $self->{query};
+
+ $o{producers} = [ map +{id => $_=~s/^p//rg}, grep /^p/, keys %ids ];
+ enrich_merge id => 'SELECT id, name, original, hidden FROM producers WHERE id IN', $o{producers};
+
+ $o{tags} = [ map +{id => $_=~s/^g//rg}, grep /^g/, keys %ids ];
+ enrich_merge id => 'SELECT id, name, searchable, applicable, state FROM tags WHERE id IN', $o{tags};
+
+ $o{qtype} = $self->{type};
+ $o{query} = $self->compact_json;
+ $o{defaultSpoil} = auth->pref('spoilers')||0;
+
+ state $schema ||= tuwf->compile({ type => 'hash', keys => {
+ qtype => {},
+ query => { type => 'array', required => 0 },
+ defaultSpoil => { uint => 1 },
+ producers => $VNWeb::Elm::apis{ProducerResult}[0],
+ tags => $VNWeb::Elm::apis{TagResult}[0],
+ }});
+ VNWeb::HTML::elm_ 'AdvSearch.Main', $schema, \%o;
+}
+
+
+sub query_encode {
+ my($self) = @_;
+ return if !$self->{query};
+ $self->{query_encode} //= _enc_query $self->compact_json;
+ $self->{query_encode};
+}
+
+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/Chars/Edit.pm b/lib/VNWeb/Chars/Edit.pm
index 392f8f35..db52f6c1 100644
--- a/lib/VNWeb/Chars/Edit.pm
+++ b/lib/VNWeb/Chars/Edit.pm
@@ -69,7 +69,7 @@ TUWF::get qr{/$RE{crev}/(?<action>edit|copy)} => sub {
$e->{main_ref} = tuwf->dbVali('SELECT 1 FROM chars WHERE main =', \$e->{id})||0;
enrich_merge tid => 'SELECT t.id AS tid, t.name, t.applicable, g.name AS group, g.order AS order, false AS new FROM traits t LEFT JOIN traits g ON g.id = t.group WHERE t.id IN', $e->{traits};
- $e->{traits} = [ sort { ($a->{order}//99) <=> ($b->{order}//99) || $a->{name} cmp $b->{name} } $e->{traits}->@* ];
+ $e->{traits} = [ sort { ($a->{order}//99) <=> ($b->{order}//99) || $a->{name} cmp $b->{name} } grep !$copy || $_->{applicable}, $e->{traits}->@* ];
enrich_merge vid => 'SELECT id AS vid, title FROM vn WHERE id IN', $e->{vns};
$e->{vns} = [ sort { $a->{title} cmp $b->{title} || $a->{vid} <=> $b->{vid} || ($a->{rid}||0) <=> ($b->{rid}||0) } $e->{vns}->@* ];
diff --git a/lib/VNWeb/Chars/Page.pm b/lib/VNWeb/Chars/Page.pm
index 8a9966ae..af1fd8c6 100644
--- a/lib/VNWeb/Chars/Page.pm
+++ b/lib/VNWeb/Chars/Page.pm
@@ -78,7 +78,7 @@ sub _rev_ {
[ b_day => 'Birthday/day', empty => 0 ],
[ s_bust => 'Bust', empty => 0 ],
[ s_waist => 'Waist', empty => 0 ],
- [ s_hip => 'Hip', empty => 0 ],
+ [ s_hip => 'Hips', empty => 0 ],
[ height => 'Height', empty => 0 ],
[ weight => 'Weight', ],
[ bloodt => 'Blood type', fmt => \%BLOOD_TYPE ],
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..97bdd96b 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 tuwf->req->{js} || tuwf->req->{pagevars}{elm};
+}
+
+
# Options:
# title => $title
# index => 1/0, default 0
@@ -444,6 +490,7 @@ sub framework_ {
my $cont = pop;
my %o = @_;
tuwf->req->{pagevars} = { $o{pagevars}->%* } if $o{pagevars};
+ tuwf->req->{js} ||= $o{js};
html_ lang => 'en', sub {
head_ sub { _head_ \%o };
@@ -456,12 +503,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;
}
}
}
@@ -511,6 +553,7 @@ sub _revision_fmtcol_ {
my sub sep_ { b_ class => 'standout', '<...>' }; # Context separator
td_ class => 'tcval', sub {
+ i_ '[empty]' if @$l > 1 && (($i == 1 && !grep $_->[0] ne '+', @$l) || ($i == 2 && !grep $_->[0] ne '-', @$l));
join_ $opt->{join}||\&br_, sub {
my($ch, $old, $new, $diff) = @$_;
my $val = $_->[$i];
@@ -540,10 +583,10 @@ sub _revision_fmtcol_ {
}
} elsif(@$l > 1 && $i == 2 && ($ch eq '+' || $ch eq 'c')) {
- b_ class => 'diff_add', sub { _revision_fmtval_ $opt, $val, $obj }
+ b_ class => 'diff_add', sub { _revision_fmtval_ $opt, $val, $obj };
} elsif(@$l > 1 && $i == 1 && ($ch eq '-' || $ch eq 'c')) {
- b_ class => 'diff_del', sub { _revision_fmtval_ $opt, $val, $obj }
- } elsif($ch eq 'c' || $ch eq 'u' || @$l == 1) {
+ b_ class => 'diff_del', sub { _revision_fmtval_ $opt, $val, $obj };
+ } elsif($ch eq 'u' || @$l == 1) {
_revision_fmtval_ $opt, $val, $obj;
}
}, @$l;
@@ -746,6 +789,7 @@ sub sortable_ {
sub searchbox_ {
my($sel, $value) = @_;
+ tuwf->req->{js} = 1;
fieldset_ class => 'search', sub {
p_ id => 'searchtabs', sub {
a_ href => '/v/all', $sel eq 'v' ? (class => 'sel') : (), 'Visual novels';
diff --git a/lib/VNWeb/Images/Vote.pm b/lib/VNWeb/Images/Vote.pm
index e8a8b2f1..aba0342f 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
@@ -42,14 +30,22 @@ elm_api Images => $SEND, { excl_voted => { anybool => 1 } }, sub {
# hopefully enough to get a good (weighted) sample and should have a good
# chance at selecting images even when the user has voted on 90%.
#
- # Performance can be further improved by adding a 'images.c_uids integer[]'
- # cache to filter out already voted images faster.
- my $tablesample = 100 * min 1, (5000 / $stats->{referenced}) * ($stats->{total} / $stats->{referenced});
+ # TABLESAMPLE is not used if there are only few images to select from, i.e.
+ # when the user has already voted on 99% of all images. Finding all
+ # applicable images in that case is slow, but at least there aren't many
+ # rows for the final ORDER BY.
+ my $tablesample =
+ !$data->{excl_voted} || tuwf->dbVali('SELECT c_imgvotes FROM users WHERE id =', \auth->uid) < $stats->{referenced}*0.99
+ ? 100 * min 1, (5000 / $stats->{referenced}) * ($stats->{total} / $stats->{referenced})
+ : 100;
+
+ # NOTE: Elm assumes that, if it receives less than 30 images, we've reached
+ # the end of the list and will not attempt to load more.
my $l = tuwf->dbAlli('
SELECT id
- FROM images i TABLESAMPLE SYSTEM (', \$tablesample, ')
+ FROM images TABLESAMPLE SYSTEM (', \$tablesample, ')
WHERE c_weight > 0',
- $data->{excl_voted} ? ('AND NOT EXISTS(SELECT 1 FROM image_votes iv WHERE iv.id = i.id AND iv.uid =', \auth->uid, ')') : (), '
+ $data->{excl_voted} ? ('AND NOT (c_uids && ARRAY[', \auth->uid, '::int])') : (), '
ORDER BY random() ^ (1.0/c_weight) DESC
LIMIT', \30
);
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/Releases/Edit.pm b/lib/VNWeb/Releases/Edit.pm
index 0163521b..ebdb9850 100644
--- a/lib/VNWeb/Releases/Edit.pm
+++ b/lib/VNWeb/Releases/Edit.pm
@@ -8,6 +8,7 @@ my $FORM = {
title => { maxlength => 300 },
original => { required => 0, default => '', maxlength => 250 },
rtype => { default => 'complete', enum => \%RELEASE_TYPE },
+ official => { anybool => 1 },
patch => { anybool => 1 },
freeware => { anybool => 1 },
doujin => { anybool => 1 },
@@ -114,6 +115,7 @@ TUWF::get qr{/$RE{vid}/add}, sub {
title => $v->{title},
original => $v->{original},
vn => [{vid => $v->{id}, title => $v->{title}}],
+ official => 1,
};
enrich_form $e;
diff --git a/lib/VNWeb/Releases/Lib.pm b/lib/VNWeb/Releases/Lib.pm
index 4aad7b50..2694cfb9 100644
--- a/lib/VNWeb/Releases/Lib.pm
+++ b/lib/VNWeb/Releases/Lib.pm
@@ -26,7 +26,7 @@ sub releases_by_vn {
# Assumption: Each release already has id, type, patch, released, gtin and enrich_extlinks().
sub enrich_release {
my($r) = @_;
- enrich_merge id => 'SELECT id, title, original, notes, minage, freeware, doujin, reso_x, reso_y, voiced, ani_story, ani_ero, uncensored FROM releases WHERE id IN', $r;
+ enrich_merge id => 'SELECT id, title, original, notes, minage, official, freeware, doujin, reso_x, reso_y, voiced, ani_story, ani_ero, uncensored FROM releases WHERE id IN', $r;
enrich_merge id => sql('SELECT rid as id, status as rlist_status FROM rlists WHERE uid =', \auth->uid, 'AND rid IN'), $r if auth;
enrich_flatten lang => id => id => sub { sql 'SELECT id, lang FROM releases_lang WHERE id IN', $_, 'ORDER BY id, lang' }, $r;
enrich_flatten platforms => id => id => sub { sql 'SELECT id, platform FROM releases_platforms WHERE id IN', $_, 'ORDER BY id, platform' }, $r;
@@ -52,13 +52,15 @@ sub release_extlinks_ {
abbr_ class => 'icons external', title => 'External link', '';
};
div_ sub {
- ul_ sub {
- li_ sub {
- a_ href => $_->[1], sub {
- span_ $_->[2] if length $_->[2];
- txt_ $_->[0];
- }
- } for $r->{extlinks}->@*;
+ div_ sub {
+ ul_ sub {
+ li_ sub {
+ a_ href => $_->[1], sub {
+ span_ $_->[2] if length $_->[2];
+ txt_ $_->[0];
+ }
+ } for $r->{extlinks}->@*;
+ }
}
}
}
@@ -107,7 +109,8 @@ sub release_row_ {
};
td_ class => 'tc4', sub {
a_ href => "/r$r->{id}", title => $r->{original}||$r->{title}, $r->{title};
- b_ class => 'grayedout', ' (patch)' if $r->{patch};
+ my $note = join ' ', $r->{official} ? () : 'unofficial', $r->{patch} ? 'patch' : ();
+ b_ class => 'grayedout', " ($note)" if $note;
};
td_ class => 'tc_icons', sub { icons_ $r };
td_ class => 'tc_prod', join ' & ', $r->{publisher} ? 'Pub' : (), $r->{developer} ? 'Dev' : () if $prodpage;
diff --git a/lib/VNWeb/Releases/Page.pm b/lib/VNWeb/Releases/Page.pm
index e60d84b6..bc885c33 100644
--- a/lib/VNWeb/Releases/Page.pm
+++ b/lib/VNWeb/Releases/Page.pm
@@ -24,6 +24,7 @@ sub _rev_ {
revision_ r => $r, \&enrich_item,
[ vn => 'Relations', fmt => sub { a_ href => "/v$_->{vid}", title => $_->{original}||$_->{title}, $_->{title} } ],
[ type => 'Type' ],
+ [ official => 'Official', fmt => 'bool' ],
[ patch => 'Patch', fmt => 'bool' ],
[ freeware => 'Freeware', fmt => 'bool' ],
[ doujin => 'Doujin', fmt => 'bool' ],
@@ -82,6 +83,7 @@ sub _infotable_ {
abbr_ class => "icons rt$r->{type}", title => $r->{type}, ' ';
txt_ ' '.$RELEASE_TYPE{$r->{type}};
txt_ ', patch' if $r->{patch};
+ txt_ ', unofficial' if !$r->{official};
}
};
diff --git a/lib/VNWeb/Reviews/Edit.pm b/lib/VNWeb/Reviews/Edit.pm
index a3323d62..c7b829bc 100644
--- a/lib/VNWeb/Reviews/Edit.pm
+++ b/lib/VNWeb/Reviews/Edit.pm
@@ -101,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 927a39f4..e9337e2f 100644
--- a/lib/VNWeb/Reviews/Page.pm
+++ b/lib/VNWeb/Reviews/Page.pm
@@ -46,12 +46,12 @@ 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};
+ br_ if $w->{c_flagged} || $w->{locked} || ($w->{spoiler} && (auth->pref('spoilers')||0) == 2);
if($w->{c_flagged}) {
br_;
b_ class => 'grayedout', 'Flagged: this review is below the voting threshold and not visible on the VN page.';
@@ -60,6 +60,10 @@ sub review_ {
br_;
b_ class => 'grayedout', 'Locked: commenting on this review has been disabled.';
}
+ if($w->{spoiler} && (auth->pref('spoilers')||0) == 2) {
+ br_;
+ b_ 'This review contains spoilers.';
+ }
}
};
tr_ class => 'reviewnotspoil', sub {
@@ -94,7 +98,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
);
@@ -119,10 +123,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,
diff --git a/lib/VNWeb/Reviews/VNTab.pm b/lib/VNWeb/Reviews/VNTab.pm
index 796193b1..b2120e18 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'
@@ -28,7 +28,10 @@ sub reviews_ {
article_ class => 'reviewbox', sub {
my $r = $_;
div_ sub {
- span_ sub { txt_ 'By '; user_ $r; txt_ ' on '.fmtdate $r->{date}, 'compact' };
+ span_ sub {
+ txt_ 'By '; user_ $r; txt_ ' on '.fmtdate $r->{date}, 'compact';
+ b_ class => 'grayedout', ' contains spoilers' if $r->{spoiler} && (auth->pref('spoilers')||0) == 2;
+ };
a_ href => "/r$r->{rid}", "r$r->{rid}" if $r->{rid};
span_ "Vote: ".fmtvote($r->{vote}) if $r->{vote};
};
@@ -43,7 +46,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 +58,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/ULists/Elm.pm b/lib/VNWeb/ULists/Elm.pm
index 4fd032dc..9177496e 100644
--- a/lib/VNWeb/ULists/Elm.pm
+++ b/lib/VNWeb/ULists/Elm.pm
@@ -57,13 +57,14 @@ elm_api UListManageLabels => undef, $LABELS, sub {
my @delete_all = map $_->{id}, grep $_->{delete} == 3, @delete;
# delete vns with: (a label in option 3) OR ((a label in option 2) AND (no labels other than in option 1 or 2))
- my @where =
+ my @where = (
@delete_all ? sql('vid IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl IN', \@delete_all, ')') : (),
@delete_empty ? sql(
'vid IN(SELECT vid FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl IN', \@delete_empty, ')',
- 'AND NOT EXISTS(SELECT 1 FROM ulist_vns_labels WHERE uid =', \$uid, 'AND lbl NOT IN(', [ @delete_lblonly, @delete_empty ], '))'
- ) : ();
- tuwf->dbExeci('DELETE FROM ulist_vns WHERE uid =', \$uid, 'AND (', sql_or(@where), ')') if @where;
+ 'AND NOT EXISTS(SELECT 1 FROM ulist_vns_labels uvl WHERE uvl.vid = uv.vid AND uid =', \$uid, 'AND lbl NOT IN', [ @delete_lblonly, @delete_empty ], ')'
+ ) : ()
+ );
+ tuwf->dbExeci('DELETE FROM ulist_vns uv WHERE uid =', \$uid, 'AND (', sql_or(@where), ')') if @where;
# (This will also delete all relevant vn<->label rows from ulist_vns_labels)
tuwf->dbExeci('DELETE FROM ulist_labels WHERE uid =', \$uid, 'AND id IN', [ map $_->{id}, @delete ]) if @delete;
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/List.pm b/lib/VNWeb/VN/List.pm
new file mode 100644
index 00000000..fbcd1d99
--- /dev/null
+++ b/lib/VNWeb/VN/List.pm
@@ -0,0 +1,108 @@
+package VNWeb::VN::List;
+
+use VNWeb::Prelude;
+use VNWeb::AdvSearch;
+
+
+sub listing_ {
+ my($opt, $list, $count) = @_;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ paginate_ \&url, $opt->{p}, [$count, 50], 't';
+ div_ class => 'mainbox browse vnbrowse', sub {
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc1', sub { txt_ 'Title'; sortable_ 'title', $opt, \&url };
+ td_ class => 'tc7', '';
+ td_ class => 'tc2', '';
+ td_ class => 'tc3', '';
+ td_ class => 'tc4', sub { txt_ 'Released'; sortable_ 'rel', $opt, \&url };
+ td_ class => 'tc5', sub { txt_ 'Popularity'; sortable_ 'pop', $opt, \&url };
+ td_ class => 'tc6', sub { txt_ 'Rating'; sortable_ 'rating', $opt, \&url };
+ } };
+ tr_ sub {
+ td_ class => 'tc1', sub { a_ href => "/v$_->{id}", title => $_->{original}||$_->{title}, $_->{title} };
+ td_ class => 'tc7', sub {
+ b_ class => $_->{userlist_obtained} == $_->{userlist_all} ? 'done' : 'todo', sprintf '%d/%d', $_->{userlist_obtained}, $_->{userlist_all} if $_->{userlist_all};
+ abbr_ title => join(', ', $_->{vnlist_labels}->@*), scalar $_->{vnlist_labels}->@* if $_->{vnlist_labels} && $_->{vnlist_labels}->@*;
+ abbr_ title => 'No labels', ' ' if $_->{vnlist_labels} && !$_->{vnlist_labels}->@*;
+ };
+ td_ class => 'tc2', sub { join_ '', sub { abbr_ class => "icons $_", title => $PLATFORM{$_}, '' if $_ ne 'unk' }, sort $_->{platforms}->@* };
+ td_ class => 'tc3', sub { join_ '', sub { abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' }, reverse sort $_->{lang}->@* };
+ td_ class => 'tc4', sub { rdate_ $_->{c_released} };
+ td_ class => 'tc5', sprintf '%.2f', ($_->{c_popularity}||0)*100;
+ td_ class => 'tc6', sub {
+ txt_ sprintf '%.2f', ($_->{c_rating}||0)/10;
+ b_ class => 'grayedout', sprintf ' (%d)', $_->{c_votecount};
+ };
+ } for @$list;
+ }
+ };
+ paginate_ \&url, $opt->{p}, [$count, 50], 'b';
+}
+
+
+TUWF::get qr{/experimental/v}, sub {
+ my $opt = tuwf->validate(get =>
+ q => { onerror => '' },
+ p => { upage => 1 },
+ f => { advsearch => 'v' },
+ s => { onerror => 'title', enum => [qw/title rel pop rating/] },
+ o => { onerror => 'a', enum => ['a','d'] },
+ )->data;
+
+ my $where = sql_and
+ 'NOT v.hidden', $opt->{f}->sql_where(),
+ $opt->{q} ? map sql('v.c_search LIKE', \"%$_%"), normalize_query $opt->{q} : ();
+
+ my $time = time;
+ my $count = tuwf->dbVali('SELECT count(*) FROM vn v WHERE', $where);
+ my $list = $count && tuwf->dbPagei({results => 50, page => $opt->{p}}, '
+ SELECT v.id, v.title, v.original, v.c_released, v.c_popularity, v.c_votecount, v.c_rating, v.c_platforms::text[] AS platforms, v.c_languages::text[] AS lang
+ , vl.userlist_all, vl.userlist_obtained
+ FROM vn v
+ LEFT JOIN (
+ SELECT irv.vid, COUNT(*) AS userlist_all
+ , SUM(CASE WHEN irl.status = 1+1 THEN 1 ELSE 0 END) AS userlist_obtained
+ FROM rlists irl
+ JOIN releases_vn irv ON irv.id = irl.rid
+ WHERE irl.uid =', \auth->uid, '
+ GROUP BY irv.vid
+ ) AS vl ON vl.vid = v.id
+ WHERE', $where, '
+ ORDER BY', sprintf {
+ title => 'v.title %s',
+ rel => 'v.c_released %s, v.title',
+ pop => 'v.c_popularity %s NULLS LAST, v.title',
+ rating => 'v.c_rating %s NULLS LAST, v.title'
+ }->{$opt->{s}}, $opt->{o} eq 'a' ? 'ASC' : 'DESC'
+ );
+ enrich_flatten vnlist_labels => id => vid => sub { sql '
+ SELECT uvl.vid, ul.label
+ FROM ulist_vns_labels uvl
+ JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl
+ WHERE uvl.uid =', \auth->uid, 'AND uvl.vid IN', $_[0], '
+ ORDER BY CASE WHEN ul.id < 10 THEN ul.id ELSE 10 END, ul.label'
+ }, $list if $count && auth;
+ $time = time - $time;
+
+ framework_ title => 'Browse visual novels', sub {
+ div_ class => 'mainbox', sub {
+ h1_ 'Browse visual novels';
+ div_ class => 'warning', sub {
+ h2_ 'EXPERIMENTAL';
+ p_ "This is Yorhel's playground. Lots of functionality is missing, lots of stuff is or will be broken. Here be dragons. Etc.";
+ };
+ br_;
+ form_ action => '/experimental/v', method => 'get', sub {
+ searchbox_ v => $opt->{q};
+ $opt->{f}->elm_;
+ };
+ p_ class => 'center', sprintf '%d results in %.3fs', $count, $time;
+ };
+ listing_ $opt, $list, $count if $count;
+ };
+};
+
+1;
diff --git a/lib/VNWeb/VN/Page.pm b/lib/VNWeb/VN/Page.pm
index ed4f6e75..2b37209b 100644
--- a/lib/VNWeb/VN/Page.pm
+++ b/lib/VNWeb/VN/Page.pm
@@ -6,10 +6,10 @@ use VNWeb::Images::Lib qw/image_flagging_display image_ enrich_image_obj/;
use VNDB::Func 'fmtrating';
-# Enrich everything necessary to at least render infobox_().
+# Enrich everything necessary to at least render infobox_() and tabs_().
# Also used by Chars::VNTab & Reviews::VNTab
sub enrich_vn {
- my($v) = @_;
+ my($v, $revonly) = @_;
enrich_merge id => 'SELECT id, c_votecount, c_olang::text[] AS c_olang FROM vn WHERE id IN', $v;
enrich_merge vid => 'SELECT id AS vid, title, original FROM vn WHERE id IN', $v->{relations};
enrich_merge aid => 'SELECT id AS aid, title_romaji, title_kanji, year, type, ann_id, lastfetch FROM anime WHERE id IN', $v->{anime};
@@ -17,6 +17,9 @@ sub enrich_vn {
enrich_image_obj image => $v;
enrich_image_obj scr => $v->{screenshots};
+ # The queries below are not relevant for revisions
+ return if $revonly;
+
# This fetches rather more information than necessary for infobox_(), but it'll have to do.
# (And we'll need it for the releases tab anyway)
$v->{releases} = tuwf->dbAlli('
@@ -27,13 +30,28 @@ sub enrich_vn {
WHERE NOT r.hidden AND rv.vid =', \$v->{id}
);
enrich_extlinks r => $v->{releases};
+
+ $v->{reviews} = tuwf->dbRowi('SELECT COUNT(*) FILTER(WHERE isfull) AS full, COUNT(*) FILTER(WHERE NOT isfull) AS mini, COUNT(*) AS total FROM reviews WHERE NOT c_flagged AND vid =', \$v->{id});
+
+ my $rating = 'avg(CASE WHEN tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) THEN NULL ELSE tv.vote END)';
+ $v->{tags} = tuwf->dbAlli("
+ SELECT t.id, t.name, t.cat, $rating as rating
+ , coalesce(avg(CASE WHEN tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) THEN NULL ELSE tv.spoiler END), t.defaultspoil) as spoiler
+ FROM tags t
+ JOIN tags_vn tv ON tv.tag = t.id
+ LEFT JOIN users u ON u.id = tv.uid
+ WHERE t.state = 1+1 AND tv.vid =", \$v->{id}, "
+ GROUP BY t.id, t.name, t.cat
+ HAVING $rating > 0
+ ORDER BY rating DESC"
+ );
}
# Enrich everything necessary for rev_() (includes enrich_vn())
sub enrich_item {
- my($v) = @_;
- enrich_vn $v;
+ my($v, $full) = @_;
+ enrich_vn $v, !$full;
enrich_merge aid => 'SELECT id AS sid, aid, name, original FROM staff_alias WHERE aid IN', $v->{staff}, $v->{seiyuu};
enrich_merge cid => 'SELECT id AS cid, name AS char_name, original AS char_original FROM chars WHERE id IN', $v->{seiyuu};
@@ -98,7 +116,8 @@ sub rev_ {
txt_ ' [';
a_ href => "/img/$_->{scr}{id}", image_flagging_display $_->{scr};
txt_ '] ';
- b_ class => 'grayedout', sprintf 'old flag: %s', $_->{nsfw} ? 'NSFW' : 'Safe';
+ # The old NSFW flag has been removed around 2020-07-14, so not relevant for edits made later on.
+ b_ class => 'grayedout', sprintf 'old flag: %s', $_->{nsfw} ? 'NSFW' : 'Safe' if $_[0]{rev_added} < 1594684800;
}],
[ image => 'Image', fmt => sub { image_ $_ } ],
[ img_nsfw => 'Image NSFW (unused)', fmt => sub { txt_ $_ ? 'Not safe' : 'Safe' } ],
@@ -134,7 +153,7 @@ sub infobox_producers_ {
my($v) = @_;
my $p = tuwf->dbAlli('
- SELECT p.id, p.name, p.original, rl.lang, bool_or(rp.developer) as developer, bool_or(rp.publisher) as publisher
+ SELECT p.id, p.name, p.original, rl.lang, bool_or(rp.developer) as developer, bool_or(rp.publisher) as publisher, min(r.type) as type, bool_or(r.official) as official
FROM releases_vn rv
JOIN releases r ON r.id = rv.id
JOIN releases_lang rl ON rl.id = rv.id
@@ -142,12 +161,13 @@ sub infobox_producers_ {
JOIN producers p ON p.id = rp.pid
WHERE NOT r.hidden AND rv.vid =', \$v->{id}, '
GROUP BY p.id, p.name, p.original, rl.lang
- ORDER BY MIN(r.released), p.name
+ ORDER BY NOT bool_or(r.official), MIN(r.released), p.name
');
return if !@$p;
+ my $hasfull = grep $_->{type} eq 'complete', @$p;
my %dev;
- my @dev = grep $_->{developer} && !$dev{$_->{id}}++, @$p;
+ my @dev = grep $_->{developer} && (!$hasfull || $_->{type} ne 'trial') && !$dev{$_->{id}}++, @$p;
tr_ sub {
td_ 'Developer';
@@ -157,7 +177,7 @@ sub infobox_producers_ {
} if @dev;
my(%lang, @lang, $lang);
- for(grep $_->{publisher}, @$p) {
+ for(grep $_->{publisher} && (!$hasfull || $_->{type} ne 'trial'), @$p) {
push @lang, $_->{lang} if !$lang{$_->{lang}};
push $lang{$_->{lang}}->@*, $_;
}
@@ -167,7 +187,7 @@ sub infobox_producers_ {
td_ sub {
join_ \&br_, sub {
abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '';
- join_ ' & ', sub { a_ href => "/p$_->{id}", title => $_->{original}||$_->{name}, $_->{name} }, $lang{$_}->@*;
+ join_ ' & ', sub { a_ href => "/p$_->{id}", $_->{official} ? () : (class => 'grayedout'), title => $_->{original}||$_->{name}, $_->{name} }, $lang{$_}->@*;
}, @lang;
}
} if keys %lang;
@@ -241,22 +261,8 @@ sub infobox_anime_ {
sub infobox_tags_ {
my($v) = @_;
- my $rating = 'avg(CASE WHEN tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) THEN NULL ELSE tv.vote END)';
- my $tags = tuwf->dbAlli("
- SELECT t.id, t.name, t.cat, count(*) as cnt, $rating as rating
- , coalesce(avg(CASE WHEN tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) THEN NULL ELSE tv.spoiler END), t.defaultspoil) as spoiler
- FROM tags t
- JOIN tags_vn tv ON tv.tag = t.id
- LEFT JOIN users u ON u.id = tv.uid
- WHERE t.state = 1+1 AND tv.vid =", \$v->{id}, "
- GROUP BY t.id, t.name, t.cat
- HAVING $rating > 0
- ORDER BY rating DESC"
- );
- return if !@$tags;
-
div_ id => 'tagops', sub {
- debug_ $tags;
+ debug_ $v->{tags};
for (keys %TAG_CATEGORY) {
input_ id => "cat_$_", type => 'checkbox', class => 'visuallyhidden',
(auth ? auth->pref("tags_$_") : $_ ne 'ero') ? (checked => 'checked') : ();
@@ -288,7 +294,7 @@ sub infobox_tags_ {
spoil_ $spoil;
b_ class => 'grayedout', sprintf ' %.1f', $_->{rating};
}
- }, @$tags;
+ }, $v->{tags}->@*;
}
}
}
@@ -329,7 +335,7 @@ sub infobox_useroptions_ {
# Also used by Chars::VNTab & Reviews::VNTab
sub infobox_ {
- my($v) = @_;
+ my($v, $notags) = @_;
div_ class => 'mainbox', sub {
itemmsg_ v => $v;
h1_ $v->{title};
@@ -380,7 +386,7 @@ sub infobox_ {
}
};
div_ class => 'clearfloat', style => 'height: 5px', ''; # otherwise the tabs below aren't positioned correctly
- infobox_tags_ $v;
+ infobox_tags_ $v if $v->{tags}->@* && !$notags;
}
}
@@ -389,19 +395,19 @@ sub infobox_ {
sub tabs_ {
my($v, $tab) = @_;
my $chars = tuwf->dbVali('SELECT COUNT(DISTINCT c.id) FROM chars c JOIN chars_vns cv ON cv.id = c.id WHERE NOT c.hidden AND cv.vid =', \$v->{id});
- my $reviews = tuwf->dbRowi('SELECT COUNT(*) FILTER(WHERE isfull) AS full, COUNT(*) FILTER(WHERE NOT isfull) AS mini FROM reviews WHERE NOT c_flagged AND vid =', \$v->{id});
- return if !$chars && !$reviews->{full} && !$reviews->{mini} && !auth->permEdit && !auth->permReview;
+ return if !$chars && !$v->{reviews}{full} && !$v->{reviews}{mini} && !auth->permEdit && !auth->permReview;
$tab ||= '';
div_ class => 'maintabs', sub {
ul_ sub {
- li_ class => ($tab eq '' ? ' tabselected' : ''), sub { a_ href => "/v$v->{id}#main", name => 'main', 'main' } if $chars || $reviews;
+ li_ class => ($tab eq '' ? ' tabselected' : ''), sub { a_ href => "/v$v->{id}#main", name => 'main', 'main' };
+ li_ class => ($tab eq 'tags' ? ' tabselected' : ''), sub { a_ href => "/v$v->{id}/tags#tags", name => 'tags', 'tags' };
li_ class => ($tab eq 'chars' ? ' tabselected' : ''), sub { a_ href => "/v$v->{id}/chars#chars", name => 'chars', "characters ($chars)" } if $chars;
- if($reviews->{mini} > 4 || $tab eq 'minireviews' || $tab eq 'fullreviews') {
- li_ class => ($tab eq 'minireviews'?' tabselected' : ''), sub { a_ href => "/v$v->{id}/minireviews#review", name => 'review', "mini reviews ($reviews->{mini})" } if $reviews->{mini};
- li_ class => ($tab eq 'fullreviews'?' tabselected' : ''), sub { a_ href => "/v$v->{id}/fullreviews#review", name => 'review', "full reviews ($reviews->{full})" } if $reviews->{full};
- } elsif($reviews->{mini} || $reviews->{full}) {
- li_ class => ($tab =~ /reviews/ ?' tabselected':''), sub { a_ href => "/v$v->{id}/reviews#review", name => 'review', sprintf 'reviews (%d)', $reviews->{mini}+$reviews->{full} };
+ if($v->{reviews}{mini} > 4 || $tab eq 'minireviews' || $tab eq 'fullreviews') {
+ li_ class => ($tab eq 'minireviews'?' tabselected' : ''), sub { a_ href => "/v$v->{id}/minireviews#review", name => 'review', "mini reviews ($v->{reviews}{mini})" } if $v->{reviews}{mini};
+ li_ class => ($tab eq 'fullreviews'?' tabselected' : ''), sub { a_ href => "/v$v->{id}/fullreviews#review", name => 'review', "full reviews ($v->{reviews}{full})" } if $v->{reviews}{full};
+ } elsif($v->{reviews}{mini} || $v->{reviews}{full}) {
+ li_ class => ($tab =~ /reviews/ ?' tabselected':''), sub { a_ href => "/v$v->{id}/reviews#review", name => 'review', sprintf 'reviews (%d)', $v->{reviews}{total} };
}
};
ul_ sub {
@@ -495,7 +501,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 {
@@ -575,7 +581,7 @@ sub stats_ {
WHERE uv.vid =', \$v->{id}, 'AND uv.vote IS NOT NULL
AND NOT EXISTS(SELECT 1 FROM users u WHERE u.id = uv.uid AND u.ign_votes)
ORDER BY uv.vote_date DESC
- LIMIT', \8
+ LIMIT', \7
);
my $rank = $v->{c_votecount} && tuwf->dbRowi('SELECT c_rating, c_popularity, c_pop_rank, c_rat_rank FROM vn v WHERE id =', \$v->{id});
@@ -604,6 +610,9 @@ sub stats_ {
txt_ ')';
}
} } };
+ tfoot_ sub { tr_ sub { td_ colspan => 3, sub {
+ a_ href => "/v$v->{id}/reviews#review", sprintf'%d review%s »', $v->{reviews}{total}, $v->{reviews}{total}==1?'':'s';
+ } } } if $v->{reviews}{total};
tr_ sub {
td_ sub {
b_ class => 'grayedout', 'hidden' if $_->{hide_list};
@@ -622,7 +631,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...';
@@ -698,11 +707,89 @@ sub screenshots_ {
}
+sub tags_ {
+ my($v) = @_;
+ if(!$v->{tags}->@*) {
+ div_ class => 'mainbox', sub {
+ h1_ 'Tags';
+ p_ 'This VN has no tags assigned to it (yet).';
+ };
+ return;
+ }
+
+ my %tags = map +($_->{id},$_), $v->{tags}->@*;
+ my $parents = tuwf->dbAlli("
+ WITH RECURSIVE parents (tag, child) AS (
+ SELECT tag::int, NULL::int FROM (VALUES", sql_join(',', map sql('(',\$_,')'), keys %tags), ") AS x(tag)
+ UNION
+ SELECT tp.parent, tp.tag FROM tags_parents tp, parents a WHERE a.tag = tp.tag
+ ) SELECT * FROM parents WHERE child IS NOT NULL"
+ );
+
+ for(@$parents) {
+ $tags{$_->{tag}} ||= { id => $_->{tag} };
+ push $tags{$_->{tag}}{childs}->@*, $_->{child};
+ $tags{$_->{child}}{notroot} = 1;
+ }
+ enrich_merge id => 'SELECT id, name, cat FROM tags WHERE id IN', grep !$_->{name}, values %tags;
+ my @roots = sort { $a->{name} cmp $b->{name} } grep !$_->{notroot}, values %tags;
+
+ # Calculate rating and spoiler for parent tags.
+ my sub scores {
+ my($t) = @_;
+ return if !$t->{childs};
+ __SUB__->($tags{$_}) for $t->{childs}->@*;
+ $t->{inherited} = 1 if !defined $t->{rating};
+ $t->{spoiler} //= min map $tags{$_}{spoiler}, $t->{childs}->@*;
+ $t->{rating} //= sum(map $tags{$_}{rating}, $t->{childs}->@*) / $t->{childs}->@*;
+ }
+ scores $_ for @roots;
+ $_->{spoiler} = $_->{spoiler} > 1.3 ? 2 : $_->{spoiler} > 0.4 ? 1 : 0 for values %tags;
+
+ my $view = viewget;
+ my sub rec {
+ my($lvl, $t) = @_;
+ return if $t->{spoiler} > $view->{spoilers};
+ li_ class => "tagvnlist-top", sub {
+ h3_ sub { a_ href => "/g$t->{id}", $t->{name} }
+ } if !$lvl;
+
+ li_ $lvl == 1 ? (class => 'tagvnlist-parent') : $t->{inherited} ? (class => 'tagvnlist-inherited') : (), sub {
+ VNWeb::TT::Lib::tagscore_($t->{rating}, $t->{inherited});
+ b_ class => 'grayedout', '━━'x($lvl-1).' ' if $lvl > 1;
+ a_ href => "/g$t->{id}", $t->{rating} ? () : (class => 'parent'), $t->{name};
+ spoil_ $t->{spoiler};
+ } if $lvl;
+
+ if($t->{childs}) {
+ __SUB__->($lvl+1, $_) for sort { $a->{name} cmp $b->{name} } map $tags{$_}, $t->{childs}->@*;
+ }
+ }
+
+ div_ class => 'mainbox', sub {
+ my $max_spoil = max map $_->{spoiler}, values %tags;
+ p_ class => 'mainopts', sub {
+ if($max_spoil) {
+ a_ mkclass(checked => $view->{spoilers} == 0), href => '?view='.viewset(spoilers=>0).'#tags', 'Hide spoilers';
+ a_ mkclass(checked => $view->{spoilers} == 1), href => '?view='.viewset(spoilers=>1).'#tags', 'Show minor spoilers';
+ a_ mkclass(standout =>$view->{spoilers} == 2), href => '?view='.viewset(spoilers=>2).'#tags', 'Spoil me!' if $max_spoil == 2;
+ }
+ } if $max_spoil;
+
+ h1_ 'Tags';
+ ul_ class => 'vntaglist', sub {
+ rec 0, $_ for @roots;
+ };
+ debug_ \%tags;
+ };
+}
+
+
TUWF::get qr{/$RE{vrev}}, sub {
my $v = db_entry v => tuwf->capture('id'), tuwf->capture('rev');
return tuwf->resNotFound if !$v;
- enrich_item $v;
+ enrich_item $v, 1;
framework_ title => $v->{title}, index => !tuwf->capture('rev'), type => 'v', dbobj => $v, hiddenmsg => 1, js => 1, og => og($v),
sub {
@@ -717,4 +804,19 @@ TUWF::get qr{/$RE{vrev}}, sub {
};
};
+
+TUWF::get qr{/$RE{vid}/tags}, sub {
+ my $v = db_entry v => tuwf->capture('id');
+ return tuwf->resNotFound if !$v;
+
+ enrich_vn $v;
+
+ framework_ title => $v->{title}, index => 1, type => 'v', dbobj => $v, hiddenmsg => 1,
+ sub {
+ infobox_ $v, 1;
+ tabs_ $v, 'tags';
+ tags_ $v;
+ };
+};
+
1;
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..e64f80ff 100644
--- a/lib/VNWeb/Validation.pm
+++ b/lib/VNWeb/Validation.pm
@@ -25,9 +25,12 @@ our @EXPORT = qw/
TUWF::set custom_validations => {
id => { uint => 1, max => (1<<26)-1 },
# 'vndbid' SQL type, accepts an arrayref with accepted prefixes.
+ # If only one prefix is supported, it will also take integers and normalizes them into the formatted form.
vndbid => sub {
- my $types = ref $_[0] ? join '|', $_[0]->@* : $_[0];
- +{ regex => qr/^(?:$types)[1-9][0-9]{0,6}$/ }
+ my $multi = ref $_[0];
+ my $types = $multi ? join '|', $_[0]->@* : $_[0];
+ my $re = qr/^(?:$types)[1-9][0-9]{0,6}$/;
+ +{ _analyze_regex => $re, func => sub { $_[0] = "${types}$_[0]" if !$multi && $_[0] =~ /^[1-9][0-9]{0,6}$/; return $_[0] =~ $re } }
},
editsum => { required => 1, length => [ 2, 5000 ] },
page => { uint => 1, min => 1, max => 1000, required => 0, default => 1, onerror => 1 },
@@ -37,6 +40,7 @@ TUWF::set custom_validations => {
language => { enum => \%LANGUAGE },
gtin => { required => 0, default => 0, func => sub { $_[0] = 0 if !length $_[0]; $_[0] eq 0 || gtintype($_[0]) } },
rdate => { uint => 1, func => \&_validate_rdate },
+ fuzzyrdate => { func => \&_validate_fuzzyrdate },
# A tri-state bool, returns undef if not present or empty, normalizes to 0/1 otherwise
undefbool => { required => 0, default => undef, func => sub { $_[0] = $_[0] ? 1 : 0; 1 } },
# An array that may be either missing (returns undef), a single scalar (returns single-element array) or a proper array
@@ -75,6 +79,18 @@ sub _validate_rdate {
}
+sub _validate_fuzzyrdate {
+ $_[0] = 0 if $_[0] =~ /^unknown$/;
+ $_[0] = 1 if $_[0] =~ /^today$/;
+ $_[0] = 99999999 if $_[0] =~ /^tba$/;
+ $_[0] = "${1}9999" if $_[0] =~ /^([0-9]{4})$/;
+ $_[0] = "${1}${2}99" if $_[0] =~ /^([0-9]{4})-([0-9]{2})$/;
+ $_[0] = "${1}${2}$3" if $_[0] =~ /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/;
+ return 1 if $_[0] eq 1;
+ VNWeb::Validation::_validate_rdate($_[0]);
+}
+
+
sub is_insecurepass {
config->{password_db} && PWLookup::lookup(config->{password_db}, shift)
}
@@ -182,6 +198,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 +234,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..3c4b6c48 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
@@ -152,7 +158,7 @@ CREATE OR REPLACE FUNCTION update_images_cache(vndbid) RETURNS void AS $$
BEGIN
UPDATE images
SET c_votecount = votecount, c_sexual_avg = sexual_avg, c_sexual_stddev = sexual_stddev
- , c_violence_avg = violence_avg, c_violence_stddev = violence_stddev, c_weight = weight
+ , c_violence_avg = violence_avg, c_violence_stddev = violence_stddev, c_weight = weight, c_uids = uids
FROM (
SELECT s.*,
CASE WHEN EXISTS(
@@ -164,8 +170,11 @@ 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
+ , coalesce(array_agg(u.id) FILTER(WHERE u.id IS NOT NULL), '{}') AS uids
FROM images i
LEFT JOIN image_votes iv ON iv.id = i.id
LEFT JOIN users u ON u.id = iv.uid
@@ -182,15 +191,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 +202,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 +351,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 +503,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 +538,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..dc23aeba 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;
@@ -171,8 +172,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_labels TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns_labels TO vndb_multi;
-GRANT SELECT (id, username, registered, ign_votes, email_confirmed, notify_dbedit, notify_announce, notify_post, notify_comment, c_vns, c_wish, c_votes, c_changes, c_imgvotes, c_tags, perm_imgvote),
- UPDATE ( c_vns, c_wish, c_votes, c_changes, c_imgvotes, c_tags ) ON users TO vndb_multi;
+GRANT SELECT (id, username, registered, ign_votes, email_confirmed, notify_dbedit, notify_announce, notify_post, notify_comment, c_vns, c_wish, c_votes, c_changes, c_imgvotes, c_tags, perm_imgvote, perm_imgmod),
+ UPDATE ( c_vns, c_wish, c_votes, c_changes, c_imgvotes, c_tags ) ON users TO vndb_multi;
GRANT DELETE ON users TO vndb_multi;
GRANT SELECT, UPDATE ON vn TO vndb_multi;
diff --git a/sql/schema.sql b/sql/schema.sql
index 3605e9cc..3dd3c305 100644
--- a/sql/schema.sql
+++ b/sql/schema.sql
@@ -55,9 +55,9 @@ CREATE TYPE cup_size AS ENUM ('', 'AAA', 'AA', 'A', 'B', 'C', 'D', 'E',
CREATE TYPE dbentry_type AS ENUM ('v', 'r', 'p', 'c', 's', 'd');
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 language AS ENUM ('ar', 'bg', 'ca', 'cs', 'da', 'de', 'el', 'en', 'eo', 'es', 'fa', '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');
@@ -226,7 +226,8 @@ CREATE TABLE images (
c_sexual_stddev float, -- [pub]
c_violence_avg float, -- [pub]
c_violence_stddev float, -- [pub]
- c_weight float NOT NULL DEFAULT 0 -- [pub]
+ c_weight float NOT NULL DEFAULT 0, -- [pub]
+ c_uids integer[] NOT NULL DEFAULT '{}'
);
-- image_votes
@@ -245,17 +246,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
@@ -355,7 +369,8 @@ CREATE TABLE releases ( -- dbentry_type=r
l_gamejolt integer NOT NULL DEFAULT 0, -- [pub]
l_nutaku text NOT NULL DEFAULT '', -- [pub]
reso_x smallint NOT NULL DEFAULT 0, -- [pub] When reso_x is 0, reso_y is either 0 for 'unknown' or 1 for 'non-standard'.
- reso_y smallint NOT NULL DEFAULT 0 -- [pub]
+ reso_y smallint NOT NULL DEFAULT 0, -- [pub]
+ official boolean NOT NULL DEFAULT TRUE -- [pub]
);
-- releases_hist
@@ -400,7 +415,8 @@ CREATE TABLE releases_hist (
l_gamejolt integer NOT NULL DEFAULT 0,
l_nutaku text NOT NULL DEFAULT '',
reso_x smallint NOT NULL DEFAULT 0,
- reso_y smallint NOT NULL DEFAULT 0
+ reso_y smallint NOT NULL DEFAULT 0,
+ official boolean NOT NULL DEFAULT TRUE
);
-- releases_lang
@@ -533,7 +549,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
@@ -905,7 +922,7 @@ CREATE TABLE users (
max_sexual smallint NOT NULL DEFAULT 0,
max_violence smallint NOT NULL DEFAULT 0,
last_reports timestamptz, -- For mods: Most recent activity seen on the reports listing
- perm_review boolean NOT NULL DEFAULT false, -- TODO: DEFAULT true when out of beta.
+ perm_review boolean NOT NULL DEFAULT true,
notify_post boolean NOT NULL DEFAULT true,
notify_comment boolean NOT NULL DEFAULT true
);
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..6890c0aa 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();
@@ -380,7 +315,7 @@ END
$$ LANGUAGE plpgsql;
CREATE TRIGGER image_votes_cache1 AFTER INSERT OR DELETE ON image_votes FOR EACH ROW EXECUTE PROCEDURE update_images_cache();
-CREATE TRIGGER image_votes_cache2 AFTER UPDATE ON image_votes FOR EACH ROW WHEN ((OLD.id, OLD.sexual, OLD.violence, OLD.ignore) IS DISTINCT FROM (NEW.id, NEW.sexual, NEW.violence, NEW.ignore)) EXECUTE PROCEDURE update_images_cache();
+CREATE TRIGGER image_votes_cache2 AFTER UPDATE ON image_votes FOR EACH ROW WHEN (OLD.id <> NEW.id OR (OLD.sexual, OLD.violence, OLD.ignore) IS DISTINCT FROM (NEW.sexual, NEW.violence, NEW.ignore)) EXECUTE PROCEDURE update_images_cache();
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;
diff --git a/util/updates/2020-11-09-images-uids-cache.sql b/util/updates/2020-11-09-images-uids-cache.sql
new file mode 100644
index 00000000..44bb973a
--- /dev/null
+++ b/util/updates/2020-11-09-images-uids-cache.sql
@@ -0,0 +1,5 @@
+ALTER TABLE images ADD COLUMN c_uids integer[] NOT NULL DEFAULT '{}';
+
+\i sql/func.sql
+
+SELECT update_images_cache(null);
diff --git a/util/updates/2020-11-10-persian-language.sql b/util/updates/2020-11-10-persian-language.sql
new file mode 100644
index 00000000..29613381
--- /dev/null
+++ b/util/updates/2020-11-10-persian-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'fa' AFTER 'es';
diff --git a/util/updates/2020-11-19-releases-official.sql b/util/updates/2020-11-19-releases-official.sql
new file mode 100644
index 00000000..badac9cf
--- /dev/null
+++ b/util/updates/2020-11-19-releases-official.sql
@@ -0,0 +1,20 @@
+ALTER TABLE releases ADD COLUMN official boolean NOT NULL DEFAULT TRUE;
+ALTER TABLE releases_hist ADD COLUMN official boolean NOT NULL DEFAULT TRUE;
+
+\i sql/editfunc.sql
+
+-- A release is considered unofficial if it was published by an individual or
+-- amateur group while the original developer is a company.
+-- This should not have many false positives, but only covers a small part of the DB.
+UPDATE releases r SET official = FALSE
+ WHERE EXISTS(SELECT 1
+ FROM releases_vn rv
+ JOIN releases_vn rv2 ON rv.vid = rv2.vid
+ JOIN releases r2 ON r2.id = rv2.id
+ JOIN releases_producers rp2 ON rp2.id = rv2.id
+ JOIN producers p ON p.id = rp2.pid
+ WHERE NOT p.hidden AND NOT r2.hidden AND rp2.developer AND rv.id = r.id AND p.type = 'co')
+ AND NOT EXISTS(SELECT 1 FROM releases_producers rp JOIN producers p ON p.id = rp.pid WHERE rp.id = r.id AND (rp.developer OR p.type = 'co'));
+
+UPDATE releases_hist rh SET official = FALSE
+ WHERE EXISTS(SELECT 1 FROM changes c JOIN releases r ON r.id = c.itemid WHERE c.id = rh.chid AND NOT r.official);